fix: 进度持久化与断点恢复 + VoiceInput 权限/异常加固 + Jev 故障优雅降级
This commit is contained in:
+75
-1
@@ -15,8 +15,9 @@ import { CONSENT_VERSION, StoryEngine } from '../src/engine/engine';
|
|||||||
import { LocalStorageEventStore } from '../src/engine/eventStore';
|
import { LocalStorageEventStore } from '../src/engine/eventStore';
|
||||||
import type { EventStore, StoryEvent } from '../src/engine/eventStore';
|
import type { EventStore, StoryEvent } from '../src/engine/eventStore';
|
||||||
import { choiceFlags, narrationFlags } from '../src/engine/flags';
|
import { choiceFlags, narrationFlags } from '../src/engine/flags';
|
||||||
|
import { MemoryProgressStore } from '../src/engine/progress';
|
||||||
import { SqliteEventStore } from '../src/engine/sqliteEventStore';
|
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 { 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';
|
import type { JevAnswer, JevQuestion } from '../src/services/jev';
|
||||||
@@ -397,6 +398,79 @@ async function walkAll(eng: StoryEngine, picker: Record<string, string> = {}) {
|
|||||||
check(rendered.every((t) => !t.includes('【若')), '渲染输出零【若】标记泄漏(差分路径)');
|
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<Record<string, JevAnswer>> {
|
||||||
|
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) {
|
if (failures > 0) {
|
||||||
console.error(`\n${failures} 项校验失败`);
|
console.error(`\n${failures} 项校验失败`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
+10
-4
@@ -2,6 +2,7 @@ import { useEffect, 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 { LocalStorageProgressStore } from './engine/progress';
|
||||||
import { SqliteEventStore, isTauriRuntime } from './engine/sqliteEventStore';
|
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';
|
||||||
@@ -20,15 +21,20 @@ export default function App() {
|
|||||||
const [consent, setConsent] = useState<'unknown' | 'needed' | 'granted'>('unknown');
|
const [consent, setConsent] = useState<'unknown' | 'needed' | 'granted'>('unknown');
|
||||||
// 引擎与存储整个会话只建一次;重玩走 engine.replay()(新 sessionId,旧事件保留)
|
// 引擎与存储整个会话只建一次;重玩走 engine.replay()(新 sessionId,旧事件保留)
|
||||||
// 运行时选择:Tauri 原生端用 SQLite,浏览器 dev 用 localStorage(同一 append-only 接口)
|
// 运行时选择: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 store = isTauriRuntime() ? new SqliteEventStore() : new LocalStorageEventStore();
|
||||||
const jev = getJevClient();
|
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(() => {
|
useEffect(() => {
|
||||||
void engine.hasConsent().then((ok) => setConsent(ok ? 'granted' : 'needed'));
|
void engine.hasConsent().then((ok) => {
|
||||||
}, [engine]);
|
// 同意门之后才恢复剧情进度(刷新/热更新前的断点)
|
||||||
|
if (ok) engine.restore(progress.load());
|
||||||
|
setConsent(ok ? 'granted' : 'needed');
|
||||||
|
});
|
||||||
|
}, [engine, progress]);
|
||||||
|
|
||||||
if (consent !== 'granted') {
|
if (consent !== 'granted') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+76
-19
@@ -2,9 +2,10 @@ import type { Chapter, FollowUpDimension, IdentityProfile, NarrationNode, SceneV
|
|||||||
import { FOLLOW_UP_DIMENSIONS, FOLLOW_UP_PRIORITY } 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, JevQuestion } from '../services/jev';
|
import type { JevAnswer, JevClient, JevQuestion } from '../services/jev';
|
||||||
import type { Flags } from './flags';
|
import type { Flags } from './flags';
|
||||||
import { choiceFlags, narrationFlags, resolveConditionalText } from './flags';
|
import { choiceFlags, narrationFlags, resolveConditionalText } from './flags';
|
||||||
|
import type { ProgressSnapshot, ProgressStore } from './progress';
|
||||||
|
|
||||||
/** 告知文案版本:文案变动时升版,consent 事件存证所同意的版本(ADR-0004) */
|
/** 告知文案版本:文案变动时升版,consent 事件存证所同意的版本(ADR-0004) */
|
||||||
export const CONSENT_VERSION = 'act1-consent-v1';
|
export const CONSENT_VERSION = 'act1-consent-v1';
|
||||||
@@ -68,13 +69,15 @@ export class StoryEngine {
|
|||||||
private chapters: Chapter[];
|
private chapters: Chapter[];
|
||||||
private store: EventStore;
|
private store: EventStore;
|
||||||
private jev: JevClient;
|
private jev: JevClient;
|
||||||
|
private progress?: ProgressStore;
|
||||||
private nodeIndex: Map<string, StoryNode> = new Map();
|
private nodeIndex: Map<string, StoryNode> = 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');
|
if (chapters.length === 0) throw new Error('StoryEngine: no chapters');
|
||||||
this.chapters = chapters;
|
this.chapters = chapters;
|
||||||
this.store = store;
|
this.store = store;
|
||||||
this.jev = jev;
|
this.jev = jev;
|
||||||
|
this.progress = progress;
|
||||||
const start = chapters.find((c) => c.id === startChapterId) ?? chapters[0];
|
const start = chapters.find((c) => c.id === startChapterId) ?? chapters[0];
|
||||||
for (const c of chapters) {
|
for (const c of chapters) {
|
||||||
for (const n of c.nodes) this.nodeIndex.set(`${c.id}/${n.id}`, n);
|
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} 节点`);
|
throw new Error(`advance() 不适用于 ${node.type} 节点`);
|
||||||
}
|
}
|
||||||
this.skipHiddenNodes();
|
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));
|
Object.assign(this.state.flags, choiceFlags(node.pointId, option.id));
|
||||||
this.state.nodeId = option.next;
|
this.state.nodeId = option.next;
|
||||||
this.skipHiddenNodes();
|
this.skipHiddenNodes();
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -177,7 +213,13 @@ export class StoryEngine {
|
|||||||
if (node.type !== 'narration') throw new Error('当前节点不是讲述点');
|
if (node.type !== 'narration') throw new Error('当前节点不是讲述点');
|
||||||
if (this.state.pendingFollowUp) throw new Error('有未处理的追问,请先 answerFollowUp / skipFollowUp');
|
if (this.state.pendingFollowUp) throw new Error('有未处理的追问,请先 answerFollowUp / skipFollowUp');
|
||||||
if (transcript === null) return this.finalizeNarration(node, null);
|
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) {
|
if (missing) {
|
||||||
this.state.pendingFollowUp = {
|
this.state.pendingFollowUp = {
|
||||||
pointId: node.pointId,
|
pointId: node.pointId,
|
||||||
@@ -273,6 +315,7 @@ export class StoryEngine {
|
|||||||
this.state.activeVariant = variant;
|
this.state.activeVariant = variant;
|
||||||
this.state.nodeId = node.next;
|
this.state.nodeId = node.next;
|
||||||
this.skipHiddenNodes();
|
this.skipHiddenNodes();
|
||||||
|
this.persist();
|
||||||
return { variant, event };
|
return { variant, event };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,23 +339,28 @@ export class StoryEngine {
|
|||||||
const silent = node.variants.find((v) => v.signature.includes('沉默'));
|
const silent = node.variants.find((v) => v.signature.includes('沉默'));
|
||||||
return silent ?? node.variants[node.variants.length - 1];
|
return silent ?? node.variants[node.variants.length - 1];
|
||||||
}
|
}
|
||||||
// 通道 1:Jev 从讲述文本提取情绪签名,作为预写变体的选择器(只做选择题)
|
// 通道 1:Jev 从讲述文本提取情绪签名,作为预写变体的选择器(只做选择题)。
|
||||||
const answer = await this.jev.ask(
|
// Jev 故障时降级到"沉默/通用"档变体继续剧情,console.warn 记录,不向用户报错。
|
||||||
{ transcript, pointId: node.pointId },
|
try {
|
||||||
{
|
const answer = await this.jev.ask(
|
||||||
emotion_signature: {
|
{ transcript, pointId: node.pointId },
|
||||||
type: 'choice',
|
{
|
||||||
instructions: `根据讲述文本选择最贴合的情绪签名(讲述点 ${node.pointId})`,
|
emotion_signature: {
|
||||||
criteria: Object.fromEntries(node.variants.map((v) => [v.signature, v.label])),
|
type: 'choice',
|
||||||
|
instructions: `根据讲述文本选择最贴合的情绪签名(讲述点 ${node.pointId})`,
|
||||||
|
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.choice);
|
||||||
const match = node.variants.find((v) => v.signature === picked.choice);
|
if (match) return match;
|
||||||
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<string, JevAnswer>;
|
||||||
|
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 choiceVal = (id: string): string => {
|
||||||
const a = answers[id];
|
const a = answers[id];
|
||||||
return a?.type === 'choice' && a.choice !== '未提及' ? a.choice : '';
|
return a?.type === 'choice' && a.choice !== '未提及' ? a.choice : '';
|
||||||
@@ -407,6 +462,7 @@ export class StoryEngine {
|
|||||||
);
|
);
|
||||||
this.state.nodeId = node.next;
|
this.state.nodeId = node.next;
|
||||||
this.skipHiddenNodes();
|
this.skipHiddenNodes();
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -447,5 +503,6 @@ export class StoryEngine {
|
|||||||
this.state.flags = {};
|
this.state.flags = {};
|
||||||
this.state.finished = false;
|
this.state.finished = false;
|
||||||
this.skipHiddenNodes();
|
this.skipHiddenNodes();
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-5
@@ -47,13 +47,17 @@ export function VoiceInput({ onSubmit, disabled }: Props) {
|
|||||||
const [live, setLive] = useState('');
|
const [live, setLive] = useState('');
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const recRef = useRef<SpeechRecognitionLike | null>(null);
|
const recRef = useRef<SpeechRecognitionLike | null>(null);
|
||||||
const finalRef = useRef('');
|
const finalRef = useRef('');
|
||||||
|
// 出错(权限被拒/识别失败)后 onend 仍会触发,用它阻止错误后的自动提交
|
||||||
|
const erroredRef = useRef(false);
|
||||||
|
|
||||||
async function submit(t: string) {
|
async function submit(t: string) {
|
||||||
const trimmed = t.trim();
|
const trimmed = t.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await onSubmit(trimmed);
|
await onSubmit(trimmed);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -74,15 +78,18 @@ export function VoiceInput({ onSubmit, disabled }: Props) {
|
|||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button className="primary" disabled={disabled || busy || !text.trim()} onClick={() => submit(text)}>
|
<button type="button" className="primary" disabled={disabled || busy || !text.trim()} onClick={() => submit(text)}>
|
||||||
提交
|
提交
|
||||||
</button>
|
</button>
|
||||||
|
{error && <p className="hint">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 语音路径:按住说话 ----
|
// ---- 语音路径:按住说话 ----
|
||||||
function start() {
|
function start() {
|
||||||
|
setError(null);
|
||||||
|
erroredRef.current = false;
|
||||||
const rec = new SR!();
|
const rec = new SR!();
|
||||||
rec.lang = 'zh-CN';
|
rec.lang = 'zh-CN';
|
||||||
rec.continuous = true;
|
rec.continuous = true;
|
||||||
@@ -98,34 +105,53 @@ export function VoiceInput({ onSubmit, disabled }: Props) {
|
|||||||
}
|
}
|
||||||
setLive(finalRef.current + interim);
|
setLive(finalRef.current + interim);
|
||||||
};
|
};
|
||||||
rec.onerror = () => setHolding(false);
|
rec.onerror = () => {
|
||||||
|
// 权限被拒或识别失败:可见的温和提示,不自动提交已识别片段
|
||||||
|
erroredRef.current = true;
|
||||||
|
setHolding(false);
|
||||||
|
setError('没听清,或麦克风没授权成功——再按一次试试。');
|
||||||
|
};
|
||||||
rec.onend = () => {
|
rec.onend = () => {
|
||||||
setHolding(false);
|
setHolding(false);
|
||||||
const t = finalRef.current;
|
const t = finalRef.current;
|
||||||
if (t.trim()) void submit(t);
|
if (!erroredRef.current && t.trim()) void submit(t);
|
||||||
};
|
};
|
||||||
recRef.current = rec;
|
recRef.current = rec;
|
||||||
setHolding(true);
|
setHolding(true);
|
||||||
rec.start();
|
try {
|
||||||
|
rec.start();
|
||||||
|
} catch {
|
||||||
|
// 权限弹窗打断按住手势等场景下 start 可能裸抛
|
||||||
|
setHolding(false);
|
||||||
|
recRef.current = null;
|
||||||
|
setError('麦克风没能启动——再按一次试试。');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stop() {
|
function stop() {
|
||||||
recRef.current?.stop();
|
try {
|
||||||
|
recRef.current?.stop();
|
||||||
|
} catch {
|
||||||
|
// InvalidStateError:权限弹窗/来电打断手势时 stop 会裸抛,吞掉即可
|
||||||
|
}
|
||||||
recRef.current = null;
|
recRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="voice-input">
|
<div className="voice-input">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={`hold-btn ${holding ? 'holding' : ''}`}
|
className={`hold-btn ${holding ? 'holding' : ''}`}
|
||||||
disabled={disabled || busy}
|
disabled={disabled || busy}
|
||||||
onPointerDown={start}
|
onPointerDown={start}
|
||||||
onPointerUp={stop}
|
onPointerUp={stop}
|
||||||
onPointerLeave={() => holding && stop()}
|
onPointerLeave={() => holding && stop()}
|
||||||
|
onPointerCancel={() => holding && stop()}
|
||||||
>
|
>
|
||||||
{busy ? '档案馆听写中…' : holding ? '松开结束 · 讲述中…' : '按住说话'}
|
{busy ? '档案馆听写中…' : holding ? '松开结束 · 讲述中…' : '按住说话'}
|
||||||
</button>
|
</button>
|
||||||
{live && <p className="voice-live">{live}</p>}
|
{live && <p className="voice-live">{live}</p>}
|
||||||
|
{error && <p className="hint">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function buildState(events: StoryEvent[]): string {
|
|||||||
export function ReportPage({ store, jev, onReplay }: Props) {
|
export function ReportPage({ store, jev, onReplay }: Props) {
|
||||||
const [scores, setScores] = useState<DimensionScore[] | null>(null);
|
const [scores, setScores] = useState<DimensionScore[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [retry, setRetry] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -79,14 +80,24 @@ export function ReportPage({ store, jev, onReplay }: Props) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [store, jev]);
|
}, [store, jev, retry]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="report">
|
<div className="report">
|
||||||
<h1>画像报告生成失败</h1>
|
<h1>画像报告生成失败</h1>
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<button className="primary" onClick={onReplay}>
|
<button
|
||||||
|
className="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setError(null);
|
||||||
|
setScores(null);
|
||||||
|
setRetry((r) => r + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
画像生成失败,点此重试
|
||||||
|
</button>
|
||||||
|
<button className="secondary" onClick={onReplay}>
|
||||||
回到剧情
|
回到剧情
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user