Files
next-tjwater-frontend/src/features/agent/session-state/answer-view-state-scope.ts
T
jiang c0e4231b83
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s
feat: expand intelligent investigation workspace
2026-10-30 17:30:19 +08:00

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;
}