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; /** 列出事件;传 sessionId 则只看该轨迹 */ list(sessionId?: string): Promise; /** 整库物理清空(含 consent 存证);删除后应用回到未同意状态 */ purge(): Promise; } /** 一次重玩 = 一条新轨迹(新 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 { return { ...partial, id: newId(), ts: Date.now() }; } /** Web 端实现:localStorage。 */ export class LocalStorageEventStore implements EventStore { async append(event: StoryEvent): Promise { const all = await this.list(); all.push(event); localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); } async list(sessionId?: string): Promise { 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 { 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。 */