96 lines
3.7 KiB
TypeScript
96 lines
3.7 KiB
TypeScript
import type { ChoiceBehavior, DimensionTag, FollowUpDimension, IdentityProfile } from './types';
|
||
|
||
/**
|
||
* 选择/讲述事件存储(ADR-0004:历史不可涂改,删除权完整保留)。
|
||
* 接口只有 append / list / purge —— 刻意不提供单条 update/delete,
|
||
* 让"不允许单独编辑历史选择"成为类型层面的约束,而非约定。
|
||
* purge 是《个人信息保护法》数据删除权(账号注销语义)下的整库物理清空,
|
||
* 不是对单条历史的修改;append-only 纪律不变。
|
||
*/
|
||
export interface EventStore {
|
||
append(event: StoryEvent): Promise<void>;
|
||
/** 列出事件;传 sessionId 则只看该轨迹 */
|
||
list(sessionId?: string): Promise<StoryEvent[]>;
|
||
/** 整库物理清空(含 consent 存证);删除后应用回到未同意状态 */
|
||
purge(): Promise<void>;
|
||
}
|
||
|
||
/** 一次重玩 = 一条新轨迹(新 sessionId),旧事件原样保留 */
|
||
export interface StoryEvent {
|
||
id: string;
|
||
sessionId: string;
|
||
ts: number;
|
||
chapterId: string;
|
||
kind: 'choice' | 'narration' | 'profile' | 'followup' | 'followup_skipped' | 'consent' | 'branch_inserted' | 'evidence_insufficient';
|
||
branchId?: string;
|
||
confidence?: number;
|
||
behavior?: ChoiceBehavior;
|
||
/** 选择点 ID,如 C1-03;consent 事件固定为 'CONSENT' */
|
||
pointId: string;
|
||
dimensions: DimensionTag[];
|
||
/** consent 事件:同意的告知文案版本(ADR-0004 存证) */
|
||
consentVersion?: string;
|
||
/** choice 事件:所选选项 */
|
||
optionId?: string;
|
||
optionLabel?: string;
|
||
/** 作答时的共同情境,避免模型脱离取舍条件解释选项。 */
|
||
choicePrompt?: string;
|
||
/** narration 事件:选中的场景变体(情绪签名 → variantId) */
|
||
variantId?: string;
|
||
emotionSignature?: string;
|
||
/** C3-02 幻境:只记录路径,不保留在室内说的话 */
|
||
narrationMode?: 'farewell_done' | 'farewell_partial' | 'exit_room';
|
||
/** followup / followup_skipped 事件:追问针对的缺失维度 */
|
||
followUpDimension?: FollowUpDimension;
|
||
/** 讲述/追问的转写文本(Web Speech API 只给文本,不留音频,符合 ADR-0004 即焚原则;沉默为 null) */
|
||
transcript?: string | null;
|
||
/** profile 事件:用户确认后的身份登记字段(匹配硬过滤字段) */
|
||
profile?: IdentityProfile;
|
||
}
|
||
|
||
const STORAGE_KEY = 'jev-dating-match.events.v1';
|
||
|
||
function newId(): string {
|
||
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||
? crypto.randomUUID()
|
||
: `e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||
}
|
||
|
||
export function makeEvent(partial: Omit<StoryEvent, 'id' | 'ts'>): StoryEvent {
|
||
return { ...partial, id: newId(), ts: Date.now() };
|
||
}
|
||
|
||
/** Web 端实现:localStorage。 */
|
||
export class LocalStorageEventStore implements EventStore {
|
||
async append(event: StoryEvent): Promise<void> {
|
||
const all = await this.list();
|
||
all.push(event);
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
||
}
|
||
|
||
async list(sessionId?: string): Promise<StoryEvent[]> {
|
||
let raw: string | null = null;
|
||
try {
|
||
raw = localStorage.getItem(STORAGE_KEY);
|
||
} catch {
|
||
return [];
|
||
}
|
||
const all: StoryEvent[] = raw ? (JSON.parse(raw) as StoryEvent[]) : [];
|
||
return sessionId ? all.filter((e) => e.sessionId === sessionId) : all;
|
||
}
|
||
|
||
async purge(): Promise<void> {
|
||
try {
|
||
localStorage.removeItem(STORAGE_KEY);
|
||
} catch {
|
||
// 隐私模式等场景:静默降级(与 list 一致)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Tauri 原生端实现:见 ./sqliteEventStore.ts(tauri-plugin-sql + SQLite,
|
||
* 同一 EventStore 接口,SQL 层同样无单条 UPDATE/DELETE;整库清空只走 purge)。
|
||
* 运行时选择在 App.tsx:window.__TAURI__ 存在用 SqliteEventStore,否则 localStorage。
|
||
*/
|