Compare commits

...
4 Commits
37 changed files with 4339 additions and 132 deletions
+5
View File
@@ -1,3 +1,8 @@
# TypeSafe AI System One (Jev) API key。 # TypeSafe AI System One (Jev) API key。
# 复制本文件为 .env 并填入真实 key;缺失时前端自动降级为 MockJevClient(确定性伪随机返分)。 # 复制本文件为 .env 并填入真实 key;缺失时前端自动降级为 MockJevClient(确定性伪随机返分)。
VITE_TYPESAFE_API_KEY= VITE_TYPESAFE_API_KEY=
# 后端代理地址(见 server/ 子包)。配置后前端走代理、不再需要上面的 key;
# 未配置时回退为直连模式(浏览器可能遇到 CORS)。
# VITE_JEV_PROXY_URL=http://localhost:8787
VITE_JEV_PROXY_URL=
+48 -14
View File
@@ -10,11 +10,12 @@
```bash ```bash
pnpm install pnpm install
pnpm dev # 浏览器开发(无 API key 时自动降级 Mock,见下) pnpm dev # 浏览器开发(无 API key 时自动降级 Mock,见下)
pnpm build # 类型检查 + 产物构建 pnpm build # 类型检查 + 产物构建
pnpm verify # 无头校验:选择点地图对照母本 + 引擎三章流程 + 重玩语义 pnpm verify # 无头校验:选择点地图对照母本 + 引擎三章流程 + 重玩语义(Mock)
pnpm lint # oxlint pnpm verify:live # 真 key 冒烟:C1-01 抽取 + C1-02 情绪签名 + 7 维 score(读 .env)
pnpm tauri dev # Tauri 桌面壳(Rust 工具链需先 source ~/.cargo/env) pnpm lint # oxlint
pnpm tauri dev # Tauri 桌面壳(Rust 工具链需先 source ~/.cargo/env)
``` ```
## Jev(TypeSafe AI System One)配置 ## 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:前端直连 `POST https://api.typesafe.ai/v1/systemone`(`HttpJevClient`)。
- 无 key:自动降级 `MockJevClient`(确定性伪随机返分,console 有提示),开发联调不受影响。 - 无 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 节) content/chapters/ Act 1 三章剧情数据(JSON,选择点 ID/维度标签对母本第 4 节)
src/engine/ 数据驱动剧情引擎(纯 TS,与 UI 解耦) src/engine/ 数据驱动剧情引擎(纯 TS,与 UI 解耦)
types.ts 章节 schema:dialogue / choice / narration / cutscene / chapterEnd types.ts 章节 schema:dialogue / choice / narration / cutscene / identity / chapterEnd
engine.ts StoryEngine 状态机:节点推进、分支、情绪签名→场景变体、重玩 engine.ts StoryEngine 状态机:节点推进、分支、情绪签名→场景变体、
身份抽取(extractIdentity)与确认(confirmIdentity)、重玩
eventStore.ts EventStore 接口(append-only,只有 append/list)+ localStorage 实现 eventStore.ts EventStore 接口(append-only,只有 append/list)+ localStorage 实现
src/services/jev.ts JevClient 接口 + HttpJevClient / MockJevClient src/services/jev.ts JevClient 接口 + HttpJevClient / MockJevClient
src/ui/ 最简影游界面(对话框/选项/按住说话占位/场景变体渐变) src/ui/ 最简影游界面(对话框/选项/VoiceInput 语音输入/身份登记/场景变体渐变)
src/ui/report/ 画像报告(Jev 7 维 score)、勋章映射、canvas 分享卡 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/桌面共用) src-tauri/ Tauri 2 壳(iOS/Android/桌面共用)
``` ```
@@ -51,14 +58,23 @@ src-tauri/ Tauri 2 壳(iOS/Android/桌面共用)
- **三通道边界(ADR-0003)**:主线全部预写在 JSON 里;Jev 在讲述点只做 - **三通道边界(ADR-0003)**:主线全部预写在 JSON 里;Jev 在讲述点只做
"情绪签名 → 预写场景变体" 的选择题(通道 1);C3-02 问心幻境通过 "情绪签名 → 预写场景变体" 的选择题(通道 1);C3-02 问心幻境通过
`variantFrom: "C1-02"` 复用第一章的记忆解析。 `variantFrom: "C1-02"` 复用第一章的记忆解析。
- **画像报告**:Act 1 结束后把全部事件轨迹汇总为 state,一次 Jev 调用并行问 - **语音输入**:`VoiceInput` 组件优先用 Web Speech API(`zh-CN`,continuous +
7 个维度(依恋/冲突风格/价值观/情绪稳定性/风险偏好/文化底蕴/择偶偏好)的 interimResults,实时转写),不支持的环境(如 Tauri WKWebView)优雅降级为文本框。
score 问题(criteria 1-10 级),勋章取置信度最高的维度。 只保留转写文本、不保存任何音频,天然符合 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` 可能有跨域限制,正式环境需加后端代理转发。 - **CORS**:浏览器直连 `api.typesafe.ai` 可能有跨域限制,正式环境需加后端代理转发。
- **ASR 未接**:语音讲述点只有"按住说话"占位按钮,松开即以占位转写文本落事件。 - **Web Speech API 覆盖**:Chrome/Edge 可用,Tauri WKWebView 与 Firefox 不支持
(自动降级文本框);原生端的正式 ASR 管线未接。
- **Tauri SQLite 未实现**:`EventStore` 目前只有 localStorage 实现;原生端应用 - **Tauri SQLite 未实现**:`EventStore` 目前只有 localStorage 实现;原生端应用
tauri-plugin-sql 实现同一接口(表只建 INSERT/SELECT,不建 UPDATE/DELETE 路径), tauri-plugin-sql 实现同一接口(表只建 INSERT/SELECT,不建 UPDATE/DELETE 路径),
预留位置见 `src/engine/eventStore.ts` 末尾注释。 预留位置见 `src/engine/eventStore.ts` 末尾注释。
@@ -68,3 +84,21 @@ src-tauri/ Tauri 2 壳(iOS/Android/桌面共用)
NPC 口吻追问,至多 1-2 句),MVP 未实现。 NPC 口吻追问,至多 1-2 句),MVP 未实现。
- **美术全部占位**:CSS 色块 + 渐变,无图片/视频素材;cutscene 节点是占位渲染。 - **美术全部占位**:CSS 色块 + 渐变,无图片/视频素材;cutscene 节点是占位渲染。
- **自适应支线路由未实现**:story-framework 第 6 节的低置信度维度插支线属于后续章节工作。 - **自适应支线路由未实现**:story-framework 第 6 节的低置信度维度插支线属于后续章节工作。
## 后端代理
`server/` 是独立子包(Node 22 + Hono,自己的 lockfile,与前端互不干扰):
```bash
pnpm --dir server install
cp server/.env.example server/.env # 填 TYPESAFE_API_KEY(key 只在服务端)
pnpm --dir server dev # 默认 http://localhost:8787
pnpm --dir server smoke # 冒烟:代理调真实 Jev + /api/asr 自测
```
- `POST /api/jev` 透传 systemone(状态码原样返回),解决浏览器直连的 CORS 问题;
前端在根 `.env` 设 `VITE_JEV_PROXY_URL=http://localhost:8787` 即切代理模式,
此时前端不需要 `VITE_TYPESAFE_API_KEY`,未配置时回退直连(见 `src/services/jev.ts`)。
- `POST /api/asr` 为移动端 ASR 预留:provider 可插拔(当前 Mock 占位),音频遵守
ADR-0004"特征提取后即焚"不落盘,返回 `{ transcript, prosody }`。
- 详见 `server/README.md`。
+12 -7
View File
@@ -22,14 +22,11 @@
}, },
{ {
"id": "c1-01", "id": "c1-01",
"type": "choice", "type": "identity",
"pointId": "C1-01", "pointId": "C1-01",
"prompt": "穿越者身份登记(将作为匹配的硬性过滤字段,见 ADR-0003)", "prompt": "登记之前,先认识一下你。用一两句话介绍自己:称呼、年龄、城市、职业、学历——想到哪说到哪,没有格式。",
"dimensions": [], "dimensions": [],
"options": [ "next": "c1-d3"
{ "id": "profile-a", "label": "杭州 · 28 · 女 · 互联网产品经理 · 硕士", "next": "c1-d3" },
{ "id": "profile-b", "label": "杭州 · 31 · 男 · 高校青年教师 · 博士", "next": "c1-d3" }
]
}, },
{ {
"id": "c1-d3", "id": "c1-d3",
@@ -48,7 +45,15 @@
"dimensions": ["attachment", "self_disclosure"], "dimensions": ["attachment", "self_disclosure"],
"followUp": { "followUp": {
"maxQuestions": 1, "maxQuestions": 1,
"strategy": "Jev 检测五感/人物/时空/情绪缺失维度,馆长口吻追问一句(MVP 未接,占位)" "strategy": "Jev 六维缺失检测(五感/人物/时空/情绪),按幻境渲染价值取最高缺口,馆长口吻模板追问一句;记不清了合法"
},
"followUps": {
"visual": "闭上眼再想一想——那个画面里,最显眼的是什么颜色,或者什么物件?",
"person": "这段离别里,你最想再见到的那个人,是谁?",
"time_place": "那是哪一年的事?在什么地方?",
"emotion": "现在回想起来,心里最先浮上来的,是什么感觉?",
"auditory": "那段记忆里有没有声音?一句话,或是一种声响。",
"olfactory_gustatory": "那天有没有什么气味或味道,你到现在还记得?"
}, },
"variants": [ "variants": [
{ "signature": "温暖×永失", "variantId": "sealed_love_tomb", "label": "「爱被封存」墓室", "tint": "#d4a96a" }, { "signature": "温暖×永失", "variantId": "sealed_love_tomb", "label": "「爱被封存」墓室", "tint": "#d4a96a" },
+18 -2
View File
@@ -50,7 +50,15 @@
"dimensions": ["values", "emotional_stability"], "dimensions": ["values", "emotional_stability"],
"followUp": { "followUp": {
"maxQuestions": 1, "maxQuestions": 1,
"strategy": "Jev 检测五感/人物/时空/情绪缺失维度,老人口吻追问一句(MVP 未接,占位)" "strategy": "Jev 六维缺失检测(五感/人物/时空/情绪),按幻境渲染价值取最高缺口,老人口吻模板追问一句;记不清了合法"
},
"followUps": {
"visual": "老朽眼拙,你再说说——那场景里最扎眼的是个什么物件?",
"person": "这事儿里头,牵着的是哪个人?说个称呼也成。",
"time_place": "是哪一年的事?在哪个地界?",
"emotion": "讲完了,心里是个什么滋味?别怕说出来。",
"auditory": "那会儿耳边有什么声响没有?一句话也算。",
"olfactory_gustatory": "人呐,记性最靠的是鼻子和舌头——那阵子有什么味道?"
}, },
"variants": [ "variants": [
{ "signature": "自责", "variantId": "intel_full", "label": "老人展开整张手绘城图(情报详细度·上)", "tint": "#6a8a6a" }, { "signature": "自责", "variantId": "intel_full", "label": "老人展开整张手绘城图(情报详细度·上)", "tint": "#6a8a6a" },
@@ -116,7 +124,15 @@
"dimensions": ["attachment", "reciprocity"], "dimensions": ["attachment", "reciprocity"],
"followUp": { "followUp": {
"maxQuestions": 1, "maxQuestions": 1,
"strategy": "Jev 检测五感/人物/时空/情绪缺失维度,温简口吻追问一句(MVP 未接,占位)" "strategy": "Jev 六维缺失检测(五感/人物/时空/情绪),按幻境渲染价值取最高缺口,温简口吻模板追问一句;记不清了合法"
},
"followUps": {
"visual": "那年的画面里,你印象最深的一幕是什么样子的?",
"person": "那段日子里,有没有谁一直陪着你?",
"time_place": "那是哪一年,在什么地方?",
"emotion": "那时候最难熬的,是哪种感觉?",
"auditory": "有没有一个声音,到现在还忘不掉?",
"olfactory_gustatory": "那段日子有什么气味或味道,留在记忆里了吗?"
}, },
"variants": [ "variants": [
{ "signature": "深暴露", "variantId": "wenjian_close_early", "label": "温简当夜就悄悄告诉你一个档案馆的秘密", "tint": "#d4844b" }, { "signature": "深暴露", "variantId": "wenjian_close_early", "label": "温简当夜就悄悄告诉你一个档案馆的秘密", "tint": "#d4844b" },
+9 -1
View File
@@ -41,7 +41,15 @@
"dimensions": ["emotional_stability", "attachment"], "dimensions": ["emotional_stability", "attachment"],
"followUp": { "followUp": {
"maxQuestions": 1, "maxQuestions": 1,
"strategy": "Jev 检测五感/人物/时空/情绪缺失维度,幻境中故人口吻追问一句(MVP 未接,占位)" "strategy": "Jev 六维缺失检测(五感/人物/时空/情绪),按幻境渲染价值取最高缺口,幻境中故人口吻模板追问一句;记不清了合法"
},
"followUps": {
"visual": "雾里的影像还模糊——再想一想,当时眼前最清楚的是什么?",
"person": "幻境里还站着一个人。那个人是谁?",
"time_place": "幻境在等一个坐标:那是何时、何地?",
"emotion": "把那场告别说完——你最后想说的那句话,带着什么心情?",
"auditory": "幻境里太安静了。当时有什么声音吗?",
"olfactory_gustatory": "幻境还原不了气味——除非你现在想起来。"
}, },
"variantFrom": "C1-02", "variantFrom": "C1-02",
"variants": [ "variants": [
+1624
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -9,10 +9,12 @@
"lint": "oxlint", "lint": "oxlint",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri",
"verify": "tsx scripts/verify.ts" "verify": "tsx scripts/verify.ts",
"verify:live": "tsx scripts/verify-live.ts"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.12.1", "@tauri-apps/api": "^2.12.1",
"@tauri-apps/plugin-sql": "^2.5.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8"
}, },
+10
View File
@@ -11,6 +11,9 @@ importers:
'@tauri-apps/api': '@tauri-apps/api':
specifier: ^2.12.1 specifier: ^2.12.1
version: 2.12.1 version: 2.12.1
'@tauri-apps/plugin-sql':
specifier: ^2.5.0
version: 2.5.0
react: react:
specifier: ^19.2.8 specifier: ^19.2.8
version: 19.3.0 version: 19.3.0
@@ -507,6 +510,9 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
hasBin: true hasBin: true
'@tauri-apps/plugin-sql@2.5.0':
resolution: {integrity: sha512-wHzfXAgMbn3G1xWQWM1LlWLJkBEfO2CkAWvu+VYigRBXBocPXZqvGEkYWSM1RrZSSc4iJYAWXkFZrgTyu8zBkw==}
'@types/node@24.19.1': '@types/node@24.19.1':
resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==}
@@ -979,6 +985,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.12.1 '@tauri-apps/cli-win32-ia32-msvc': 2.12.1
'@tauri-apps/cli-win32-x64-msvc': 2.12.1 '@tauri-apps/cli-win32-x64-msvc': 2.12.1
'@tauri-apps/plugin-sql@2.5.0':
dependencies:
'@tauri-apps/api': 2.12.1
'@types/node@24.19.1': '@types/node@24.19.1':
dependencies: dependencies:
undici-types: 7.24.6 undici-types: 7.24.6
+110
View File
@@ -0,0 +1,110 @@
/**
* 真 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 = '外婆走的那天我在外地考试,没赶上最后一面。后来我每年清明都回去,院子里的桂花树还在。';
let result = await engine.submitNarration(farewell);
console.log('\n[2] C1-02 情绪签名 → 场景变体');
console.log(' 输入:', farewell);
if (!result && engine.state.pendingFollowUp) {
// 追问补全(六维缺失检测):真 key 下可能挂起追问,回答一句完成流程
const p = engine.state.pendingFollowUp;
console.log(` 追问(${p.dimension}): ${p.prompt}`);
result = await engine.answerFollowUp('院子里是桂花的甜香,风一吹就往屋里钻。');
}
if (!result) throw new Error('讲述流程未完成');
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 冒烟完成');
+141 -11
View File
@@ -2,14 +2,21 @@
* 内容完整性与引擎流程验证(不走浏览器): * 内容完整性与引擎流程验证(不走浏览器):
* 1. 章节 JSON 的选择点 ID、维度标签与 docs/story-framework.md 第 4 节一一对应; * 1. 章节 JSON 的选择点 ID、维度标签与 docs/story-framework.md 第 4 节一一对应;
* 2. 节点图完整(startNodeId 存在、所有 next 跳转有效、全部节点可达); * 2. 节点图完整(startNodeId 存在、所有 next 跳转有效、全部节点可达);
* 3. 用 MockJevClient + 内存 EventStore 跑通三章 → 16 个选择点全部落事件 → Jev 7 维打分。 * 3. 用 MockJevClient + 内存 EventStore 跑通三章 → 16 个选择点全部落事件 → Jev 7 维打分;
* 4. 追问补全流程:缺失检测 → 追问事件 / 跳过事件("记不清了"合法)→ 回答合并进讲述文本;
* 5. EventStore 各实现(含 SqliteEventStore)的 append-only 性质(接口层面断言无 update/delete;
* SQLite 实连只能在 Tauri 运行时内进行,Node 下不实例化)。
* 运行:pnpm verify * 运行:pnpm verify
*/ */
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { StoryEngine } from '../src/engine/engine'; import { StoryEngine } from '../src/engine/engine';
import { LocalStorageEventStore } from '../src/engine/eventStore';
import type { EventStore, StoryEvent } from '../src/engine/eventStore'; import type { EventStore, StoryEvent } from '../src/engine/eventStore';
import { SqliteEventStore } from '../src/engine/sqliteEventStore';
import type { Chapter, ChoiceNode, NarrationNode } from '../src/engine/types'; import type { Chapter, ChoiceNode, NarrationNode } from '../src/engine/types';
import { FOLLOW_UP_DIMENSIONS } from '../src/engine/types';
import { MockJevClient } from '../src/services/jev'; import { MockJevClient } from '../src/services/jev';
import type { JevAnswer, JevQuestion } from '../src/services/jev';
// story-framework.md 第 4 节的权威选择点地图(ID → 期望维度标签) // story-framework.md 第 4 节的权威选择点地图(ID → 期望维度标签)
const EXPECTED: Record<string, string[]> = { const EXPECTED: Record<string, string[]> = {
@@ -84,7 +91,7 @@ for (const ch of chapters) {
} }
check(reachable.size === ch.nodes.length, `${ch.id}: 全部 ${ch.nodes.length} 个节点可达`); check(reachable.size === ch.nodes.length, `${ch.id}: 全部 ${ch.nodes.length} 个节点可达`);
for (const n of ch.nodes) { 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 全局唯一`); check(!seenPoints.has(n.pointId), `${n.pointId}: 选择点 ID 全局唯一`);
seenPoints.set(n.pointId, n.dimensions); seenPoints.set(n.pointId, n.dimensions);
} }
@@ -113,6 +120,12 @@ for (const n of narrationNodes) {
); );
} }
check(narrationNodes.find((n) => n.pointId === 'C3-02')?.variantFrom === 'C1-02', 'C3-02 变体由 C1-02 驱动(variantFrom)'); check(narrationNodes.find((n) => n.pointId === 'C3-02')?.variantFrom === 'C1-02', 'C3-02 变体由 C1-02 驱动(variantFrom)');
for (const n of narrationNodes) {
check(
FOLLOW_UP_DIMENSIONS.every((d) => typeof n.followUps?.[d] === 'string' && n.followUps[d]!.length > 0),
`${n.pointId}: 六维 followUps 追问模板齐全`,
);
}
// --- 4. Mock 模式端到端跑通三章 --- // --- 4. Mock 模式端到端跑通三章 ---
const store = new MemoryStore(); const store = new MemoryStore();
@@ -123,30 +136,39 @@ while (!engine.state.finished && guard++ < 200) {
const node = engine.currentNode(); const node = engine.currentNode();
if (node.type === 'choice') { if (node.type === 'choice') {
await engine.choose((node as ChoiceNode).options[0].id); 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') { } else if (node.type === 'narration') {
// C2-05 走"沉默"路径验证合法选项,其余讲述 // C2-05 走"沉默"路径验证合法选项,其余讲述;Mock 检测到缺失维度则回答追问
if ((node as NarrationNode).pointId === 'C2-05') await engine.skipNarration(); if ((node as NarrationNode).pointId === 'C2-05') await engine.skipNarration();
else await engine.submitNarration('(验证用占位讲述)'); else {
await engine.submitNarration('(验证用占位讲述)');
if (engine.state.pendingFollowUp) await engine.answerFollowUp('(验证用追问回答)');
}
} else { } else {
engine.advance(); engine.advance();
} }
} }
check(engine.state.finished, '三章流程跑通(Mock 模式)'); check(engine.state.finished, '三章流程跑通(Mock 模式)');
const eventPoints = store.events.map((e) => e.pointId).sort(); const eventPoints = store.events
.filter((e) => e.kind === 'choice' || e.kind === 'narration' || e.kind === 'profile')
.map((e) => e.pointId)
.sort();
check( check(
JSON.stringify(eventPoints) === JSON.stringify(expectedIds), JSON.stringify(eventPoints) === JSON.stringify(expectedIds),
`事件轨迹覆盖全部 ${expectedIds.length} 个选择点`, `事件轨迹覆盖全部 ${expectedIds.length} 个选择点`,
); );
const c305 = store.events.find((e) => e.pointId === 'C3-02'); const c305 = store.events.find((e) => e.pointId === 'C3-02' && e.kind === 'narration');
const c102 = store.events.find((e) => e.pointId === 'C1-02'); const c102 = store.events.find((e) => e.pointId === 'C1-02' && e.kind === 'narration');
check(!!c305?.variantId && c305.variantId === c102?.variantId, 'C3-02 幻境变体复用 C1-02 情绪解析结果'); check(!!c305?.variantId && c305.variantId === c102?.variantId, 'C3-02 幻境变体复用 C1-02 情绪解析结果');
// --- 5. 画像报告:7 维 score 问题 --- // --- 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 dims = ['attachment', 'conflict_style', 'values', 'emotional_stability', 'risk', 'culture', 'mate_preference'];
const answers = await jev.ask( const answers = await jev.ask(
store.events, 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( check(
dims.every((d) => answers[d]?.type === 'score' && answers[d].confidence >= 0 && answers[d].confidence <= 1), dims.every((d) => answers[d]?.type === 'score' && answers[d].confidence >= 0 && answers[d].confidence <= 1),
@@ -158,12 +180,120 @@ const before = store.events.length;
const oldSession = engine.state.sessionId; const oldSession = engine.state.sessionId;
engine.replay('chapter1'); engine.replay('chapter1');
check(engine.state.sessionId !== oldSession, '重玩产生新 sessionId'); 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('(验证用占位讲述)');
if (engine.state.pendingFollowUp) await engine.answerFollowUp('(验证用追问回答)');
} else {
engine.advance();
}
}
const firstChoice = engine.currentNode() as ChoiceNode; const firstChoice = engine.currentNode() as ChoiceNode;
await engine.choose(firstChoice.options[0].id); await engine.choose(firstChoice.options[0].id);
check(store.events.length === before + 1, '重玩追加新事件,旧事件不变'); const added = store.events.slice(before);
check(
added.filter((e) => e.kind === 'profile').length === 1 &&
added.filter((e) => e.kind === 'narration').length === 1 &&
added.filter((e) => e.kind === 'choice').length === 1,
`重玩追加新事件(profile + narration + choice,含追问事件共 ${added.length} 条),旧事件不变`,
);
check((await store.list(oldSession)).length === before, '旧轨迹事件完整保留'); check((await store.list(oldSession)).length === before, '旧轨迹事件完整保留');
// --- 7. 追问补全流程(可控 noul 概率的 stub 驱动三条路径) ---
/** noul 概率可控的 Jev stub:missing 里的维度返回 0.1(缺失),其余 0.9;非 noul 题走 Mock */
class FixedNoulJev extends MockJevClient {
constructor(private missing: string[]) {
super();
}
override async ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>> {
const out = await super.ask(state, questions);
for (const [id, q] of Object.entries(questions)) {
if (q.type === 'noul') {
out[id] = { type: 'noul', probability: this.missing.includes(id) ? 0.1 : 0.9, confidence: 0.9 };
}
}
return out;
}
}
/** 推进到指定节点(途中选择点取第一项、身份点直接确认、讲述点沉默略过) */
async function walkTo(eng: StoryEngine, nodeId: string) {
let g = 0;
while (eng.currentNode().id !== nodeId && g++ < 60) {
const n = eng.currentNode();
if (n.type === 'choice') await eng.choose((n as ChoiceNode).options[0].id);
else if (n.type === 'identity') await eng.confirmIdentity(await eng.extractIdentity('占位自我介绍'));
else if (n.type === 'narration') await eng.skipNarration();
else eng.advance();
}
}
// 7a. 缺失检测 → 追问挂起 → 回答合并
{
const s = new MemoryStore();
const eng = new StoryEngine(chapters, s, new FixedNoulJev(['visual']));
await walkTo(eng, 'c1-02');
const c102 = chapters[0].nodes.find((n) => n.id === 'c1-02') as NarrationNode;
const r = await eng.submitNarration('去年夏天我离开了家乡');
const pending = eng.state.pendingFollowUp;
check(r === null && pending?.dimension === 'visual', '缺失检测:视觉缺失(最高优先级)→ 追问挂起,不立即落 narration 事件');
check(pending?.prompt === c102.followUps.visual, '追问话术取自节点 JSON 的 followUps 模板');
check(eng.state.nodeId === 'c1-02', '追问挂起期间节点不推进');
let threw = false;
try {
await eng.submitNarration('重复提交');
} catch {
threw = true;
}
check(threw, '追问挂起期间重复提交被拒(预算纪律:一个讲述点一次追问)');
const res = await eng.answerFollowUp('站台是灰蓝色的');
const narrationEv = s.events.find((e) => e.kind === 'narration');
const followupEv = s.events.find((e) => e.kind === 'followup');
check(!!followupEv && followupEv.followUpDimension === 'visual', '追问回答落 followup 事件(含缺失维度)');
check(
!!narrationEv?.transcript?.includes('去年夏天我离开了家乡') && narrationEv.transcript.includes('站台是灰蓝色的'),
'追问回答合并进讲述文本(state 合并后进情绪签名选择)',
);
check(!!res.variant && eng.state.pendingFollowUp === null && eng.state.nodeId === 'c1-d4', '追问收尾:变体解析完成并推进,无第二次追问');
}
// 7b. "记不清了"跳过 → followup_skipped 事件
{
const s = new MemoryStore();
const eng = new StoryEngine(chapters, s, new FixedNoulJev(['person']));
await walkTo(eng, 'c2-02');
await eng.submitNarration('有件事我一直后悔');
check(eng.state.pendingFollowUp?.dimension === 'person', '缺失检测:人物缺失 → 追问挂起');
await eng.skipFollowUp();
const skipped = s.events.find((e) => e.kind === 'followup_skipped');
check(!!skipped && skipped.followUpDimension === 'person' && skipped.transcript === null, '跳过落 followup_skipped 事件(对什么沉默也是数据)');
const narrationEv = s.events.find((e) => e.kind === 'narration' && e.pointId === 'C2-02');
check(narrationEv?.transcript === '有件事我一直后悔', '跳过后讲述原文不变,流程继续');
}
// 7c. 六维俱全 → 不追问,直接走完
{
const s = new MemoryStore();
const eng = new StoryEngine(chapters, s, new FixedNoulJev([]));
await walkTo(eng, 'c2-02');
const r = await eng.submitNarration('一段细节齐全的讲述');
check(r !== null && eng.state.pendingFollowUp === null, '无缺失维度时不触发追问');
check(!s.events.some((e) => e.kind === 'followup' || e.kind === 'followup_skipped'), '无追问时不产生追问事件');
}
// --- 8. append-only:EventStore 实现不存在 update/delete 路径 ---
// (SQLite 实连只能在 Tauri 运行时内进行,这里只做接口层面断言)
for (const cls of [LocalStorageEventStore, SqliteEventStore, MemoryStore]) {
const props = Object.getOwnPropertyNames(cls.prototype).filter((p) => p !== 'constructor');
check(
!props.some((p) => ['update', 'delete', 'remove', 'set'].includes(p)),
`${cls.name}: 只有 ${props.sort().join('/')}(append-only,无 update/delete)`,
);
}
if (failures > 0) { if (failures > 0) {
console.error(`\n${failures} 项校验失败`); console.error(`\n${failures} 项校验失败`);
process.exit(1); process.exit(1);
+6
View File
@@ -0,0 +1,6 @@
# TypeSafe AI System One (Jev) API key,服务端专用。
# 复制本文件为 .env 并填入真实 key;key 只存在于服务端,绝不下发到前端。
TYPESAFE_API_KEY=
# 代理服务监听端口(默认 8787)
# PORT=8787
+2
View File
@@ -0,0 +1,2 @@
node_modules
.env
+56
View File
@@ -0,0 +1,56 @@
# 后端代理层(server/)
为浏览器/Tauri 前端代理 TypeSafe AI(Jev)与 ASR 请求。key 只存在于服务端,
前端不需要 `VITE_TYPESAFE_API_KEY`,也解决了浏览器直连 `api.typesafe.ai` 的 CORS 问题。
独立子包:自己的 `package.json` 与 lockfile,与根仓库前端互不干扰。Node ≥ 22。
## 怎么跑
```bash
pnpm --dir server install
cp server/.env.example server/.env # 填入 TYPESAFE_API_KEY
pnpm --dir server dev # tsx watch,默认 http://localhost:8787
pnpm --dir server smoke # 冒烟:代理调一次真实 Jev + /api/asr 自测
```
前端侧在根 `.env` 里加一行即切换到代理模式(此时前端无需 key):
```
VITE_JEV_PROXY_URL=http://localhost:8787
```
## 环境变量
| 变量 | 说明 |
| --- | --- |
| `TYPESAFE_API_KEY` | Jev API key,**只在服务端**,绝不下发前端、不打印日志 |
| `PORT` | 监听端口,默认 `8787` |
## 路由
- `POST /api/jev`:请求体原样透传到 `https://api.typesafe.ai/v1/systemone`,
上游状态码(401/422/429/529 等)与响应体原样返回。
- `POST /api/asr`:multipart/form-data,文件字段名 `audio`,返回
`{ transcript, prosody }`。`prosody` 暂为空数组占位,后续接声学特征提取
(语速/停顿/音高/能量等,见 ADR-0004)。
- `GET /healthz`:存活检查。
CORS 仅放行 `http://localhost:1420`(Tauri dev)与 `http://localhost:5173`(Vite dev)。
## 接入真实 ASR provider
当前 `/api/asr` 走 `MockAsrProvider`(占位转写)。换真实 provider 只需两步:
1. 在 `src/asr/provider.ts` 新增实现 `AsrProvider` 接口的类,输入是
`input.data`(内存 buffer,**不要写盘**——ADR-0004"特征提取后即焚"):
- **阿里云 NLS**:一句话识别 RESTful API,`input.data` 直接作请求体,
appkey/token 从 `process.env` 读(加到 `server/.env`);
- **讯飞语音听写**:WebSocket/REST 端点,同样以 `input.data` 为输入;
- **Whisper 兼容端点**(OpenAI / Groq / 自托管 faster-whisper):
`fetch(url, { method: 'POST', body: FormData(file=Blob(input.data)) })`,
取返回 JSON 的 `text` 字段。
2. 在 `src/routes/asr.ts` 把 `new MockAsrProvider()` 换成新 provider 实例。
韵律特征提取(ADR-0004 的 (b) 类产物)接入点同上:在 provider 内对
`input.data` 提取统计向量填入 `prosody`,音频 buffer 在请求结束后即弃。
+24
View File
@@ -0,0 +1,24 @@
{
"name": "jev-dating-match-server",
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"smoke": "tsx scripts/smoke.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hono/node-server": "^1.19.0",
"hono": "^4.10.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
"tsx": "^4.20.0",
"typescript": "^5.7.0"
}
}
+352
View File
@@ -0,0 +1,352 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@hono/node-server':
specifier: ^1.19.0
version: 1.19.17(hono@4.13.12)
hono:
specifier: ^4.10.0
version: 4.13.12
devDependencies:
'@types/node':
specifier: ^22.10.0
version: 22.20.5
tsx:
specifier: ^4.20.0
version: 4.23.15
typescript:
specifier: ^5.7.0
version: 5.9.3
packages:
'@esbuild/aix-ppc64@0.28.2':
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.28.2':
resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.28.2':
resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.28.2':
resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.28.2':
resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.28.2':
resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.28.2':
resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.28.2':
resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.28.2':
resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.28.2':
resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.28.2':
resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.28.2':
resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.28.2':
resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.28.2':
resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.28.2':
resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.28.2':
resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.28.2':
resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.28.2':
resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.28.2':
resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.28.2':
resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.28.2':
resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.28.2':
resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.28.2':
resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.28.2':
resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.28.2':
resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.28.2':
resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@hono/node-server@1.19.17':
resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
'@types/node@22.20.5':
resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==}
esbuild@0.28.2:
resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
hono@4.13.12:
resolution: {integrity: sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==}
engines: {node: '>=16.9.0'}
tsx@4.23.15:
resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==}
engines: {node: '>=18.0.0'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
snapshots:
'@esbuild/aix-ppc64@0.28.2':
optional: true
'@esbuild/android-arm64@0.28.2':
optional: true
'@esbuild/android-arm@0.28.2':
optional: true
'@esbuild/android-x64@0.28.2':
optional: true
'@esbuild/darwin-arm64@0.28.2':
optional: true
'@esbuild/darwin-x64@0.28.2':
optional: true
'@esbuild/freebsd-arm64@0.28.2':
optional: true
'@esbuild/freebsd-x64@0.28.2':
optional: true
'@esbuild/linux-arm64@0.28.2':
optional: true
'@esbuild/linux-arm@0.28.2':
optional: true
'@esbuild/linux-ia32@0.28.2':
optional: true
'@esbuild/linux-loong64@0.28.2':
optional: true
'@esbuild/linux-mips64el@0.28.2':
optional: true
'@esbuild/linux-ppc64@0.28.2':
optional: true
'@esbuild/linux-riscv64@0.28.2':
optional: true
'@esbuild/linux-s390x@0.28.2':
optional: true
'@esbuild/linux-x64@0.28.2':
optional: true
'@esbuild/netbsd-arm64@0.28.2':
optional: true
'@esbuild/netbsd-x64@0.28.2':
optional: true
'@esbuild/openbsd-arm64@0.28.2':
optional: true
'@esbuild/openbsd-x64@0.28.2':
optional: true
'@esbuild/openharmony-arm64@0.28.2':
optional: true
'@esbuild/sunos-x64@0.28.2':
optional: true
'@esbuild/win32-arm64@0.28.2':
optional: true
'@esbuild/win32-ia32@0.28.2':
optional: true
'@esbuild/win32-x64@0.28.2':
optional: true
'@hono/node-server@1.19.17(hono@4.13.12)':
dependencies:
hono: 4.13.12
'@types/node@22.20.5':
dependencies:
undici-types: 6.21.0
esbuild@0.28.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.2
'@esbuild/android-arm': 0.28.2
'@esbuild/android-arm64': 0.28.2
'@esbuild/android-x64': 0.28.2
'@esbuild/darwin-arm64': 0.28.2
'@esbuild/darwin-x64': 0.28.2
'@esbuild/freebsd-arm64': 0.28.2
'@esbuild/freebsd-x64': 0.28.2
'@esbuild/linux-arm': 0.28.2
'@esbuild/linux-arm64': 0.28.2
'@esbuild/linux-ia32': 0.28.2
'@esbuild/linux-loong64': 0.28.2
'@esbuild/linux-mips64el': 0.28.2
'@esbuild/linux-ppc64': 0.28.2
'@esbuild/linux-riscv64': 0.28.2
'@esbuild/linux-s390x': 0.28.2
'@esbuild/linux-x64': 0.28.2
'@esbuild/netbsd-arm64': 0.28.2
'@esbuild/netbsd-x64': 0.28.2
'@esbuild/openbsd-arm64': 0.28.2
'@esbuild/openbsd-x64': 0.28.2
'@esbuild/openharmony-arm64': 0.28.2
'@esbuild/sunos-x64': 0.28.2
'@esbuild/win32-arm64': 0.28.2
'@esbuild/win32-ia32': 0.28.2
'@esbuild/win32-x64': 0.28.2
fsevents@2.3.3:
optional: true
hono@4.13.12: {}
tsx@4.23.15:
dependencies:
esbuild: 0.28.2
optionalDependencies:
fsevents: 2.3.3
typescript@5.9.3: {}
undici-types@6.21.0: {}
+76
View File
@@ -0,0 +1,76 @@
/**
* 代理冒烟(pnpm --dir server smoke):
* 通过本地代理调一次真实 Jev(1 个 score 问题,控制 token),
* 再用一段程序生成的 WAV 测 /api/asr(Mock provider)。
* key 只存在于服务端,本脚本不接触、不打印 key。
* 先启动服务:pnpm --dir server dev
*/
const BASE = process.env.PROXY_URL ?? 'http://localhost:8787';
// --- 1. /api/jev:1 个 score 问题 ---
const jevRes = await fetch(`${BASE}/api/jev`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'jev-latest',
state: '用户在互动剧情中选择「直接进城,赌一把」,并向同伴坦言「我们是去记录的,不是去拿的」。',
questions: {
risk: {
type: 'score',
instructions: '根据用户行为评估其「风险偏好」维度水平',
criteria: ['非常保守', '较为保守', '中等', '较为冒险', '非常冒险'],
},
},
}),
});
console.log(`[1] POST /api/jev → ${jevRes.status}`);
const jevBody = (await jevRes.json()) as {
answers?: Record<string, { type: string; score?: number; confidence?: number }>;
usage?: { input_tokens: number; output_tokens: number };
};
if (!jevRes.ok) {
console.error(' 上游错误:', JSON.stringify(jevBody));
process.exit(1);
}
const risk = jevBody.answers?.risk;
console.log(` risk: score=${risk?.score} confidence=${risk?.confidence}`);
console.log(` usage: input=${jevBody.usage?.input_tokens} output=${jevBody.usage?.output_tokens} tokens`);
// --- 2. /api/asr:程序生成的 0.5s 静音 WAV,走 Mock provider ---
const wav = makeSilentWav(0.5);
const form = new FormData();
form.append('audio', new Blob([wav], { type: 'audio/wav' }), 'smoke.wav');
const asrRes = await fetch(`${BASE}/api/asr`, { method: 'POST', body: form });
console.log(`[2] POST /api/asr → ${asrRes.status}`);
console.log(' ', JSON.stringify(await asrRes.json()));
// --- 3. CORS 预检:模拟 Tauri 开发端口来源 ---
const preflight = await fetch(`${BASE}/api/jev`, {
method: 'OPTIONS',
headers: { Origin: 'http://localhost:1420', 'Access-Control-Request-Method': 'POST' },
});
console.log(`[3] OPTIONS /api/jev (Origin: localhost:1420) → ${preflight.status}, allow-origin: ${preflight.headers.get('access-control-allow-origin')}`);
console.log('\n冒烟完成');
/** 生成一段 16-bit PCM 单声道静音 WAV(44 字节头 + 数据) */
function makeSilentWav(seconds: number): Uint8Array {
const rate = 16000;
const n = Math.floor(rate * seconds);
const buf = new ArrayBuffer(44 + n * 2);
const v = new DataView(buf);
const writeStr = (off: number, s: string) => [...s].forEach((ch, i) => v.setUint8(off + i, ch.charCodeAt(0)));
writeStr(0, 'RIFF');
v.setUint32(4, 36 + n * 2, true);
writeStr(8, 'WAVEfmt ');
v.setUint32(16, 16, true);
v.setUint16(20, 1, true); // PCM
v.setUint16(22, 1, true); // mono
v.setUint32(24, rate, true);
v.setUint32(28, rate * 2, true);
v.setUint16(32, 2, true);
v.setUint16(34, 16, true);
writeStr(36, 'data');
v.setUint32(40, n * 2, true);
return new Uint8Array(buf);
}
+49
View File
@@ -0,0 +1,49 @@
/**
* ASR provider 可插拔接口(ADR-0004:音频"特征提取后即焚")。
*
* 音频只存在于处理窗口内的内存 buffer,绝不落盘、绝不入库。
* 删除前允许提取两类产物:
* (a) transcript —— ASR 转写文本;
* (b) prosody —— 韵律/声学特征(语速、停顿分布、音高变化范围、能量、
* 填充词频率、情绪唤醒度等),以不可逆统计向量形式存储,
* 不含声纹。当前为占位空数组,后续接声学特征提取管线。
*/
export interface AsrInput {
/** 音频字节,仅存活于本次请求的处理窗口内 */
data: Uint8Array;
contentType: string;
filename?: string;
}
export interface AsrResult {
transcript: string;
/** 韵律特征占位:后续接声学特征提取后填充(见 ADR-0004"敏感数据处理") */
prosody: unknown[];
}
export interface AsrProvider {
transcribe(input: AsrInput): Promise<AsrResult>;
}
/**
* 占位实现:不做真实识别,返回固定占位文本,用于联调前后端管线。
*
* === 接入真实 provider 只需替换本类 ===
* - 阿里云 NLS(一句话识别/录音文件识别):在 transcribe() 内用
* input.data 调 NLS RESTful API(appkey + token 从 server/.env 读取),
* 把返回的 result 字段填入 transcript;
* - 讯飞语音听写:改用讯飞 WebSocket/REST 端点,同样以 input.data 为输入;
* - Whisper 兼容端点(OpenAI / Groq / 自托管 faster-whisper):
* fetch(WHISPER_URL, { method: 'POST', body: FormData(file=Blob(input.data)) }),
* 取 JSON 的 text 字段。
* 三种接入的输入都是 input.data(内存 buffer),都不需要把音频写盘——
* 保持 ADR-0004 的"即焚"语义。
*/
export class MockAsrProvider implements AsrProvider {
async transcribe(input: AsrInput): Promise<AsrResult> {
return {
transcript: `[mock-asr] 占位转写(收到 ${input.data.byteLength} 字节 ${input.contentType || '未知类型'} 音频,未做真实识别)`,
prosody: [], // 占位:后续接声学特征提取(语速/停顿/音高/能量等)
};
}
}
+20
View File
@@ -0,0 +1,20 @@
/**
* 极简 .env 加载(不引 dotenv,与根仓库 scripts/verify-live.ts 风格一致)。
* 只解析 KEY=VALUE 行,忽略注释与空行;任何输出都不打印 value。
*/
import { readFileSync } from 'node:fs';
export function loadEnvFile(path: URL): void {
let raw: string;
try {
raw = readFileSync(path, 'utf8');
} catch {
return; // .env 不存在时静默跳过(允许用真实环境变量注入)
}
for (const line of raw.split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/);
if (m && !m[2].startsWith('#') && process.env[m[1]] === undefined) {
process.env[m[1]] = m[2];
}
}
}
+33
View File
@@ -0,0 +1,33 @@
/**
* 后端代理层入口(Node 22 + Hono)。
* - POST /api/jev 透传 TypeSafe AI systemone(key 只在服务端)
* - POST /api/asr 音频转写占位(provider 可插拔,音频即焚)
* CORS 仅放行 Vite 开发端口 5173 与 Tauri 开发端口 1420。
*/
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { loadEnvFile } from './env.js';
import { jevRoute } from './routes/jev.js';
import { asrRoute } from './routes/asr.js';
loadEnvFile(new URL('../.env', import.meta.url));
const app = new Hono();
app.use(
'*',
cors({
origin: ['http://localhost:1420', 'http://localhost:5173'],
}),
);
app.route('/api/jev', jevRoute);
app.route('/api/asr', asrRoute);
app.get('/healthz', (c) => c.json({ ok: true }));
const port = Number(process.env.PORT ?? 8787);
serve({ fetch: app.fetch, port }, (info) => {
console.log(`[server] 代理已启动: http://localhost:${info.port}`);
console.log(`[server] TYPESAFE_API_KEY ${process.env.TYPESAFE_API_KEY ? '已加载(内容不打印)' : '未配置,/api/jev 将返回 503'}`);
});
+30
View File
@@ -0,0 +1,30 @@
/**
* ASR 路由:POST /api/asr(multipart/form-data,文件字段名 "audio")。
* 为移动端 ASR 预留(Tauri WKWebView 不支持 Web Speech API 的环境走这里)。
*
* ADR-0004 约束:音频处理完即弃——
* 只在内存中传给 provider,不写临时文件、不进任何存储;
* 响应只含 { transcript, prosody },不含音频或可还原声音的内容。
*/
import { Hono } from 'hono';
import { MockAsrProvider, type AsrProvider } from '../asr/provider.js';
// 接入真实 provider 时改为 new AliyunNlsProvider() / new WhisperProvider(...) 等,
// 构造参数(appkey、token、endpoint)从 process.env 读取。
const provider: AsrProvider = new MockAsrProvider();
export const asrRoute = new Hono().post('/', async (c) => {
const form = await c.req.parseBody();
const file = form['audio'];
if (!(file instanceof File)) {
return c.json({ error: '缺少 multipart 文件字段 "audio"' }, 400);
}
const data = new Uint8Array(await file.arrayBuffer());
// data 仅在本请求作用域内存活,函数返回后由 GC 回收;不落盘(ADR-0004)。
const result = await provider.transcribe({
data,
contentType: file.type,
filename: file.name,
});
return c.json(result);
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Jev 代理路由:POST /api/jev
* 透传 systemone 请求到 https://api.typesafe.ai/v1/systemone,
* key 从服务端环境变量 TYPESAFE_API_KEY 读取(不下发到前端)。
* 上游状态码(401/422/429/529 等)与响应体原样透传回前端。
* key 永不写入日志或响应。
*/
import { Hono } from 'hono';
const UPSTREAM_URL = 'https://api.typesafe.ai/v1/systemone';
export const jevRoute = new Hono().post('/', async (c) => {
const apiKey = process.env.TYPESAFE_API_KEY;
if (!apiKey) {
return c.json({ error: 'server 未配置 TYPESAFE_API_KEY(见 server/README.md)' }, 503);
}
const body = await c.req.text(); // 请求体原样透传,不解析不修改
const upstream = await fetch(UPSTREAM_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body,
});
const text = await upstream.text();
return new Response(text, {
status: upstream.status,
headers: { 'Content-Type': upstream.headers.get('content-type') ?? 'application/json' },
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023"],
"types": ["node"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src", "scripts"]
}
+916 -3
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -23,3 +23,4 @@ serde = { version = "1.0", features = ["derive"] }
log = "0.4" log = "0.4"
tauri = { version = "2.12.1" } tauri = { version = "2.12.1" }
tauri-plugin-log = "2" tauri-plugin-log = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
+2 -1
View File
@@ -6,6 +6,7 @@
"main" "main"
], ],
"permissions": [ "permissions": [
"core:default" "core:default",
"sql:default"
] ]
} }
+1
View File
@@ -1,6 +1,7 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_sql::Builder::default().build())
.setup(|app| { .setup(|app| {
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
app.handle().plugin( app.handle().plugin(
+54
View File
@@ -277,3 +277,57 @@ button.secondary {
display: block; display: block;
margin: 0 auto 12px; 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;
}
+3 -1
View File
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
import './App.css'; import './App.css';
import { StoryEngine } from './engine/engine'; import { StoryEngine } from './engine/engine';
import { LocalStorageEventStore } from './engine/eventStore'; import { LocalStorageEventStore } from './engine/eventStore';
import { SqliteEventStore, isTauriRuntime } from './engine/sqliteEventStore';
import type { Chapter } from './engine/types'; import type { Chapter } from './engine/types';
import { getJevClient } from './services/jev'; import { getJevClient } from './services/jev';
import { StoryPlayer } from './ui/StoryPlayer'; import { StoryPlayer } from './ui/StoryPlayer';
@@ -15,8 +16,9 @@ const chapters = [chapter1, chapter2, chapter3] as Chapter[];
export default function App() { export default function App() {
const [screen, setScreen] = useState<'story' | 'report'>('story'); const [screen, setScreen] = useState<'story' | 'report'>('story');
// 引擎与存储整个会话只建一次;重玩走 engine.replay()(新 sessionId,旧事件保留) // 引擎与存储整个会话只建一次;重玩走 engine.replay()(新 sessionId,旧事件保留)
// 运行时选择:Tauri 原生端用 SQLite,浏览器 dev 用 localStorage(同一 append-only 接口)
const { engine, store, jev } = useMemo(() => { const { engine, store, jev } = useMemo(() => {
const store = new LocalStorageEventStore(); const store = isTauriRuntime() ? new SqliteEventStore() : new LocalStorageEventStore();
const jev = getJevClient(); const jev = getJevClient();
return { engine: new StoryEngine(chapters, store, jev), store, jev }; return { engine: new StoryEngine(chapters, store, jev), store, jev };
}, []); }, []);
+219 -10
View File
@@ -1,7 +1,8 @@
import type { Chapter, NarrationNode, SceneVariant, StoryNode } from './types'; import type { Chapter, FollowUpDimension, IdentityProfile, NarrationNode, SceneVariant, StoryNode } from './types';
import { FOLLOW_UP_DIMENSIONS, FOLLOW_UP_PRIORITY } from './types';
import type { EventStore, StoryEvent } from './eventStore'; import type { EventStore, StoryEvent } from './eventStore';
import { makeEvent } from './eventStore'; import { makeEvent } from './eventStore';
import type { JevClient } from '../services/jev'; import type { JevClient, JevQuestion } from '../services/jev';
export interface EngineState { export interface EngineState {
sessionId: string; sessionId: string;
@@ -9,6 +10,8 @@ export interface EngineState {
nodeId: string; nodeId: string;
/** 当前场景变体(讲述点情绪签名解析结果,驱动 UI 场景配色) */ /** 当前场景变体(讲述点情绪签名解析结果,驱动 UI 场景配色) */
activeVariant: SceneVariant | null; activeVariant: SceneVariant | null;
/** 追问补全:讲述后检测到缺失维度时挂起,等用户回答或"记不清了" */
pendingFollowUp: PendingFollowUp | null;
/** 全部三章完成后为 true → 进入画像报告 */ /** 全部三章完成后为 true → 进入画像报告 */
finished: boolean; finished: boolean;
} }
@@ -18,6 +21,31 @@ export interface NarrationResult {
event: StoryEvent; event: StoryEvent;
} }
/** 一次挂起的追问:原始讲述文本先存着,回答合并后才落 narration 事件 */
export interface PendingFollowUp {
pointId: string;
dimension: FollowUpDimension;
/** 模板化追问话术(取自节点 JSON 的 followUps) */
prompt: string;
/** 已问次数(本轮上限 1,字段预留第二次追问位置) */
asked: number;
/** 用户最初的讲述文本,追问回答将合并进来 */
baseTranscript: string;
}
/** 六维缺失检测的 noul 问题文案(一次 Jev 调用并行问完) */
const MISSING_DETECTION_INSTRUCTIONS: Record<FollowUpDimension, string> = {
visual: '这段讲述是否包含视觉细节(颜色、形状、光线、物件外观等)',
auditory: '这段讲述是否包含听觉细节(声音、语气、环境音等)',
olfactory_gustatory: '这段讲述是否包含嗅觉或味觉细节(气味、味道等)',
person: '这段讲述是否提及具体的人物',
time_place: '这段讲述是否提及具体的时间或地点',
emotion: '这段讲述是否有明确的情绪表达',
};
/** noul 概率低于此阈值视为该维度缺失 */
const MISSING_THRESHOLD = 0.5;
function newSessionId(): string { function newSessionId(): string {
return typeof crypto !== 'undefined' && 'randomUUID' in crypto return typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID() ? crypto.randomUUID()
@@ -49,6 +77,7 @@ export class StoryEngine {
chapterId: start.id, chapterId: start.id,
nodeId: start.startNodeId, nodeId: start.startNodeId,
activeVariant: null, activeVariant: null,
pendingFollowUp: null,
finished: false, finished: false,
}; };
} }
@@ -104,12 +133,96 @@ export class StoryEngine {
} }
/** /**
* V 类讲述:落事件(transcript 为 ASR 占位,沉默为 null), * V 类讲述提交。追问补全流程(CONTEXT「追问补全」):
* 并解析情绪签名 → 场景变体(ADR-0003 通道 1,只做选择)。 * 文本讲述 → Jev 六维缺失检测 → 有缺口则挂起追问(返回 null,
* state.pendingFollowUp 挂起,UI 展示追问话术)→ answerFollowUp/skipFollowUp 收尾;
* 无缺口或沉默则直接走完:落 narration 事件 + 情绪签名→场景变体(ADR-0003 通道 1)。
*/ */
async submitNarration(transcript: string | null): Promise<NarrationResult> { async submitNarration(transcript: string | null): Promise<NarrationResult | null> {
const node = this.currentNode(); const node = this.currentNode();
if (node.type !== 'narration') throw new Error('当前节点不是讲述点'); if (node.type !== 'narration') throw new Error('当前节点不是讲述点');
if (this.state.pendingFollowUp) throw new Error('有未处理的追问,请先 answerFollowUp / skipFollowUp');
if (transcript === null) return this.finalizeNarration(node, null);
const missing = await this.detectMissing(node, transcript);
if (missing) {
this.state.pendingFollowUp = {
pointId: node.pointId,
dimension: missing,
prompt: node.followUps[missing]!,
asked: 1,
baseTranscript: transcript,
};
return null;
}
return this.finalizeNarration(node, transcript);
}
/** 追问被回答:落 followup 事件,回答合并进讲述文本,再走情绪签名选择 */
async answerFollowUp(answer: string): Promise<NarrationResult> {
const { node, pending } = this.takePendingFollowUp();
await this.store.append(
makeEvent({
sessionId: this.state.sessionId,
chapterId: this.state.chapterId,
kind: 'followup',
pointId: node.pointId,
dimensions: node.dimensions,
followUpDimension: pending.dimension,
transcript: answer,
}),
);
this.state.pendingFollowUp = null;
return this.finalizeNarration(node, `${pending.baseTranscript}\n${answer}`);
}
/** "记不清了"是合法回答(ADR-0004)——跳过也落事件,对什么沉默是数据 */
async skipFollowUp(): Promise<NarrationResult> {
const { node, pending } = this.takePendingFollowUp();
await this.store.append(
makeEvent({
sessionId: this.state.sessionId,
chapterId: this.state.chapterId,
kind: 'followup_skipped',
pointId: node.pointId,
dimensions: node.dimensions,
followUpDimension: pending.dimension,
transcript: null,
}),
);
this.state.pendingFollowUp = null;
return this.finalizeNarration(node, pending.baseTranscript);
}
private takePendingFollowUp(): { node: NarrationNode; pending: PendingFollowUp } {
const pending = this.state.pendingFollowUp;
if (!pending) throw new Error('当前没有待处理的追问');
const node = this.currentNode();
if (node.type !== 'narration' || node.pointId !== pending.pointId) {
throw new Error('追问挂起状态与当前节点不一致');
}
return { node, pending };
}
/**
* 缺失检测:一次 Jev 调用并行问 6 个 noul 问题;概率 <0.5 视为缺失;
* 按幻境渲染价值优先级取一个(FOLLOW_UP_PRIORITY),且节点 JSON 里要有对应模板。
* 预算纪律:每个讲述点最多追问 1 次(maxQuestions ≥2 的第二轮留给后续迭代)。
*/
private async detectMissing(node: NarrationNode, transcript: string): Promise<FollowUpDimension | null> {
if (node.followUp.maxQuestions < 1) return null;
const questions: Record<string, JevQuestion> = Object.fromEntries(
FOLLOW_UP_DIMENSIONS.map((d) => [d, { type: 'noul', instructions: MISSING_DETECTION_INSTRUCTIONS[d] }]),
);
const answers = await this.jev.ask({ transcript, pointId: node.pointId }, questions);
for (const dim of FOLLOW_UP_PRIORITY) {
const a = answers[dim];
if (node.followUps[dim] && a?.type === 'noul' && a.probability < MISSING_THRESHOLD) return dim;
}
return null;
}
/** 讲述收尾:落 narration 事件、解析情绪签名 → 场景变体、推进节点 */
private async finalizeNarration(node: NarrationNode, transcript: string | null): Promise<NarrationResult> {
const variant = await this.resolveVariant(node, transcript); const variant = await this.resolveVariant(node, transcript);
const event = makeEvent({ const event = makeEvent({
sessionId: this.state.sessionId, sessionId: this.state.sessionId,
@@ -127,9 +240,11 @@ export class StoryEngine {
return { variant, event }; return { variant, event };
} }
/** 沉默永远是合法选项(CONTEXT:在什么面前沉默本身也是数据) */ /** 沉默永远是合法选项(CONTEXT:在什么面前沉默本身也是数据);沉默路径不触发追问 */
async skipNarration(): Promise<NarrationResult> { async skipNarration(): Promise<NarrationResult> {
return this.submitNarration(null); const r = await this.submitNarration(null);
if (!r) throw new Error('unreachable: 沉默路径不触发追问');
return r;
} }
private async resolveVariant(node: NarrationNode, transcript: string | null): Promise<SceneVariant> { private async resolveVariant(node: NarrationNode, transcript: string | null): Promise<SceneVariant> {
@@ -151,19 +266,112 @@ export class StoryEngine {
{ {
emotion_signature: { emotion_signature: {
type: 'choice', type: 'choice',
description: `根据讲述文本选择最贴合的情绪签名(讲述点 ${node.pointId})`, instructions: `根据讲述文本选择最贴合的情绪签名(讲述点 ${node.pointId})`,
options: node.variants.map((v) => v.signature), criteria: Object.fromEntries(node.variants.map((v) => [v.signature, v.label])),
}, },
}, },
); );
const picked = answer.emotion_signature; const picked = answer.emotion_signature;
if (picked?.type === 'choice') { 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; if (match) return match;
} }
return node.variants[0]; 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),不修改旧事件。 * 重玩(ADR-0004):产生新轨迹(新 sessionId),不修改旧事件。
* 默认重玩当前章,也可指定章节。 * 默认重玩当前章,也可指定章节。
@@ -175,6 +383,7 @@ export class StoryEngine {
this.state.chapterId = chapter.id; this.state.chapterId = chapter.id;
this.state.nodeId = chapter.startNodeId; this.state.nodeId = chapter.startNodeId;
this.state.activeVariant = null; this.state.activeVariant = null;
this.state.pendingFollowUp = null;
this.state.finished = false; this.state.finished = false;
} }
} }
+10 -6
View File
@@ -1,4 +1,4 @@
import type { DimensionTag } from './types'; import type { DimensionTag, FollowUpDimension, IdentityProfile } from './types';
/** /**
* 选择/讲述事件存储(ADR-0004:历史不可涂改)。 * 选择/讲述事件存储(ADR-0004:历史不可涂改)。
@@ -17,7 +17,7 @@ export interface StoryEvent {
sessionId: string; sessionId: string;
ts: number; ts: number;
chapterId: string; chapterId: string;
kind: 'choice' | 'narration'; kind: 'choice' | 'narration' | 'profile' | 'followup' | 'followup_skipped';
/** 选择点 ID,如 C1-03 */ /** 选择点 ID,如 C1-03 */
pointId: string; pointId: string;
dimensions: DimensionTag[]; dimensions: DimensionTag[];
@@ -27,8 +27,12 @@ export interface StoryEvent {
/** narration 事件:选中的场景变体(情绪签名 → variantId) */ /** narration 事件:选中的场景变体(情绪签名 → variantId) */
variantId?: string; variantId?: string;
emotionSignature?: string; emotionSignature?: string;
/** 讲述转写文本占位(ASR 未接,MVP 为手输或占位文本;沉默为 null) */ /** followup / followup_skipped 事件:追问针对的缺失维度 */
followUpDimension?: FollowUpDimension;
/** 讲述/追问的转写文本(Web Speech API 只给文本,不留音频,符合 ADR-0004 即焚原则;沉默为 null) */
transcript?: string | null; transcript?: string | null;
/** profile 事件:用户确认后的身份登记字段(匹配硬过滤字段) */
profile?: IdentityProfile;
} }
const STORAGE_KEY = 'jev-dating-match.events.v1'; const STORAGE_KEY = 'jev-dating-match.events.v1';
@@ -64,7 +68,7 @@ export class LocalStorageEventStore implements EventStore {
} }
/** /**
* Tauri 原生端实现预留:后续用 tauri-plugin-sql(SQLite)实现同一接口, * Tauri 原生端实现:见 ./sqliteEventStore.ts(tauri-plugin-sql + SQLite,
* 表结构即 StoryEvent 一行一条,只写 INSERT/SELECT,不建 UPDATE/DELETE 路径。 * 同一 EventStore 接口,SQL 层同样只写 INSERT/SELECT)。
* 见 README「已知缺口」。 * 运行时选择在 App.tsx:window.__TAURI__ 存在用 SqliteEventStore,否则 localStorage。
*/ */
+55
View File
@@ -0,0 +1,55 @@
import type { EventStore, StoryEvent } from './eventStore';
import type Database from '@tauri-apps/plugin-sql';
/** Tauri 原生端运行时判定(浏览器 dev 无 window.__TAURI__) */
export function isTauriRuntime(): boolean {
return typeof window !== 'undefined' && '__TAURI__' in window;
}
const DB_PATH = 'sqlite:events.db';
/**
* Tauri 原生端事件存储:tauri-plugin-sql(sqlx + SQLite)。
* 与 LocalStorageEventStore 实现同一 EventStore 接口——ADR-0004 的
* append-only 约束在 SQL 层同样成立:本类只写 INSERT/SELECT,
* 不存在 UPDATE/DELETE 路径。重玩 = 新 sessionId 的新行,旧行原样保留。
* payload 为 StoryEvent 整行 JSON(事件 schema 演进不动表结构)。
*/
export class SqliteEventStore implements EventStore {
private db: Promise<Database>;
constructor(path: string = DB_PATH) {
// 动态 import:浏览器 dev 永不加载本类,避免把插件打进 web 路径的初始化
this.db = (async () => {
const { default: Database } = await import('@tauri-apps/plugin-sql');
const db = await Database.load(path);
await db.execute(`
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sessionId TEXT NOT NULL,
chapterId TEXT NOT NULL,
pointId TEXT NOT NULL,
payload TEXT NOT NULL,
createdAt INTEGER NOT NULL
)
`);
return db;
})();
}
async append(event: StoryEvent): Promise<void> {
const db = await this.db;
await db.execute(
'INSERT INTO events (sessionId, chapterId, pointId, payload, createdAt) VALUES ($1, $2, $3, $4, $5)',
[event.sessionId, event.chapterId, event.pointId, JSON.stringify(event), event.ts],
);
}
async list(sessionId?: string): Promise<StoryEvent[]> {
const db = await this.db;
const rows = sessionId
? await db.select<{ payload: string }[]>('SELECT payload FROM events WHERE sessionId = $1 ORDER BY id', [sessionId])
: await db.select<{ payload: string }[]>('SELECT payload FROM events ORDER BY id');
return rows.map((r) => JSON.parse(r.payload) as StoryEvent);
}
}
+48 -4
View File
@@ -21,7 +21,7 @@ interface BaseNode {
type: NodeType; type: NodeType;
} }
export type NodeType = 'dialogue' | 'choice' | 'narration' | 'cutscene' | 'chapterEnd'; export type NodeType = 'dialogue' | 'choice' | 'narration' | 'cutscene' | 'identity' | 'chapterEnd';
/** 对话节点:角色 + 台词 + 立绘占位(sprite 仅作 CSS 色块标识) */ /** 对话节点:角色 + 台词 + 立绘占位(sprite 仅作 CSS 色块标识) */
export interface DialogueNode extends BaseNode { export interface DialogueNode extends BaseNode {
@@ -61,7 +61,28 @@ export interface SceneVariant {
tint: string; tint: string;
} }
/** 语音讲述点(V 类):提示语 + 追问策略占位 + 情绪签名→变体选择 */ /** 追问缺失检测的六个维度(五感拆三 + 人物 + 时空 + 情绪) */
export const FOLLOW_UP_DIMENSIONS = [
'visual', // 视觉细节
'auditory', // 听觉细节
'olfactory_gustatory', // 嗅觉或味觉细节
'person', // 具体人物
'time_place', // 具体时间地点
'emotion', // 明确情绪表达
] as const;
export type FollowUpDimension = (typeof FOLLOW_UP_DIMENSIONS)[number];
/** 追问选择优先级(对幻境渲染价值排序):视觉 > 人物 > 时空 > 情绪 > 听觉 > 嗅觉味觉 */
export const FOLLOW_UP_PRIORITY: FollowUpDimension[] = [
'visual',
'person',
'time_place',
'emotion',
'auditory',
'olfactory_gustatory',
];
/** 语音讲述点(V 类):提示语 + 追问补全 + 情绪签名→变体选择 */
export interface NarrationNode extends BaseNode { export interface NarrationNode extends BaseNode {
type: 'narration'; type: 'narration';
pointId: string; pointId: string;
@@ -70,8 +91,10 @@ export interface NarrationNode extends BaseNode {
/** 讲述提示语 */ /** 讲述提示语 */
prompt: string; prompt: string;
dimensions: DimensionTag[]; dimensions: DimensionTag[];
/** 追问策略占位(Jev 缺失维度检测 + 馆长口吻追问,至多 1-2 句,MVP 未接) */ /** 追问策略:Jev 六维缺失检测 + NPC 口吻模板追问,至多 1-2 句(工程实现先 1 句) */
followUp: { maxQuestions: number; strategy: string }; followUp: { maxQuestions: number; strategy: string };
/** 模板化追问话术,按缺失维度取一条(LLM 口吻化生成后续再接) */
followUps: Partial<Record<FollowUpDimension, string>>;
/** 预写场景变体表 */ /** 预写场景变体表 */
variants: SceneVariant[]; variants: SceneVariant[];
/** /**
@@ -97,7 +120,28 @@ export interface ChapterEndNode extends BaseNode {
nextChapter?: string; 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 { export interface Chapter {
id: string; id: string;
+76 -38
View File
@@ -3,27 +3,38 @@
* REST: POST https://api.typesafe.ai/v1/systemone * REST: POST https://api.typesafe.ai/v1/systemone
* Header: Authorization: Bearer <TYPESAFE_API_KEY> * Header: Authorization: Bearer <TYPESAFE_API_KEY>
* 请求: { model: "jev-latest", state: <文本或JSON>, questions: { <id>: Question } } * 请求: { 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 { export interface NoulQuestion {
type: 'noul'; type: 'noul';
description: string; instructions: string;
} }
/** 选择题(≤255 选项),criteria 为 map */ /** 选择题(≤255 选项),criteria 为 { 选项: 描述 } 的 map */
export interface ChoiceQuestion { export interface ChoiceQuestion {
type: 'choice'; type: 'choice';
description: string; instructions: string;
options: string[]; criteria: Record<string, string>;
criteria?: Record<string, string>;
} }
/** 打分题,criteria 为 2-10 级有序数组(从低到高) */ /** 打分题,criteria 为 2-10 级有序数组(索引 0 起,由低到高) */
export interface ScoreQuestion { export interface ScoreQuestion {
type: 'score'; type: 'score';
description: string; instructions: string;
criteria: string[]; criteria: string[];
} }
@@ -37,8 +48,15 @@ export interface JevRequest {
export type JevAnswer = export type JevAnswer =
| { type: 'noul'; probability: number; confidence: number } | { type: 'noul'; probability: number; confidence: number }
| { type: 'choice'; selected: string; distribution: Record<string, number>; confidence: number } | { type: 'choice'; choice: string; probabilities: Record<string, number>; confidence: number }
| { type: 'score'; score: number; min: number; max: number; confidence: number }; | {
type: 'score';
/** 概率加权浮点分数,范围 [0, criteria.length-1],可落在两级之间 */
score: number;
confidence: number;
legend: Record<string, string>;
probabilities: Record<string, number>;
};
export interface JevClient { export interface JevClient {
ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>>; ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>>;
@@ -46,21 +64,30 @@ export interface JevClient {
const API_URL = 'https://api.typesafe.ai/v1/systemone'; const API_URL = 'https://api.typesafe.ai/v1/systemone';
/** 直连 REST 的实现。浏览器调用可能遇到 CORS,正式环境需走后端代理(见 README)。 */ /**
* REST 实现,两种模式:
* - 代理模式(优先):传 proxyUrl,请求发到 `${proxyUrl}/api/jev`,key 在服务端,
* 前端不带 Authorization(见 server/ 子包);
* - 直连模式(fallback):传 apiKey,直连 api.typesafe.ai,浏览器可能遇到 CORS。
*/
export class HttpJevClient implements JevClient { export class HttpJevClient implements JevClient {
private apiKey: string; private apiKey?: string;
private proxyUrl?: string;
constructor(apiKey: string) { constructor(apiKey?: string, proxyUrl?: string) {
this.apiKey = apiKey; this.apiKey = apiKey;
this.proxyUrl = proxyUrl;
} }
async ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>> { async ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>> {
const res = await fetch(API_URL, { const url = this.proxyUrl ? `${this.proxyUrl}/api/jev` : API_URL;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (!this.proxyUrl && this.apiKey) {
headers.Authorization = `Bearer ${this.apiKey}`;
}
const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers,
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({ model: 'jev-latest', state, questions } satisfies JevRequest), body: JSON.stringify({ model: 'jev-latest', state, questions } satisfies JevRequest),
}); });
if (!res.ok) { if (!res.ok) {
@@ -92,7 +119,7 @@ function prng(seed: number, n: number): number[] {
return out; return out;
} }
/** 无 key 开发用:确定性伪随机返分(同一输入永远同一输出,便于联调)。 */ /** 无 key 开发用:确定性伪随机返分(同一输入永远同一输出,输出形状与真实 API 对齐)。 */
export class MockJevClient implements JevClient { export class MockJevClient implements JevClient {
async ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>> { async ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>> {
const stateKey = JSON.stringify(state) ?? ''; const stateKey = JSON.stringify(state) ?? '';
@@ -103,25 +130,33 @@ export class MockJevClient implements JevClient {
if (q.type === 'noul') { if (q.type === 'noul') {
answers[id] = { type: 'noul', probability: Math.round(prng(seed, 2)[1] * 100) / 100, confidence }; answers[id] = { type: 'noul', probability: Math.round(prng(seed, 2)[1] * 100) / 100, confidence };
} else if (q.type === 'choice') { } else if (q.type === 'choice') {
const r = prng(seed, q.options.length + 1); const options = Object.keys(q.criteria);
const weights = r.slice(0, q.options.length); const r = prng(seed, options.length);
const total = weights.reduce((a, b) => a + b, 0) || 1; const total = r.reduce((a, b) => a + b, 0) || 1;
const distribution: Record<string, number> = {}; const probabilities: Record<string, number> = {};
let selected = q.options[0]; let choice = options[0];
let best = -1; let best = -1;
q.options.forEach((opt, i) => { options.forEach((opt, i) => {
distribution[opt] = Math.round((weights[i] / total) * 100) / 100; probabilities[opt] = Math.round((r[i] / total) * 100) / 100;
if (weights[i] > best) { if (r[i] > best) {
best = weights[i]; best = r[i];
selected = opt; choice = opt;
} }
}); });
answers[id] = { type: 'choice', selected, distribution, confidence }; answers[id] = { type: 'choice', choice, probabilities, confidence };
} else { } else {
const min = 1; const r = prng(seed, q.criteria.length);
const max = q.criteria.length; const total = r.reduce((a, b) => a + b, 0) || 1;
const score = min + Math.floor(prng(seed, 1)[0] * max); const probabilities: Record<string, number> = {};
answers[id] = { type: 'score', score, min, max, confidence }; 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; return answers;
@@ -130,14 +165,17 @@ export class MockJevClient implements JevClient {
let singleton: JevClient | null = null; let singleton: JevClient | null = null;
/** key 缺失时自动降级 Mock 并提示。 */ /** 有代理走代理,有 key 直连,两者都缺时降级 Mock 并提示。 */
export function getJevClient(): JevClient { export function getJevClient(): JevClient {
if (singleton) return singleton; if (singleton) return singleton;
const key = import.meta.env.VITE_TYPESAFE_API_KEY as string | undefined; const proxyUrl = import.meta.env.VITE_JEV_PROXY_URL;
if (key) { const key = import.meta.env.VITE_TYPESAFE_API_KEY;
if (proxyUrl) {
singleton = new HttpJevClient(undefined, proxyUrl);
} else if (key) {
singleton = new HttpJevClient(key); singleton = new HttpJevClient(key);
} else { } else {
console.warn('[jev] 未配置 VITE_TYPESAFE_API_KEY,降级为 MockJevClient(确定性伪随机返分)'); console.warn('[jev] 未配置 VITE_JEV_PROXY_URL / VITE_TYPESAFE_API_KEY,降级为 MockJevClient(确定性伪随机返分)');
singleton = new MockJevClient(); singleton = new MockJevClient();
} }
return singleton; return singleton;
+126 -19
View File
@@ -1,6 +1,17 @@
import { useState } from 'react'; import { useState } from 'react';
import type { StoryEngine } from '../engine/engine'; import type { StoryEngine, PendingFollowUp } from '../engine/engine';
import type { ChoiceNode, NarrationNode } from '../engine/types'; import type { ChoiceNode, FollowUpDimension, IdentityNode, IdentityProfile, NarrationNode } from '../engine/types';
import { VoiceInput } from './VoiceInput';
/** 追问维度展示名 */
const FOLLOW_UP_LABELS: Record<FollowUpDimension, string> = {
visual: '画面',
auditory: '声音',
olfactory_gustatory: '气味与味道',
person: '人物',
time_place: '时间与地点',
emotion: '情绪',
};
/** speaker/sprite → 立绘占位色(无图片素材,CSS 色块) */ /** speaker/sprite → 立绘占位色(无图片素材,CSS 色块) */
const SPRITE_COLORS: Record<string, string> = { const SPRITE_COLORS: Record<string, string> = {
@@ -17,7 +28,7 @@ interface Props {
onFinish: () => void; onFinish: () => void;
} }
/** 最简影游界面:对话框 / 选项按钮 / 讲述点按住说话占位 / 场景变体渐变 */ /** 最简影游界面:对话框 / 选项按钮 / 讲述点语音输入 / 身份登记 / 场景变体渐变 */
export function StoryPlayer({ engine, onFinish }: Props) { export function StoryPlayer({ engine, onFinish }: Props) {
// tick 只是触发重渲染;引擎是唯一事实源 // tick 只是触发重渲染;引擎是唯一事实源
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
@@ -60,11 +71,16 @@ export function StoryPlayer({ engine, onFinish }: Props) {
{node.type === 'narration' && ( {node.type === 'narration' && (
<NarrationView <NarrationView
node={node} node={node}
pendingFollowUp={engine.state.pendingFollowUp}
onSubmit={async (text) => { await engine.submitNarration(text); rerender(); }} onSubmit={async (text) => { await engine.submitNarration(text); rerender(); }}
onSkip={async () => { await engine.skipNarration(); rerender(); }} onSkip={async () => { await engine.skipNarration(); rerender(); }}
onFollowUpAnswer={async (text) => { await engine.answerFollowUp(text); rerender(); }}
onFollowUpSkip={async () => { await engine.skipFollowUp(); rerender(); }}
/> />
)} )}
{node.type === 'identity' && <IdentityView node={node} engine={engine} onDone={rerender} />}
{node.type === 'cutscene' && ( {node.type === 'cutscene' && (
<div className="cutscene" onClick={() => { engine.advance(); rerender(); }}> <div className="cutscene" onClick={() => { engine.advance(); rerender(); }}>
<div className="cutscene-placeholder">▶ {node.video}</div> <div className="cutscene-placeholder">▶ {node.video}</div>
@@ -119,37 +135,128 @@ function ChoiceView({ node, onPick }: { node: ChoiceNode; onPick: (id: string) =
function NarrationView({ function NarrationView({
node, node,
pendingFollowUp,
onSubmit, onSubmit,
onSkip, onSkip,
onFollowUpAnswer,
onFollowUpSkip,
}: { }: {
node: NarrationNode; node: NarrationNode;
pendingFollowUp: PendingFollowUp | null;
onSubmit: (text: string) => void; onSubmit: (text: string) => void;
onSkip: () => void; onSkip: () => void;
onFollowUpAnswer: (text: string) => void;
onFollowUpSkip: () => void;
}) { }) {
const [holding, setHolding] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// 追问补全阶段:讲述已收到,NPC 就缺失的细节追问一句(至多 1 句;"记不清了"合法)
if (pendingFollowUp) {
return (
<div className="narration-box">
<div className="mechanism-tag">{node.mechanism} · 追问({FOLLOW_UP_LABELS[pendingFollowUp.dimension]})</div>
<p className="narration-prompt">{pendingFollowUp.prompt}</p>
<VoiceInput
disabled={busy}
onSubmit={async (t) => { setBusy(true); await onFollowUpAnswer(t); setBusy(false); }}
/>
<button className="secondary" disabled={busy} onClick={onFollowUpSkip}>
记不清了(跳过追问)
</button>
<p className="hint">追问至多 {node.followUp.maxQuestions} 句;想不起来可以直说,也是合法回答。</p>
</div>
);
}
return ( return (
<div className="narration-box"> <div className="narration-box">
<div className="mechanism-tag">{node.mechanism} · 语音讲述点</div> <div className="mechanism-tag">{node.mechanism} · 语音讲述点</div>
<p className="narration-prompt">{node.prompt}</p> <p className="narration-prompt">{node.prompt}</p>
<button <VoiceInput disabled={busy} onSubmit={async (t) => { setBusy(true); await onSubmit(t); setBusy(false); }} />
className={`hold-btn ${holding ? 'holding' : ''}`}
disabled={busy}
onPointerDown={() => setHolding(true)}
onPointerUp={async () => {
setHolding(false);
setBusy(true);
// ASR 未接:松开即结束录音占位,转写文本用占位字符串落事件
await onSubmit('(语音占位转写)');
setBusy(false);
}}
>
{holding ? '松开结束 · 讲述中…' : '按住说话'}
</button>
<button className="secondary" disabled={busy} onClick={onSkip}> <button className="secondary" disabled={busy} onClick={onSkip}>
保持沉默(也是合法选项) 保持沉默(也是合法选项)
</button> </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> </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>
);
}
+22 -13
View File
@@ -18,15 +18,23 @@ interface Props {
onReplay: () => void; onReplay: () => void;
} }
/** 把 append-only 事件轨迹汇总为喂给 Jev 的 state 文本 */ /** 把 append-only 事件轨迹汇总为喂给 Jev 的 state 文本(profile 身份字段是硬过滤字段,不进画像) */
function buildState(events: StoryEvent[]): string { function buildState(events: StoryEvent[]): string {
const lines = events.map((e) => { const lines = events
if (e.kind === 'choice') { .filter((e) => e.kind !== 'profile')
return `[${e.pointId}] 选择「${e.optionLabel}」(维度: ${e.dimensions.join('/') || '字段采集'})`; .map((e) => {
} if (e.kind === 'choice') {
const t = e.transcript === null ? '(沉默)' : `(讲述: ${e.transcript})`; return `[${e.pointId}] 选择「${e.optionLabel}」(维度: ${e.dimensions.join('/') || '字段采集'})`;
return `[${e.pointId}] 讲述${t} 情绪签名: ${e.emotionSignature ?? '未知'}`; }
}); if (e.kind === 'followup') {
return `[${e.pointId}] 追问(${e.followUpDimension})回答: ${e.transcript}`;
}
if (e.kind === 'followup_skipped') {
return `[${e.pointId}] 追问(${e.followUpDimension})回答: 记不清了(跳过)`;
}
const t = e.transcript === null ? '(沉默)' : `(讲述: ${e.transcript})`;
return `[${e.pointId}] 讲述${t} 情绪签名: ${e.emotionSignature ?? '未知'}`;
});
return `用户在互动剧情《苍梧疑冢》第一幕中的行为轨迹(按时间排序,含初刷与重玩轨迹):\n${lines.join('\n')}`; return `用户在互动剧情《苍梧疑冢》第一幕中的行为轨迹(按时间排序,含初刷与重玩轨迹):\n${lines.join('\n')}`;
} }
@@ -40,14 +48,14 @@ export function ReportPage({ store, jev, onReplay }: Props) {
(async () => { (async () => {
const events = await store.list(); const events = await store.list();
const state = buildState(events); const state = buildState(events);
// 7 维 score 问题,criteria 为 1-10 级有序数组 // 7 维 score 问题,criteria 为 10 级有序数组(索引 0-9 对应 1-10 级)
const criteria = Array.from({ length: 10 }, (_, i) => String(i + 1)); const criteria = Array.from({ length: 10 }, (_, i) => `${i + 1} 级`);
const questions = Object.fromEntries( const questions = Object.fromEntries(
PROFILE_DIMENSIONS.map((d) => [ PROFILE_DIMENSIONS.map((d) => [
d.id, d.id,
{ {
type: 'score' as const, type: 'score' as const,
description: `根据用户行为轨迹,评估其「${d.label}」维度水平:${d.description}`, instructions: `根据用户行为轨迹,评估其「${d.label}」维度水平:${d.description}`,
criteria, criteria,
}, },
]), ]),
@@ -58,8 +66,9 @@ export function ReportPage({ store, jev, onReplay }: Props) {
setScores( setScores(
PROFILE_DIMENSIONS.map((d) => { PROFILE_DIMENSIONS.map((d) => {
const a = answers[d.id]; const a = answers[d.id];
// API score 是 [0, criteria.length-1] 的概率加权浮点值,+1 映射到 1-10 级展示
return a?.type === 'score' 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 }; : { id: d.id, label: d.label, score: 0, max: 10, confidence: 0 };
}), }),
); );
@@ -118,7 +127,7 @@ export function ReportPage({ store, jev, onReplay }: Props) {
<div className="dimension-fill" style={{ width: `${(s.score / s.max) * 100}%` }} /> <div className="dimension-fill" style={{ width: `${(s.score / s.max) * 100}%` }} />
</div> </div>
<span className="dimension-value"> <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> </span>
</div> </div>
))} ))}
+1 -1
View File
@@ -71,7 +71,7 @@ export function ShareCard({ badgeDimension, scores }: Props) {
ctx.fillStyle = '#d4a96a'; ctx.fillStyle = '#d4a96a';
ctx.fillRect(260, y - 16, (240 * s.score) / s.max, 18); ctx.fillRect(260, y - 16, (240 * s.score) / s.max, 18);
ctx.fillStyle = '#d8ccb8'; ctx.fillStyle = '#d8ccb8';
ctx.fillText(`${s.score}`, 520, y); ctx.fillText(s.score.toFixed(1), 520, y);
}); });
ctx.textAlign = 'center'; ctx.textAlign = 'center';