import type { Spec } from "@json-render/core"; import type { AgentApiClient } from "../api/client"; import { AnswerViewStateApiError, type AnswerViewStateRecord, type AnswerViewStateWrite } from "../api/answer-view-state"; import { normalizeAnswerViewState, type AnswerViewState } from "../answer-ui/answer-view-state"; export type AnswerViewSaveStatus = "loading" | "saving" | "saved" | "unsaved" | "conflict"; export type AnswerViewSnapshot = { state: AnswerViewState; status: AnswerViewSaveStatus; error?: string; }; type PendingWrite = { payload: AnswerViewStateWrite; change: number }; export type AnswerViewEntry = { readonly sessionId: string; readonly messageId: string; readonly blockId: string; spec: Spec; snapshot: AnswerViewSnapshot; remote?: AnswerViewStateRecord; dirty: Map; change: number; listeners: Set<() => void>; loading?: Promise; saving?: Promise; pending?: PendingWrite; conflict?: AnswerViewStateRecord; timer?: ReturnType; started: boolean; released: boolean; }; // This cache belongs to one authenticated workbench/project, never to the module. export class AnswerViewStateScope { private entries = new Map(); private disposed = false; private invalidIdentity?: string; private leases = 0; constructor(readonly scope: string, private readonly client: Pick) {} retain() { this.leases++; } release() { this.leases--; queueMicrotask(() => { if (this.leases === 0) this.dispose(); }); } dispose = () => { this.disposed = true; for (const entry of this.entries.values()) { clearTimeout(entry.timer); entry.listeners.clear(); } this.entries.clear(); }; entry(sessionId: string, messageId: string, blockId: string, spec: Spec): AnswerViewEntry { const key = JSON.stringify([this.scope, sessionId, messageId, blockId]); let entry = this.entries.get(key); if (!entry) { entry = { sessionId, messageId, blockId, spec, snapshot: { state: normalizeAnswerViewState(spec), status: this.invalidIdentity ? "unsaved" : "loading", error: this.invalidIdentity }, dirty: new Map(), change: 0, listeners: new Set(), started: false, released: false }; this.entries.set(key, entry); } entry.spec = spec; return entry; } subscribe(entry: AnswerViewEntry, listener: () => void, enabled = true) { entry.listeners.add(listener); entry.released = false; if (enabled && !entry.started) { entry.started = true; void this.restore(entry); } return () => { entry.listeners.delete(listener); if (!entry.listeners.size) { entry.released = true; clearTimeout(entry.timer); // Preserve the captured entry and client when the active session changes. if (entry.started && !entry.snapshot.error && !entry.conflict) void this.save(entry); } }; } update(entry: AnswerViewEntry, state: AnswerViewState, paths: string[]) { if (this.disposed || !paths.length) return; entry.change++; for (const path of paths) entry.dirty.set(path, entry.change); this.publish(entry, { ...entry.snapshot, state: normalizeAnswerViewState(entry.spec, state), status: entry.conflict ? "conflict" : entry.snapshot.status === "unsaved" ? "unsaved" : entry.loading ? "loading" : entry.saving ? "saving" : "unsaved" }); if (!entry.started) return; // One bounded re-read on the first user edit can recover a final-message save race. if (!entry.remote && !entry.loading && entry.change === 1) { void this.restore(entry); return; } if (entry.remote && !entry.pending && !entry.conflict && !entry.saving) this.schedule(entry); } retry(entry: AnswerViewEntry) { if (this.disposed || !entry.started || entry.conflict) return; if (!entry.remote) void this.restore(entry); else void this.save(entry); } reload(entry: AnswerViewEntry) { if (this.disposed) return; // The UI explicitly confirms replacing local edits before calling this. entry.dirty.clear(); entry.pending = undefined; const current = entry.conflict; entry.conflict = undefined; if (current) { entry.remote = current; this.publish(entry, { state: normalizeAnswerViewState(entry.spec, current), status: "saved" }); } else void this.restore(entry); } resave(entry: AnswerViewEntry) { if (this.disposed || !entry.conflict) return; entry.remote = entry.conflict; entry.conflict = undefined; entry.pending = undefined; entry.snapshot = { ...entry.snapshot, state: normalizeAnswerViewState(entry.spec, entry.snapshot.state) }; // Explicit intent: keep every field in the visible local draft. entry.change++; for (const path of statePaths(entry.snapshot.state)) entry.dirty.set(path, entry.change); void this.save(entry); } private publish(entry: AnswerViewEntry, snapshot: AnswerViewSnapshot) { if (this.disposed) return; entry.snapshot = snapshot; entry.listeners.forEach((listener) => listener()); } private fail(entry: AnswerViewEntry, error: unknown) { if (this.disposed) return; if (error instanceof AnswerViewStateApiError && error.code === "ANSWER_VIEW_IDENTITY_CHANGED") { this.invalidIdentity = error.message; for (const cached of this.entries.values()) { cached.dirty.clear(); cached.pending = undefined; cached.remote = undefined; cached.conflict = undefined; this.publish(cached, { state: normalizeAnswerViewState(cached.spec), status: "unsaved", error: error.message }); } this.dispose(); } else this.publish(entry, { ...entry.snapshot, status: "unsaved", error: errorMessage(error) }); } private schedule(entry: AnswerViewEntry) { clearTimeout(entry.timer); entry.timer = setTimeout(() => void this.save(entry), 500); } private async restore(entry: AnswerViewEntry) { if (this.disposed || !entry.started || entry.loading || entry.saving) return; this.publish(entry, { ...entry.snapshot, status: "loading", error: undefined }); const task = (async () => { try { const remote = await this.client.getAnswerViewState(entry.sessionId, entry.messageId, entry.blockId); if (this.disposed) return; checkIdentity(entry, remote); entry.remote = remote; const restored = normalizeAnswerViewState(entry.spec, remote); const state = normalizeAnswerViewState(entry.spec, mergeDirty(restored, entry.snapshot.state, entry.dirty.keys())); this.publish(entry, { state, status: entry.dirty.size ? "unsaved" : "saved" }); } catch (error) { this.fail(entry, error); } })(); entry.loading = task; await task; entry.loading = undefined; if (!this.disposed && entry.remote && entry.dirty.size) { if (entry.released) void this.save(entry); else this.schedule(entry); } } private async save(entry: AnswerViewEntry) { clearTimeout(entry.timer); if (this.disposed || !entry.started || entry.loading || entry.saving || entry.conflict || !entry.remote || !entry.dirty.size) return; const pending = entry.pending ?? { payload: { message_id: entry.messageId, block_id: entry.blockId, schema_digest: entry.remote.schema_digest, expected_revision: entry.remote.revision, request_id: crypto.randomUUID(), nodes: structuredClone(entry.snapshot.state.nodes), block: { ...entry.snapshot.state.block } }, change: entry.change, }; if (new TextEncoder().encode(JSON.stringify(pending.payload)).byteLength > 32 * 1024) { this.publish(entry, { ...entry.snapshot, status: "unsaved", error: "交互状态超过 32 KiB,当前草稿仅保留在内存中。" }); return; } entry.pending = pending; this.publish(entry, { ...entry.snapshot, status: "saving", error: undefined }); let succeeded = false; const task = (async () => { try { const remote = await this.client.putAnswerViewState(entry.sessionId, pending.payload); if (this.disposed) return; checkIdentity(entry, remote); entry.remote = remote; entry.pending = undefined; for (const [path, change] of entry.dirty) if (change <= pending.change) entry.dirty.delete(path); const state = normalizeAnswerViewState(entry.spec, mergeDirty(normalizeAnswerViewState(entry.spec, remote), entry.snapshot.state, entry.dirty.keys())); this.publish(entry, { state, status: entry.dirty.size ? "unsaved" : "saved" }); succeeded = true; } catch (error) { if (error instanceof AnswerViewStateApiError && error.status === 409 && error.current && error.current.message_id === entry.messageId && error.current.block_id === entry.blockId) { entry.conflict = error.current; this.publish(entry, { ...entry.snapshot, status: "conflict", error: error.code === "ANSWER_VIEW_SCHEMA_CHANGED" ? "回答内容已更新,当前草稿尚未保存。" : "另一个窗口已更新交互状态,当前草稿尚未保存。" }); } else this.fail(entry, error); } })(); entry.saving = task; await task; entry.saving = undefined; if (!this.disposed && succeeded && entry.dirty.size) { if (entry.released) void this.save(entry); else this.schedule(entry); } } } function checkIdentity(entry: AnswerViewEntry, remote: AnswerViewStateRecord) { if (remote.message_id !== entry.messageId || remote.block_id !== entry.blockId) throw new Error("交互状态身份不匹配,请重试"); } function errorMessage(error: unknown) { return error instanceof Error ? error.message : "交互状态未保存,请重试"; } function statePaths(state: AnswerViewState) { return ["/block/evidenceFilter", ...Object.keys(state.nodes).map((key) => `/nodes/${key}`)]; } export function mergeDirty(restored: AnswerViewState, local: AnswerViewState, paths: Iterable): AnswerViewState { const result = structuredClone(restored); for (const path of paths) { const keys = path.split("/").slice(1).map((key) => key.replaceAll("~1", "/").replaceAll("~0", "~")); if (!keys.length || !["nodes", "block"].includes(keys[0])) continue; let source: unknown = local; let target = result as unknown as Record; for (const key of keys.slice(0, -1)) { source = source && typeof source === "object" && Object.hasOwn(source, key) ? (source as Record)[key] : undefined; if (!Object.hasOwn(target, key) || !target[key] || typeof target[key] !== "object") { Object.defineProperty(target, key, { value: {}, enumerable: true, configurable: true, writable: true }); } target = target[key] as Record; } const field = keys.at(-1)!; const value = source && typeof source === "object" && Object.hasOwn(source, field) ? (source as Record)[field] : undefined; if (value === undefined) delete target[field]; else Object.defineProperty(target, field, { value: structuredClone(value), enumerable: true, configurable: true, writable: true }); } return result; }