From e854b4e6101d6eb185c2bde0d83d5715c1b3c345 Mon Sep 17 00:00:00 2001 From: Jayway Liu <14022498+bluejayway@user.noreply.gitee.com> Date: Sat, 3 Oct 2026 18:30:36 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=BF=9B=E5=BA=A6=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E4=B8=8E=E6=96=AD=E7=82=B9=E6=81=A2=E5=A4=8D=20+=20Vo?= =?UTF-8?q?iceInput=20=E6=9D=83=E9=99=90/=E5=BC=82=E5=B8=B8=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=20+=20Jev=20=E6=95=85=E9=9A=9C=E4=BC=98=E9=9B=85?= =?UTF-8?q?=E9=99=8D=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/verify.ts | 76 ++++++++++++++++++++++++++++- src/App.tsx | 14 ++++-- src/engine/engine.ts | 95 ++++++++++++++++++++++++++++-------- src/engine/progress.ts | 53 ++++++++++++++++++++ src/ui/VoiceInput.tsx | 36 ++++++++++++-- src/ui/report/ReportPage.tsx | 15 +++++- 6 files changed, 258 insertions(+), 31 deletions(-) create mode 100644 src/engine/progress.ts diff --git a/scripts/verify.ts b/scripts/verify.ts index 1ad5496..81bdefa 100644 --- a/scripts/verify.ts +++ b/scripts/verify.ts @@ -15,8 +15,9 @@ import { CONSENT_VERSION, StoryEngine } from '../src/engine/engine'; import { LocalStorageEventStore } from '../src/engine/eventStore'; import type { EventStore, StoryEvent } from '../src/engine/eventStore'; import { choiceFlags, narrationFlags } from '../src/engine/flags'; +import { MemoryProgressStore } from '../src/engine/progress'; import { SqliteEventStore } from '../src/engine/sqliteEventStore'; -import type { Chapter, ChoiceNode, NarrationNode } from '../src/engine/types'; +import type { Chapter, ChoiceNode, IdentityProfile, NarrationNode } from '../src/engine/types'; import { FOLLOW_UP_DIMENSIONS } from '../src/engine/types'; import { MockJevClient } from '../src/services/jev'; import type { JevAnswer, JevQuestion } from '../src/services/jev'; @@ -397,6 +398,79 @@ async function walkAll(eng: StoryEngine, picker: Record = {}) { check(rendered.every((t) => !t.includes('【若')), '渲染输出零【若】标记泄漏(差分路径)'); } +// --- 11. 进度持久化与恢复(真机刷新/热更新断点续玩) --- +{ + const s = new MemoryStore(); + const progress = new MemoryProgressStore(); + const eng = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress); + await walkTo(eng, 'c1-d5'); // 途经 C1-01 身份点与 C1-03 选择点(产生 flag) + const snap = progress.load(); + check( + snap?.chapterId === 'chapter1' && snap.nodeId === 'c1-d5', + `推进后持久化当前位置(${snap?.chapterId}/${snap?.nodeId})`, + ); + const eng2 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress); + const restored = eng2.restore(progress.load()); + check( + restored && eng2.currentNode().id === 'c1-d5' && eng2.state.sessionId === snap?.sessionId, + '重建引擎从持久化位置恢复(sessionId 一致,事件轨迹续接)', + ); + check(eng2.state.flags.c1_03_motive === 'a_truth', '恢复后 flags 随快照还原(条件文本不断档)'); + // 内容更新导致持久化节点消失 → 降级该章开头 + const eng3 = new StoryEngine(chapters, s, new MockJevClient(), undefined, progress); + check( + eng3.restore({ sessionId: 'ghost', chapterId: 'chapter1', nodeId: 'deleted-node', flags: {} }) && + eng3.currentNode().id === chapters[0].startNodeId, + '持久化节点已不存在 → 降级到该章开头(不崩溃)', + ); + check(!eng3.restore({ sessionId: 'ghost', chapterId: 'chapter99', nodeId: 'x', flags: {} }), '持久化章节不存在 → 不恢复'); + // replay:新 sessionId + 持久化位置重置(条件不满足的开场节点会被跳过,落点是跳过后的首个可见节点) + eng2.replay('chapter2'); + const after = progress.load(); + check( + after?.chapterId === 'chapter2' && + after.nodeId === eng2.state.nodeId && + eng2.state.sessionId !== snap?.sessionId, + 'replay 产生新 sessionId 并重置持久化位置到目标章(跳过条件隐藏节点后的首个可见节点)', + ); +} + +// --- 12. Jev 调用失败优雅降级(CORS / 网络故障不卡死) --- +class FailingJev extends MockJevClient { + override async ask(): Promise> { + throw new Error('模拟 Jev 故障:CORS / network'); + } +} +{ + // 12a. 讲述流程:缺失检测失败 → 不追问;情绪签名失败 → 沉默/通用档变体;节点照常推进 + const s = new MemoryStore(); + const eng = new StoryEngine(chapters, s, new FailingJev()); + await walkTo(eng, 'c1-02'); + const r = await eng.submitNarration('去年夏天我离开了家乡,站台上都是雾'); + check(r !== null && eng.state.pendingFollowUp === null, 'Jev 故障:缺失检测失败降级为不追问,流程继续'); + check(eng.state.nodeId === 'c1-d4', 'Jev 故障:变体解析失败后节点照常推进'); + const c102 = chapters[0].nodes.find((n) => n.id === 'c1-02') as NarrationNode; + const silentVariant = c102.variants.find((v) => v.signature.includes('沉默'))!; + const ev = s.events.find((e) => e.kind === 'narration' && e.pointId === 'C1-02'); + check(ev?.variantId === silentVariant.variantId, 'Jev 故障:情绪签名解析降级到沉默/通用档变体'); + // 12b. 身份抽取失败 → 全空字段(UI 直接进可手填确认页),不抛出 + const eng2 = new StoryEngine(chapters, new MemoryStore(), new FailingJev()); + await walkTo(eng2, 'c1-01'); + let profile: IdentityProfile | null = null; + let threw = false; + try { + profile = await eng2.extractIdentity('我叫阿黎,29岁,在杭州'); + } catch { + threw = true; + } + check( + !threw && profile !== null && Object.values(profile).every((v) => v === ''), + 'Jev 故障:身份抽取降级为全空字段进手填确认页(不抛出)', + ); + await eng2.confirmIdentity(profile!); + check(eng2.state.nodeId === 'c1-d2', 'Jev 故障:手填确认后流程照常推进'); +} + if (failures > 0) { console.error(`\n${failures} 项校验失败`); process.exit(1); diff --git a/src/App.tsx b/src/App.tsx index ea81935..6807ec7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import './App.css'; import { StoryEngine } from './engine/engine'; import { LocalStorageEventStore } from './engine/eventStore'; +import { LocalStorageProgressStore } from './engine/progress'; import { SqliteEventStore, isTauriRuntime } from './engine/sqliteEventStore'; import type { Chapter } from './engine/types'; import { getJevClient } from './services/jev'; @@ -20,15 +21,20 @@ export default function App() { const [consent, setConsent] = useState<'unknown' | 'needed' | 'granted'>('unknown'); // 引擎与存储整个会话只建一次;重玩走 engine.replay()(新 sessionId,旧事件保留) // 运行时选择:Tauri 原生端用 SQLite,浏览器 dev 用 localStorage(同一 append-only 接口) - const { engine, store, jev } = useMemo(() => { + const { engine, store, jev, progress } = useMemo(() => { const store = isTauriRuntime() ? new SqliteEventStore() : new LocalStorageEventStore(); const jev = getJevClient(); - return { engine: new StoryEngine(chapters, store, jev), store, jev }; + const progress = new LocalStorageProgressStore(); + return { engine: new StoryEngine(chapters, store, jev, undefined, progress), store, jev, progress }; }, []); useEffect(() => { - void engine.hasConsent().then((ok) => setConsent(ok ? 'granted' : 'needed')); - }, [engine]); + void engine.hasConsent().then((ok) => { + // 同意门之后才恢复剧情进度(刷新/热更新前的断点) + if (ok) engine.restore(progress.load()); + setConsent(ok ? 'granted' : 'needed'); + }); + }, [engine, progress]); if (consent !== 'granted') { return ( diff --git a/src/engine/engine.ts b/src/engine/engine.ts index 0e19b2d..7725d14 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -2,9 +2,10 @@ import type { Chapter, FollowUpDimension, IdentityProfile, NarrationNode, SceneV import { FOLLOW_UP_DIMENSIONS, FOLLOW_UP_PRIORITY } from './types'; import type { EventStore, StoryEvent } from './eventStore'; import { makeEvent } from './eventStore'; -import type { JevClient, JevQuestion } from '../services/jev'; +import type { JevAnswer, JevClient, JevQuestion } from '../services/jev'; import type { Flags } from './flags'; import { choiceFlags, narrationFlags, resolveConditionalText } from './flags'; +import type { ProgressSnapshot, ProgressStore } from './progress'; /** 告知文案版本:文案变动时升版,consent 事件存证所同意的版本(ADR-0004) */ export const CONSENT_VERSION = 'act1-consent-v1'; @@ -68,13 +69,15 @@ export class StoryEngine { private chapters: Chapter[]; private store: EventStore; private jev: JevClient; + private progress?: ProgressStore; private nodeIndex: Map = new Map(); - constructor(chapters: Chapter[], store: EventStore, jev: JevClient, startChapterId?: string) { + constructor(chapters: Chapter[], store: EventStore, jev: JevClient, startChapterId?: string, progress?: ProgressStore) { if (chapters.length === 0) throw new Error('StoryEngine: no chapters'); this.chapters = chapters; this.store = store; this.jev = jev; + this.progress = progress; const start = chapters.find((c) => c.id === startChapterId) ?? chapters[0]; for (const c of chapters) { for (const n of c.nodes) this.nodeIndex.set(`${c.id}/${n.id}`, n); @@ -120,6 +123,38 @@ export class StoryEngine { throw new Error(`advance() 不适用于 ${node.type} 节点`); } this.skipHiddenNodes(); + this.persist(); + } + + /** 进度持久化:每次状态迁移后落盘(真机刷新/热更新后从断点恢复) */ + private persist(): void { + this.progress?.save({ + sessionId: this.state.sessionId, + chapterId: this.state.chapterId, + nodeId: this.state.nodeId, + flags: { ...this.state.flags }, + }); + } + + /** + * 从持久化位置恢复。持久化的 nodeId 在新版章节数据里已不存在时, + * 降级到该章开头而不是崩溃;章节本身不存在则不恢复(返回 false)。 + */ + restore(snapshot: ProgressSnapshot | null): boolean { + if (!snapshot) return false; + const chapter = this.chapters.find((c) => c.id === snapshot.chapterId); + if (!chapter) return false; + const nodeExists = this.nodeIndex.has(`${snapshot.chapterId}/${snapshot.nodeId}`); + this.state.sessionId = snapshot.sessionId; + this.state.chapterId = snapshot.chapterId; + this.state.nodeId = nodeExists ? snapshot.nodeId : chapter.startNodeId; + this.state.flags = { ...snapshot.flags }; + this.state.activeVariant = null; + this.state.pendingFollowUp = null; + this.state.finished = false; + this.skipHiddenNodes(); + this.persist(); + return true; } /** @@ -164,6 +199,7 @@ export class StoryEngine { Object.assign(this.state.flags, choiceFlags(node.pointId, option.id)); this.state.nodeId = option.next; this.skipHiddenNodes(); + this.persist(); } /** @@ -177,7 +213,13 @@ export class StoryEngine { 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); + // Jev 故障(CORS / 网络)时降级:跳过追问环节,剧情继续,不卡死 + let missing: FollowUpDimension | null = null; + try { + missing = await this.detectMissing(node, transcript); + } catch (e) { + console.warn('[engine] 缺失检测失败,跳过追问继续剧情', e); + } if (missing) { this.state.pendingFollowUp = { pointId: node.pointId, @@ -273,6 +315,7 @@ export class StoryEngine { this.state.activeVariant = variant; this.state.nodeId = node.next; this.skipHiddenNodes(); + this.persist(); return { variant, event }; } @@ -296,23 +339,28 @@ export class StoryEngine { const silent = node.variants.find((v) => v.signature.includes('沉默')); return silent ?? node.variants[node.variants.length - 1]; } - // 通道 1:Jev 从讲述文本提取情绪签名,作为预写变体的选择器(只做选择题) - const answer = await this.jev.ask( - { transcript, pointId: node.pointId }, - { - emotion_signature: { - type: 'choice', - instructions: `根据讲述文本选择最贴合的情绪签名(讲述点 ${node.pointId})`, - criteria: Object.fromEntries(node.variants.map((v) => [v.signature, v.label])), + // 通道 1:Jev 从讲述文本提取情绪签名,作为预写变体的选择器(只做选择题)。 + // Jev 故障时降级到"沉默/通用"档变体继续剧情,console.warn 记录,不向用户报错。 + try { + const answer = await this.jev.ask( + { transcript, pointId: node.pointId }, + { + emotion_signature: { + type: 'choice', + 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.choice); - if (match) return match; + ); + const picked = answer.emotion_signature; + if (picked?.type === 'choice') { + const match = node.variants.find((v) => v.signature === picked.choice); + if (match) return match; + } + } catch (e) { + console.warn('[engine] 情绪签名解析失败,降级到沉默/通用档变体', e); } - return node.variants[0]; + return node.variants.find((v) => v.signature.includes('沉默')) ?? node.variants[0]; } /** @@ -373,7 +421,14 @@ export class StoryEngine { }, }, }; - const answers = await this.jev.ask({ transcript }, questions); + // Jev 故障(CORS / 网络)时降级:返回全空字段,直接进入可手填的确认页 + let answers: Record; + try { + answers = await this.jev.ask({ transcript }, questions); + } catch (e) { + console.warn('[engine] 身份字段抽取失败,降级为手填确认页', e); + return { city: '', age: '', gender: '', occupation: '', education: '' }; + } const choiceVal = (id: string): string => { const a = answers[id]; return a?.type === 'choice' && a.choice !== '未提及' ? a.choice : ''; @@ -407,6 +462,7 @@ export class StoryEngine { ); this.state.nodeId = node.next; this.skipHiddenNodes(); + this.persist(); } /** @@ -447,5 +503,6 @@ export class StoryEngine { this.state.flags = {}; this.state.finished = false; this.skipHiddenNodes(); + this.persist(); } } diff --git a/src/engine/progress.ts b/src/engine/progress.ts new file mode 100644 index 0000000..780dcc8 --- /dev/null +++ b/src/engine/progress.ts @@ -0,0 +1,53 @@ +import type { Flags } from './flags'; + +/** + * 剧情进度持久化(真机刷新/热更新恢复用)。 + * 引擎位置本来只在内存里,权限弹窗等引发的页面刷新会让玩家回到开头—— + * 每次状态迁移后把当前位置落盘,启动时恢复。 + */ + +export interface ProgressSnapshot { + sessionId: string; + chapterId: string; + nodeId: string; + /** 分支变量随位置一起持久化,恢复后条件文本渲染不断档 */ + flags: Flags; +} + +export interface ProgressStore { + save(p: ProgressSnapshot): void; + load(): ProgressSnapshot | null; +} + +const PROGRESS_KEY = 'jev-dating-match.progress.v1'; + +/** 浏览器 / Tauri WKWebView 通用:localStorage。 */ +export class LocalStorageProgressStore implements ProgressStore { + save(p: ProgressSnapshot): void { + try { + localStorage.setItem(PROGRESS_KEY, JSON.stringify(p)); + } catch { + // 隐私模式等写不进去的场景:静默降级为不持久化,不影响游玩 + } + } + + load(): ProgressSnapshot | null { + try { + const raw = localStorage.getItem(PROGRESS_KEY); + return raw ? (JSON.parse(raw) as ProgressSnapshot) : null; + } catch { + return null; + } + } +} + +/** verify / 测试用内存实现 */ +export class MemoryProgressStore implements ProgressStore { + private snap: ProgressSnapshot | null = null; + save(p: ProgressSnapshot): void { + this.snap = p; + } + load(): ProgressSnapshot | null { + return this.snap; + } +} diff --git a/src/ui/VoiceInput.tsx b/src/ui/VoiceInput.tsx index 8743f18..9550925 100644 --- a/src/ui/VoiceInput.tsx +++ b/src/ui/VoiceInput.tsx @@ -47,13 +47,17 @@ export function VoiceInput({ onSubmit, disabled }: Props) { const [live, setLive] = useState(''); const [text, setText] = useState(''); const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); const recRef = useRef(null); const finalRef = useRef(''); + // 出错(权限被拒/识别失败)后 onend 仍会触发,用它阻止错误后的自动提交 + const erroredRef = useRef(false); async function submit(t: string) { const trimmed = t.trim(); if (!trimmed) return; setBusy(true); + setError(null); try { await onSubmit(trimmed); } finally { @@ -74,15 +78,18 @@ export function VoiceInput({ onSubmit, disabled }: Props) { value={text} onChange={(e) => setText(e.target.value)} /> - + {error &&

{error}

} ); } // ---- 语音路径:按住说话 ---- function start() { + setError(null); + erroredRef.current = false; const rec = new SR!(); rec.lang = 'zh-CN'; rec.continuous = true; @@ -98,34 +105,53 @@ export function VoiceInput({ onSubmit, disabled }: Props) { } setLive(finalRef.current + interim); }; - rec.onerror = () => setHolding(false); + rec.onerror = () => { + // 权限被拒或识别失败:可见的温和提示,不自动提交已识别片段 + erroredRef.current = true; + setHolding(false); + setError('没听清,或麦克风没授权成功——再按一次试试。'); + }; rec.onend = () => { setHolding(false); const t = finalRef.current; - if (t.trim()) void submit(t); + if (!erroredRef.current && t.trim()) void submit(t); }; recRef.current = rec; setHolding(true); - rec.start(); + try { + rec.start(); + } catch { + // 权限弹窗打断按住手势等场景下 start 可能裸抛 + setHolding(false); + recRef.current = null; + setError('麦克风没能启动——再按一次试试。'); + } } function stop() { - recRef.current?.stop(); + try { + recRef.current?.stop(); + } catch { + // InvalidStateError:权限弹窗/来电打断手势时 stop 会裸抛,吞掉即可 + } recRef.current = null; } return (
{live &&

{live}

} + {error &&

{error}

}
); } diff --git a/src/ui/report/ReportPage.tsx b/src/ui/report/ReportPage.tsx index f844433..b6a2542 100644 --- a/src/ui/report/ReportPage.tsx +++ b/src/ui/report/ReportPage.tsx @@ -42,6 +42,7 @@ function buildState(events: StoryEvent[]): string { export function ReportPage({ store, jev, onReplay }: Props) { const [scores, setScores] = useState(null); const [error, setError] = useState(null); + const [retry, setRetry] = useState(0); useEffect(() => { let cancelled = false; @@ -79,14 +80,24 @@ export function ReportPage({ store, jev, onReplay }: Props) { return () => { cancelled = true; }; - }, [store, jev]); + }, [store, jev, retry]); if (error) { return (

画像报告生成失败

{error}

- +