fix: 进度持久化与断点恢复 + VoiceInput 权限/异常加固 + Jev 故障优雅降级
This commit is contained in:
+76
-19
@@ -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<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');
|
||||
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<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 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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user