From bed4d734ca4ab489ed4c45d63eb6690d0d248b3d Mon Sep 17 00:00:00 2001 From: Jayway Liu <14022498+bluejayway@user.noreply.gitee.com> Date: Sat, 3 Oct 2026 12:43:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=B9=E9=BD=90=20Jev=20=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=20API=20schema=E3=80=81Web=20Speech=20=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E8=BE=93=E5=85=A5=EF=BC=88=E4=BC=98=E9=9B=85=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=EF=BC=89=E3=80=81C1-01=20=E8=AF=AD=E9=9F=B3=E5=8C=96?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E7=99=BB=E8=AE=B0=E4=B8=8E=E7=9C=9F=20key=20?= =?UTF-8?q?=E5=86=92=E7=83=9F=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 44 +++++++---- content/chapters/chapter1.json | 9 +-- package.json | 3 +- scripts/verify-live.ts | 103 ++++++++++++++++++++++++++ scripts/verify.ts | 22 ++++-- src/App.css | 54 ++++++++++++++ src/engine/engine.ts | 103 ++++++++++++++++++++++++-- src/engine/eventStore.ts | 8 +- src/engine/types.ts | 25 ++++++- src/services/jev.ts | 80 +++++++++++++------- src/ui/StoryPlayer.tsx | 105 +++++++++++++++++++++----- src/ui/VoiceInput.tsx | 131 +++++++++++++++++++++++++++++++++ src/ui/report/ReportPage.tsx | 29 ++++---- src/ui/report/ShareCard.tsx | 2 +- 14 files changed, 623 insertions(+), 95 deletions(-) create mode 100644 scripts/verify-live.ts create mode 100644 src/ui/VoiceInput.tsx diff --git a/README.md b/README.md index 34d138d..8d14971 100644 --- a/README.md +++ b/README.md @@ -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= - 有 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` 末尾注释。 diff --git a/content/chapters/chapter1.json b/content/chapters/chapter1.json index 331a5b4..6a42eed 100644 --- a/content/chapters/chapter1.json +++ b/content/chapters/chapter1.json @@ -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", diff --git a/package.json b/package.json index eb60b12..e00a991 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/verify-live.ts b/scripts/verify-live.ts new file mode 100644 index 0000000..7c471a4 --- /dev/null +++ b/scripts/verify-live.ts @@ -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 = 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 冒烟完成'); diff --git a/scripts/verify.ts b/scripts/verify.ts index 19e70f0..85901f5 100644 --- a/scripts/verify.ts +++ b/scripts/verify.ts @@ -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) { diff --git a/src/App.css b/src/App.css index 705a12d..0379158 100644 --- a/src/App.css +++ b/src/App.css @@ -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; +} diff --git a/src/engine/engine.ts b/src/engine/engine.ts index d66e29d..f1833f8 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -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 { + 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 = { + 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 { + 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),不修改旧事件。 * 默认重玩当前章,也可指定章节。 diff --git a/src/engine/eventStore.ts b/src/engine/eventStore.ts index f996c91..576344b 100644 --- a/src/engine/eventStore.ts +++ b/src/engine/eventStore.ts @@ -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'; diff --git a/src/engine/types.ts b/src/engine/types.ts index de0268c..b3ecb03 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -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; diff --git a/src/services/jev.ts b/src/services/jev.ts index e9cdf7e..e760105 100644 --- a/src/services/jev.ts +++ b/src/services/jev.ts @@ -3,27 +3,38 @@ * REST: POST https://api.typesafe.ai/v1/systemone * Header: Authorization: Bearer * 请求: { model: "jev-latest", state: <文本或JSON>, questions: { : Question } } - * 响应: answers[] 含分数/概率分布/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; + instructions: string; + criteria: Record; } -/** 打分题,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; confidence: number } - | { type: 'score'; score: number; min: number; max: number; confidence: number }; + | { type: 'choice'; choice: string; probabilities: Record; confidence: number } + | { + type: 'score'; + /** 概率加权浮点分数,范围 [0, criteria.length-1],可落在两级之间 */ + score: number; + confidence: number; + legend: Record; + probabilities: Record; + }; export interface JevClient { ask(state: unknown, questions: Record): Promise>; @@ -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): Promise> { 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 = {}; - 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 = {}; + 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 = {}; + const legend: Record = {}; + 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 { diff --git a/src/ui/StoryPlayer.tsx b/src/ui/StoryPlayer.tsx index 1b024bd..3a411fc 100644 --- a/src/ui/StoryPlayer.tsx +++ b/src/ui/StoryPlayer.tsx @@ -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 = { @@ -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' && } + {node.type === 'cutscene' && (
{ engine.advance(); rerender(); }}>
▶ {node.video}
@@ -126,30 +129,96 @@ function NarrationView({ onSubmit: (text: string) => void; onSkip: () => void; }) { - const [holding, setHolding] = useState(false); const [busy, setBusy] = useState(false); return (
{node.mechanism} · 语音讲述点

{node.prompt}

- + { setBusy(true); await onSubmit(t); setBusy(false); }} /> -

讲述后档案馆将就缺失的细节追问至多 {node.followUp.maxQuestions} 句(MVP 未接,占位)

+

讲述后档案馆将就缺失的细节追问至多 {node.followUp.maxQuestions} 句(追问未接,占位)

+
+ ); +} + +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(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 ( +
+
身份登记 · 确认
+

档案馆听写的结果如下,不对的地方直接改——这几项只用于帮你找到合适的人。

+ {IDENTITY_FIELDS.map((f) => ( + + ))} + +
+ ); + } + + return ( +
+
穿越者身份登记
+

{node.prompt}

+ + {busy &&

馆长正在为你登记……

} + {error &&

登记失败:{error}(可直接手填)

} +
); } diff --git a/src/ui/VoiceInput.tsx b/src/ui/VoiceInput.tsx new file mode 100644 index 0000000..8743f18 --- /dev/null +++ b/src/ui/VoiceInput.tsx @@ -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; +} + +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; + 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(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 ( +
+