feat: 对齐 Jev 真实 API schema、Web Speech 语音输入(优雅降级)、C1-01 语音化身份登记与真 key 冒烟脚本

This commit is contained in:
Jayway Liu
2026-10-03 12:43:41 +08:00
parent bf9d7dad5b
commit bed4d734ca
14 changed files with 623 additions and 95 deletions
+30 -14
View File
@@ -10,11 +10,12 @@
```bash
pnpm install
pnpm dev # 浏览器开发(无 API key 时自动降级 Mock,见下)
pnpm build # 类型检查 + 产物构建
pnpm verify # 无头校验:选择点地图对照母本 + 引擎三章流程 + 重玩语义
pnpm lint # oxlint
pnpm tauri dev # Tauri 桌面壳(Rust 工具链需先 source ~/.cargo/env)
pnpm dev # 浏览器开发(无 API key 时自动降级 Mock,见下)
pnpm build # 类型检查 + 产物构建
pnpm verify # 无头校验:选择点地图对照母本 + 引擎三章流程 + 重玩语义(Mock)
pnpm verify:live # 真 key 冒烟:C1-01 抽取 + C1-02 情绪签名 + 7 维 score(读 .env)
pnpm lint # oxlint
pnpm tauri dev # Tauri 桌面壳(Rust 工具链需先 source ~/.cargo/env)
```
## Jev(TypeSafe AI System One)配置
@@ -27,20 +28,26 @@ VITE_TYPESAFE_API_KEY=<your key>
- 有 key:前端直连 `POST https://api.typesafe.ai/v1/systemone`(`HttpJevClient`)。
- 无 key:自动降级 `MockJevClient`(确定性伪随机返分,console 有提示),开发联调不受影响。
- `.env` 已在 `.gitignore` 中,绝不能入库。
- `.env` 已在 `.gitignore` 中,绝不能入库;`scripts/verify-live.ts` 手动解析 `.env`
(不引 dotenv),任何输出都不打印 key。
- 请求/响应 schema 已按真接口 curl 实测对齐(2026-10-03):Question 文本字段为
`instructions`;choice 的 `criteria` 是 `{选项: 描述}` map;score 的 `criteria`
为 2-10 级有序数组(索引 0 起),答案是概率加权浮点值 + `legend` + `probabilities`。
## 项目结构
```
content/chapters/ Act 1 三章剧情数据(JSON,选择点 ID/维度标签对母本第 4 节)
src/engine/ 数据驱动剧情引擎(纯 TS,与 UI 解耦)
types.ts 章节 schema:dialogue / choice / narration / cutscene / chapterEnd
engine.ts StoryEngine 状态机:节点推进、分支、情绪签名→场景变体、重玩
types.ts 章节 schema:dialogue / choice / narration / cutscene / identity / chapterEnd
engine.ts StoryEngine 状态机:节点推进、分支、情绪签名→场景变体、
身份抽取(extractIdentity)与确认(confirmIdentity)、重玩
eventStore.ts EventStore 接口(append-only,只有 append/list)+ localStorage 实现
src/services/jev.ts JevClient 接口 + HttpJevClient / MockJevClient
src/ui/ 最简影游界面(对话框/选项/按住说话占位/场景变体渐变)
src/ui/ 最简影游界面(对话框/选项/VoiceInput 语音输入/身份登记/场景变体渐变)
src/ui/report/ 画像报告(Jev 7 维 score)、勋章映射、canvas 分享卡
scripts/verify.ts 内容完整性与流程校验(pnpm verify)
scripts/verify.ts 内容完整性与流程校验(Mock 回归)
scripts/verify-live.ts 真 key 冒烟
src-tauri/ Tauri 2 壳(iOS/Android/桌面共用)
```
@@ -51,14 +58,23 @@ src-tauri/ Tauri 2 壳(iOS/Android/桌面共用)
- **三通道边界(ADR-0003)**:主线全部预写在 JSON 里;Jev 在讲述点只做
"情绪签名 → 预写场景变体" 的选择题(通道 1);C3-02 问心幻境通过
`variantFrom: "C1-02"` 复用第一章的记忆解析。
- **画像报告**:Act 1 结束后把全部事件轨迹汇总为 state,一次 Jev 调用并行问
7 个维度(依恋/冲突风格/价值观/情绪稳定性/风险偏好/文化底蕴/择偶偏好)的
score 问题(criteria 1-10 级),勋章取置信度最高的维度。
- **语音输入**:`VoiceInput` 组件优先用 Web Speech API(`zh-CN`,continuous +
interimResults,实时转写),不支持的环境(如 Tauri WKWebView)优雅降级为文本框。
只保留转写文本、不保存任何音频,天然符合 ADR-0004"特征提取后即焚"。
- **C1-01 语音化身份登记**:馆长邀请自由介绍(称呼/年龄/城市/职业/学历),
Jev 一次调用并行抽取:gender(choice)、age(score 分档)、occupation(choice)、
education(choice)、city(noul 判断是否提及 + 城市 choice 兜底);
抽不出的字段留空,进**确认页**由用户修改补填,确认后 append `profile` 事件
(身份字段是匹配硬过滤字段,用户有权直接改,不进行为信号流)。
- **画像报告**:Act 1 结束后把全部行为轨迹(不含 profile 字段)汇总为 state,
一次 Jev 调用并行问 7 个维度的 score 问题(10 级 criteria),API 返回的
0-9 浮点分数 +1 映射为 1-10 级展示;勋章取置信度最高的维度。
## 已知缺口
- **CORS**:浏览器直连 `api.typesafe.ai` 可能有跨域限制,正式环境需加后端代理转发。
- **ASR 未接**:语音讲述点只有"按住说话"占位按钮,松开即以占位转写文本落事件。
- **Web Speech API 覆盖**:Chrome/Edge 可用,Tauri WKWebView 与 Firefox 不支持
(自动降级文本框);原生端的正式 ASR 管线未接。
- **Tauri SQLite 未实现**:`EventStore` 目前只有 localStorage 实现;原生端应用
tauri-plugin-sql 实现同一接口(表只建 INSERT/SELECT,不建 UPDATE/DELETE 路径),
预留位置见 `src/engine/eventStore.ts` 末尾注释。
+3 -6
View File
@@ -22,14 +22,11 @@
},
{
"id": "c1-01",
"type": "choice",
"type": "identity",
"pointId": "C1-01",
"prompt": "穿越者身份登记(将作为匹配的硬性过滤字段,见 ADR-0003)",
"prompt": "登记之前,先认识一下你。用一两句话介绍自己:称呼、年龄、城市、职业、学历——想到哪说到哪,没有格式。",
"dimensions": [],
"options": [
{ "id": "profile-a", "label": "杭州 · 28 · 女 · 互联网产品经理 · 硕士", "next": "c1-d3" },
{ "id": "profile-b", "label": "杭州 · 31 · 男 · 高校青年教师 · 博士", "next": "c1-d3" }
]
"next": "c1-d3"
},
{
"id": "c1-d3",
+2 -1
View File
@@ -9,7 +9,8 @@
"lint": "oxlint",
"preview": "vite preview",
"tauri": "tauri",
"verify": "tsx scripts/verify.ts"
"verify": "tsx scripts/verify.ts",
"verify:live": "tsx scripts/verify-live.ts"
},
"dependencies": {
"@tauri-apps/api": "^2.12.1",
+103
View File
@@ -0,0 +1,103 @@
/**
* 真 key 冒烟(pnpm verify:live):直连 https://api.typesafe.ai/v1/systemone。
* 手动解析根目录 .env(不引 dotenv),任何输出都不打印 key 本身。
* 只跑三次调用控制 token 消耗:
* 1. C1-01 身份抽取问题组(gender/age/occupation/education/city)
* 2. C1-02 情绪签名 → 场景变体选择(choice)
* 3. 画像报告 7 维 score
*/
import { readFileSync } from 'node:fs';
import { StoryEngine } from '../src/engine/engine';
import type { EventStore, StoryEvent } from '../src/engine/eventStore';
import type { Chapter } from '../src/engine/types';
import { HttpJevClient, type JevQuestion } from '../src/services/jev';
function loadApiKey(): string | null {
let raw: string;
try {
raw = readFileSync(new URL('../.env', import.meta.url), 'utf8');
} catch {
return null;
}
for (const line of raw.split('\n')) {
const m = line.match(/^\s*VITE_TYPESAFE_API_KEY\s*=\s*(.+?)\s*$/);
if (m && m[1] && !m[1].startsWith('#')) return m[1];
}
return null;
}
class MemoryStore implements EventStore {
events: StoryEvent[] = [];
async append(e: StoryEvent) {
this.events.push(e);
}
async list(sessionId?: string) {
return sessionId ? this.events.filter((e) => e.sessionId === sessionId) : [...this.events];
}
}
const key = loadApiKey();
if (!key) {
console.error('未在 .env 中找到 VITE_TYPESAFE_API_KEY,跳过真 key 冒烟');
process.exit(1);
}
console.log('已加载 API key(长度 ' + key.length + ',不打印内容)');
const jev = new HttpJevClient(key);
const chapters = [1, 2, 3].map(
(i) => JSON.parse(readFileSync(new URL(`../content/chapters/chapter${i}.json`, import.meta.url), 'utf8')) as Chapter,
);
// --- 1. C1-01 身份抽取(走引擎真实代码路径) ---
const store = new MemoryStore();
const engine = new StoryEngine(chapters, store, jev);
while (engine.currentNode().type !== 'identity') engine.advance();
const intro = '我叫林晚,今年29岁,在杭州一家互联网公司做产品经理,硕士毕业五年了。';
const profile = await engine.extractIdentity(intro);
console.log('\n[1] C1-01 身份抽取');
console.log(' 输入:', intro);
console.log(' 抽取:', JSON.stringify(profile));
// --- 2. C1-02 情绪签名 → 场景变体(choice 格式实测) ---
await engine.confirmIdentity(profile);
while (engine.currentNode().type !== 'narration') engine.advance();
const farewell = '外婆走的那天我在外地考试,没赶上最后一面。后来我每年清明都回去,院子里的桂花树还在。';
const result = await engine.submitNarration(farewell);
console.log('\n[2] C1-02 情绪签名 → 场景变体');
console.log(' 输入:', farewell);
console.log(` 签名: ${result.variant.signature} → 变体: ${result.variant.variantId}(${result.variant.label})`);
// --- 3. 画像报告 7 维 score ---
const dims: [string, string][] = [
['attachment', '依恋类型'],
['conflict_style', '冲突处理风格'],
['values', '价值观与人生优先级'],
['emotional_stability', '情绪稳定性'],
['risk', '风险偏好'],
['culture', '文化底蕴'],
['mate_preference', '择偶偏好'],
];
const criteria = Array.from({ length: 10 }, (_, i) => `${i + 1} 级`);
const state = `用户在互动剧情中的行为轨迹:
[C1-01] 身份登记:${JSON.stringify(profile)}
[C1-02] 离别之门讲述:${farewell}(情绪签名: ${result.variant.signature})
[C1-04] 老鬼提议带纪念品,选择「我们是去记录的,不是去拿的」当场回绝
[C1-06] 穿越落点选择「精确落点:直接进城,赌一把」`;
const questions: Record<string, JevQuestion> = Object.fromEntries(
dims.map(([id, label]) => [
id,
{ type: 'score' as const, instructions: `根据用户行为轨迹评估其「${label}」维度水平`, criteria },
]),
);
const answers = await jev.ask(state, questions);
console.log('\n[3] 画像报告 7 维 score(1-10 级,浮点为概率加权)');
for (const [id, label] of dims) {
const a = answers[id];
if (a?.type === 'score') {
console.log(` ${label}: ${(a.score + 1).toFixed(1)}/10 置信度 ${(a.confidence * 100).toFixed(0)}%`);
} else {
console.log(` ${label}: 未返回 score 答案`, JSON.stringify(a));
}
}
console.log('\n真 key 冒烟完成');
+17 -5
View File
@@ -84,7 +84,7 @@ for (const ch of chapters) {
}
check(reachable.size === ch.nodes.length, `${ch.id}: 全部 ${ch.nodes.length} 个节点可达`);
for (const n of ch.nodes) {
if (n.type === 'choice' || n.type === 'narration') {
if (n.type === 'choice' || n.type === 'narration' || n.type === 'identity') {
check(!seenPoints.has(n.pointId), `${n.pointId}: 选择点 ID 全局唯一`);
seenPoints.set(n.pointId, n.dimensions);
}
@@ -123,6 +123,9 @@ while (!engine.state.finished && guard++ < 200) {
const node = engine.currentNode();
if (node.type === 'choice') {
await engine.choose((node as ChoiceNode).options[0].id);
} else if (node.type === 'identity') {
const profile = await engine.extractIdentity('我叫阿黎,29岁,在杭州做互联网产品经理,硕士学历');
await engine.confirmIdentity(profile);
} else if (node.type === 'narration') {
// C2-05 走"沉默"路径验证合法选项,其余讲述
if ((node as NarrationNode).pointId === 'C2-05') await engine.skipNarration();
@@ -142,11 +145,11 @@ const c102 = store.events.find((e) => e.pointId === 'C1-02');
check(!!c305?.variantId && c305.variantId === c102?.variantId, 'C3-02 幻境变体复用 C1-02 情绪解析结果');
// --- 5. 画像报告:7 维 score 问题 ---
const criteria = Array.from({ length: 10 }, (_, i) => String(i + 1));
const criteria = Array.from({ length: 10 }, (_, i) => `${i + 1} 级`);
const dims = ['attachment', 'conflict_style', 'values', 'emotional_stability', 'risk', 'culture', 'mate_preference'];
const answers = await jev.ask(
store.events,
Object.fromEntries(dims.map((d) => [d, { type: 'score' as const, description: d, criteria }])),
Object.fromEntries(dims.map((d) => [d, { type: 'score' as const, instructions: d, criteria }])),
);
check(
dims.every((d) => answers[d]?.type === 'score' && answers[d].confidence >= 0 && answers[d].confidence <= 1),
@@ -158,10 +161,19 @@ const before = store.events.length;
const oldSession = engine.state.sessionId;
engine.replay('chapter1');
check(engine.state.sessionId !== oldSession, '重玩产生新 sessionId');
while (engine.currentNode().type !== 'choice') engine.advance();
while (engine.currentNode().type !== 'choice') {
const n = engine.currentNode();
if (n.type === 'identity') {
await engine.confirmIdentity(await engine.extractIdentity('占位自我介绍'));
} else if (n.type === 'narration') {
await engine.submitNarration('(验证用占位讲述)');
} else {
engine.advance();
}
}
const firstChoice = engine.currentNode() as ChoiceNode;
await engine.choose(firstChoice.options[0].id);
check(store.events.length === before + 1, '重玩追加新事件,旧事件不变');
check(store.events.length === before + 3, '重玩追加新事件(profile + narration + choice),旧事件不变');
check((await store.list(oldSession)).length === before, '旧轨迹事件完整保留');
if (failures > 0) {
+54
View File
@@ -277,3 +277,57 @@ button.secondary {
display: block;
margin: 0 auto 12px;
}
/* ---- 语音输入与身份登记 ---- */
.voice-input {
margin-top: 12px;
}
.voice-live {
margin-top: 10px;
padding: 10px 14px;
background: rgba(212, 169, 106, 0.1);
border-left: 3px solid #d4a96a;
border-radius: 4px;
color: #e8dcc8;
line-height: 1.7;
}
.voice-textarea {
width: 100%;
box-sizing: border-box;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid #4a3f5a;
background: #241e33;
color: #e8dcc8;
font-size: 15px;
line-height: 1.7;
resize: vertical;
margin-bottom: 8px;
}
.identity-box {
background: rgba(20, 16, 30, 0.85);
border: 1px solid #3a3248;
border-radius: 12px;
padding: 20px;
}
.identity-field {
display: grid;
grid-template-columns: 80px 1fr;
align-items: center;
gap: 12px;
margin: 10px 0;
color: #d8ccb8;
}
.identity-field input {
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #4a3f5a;
background: #241e33;
color: #e8dcc8;
font-size: 15px;
}
+98 -5
View File
@@ -1,7 +1,7 @@
import type { Chapter, NarrationNode, SceneVariant, StoryNode } from './types';
import type { Chapter, IdentityProfile, NarrationNode, SceneVariant, StoryNode } from './types';
import type { EventStore, StoryEvent } from './eventStore';
import { makeEvent } from './eventStore';
import type { JevClient } from '../services/jev';
import type { JevClient, JevQuestion } from '../services/jev';
export interface EngineState {
sessionId: string;
@@ -151,19 +151,112 @@ export class StoryEngine {
{
emotion_signature: {
type: 'choice',
description: `根据讲述文本选择最贴合的情绪签名(讲述点 ${node.pointId})`,
options: node.variants.map((v) => v.signature),
instructions: `根据讲述文本选择最贴合的情绪签名(讲述点 ${node.pointId})`,
criteria: Object.fromEntries(node.variants.map((v) => [v.signature, v.label])),
},
},
);
const picked = answer.emotion_signature;
if (picked?.type === 'choice') {
const match = node.variants.find((v) => v.signature === picked.selected);
const match = node.variants.find((v) => v.signature === picked.choice);
if (match) return match;
}
return node.variants[0];
}
/**
* C1-01 身份登记:从自由介绍文本中用 Jev 抽取结构化字段(匹配硬过滤字段)。
* 抽取不出来的字段留空,由用户在确认页补填。
*/
async extractIdentity(transcript: string): Promise<IdentityProfile> {
const node = this.currentNode();
if (node.type !== 'identity') throw new Error('当前节点不是身份登记点');
const ageBands = ['18-22', '23-27', '28-32', '33-40', '41及以上'];
const questions: Record<string, JevQuestion> = {
gender: {
type: 'choice',
instructions: '从这段自我介绍中判断说话人的性别',
criteria: { 男: '明确为男性', 女: '明确为女性', 其他: '非二元性别', 未提及: '文本中没有可判断的信息' },
},
age: {
type: 'score',
instructions: '根据自我介绍判断说话人所属的年龄段',
criteria: ageBands,
},
occupation: {
type: 'choice',
instructions: '从这段自我介绍中判断说话人的职业类别',
criteria: {
互联网: '互联网/软件/产品经理等',
学术科研: '高校、科研院所、教师、研究员',
金融: '银行、证券、投资等',
体制内: '公务员、事业单位、国企',
自由职业: '自由职业、个体、创作者',
学生: '在校学生',
其他: '以上都不是',
未提及: '文本中没有可判断的信息',
},
},
education: {
type: 'choice',
instructions: '从这段自我介绍中判断说话人的最高学历',
criteria: {
博士: '博士',
硕士: '硕士(含在读)',
本科: '本科',
大专及以下: '大专或以下',
未提及: '文本中没有可判断的信息',
},
},
city_mentioned: {
type: 'noul',
instructions: '自我介绍中是否提及了说话人所在的城市',
},
city: {
type: 'choice',
instructions: '自我介绍中提到的城市是哪一个(首发城市杭州,其余为常见城市兜底)',
criteria: {
杭州: '杭州', 北京: '北京', 上海: '上海', 深圳: '深圳', 广州: '广州',
南京: '南京', 成都: '成都', 西安: '西安', 武汉: '武汉',
未提及: '没有提及,或不在以上城市中',
},
},
};
const answers = await this.jev.ask({ transcript }, questions);
const choiceVal = (id: string): string => {
const a = answers[id];
return a?.type === 'choice' && a.choice !== '未提及' ? a.choice : '';
};
let age = '';
const ageAnswer = answers.age;
// 年龄段是 score 分档:置信度太低(视为未提及)则留空给用户补填
if (ageAnswer?.type === 'score' && ageAnswer.confidence >= 0.4) {
const idx = Math.max(0, Math.min(ageBands.length - 1, Math.round(ageAnswer.score)));
age = ageBands[idx];
}
let city = choiceVal('city');
const mentioned = answers.city_mentioned;
if (mentioned?.type === 'noul' && mentioned.probability < 0.5) city = '';
return { city, age, gender: choiceVal('gender'), occupation: choiceVal('occupation'), education: choiceVal('education') };
}
/** C1-01 确认页通过后调用:append profile 事件并推进 */
async confirmIdentity(profile: IdentityProfile): Promise<void> {
const node = this.currentNode();
if (node.type !== 'identity') throw new Error('当前节点不是身份登记点');
await this.store.append(
makeEvent({
sessionId: this.state.sessionId,
chapterId: this.state.chapterId,
kind: 'profile',
pointId: node.pointId,
dimensions: node.dimensions,
profile,
}),
);
this.state.nodeId = node.next;
}
/**
* 重玩(ADR-0004):产生新轨迹(新 sessionId),不修改旧事件。
* 默认重玩当前章,也可指定章节。
+5 -3
View File
@@ -1,4 +1,4 @@
import type { DimensionTag } from './types';
import type { DimensionTag, IdentityProfile } from './types';
/**
* 选择/讲述事件存储(ADR-0004:历史不可涂改)。
@@ -17,7 +17,7 @@ export interface StoryEvent {
sessionId: string;
ts: number;
chapterId: string;
kind: 'choice' | 'narration';
kind: 'choice' | 'narration' | 'profile';
/** 选择点 ID,如 C1-03 */
pointId: string;
dimensions: DimensionTag[];
@@ -27,8 +27,10 @@ export interface StoryEvent {
/** narration 事件:选中的场景变体(情绪签名 → variantId) */
variantId?: string;
emotionSignature?: string;
/** 讲述转写文本占位(ASR 未接,MVP 为手输或占位文本;沉默为 null) */
/** 讲述转写文本(Web Speech API 只给文本,不留音频,符合 ADR-0004 即焚原则;沉默为 null) */
transcript?: string | null;
/** profile 事件:用户确认后的身份登记字段(匹配硬过滤字段) */
profile?: IdentityProfile;
}
const STORAGE_KEY = 'jev-dating-match.events.v1';
+23 -2
View File
@@ -21,7 +21,7 @@ interface BaseNode {
type: NodeType;
}
export type NodeType = 'dialogue' | 'choice' | 'narration' | 'cutscene' | 'chapterEnd';
export type NodeType = 'dialogue' | 'choice' | 'narration' | 'cutscene' | 'identity' | 'chapterEnd';
/** 对话节点:角色 + 台词 + 立绘占位(sprite 仅作 CSS 色块标识) */
export interface DialogueNode extends BaseNode {
@@ -97,7 +97,28 @@ export interface ChapterEndNode extends BaseNode {
nextChapter?: string;
}
export type StoryNode = DialogueNode | ChoiceNode | NarrationNode | CutsceneNode | ChapterEndNode;
/** 身份登记字段(C1-01):匹配硬过滤字段(ADR-0003),用户有权直接修改确认 */
export interface IdentityProfile {
city: string;
age: string;
gender: string;
occupation: string;
education: string;
}
/**
* 身份登记节点(C1-01 专用):语音/文本自由介绍 → Jev 抽取结构化字段 → 确认页。
* 确认结果作为 profile 事件 append(用户可直接修改,不算行为信号流)。
*/
export interface IdentityNode extends BaseNode {
type: 'identity';
pointId: string;
prompt: string;
dimensions: DimensionTag[];
next: string;
}
export type StoryNode = DialogueNode | ChoiceNode | NarrationNode | CutsceneNode | IdentityNode | ChapterEndNode;
export interface Chapter {
id: string;
+53 -27
View File
@@ -3,27 +3,38 @@
* REST: POST https://api.typesafe.ai/v1/systemone
* Header: Authorization: Bearer <TYPESAFE_API_KEY>
* 请求: { model: "jev-latest", state: <文本或JSON>, questions: { <id>: Question } }
* 响应: answers[<id>] 含分数/概率分布/confidence(0-1)
*
* 以下 schema 按真实接口 curl 实测对齐(2026-10-03):
* - Question 的文本字段是 instructions;
* - choice 的 criteria 是 { 选项: 描述 } 的 map(选项数 ≤255);
* - score 的 criteria 是 2-10 级有序数组,索引从 0 开始(min=0, max=criteria.length-1);
* - score 答案是概率加权浮点值,可落在两级之间;legend 为 { 索引字符串: 等级描述 }。
*
* 实测响应样例:
* {"model":"jev-1.13.0","answers":{"risk":{"type":"score","score":1.46,"confidence":0.62,
* "legend":{"0":"非常保守",...},"probabilities":{"0":0.0,"1":0.55,...}},
* "hobby":{"type":"choice","choice":"outdoor","confidence":0.99,
* "probabilities":{"social":0.0,"outdoor":0.99,...}}},
* "usage":{"input_tokens":446,"output_tokens":57}}
*/
/** 是非概率问题 */
export interface NoulQuestion {
type: 'noul';
description: string;
instructions: string;
}
/** 选择题(≤255 选项),criteria 为 map */
/** 选择题(≤255 选项),criteria 为 { 选项: 描述 } 的 map */
export interface ChoiceQuestion {
type: 'choice';
description: string;
options: string[];
criteria?: Record<string, string>;
instructions: string;
criteria: Record<string, string>;
}
/** 打分题,criteria 为 2-10 级有序数组(从低到高) */
/** 打分题,criteria 为 2-10 级有序数组(索引 0 起,由低到高) */
export interface ScoreQuestion {
type: 'score';
description: string;
instructions: string;
criteria: string[];
}
@@ -37,8 +48,15 @@ export interface JevRequest {
export type JevAnswer =
| { type: 'noul'; probability: number; confidence: number }
| { type: 'choice'; selected: string; distribution: Record<string, number>; confidence: number }
| { type: 'score'; score: number; min: number; max: number; confidence: number };
| { type: 'choice'; choice: string; probabilities: Record<string, number>; confidence: number }
| {
type: 'score';
/** 概率加权浮点分数,范围 [0, criteria.length-1],可落在两级之间 */
score: number;
confidence: number;
legend: Record<string, string>;
probabilities: Record<string, number>;
};
export interface JevClient {
ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>>;
@@ -92,7 +110,7 @@ function prng(seed: number, n: number): number[] {
return out;
}
/** 无 key 开发用:确定性伪随机返分(同一输入永远同一输出,便于联调)。 */
/** 无 key 开发用:确定性伪随机返分(同一输入永远同一输出,输出形状与真实 API 对齐)。 */
export class MockJevClient implements JevClient {
async ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>> {
const stateKey = JSON.stringify(state) ?? '';
@@ -103,25 +121,33 @@ export class MockJevClient implements JevClient {
if (q.type === 'noul') {
answers[id] = { type: 'noul', probability: Math.round(prng(seed, 2)[1] * 100) / 100, confidence };
} else if (q.type === 'choice') {
const r = prng(seed, q.options.length + 1);
const weights = r.slice(0, q.options.length);
const total = weights.reduce((a, b) => a + b, 0) || 1;
const distribution: Record<string, number> = {};
let selected = q.options[0];
const options = Object.keys(q.criteria);
const r = prng(seed, options.length);
const total = r.reduce((a, b) => a + b, 0) || 1;
const probabilities: Record<string, number> = {};
let choice = options[0];
let best = -1;
q.options.forEach((opt, i) => {
distribution[opt] = Math.round((weights[i] / total) * 100) / 100;
if (weights[i] > best) {
best = weights[i];
selected = opt;
options.forEach((opt, i) => {
probabilities[opt] = Math.round((r[i] / total) * 100) / 100;
if (r[i] > best) {
best = r[i];
choice = opt;
}
});
answers[id] = { type: 'choice', selected, distribution, confidence };
answers[id] = { type: 'choice', choice, probabilities, confidence };
} else {
const min = 1;
const max = q.criteria.length;
const score = min + Math.floor(prng(seed, 1)[0] * max);
answers[id] = { type: 'score', score, min, max, confidence };
const r = prng(seed, q.criteria.length);
const total = r.reduce((a, b) => a + b, 0) || 1;
const probabilities: Record<string, number> = {};
const legend: Record<string, string> = {};
let score = 0;
q.criteria.forEach((level, i) => {
const p = r[i] / total;
probabilities[String(i)] = Math.round(p * 100) / 100;
legend[String(i)] = level;
score += i * p;
});
answers[id] = { type: 'score', score: Math.round(score * 100) / 100, confidence, legend, probabilities };
}
}
return answers;
@@ -133,7 +159,7 @@ let singleton: JevClient | null = null;
/** key 缺失时自动降级 Mock 并提示。 */
export function getJevClient(): JevClient {
if (singleton) return singleton;
const key = import.meta.env.VITE_TYPESAFE_API_KEY as string | undefined;
const key = import.meta.env.VITE_TYPESAFE_API_KEY;
if (key) {
singleton = new HttpJevClient(key);
} else {
+87 -18
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import type { StoryEngine } from '../engine/engine';
import type { ChoiceNode, NarrationNode } from '../engine/types';
import type { ChoiceNode, IdentityNode, IdentityProfile, NarrationNode } from '../engine/types';
import { VoiceInput } from './VoiceInput';
/** speaker/sprite → 立绘占位色(无图片素材,CSS 色块) */
const SPRITE_COLORS: Record<string, string> = {
@@ -17,7 +18,7 @@ interface Props {
onFinish: () => void;
}
/** 最简影游界面:对话框 / 选项按钮 / 讲述点按住说话占位 / 场景变体渐变 */
/** 最简影游界面:对话框 / 选项按钮 / 讲述点语音输入 / 身份登记 / 场景变体渐变 */
export function StoryPlayer({ engine, onFinish }: Props) {
// tick 只是触发重渲染;引擎是唯一事实源
const [tick, setTick] = useState(0);
@@ -65,6 +66,8 @@ export function StoryPlayer({ engine, onFinish }: Props) {
/>
)}
{node.type === 'identity' && <IdentityView node={node} engine={engine} onDone={rerender} />}
{node.type === 'cutscene' && (
<div className="cutscene" onClick={() => { engine.advance(); rerender(); }}>
<div className="cutscene-placeholder">▶ {node.video}</div>
@@ -126,30 +129,96 @@ function NarrationView({
onSubmit: (text: string) => void;
onSkip: () => void;
}) {
const [holding, setHolding] = useState(false);
const [busy, setBusy] = useState(false);
return (
<div className="narration-box">
<div className="mechanism-tag">{node.mechanism} · 语音讲述点</div>
<p className="narration-prompt">{node.prompt}</p>
<button
className={`hold-btn ${holding ? 'holding' : ''}`}
disabled={busy}
onPointerDown={() => setHolding(true)}
onPointerUp={async () => {
setHolding(false);
setBusy(true);
// ASR 未接:松开即结束录音占位,转写文本用占位字符串落事件
await onSubmit('(语音占位转写)');
setBusy(false);
}}
>
{holding ? '松开结束 · 讲述中…' : '按住说话'}
</button>
<VoiceInput disabled={busy} onSubmit={async (t) => { setBusy(true); await onSubmit(t); setBusy(false); }} />
<button className="secondary" disabled={busy} onClick={onSkip}>
保持沉默(也是合法选项)
</button>
<p className="hint">讲述后档案馆将就缺失的细节追问至多 {node.followUp.maxQuestions} 句(MVP 未接,占位)</p>
<p className="hint">讲述后档案馆将就缺失的细节追问至多 {node.followUp.maxQuestions} 句(追问未接,占位)</p>
</div>
);
}
const IDENTITY_FIELDS: { key: keyof IdentityProfile; label: string; placeholder: string }[] = [
{ key: 'city', label: '城市', placeholder: '如:杭州' },
{ key: 'age', label: '年龄段', placeholder: '如:28-32' },
{ key: 'gender', label: '性别', placeholder: '男 / 女 / 其他' },
{ key: 'occupation', label: '职业', placeholder: '如:互联网' },
{ key: 'education', label: '学历', placeholder: '如:硕士' },
];
/** C1-01 身份登记:语音/文本自由介绍 → Jev 抽取 → 确认页(可修改,确认后 append profile 事件) */
function IdentityView({ node, engine, onDone }: { node: IdentityNode; engine: StoryEngine; onDone: () => void }) {
const [phase, setPhase] = useState<'input' | 'confirm'>('input');
const [profile, setProfile] = useState<IdentityProfile | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleTranscript(text: string) {
setBusy(true);
setError(null);
try {
setProfile(await engine.extractIdentity(text));
setPhase('confirm');
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
if (phase === 'confirm' && profile) {
return (
<div className="identity-box">
<div className="mechanism-tag">身份登记 · 确认</div>
<p className="narration-prompt">档案馆听写的结果如下,不对的地方直接改——这几项只用于帮你找到合适的人。</p>
{IDENTITY_FIELDS.map((f) => (
<label key={f.key} className="identity-field">
<span>{f.label}</span>
<input
value={profile[f.key]}
placeholder={f.placeholder}
onChange={(e) => setProfile({ ...profile, [f.key]: e.target.value })}
/>
</label>
))}
<button
className="primary"
disabled={busy}
onClick={async () => {
setBusy(true);
await engine.confirmIdentity(profile);
setBusy(false);
onDone();
}}
>
确认登记
</button>
</div>
);
}
return (
<div className="identity-box">
<div className="mechanism-tag">穿越者身份登记</div>
<p className="narration-prompt">{node.prompt}</p>
<VoiceInput disabled={busy} onSubmit={handleTranscript} />
{busy && <p className="hint">馆长正在为你登记……</p>}
{error && <p className="hint">登记失败:{error}(可直接手填)</p>}
<button
className="secondary"
disabled={busy}
onClick={() => {
setProfile({ city: '', age: '', gender: '', occupation: '', education: '' });
setPhase('confirm');
}}
>
不想说?直接填写
</button>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { useMemo, useRef, useState } from 'react';
/**
* 按住说话(Web Speech API,优雅降级)。
* - 支持 SpeechRecognition 的环境:按住说话,实时显示转写,松开提交最终文本;
* - 不支持的环境(如 Tauri WKWebView):降级为文本输入框,不显示为错误。
* ADR-0004:只保留转写文本,不保存任何音频(Web Speech API 天然满足——直接给文本)。
*/
interface SpeechRecognitionResultLike {
isFinal: boolean;
0: { transcript: string };
}
interface SpeechRecognitionEventLike {
resultIndex: number;
results: ArrayLike<SpeechRecognitionResultLike>;
}
interface SpeechRecognitionLike {
lang: string;
continuous: boolean;
interimResults: boolean;
onresult: ((e: SpeechRecognitionEventLike) => void) | null;
onend: (() => void) | null;
onerror: (() => void) | null;
start(): void;
stop(): void;
}
type SRCtor = new () => SpeechRecognitionLike;
function getSRCtor(): SRCtor | null {
const w = window as unknown as { SpeechRecognition?: SRCtor; webkitSpeechRecognition?: SRCtor };
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
}
interface Props {
/** 松开(或文本提交)后回调最终转写文本 */
onSubmit: (text: string) => void | Promise<void>;
disabled?: boolean;
}
export function VoiceInput({ onSubmit, disabled }: Props) {
const SR = useMemo(() => getSRCtor(), []);
const [holding, setHolding] = useState(false);
const [live, setLive] = useState('');
const [text, setText] = useState('');
const [busy, setBusy] = useState(false);
const recRef = useRef<SpeechRecognitionLike | null>(null);
const finalRef = useRef('');
async function submit(t: string) {
const trimmed = t.trim();
if (!trimmed) return;
setBusy(true);
try {
await onSubmit(trimmed);
} finally {
setBusy(false);
setLive('');
setText('');
}
}
// ---- 降级路径:纯文本输入(不是错误,只是换个输入方式) ----
if (!SR) {
return (
<div className="voice-input">
<textarea
className="voice-textarea"
placeholder="此环境不支持语音输入,打字讲也一样——内容到位就行。"
rows={3}
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button className="primary" disabled={disabled || busy || !text.trim()} onClick={() => submit(text)}>
提交
</button>
</div>
);
}
// ---- 语音路径:按住说话 ----
function start() {
const rec = new SR!();
rec.lang = 'zh-CN';
rec.continuous = true;
rec.interimResults = true;
finalRef.current = '';
setLive('');
rec.onresult = (e) => {
let interim = '';
for (let i = e.resultIndex; i < e.results.length; i++) {
const r = e.results[i];
if (r.isFinal) finalRef.current += r[0].transcript;
else interim += r[0].transcript;
}
setLive(finalRef.current + interim);
};
rec.onerror = () => setHolding(false);
rec.onend = () => {
setHolding(false);
const t = finalRef.current;
if (t.trim()) void submit(t);
};
recRef.current = rec;
setHolding(true);
rec.start();
}
function stop() {
recRef.current?.stop();
recRef.current = null;
}
return (
<div className="voice-input">
<button
className={`hold-btn ${holding ? 'holding' : ''}`}
disabled={disabled || busy}
onPointerDown={start}
onPointerUp={stop}
onPointerLeave={() => holding && stop()}
>
{busy ? '档案馆听写中…' : holding ? '松开结束 · 讲述中…' : '按住说话'}
</button>
{live && <p className="voice-live">{live}</p>}
</div>
);
}
+16 -13
View File
@@ -18,15 +18,17 @@ interface Props {
onReplay: () => void;
}
/** 把 append-only 事件轨迹汇总为喂给 Jev 的 state 文本 */
/** 把 append-only 事件轨迹汇总为喂给 Jev 的 state 文本(profile 身份字段是硬过滤字段,不进画像) */
function buildState(events: StoryEvent[]): string {
const lines = events.map((e) => {
if (e.kind === 'choice') {
return `[${e.pointId}] 选择「${e.optionLabel}」(维度: ${e.dimensions.join('/') || '字段采集'})`;
}
const t = e.transcript === null ? '(沉默)' : `(讲述: ${e.transcript})`;
return `[${e.pointId}] 讲述${t} 情绪签名: ${e.emotionSignature ?? '未知'}`;
});
const lines = events
.filter((e) => e.kind !== 'profile')
.map((e) => {
if (e.kind === 'choice') {
return `[${e.pointId}] 选择「${e.optionLabel}」(维度: ${e.dimensions.join('/') || '字段采集'})`;
}
const t = e.transcript === null ? '(沉默)' : `(讲述: ${e.transcript})`;
return `[${e.pointId}] 讲述${t} 情绪签名: ${e.emotionSignature ?? '未知'}`;
});
return `用户在互动剧情《苍梧疑冢》第一幕中的行为轨迹(按时间排序,含初刷与重玩轨迹):\n${lines.join('\n')}`;
}
@@ -40,14 +42,14 @@ export function ReportPage({ store, jev, onReplay }: Props) {
(async () => {
const events = await store.list();
const state = buildState(events);
// 7 维 score 问题,criteria 为 1-10 级有序数组
const criteria = Array.from({ length: 10 }, (_, i) => String(i + 1));
// 7 维 score 问题,criteria 为 10 级有序数组(索引 0-9 对应 1-10 级)
const criteria = Array.from({ length: 10 }, (_, i) => `${i + 1} 级`);
const questions = Object.fromEntries(
PROFILE_DIMENSIONS.map((d) => [
d.id,
{
type: 'score' as const,
description: `根据用户行为轨迹,评估其「${d.label}」维度水平:${d.description}`,
instructions: `根据用户行为轨迹,评估其「${d.label}」维度水平:${d.description}`,
criteria,
},
]),
@@ -58,8 +60,9 @@ export function ReportPage({ store, jev, onReplay }: Props) {
setScores(
PROFILE_DIMENSIONS.map((d) => {
const a = answers[d.id];
// API score 是 [0, criteria.length-1] 的概率加权浮点值,+1 映射到 1-10 级展示
return a?.type === 'score'
? { id: d.id, label: d.label, score: a.score, max: a.max, confidence: a.confidence }
? { id: d.id, label: d.label, score: Math.round((a.score + 1) * 10) / 10, max: 10, confidence: a.confidence }
: { id: d.id, label: d.label, score: 0, max: 10, confidence: 0 };
}),
);
@@ -118,7 +121,7 @@ export function ReportPage({ store, jev, onReplay }: Props) {
<div className="dimension-fill" style={{ width: `${(s.score / s.max) * 100}%` }} />
</div>
<span className="dimension-value">
{s.score}/{s.max} · 置信度 {Math.round(s.confidence * 100)}%
{s.score.toFixed(1)}/{s.max} · 置信度 {Math.round(s.confidence * 100)}%
</span>
</div>
))}
+1 -1
View File
@@ -71,7 +71,7 @@ export function ShareCard({ badgeDimension, scores }: Props) {
ctx.fillStyle = '#d4a96a';
ctx.fillRect(260, y - 16, (240 * s.score) / s.max, 18);
ctx.fillStyle = '#d8ccb8';
ctx.fillText(`${s.score}`, 520, y);
ctx.fillText(s.score.toFixed(1), 520, y);
});
ctx.textAlign = 'center';