210 lines
11 KiB
TypeScript
210 lines
11 KiB
TypeScript
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<string, number>; change: number; listeners: Set<() => void>;
|
|
loading?: Promise<void>; saving?: Promise<void>; pending?: PendingWrite;
|
|
conflict?: AnswerViewStateRecord; timer?: ReturnType<typeof setTimeout>;
|
|
started: boolean; released: boolean;
|
|
};
|
|
|
|
// This cache belongs to one authenticated workbench/project, never to the module.
|
|
export class AnswerViewStateScope {
|
|
private entries = new Map<string, AnswerViewEntry>();
|
|
private disposed = false;
|
|
private invalidIdentity?: string;
|
|
private leases = 0;
|
|
constructor(readonly scope: string, private readonly client: Pick<AgentApiClient, "getAnswerViewState" | "putAnswerViewState">) {}
|
|
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<string>): 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<string, unknown>;
|
|
for (const key of keys.slice(0, -1)) {
|
|
source = source && typeof source === "object" && Object.hasOwn(source, key) ? (source as Record<string, unknown>)[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<string, unknown>;
|
|
}
|
|
const field = keys.at(-1)!;
|
|
const value = source && typeof source === "object" && Object.hasOwn(source, field) ? (source as Record<string, unknown>)[field] : undefined;
|
|
if (value === undefined) delete target[field]; else Object.defineProperty(target, field, { value: structuredClone(value), enumerable: true, configurable: true, writable: true });
|
|
}
|
|
return result;
|
|
}
|