Integrate Canvas V2 subgraphs, editable edges and AI runs

This commit is contained in:
2026-11-06 19:59:56 +08:00
parent 3478daeccb
commit 05d96abd0e
35 changed files with 1626 additions and 443 deletions
+16 -1
View File
@@ -1,3 +1,4 @@
import { CANVAS_ACCEPT } from "./canvas-v2.ts";
import { decodeCanvas, type CanvasDocument } from "./canvas.ts";
export type CanvasSnapshot = { version: number; document: CanvasDocument };
@@ -23,6 +24,7 @@ export class CanvasSync {
version: number;
saved: string;
error = "";
document: CanvasDocument;
private pending: CanvasDocument | null = null;
private running: Promise<void> | null = null;
private request: CanvasRequest;
@@ -31,6 +33,7 @@ export class CanvasSync {
constructor(request: CanvasRequest, path: string, initial: CanvasSnapshot, notify: (state: SyncState) => void) {
this.request = request; this.path = path; this.version = initial.version;
this.saved = canvasFingerprint(initial.document); this.notify = notify;
this.document = initial.document;
}
enqueue(document: CanvasDocument): Promise<void> {
this.pending = document;
@@ -39,6 +42,12 @@ export class CanvasSync {
return this.running;
}
retry() { this.error = ""; return this.pending ? this.enqueue(this.pending) : Promise.resolve(); }
async settled() { await this.running; }
adopt(snapshot: CanvasSnapshot) {
if (this.running) throw new Error("canvas_busy");
this.pending = null; this.error = ""; this.version = snapshot.version; this.document = snapshot.document;
this.saved = canvasFingerprint(snapshot.document); this.notify({ status: "saved", version: this.version });
}
private async drain() {
while (this.pending) {
const document = this.pending; this.pending = null;
@@ -48,9 +57,10 @@ export class CanvasSync {
if (fingerprint === this.saved) { this.notify({ status: "saved", version: this.version }); continue; }
const body = JSON.stringify({ document: canvasPayload(document) });
if (new TextEncoder().encode(body).length > 5 * 1024 * 1024) throw new Error("canvas_too_large");
const result = await this.request<{ version: number }>(this.path, { method: "PATCH", headers: { "If-Match": `"${this.version}"` }, body });
const result = await this.request<{ version: number }>(this.path, { method: "PATCH", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${this.version}"` }, body });
if (!Number.isSafeInteger(result?.version) || result.version !== this.version + 1) throw new Error("invalid_canvas_snapshot");
this.version = result.version; this.saved = fingerprint;
this.document = document;
this.notify({ status: "saved", version: this.version });
} catch (error) {
this.pending ||= document;
@@ -75,3 +85,8 @@ export function readLocalRecovery(storage: Pick<Storage, "getItem">, key: string
const doc = decodeCanvas(legacy);
return !snapshot || canvasFingerprint(doc) !== canvasFingerprint(snapshot.document) ? doc : null;
}
export function canRestoreCanvasDraft(draft: CanvasDocument, remote: CanvasDocument) {
if (draft.schema_version < remote.schema_version) return false;
const relations = (document: CanvasDocument) => document.nodes.filter((node) => node.data.subgraph).map((node) => ({ id: node.id, subgraph: node.data.subgraph, ports: node.data.ports || [] })).sort((a, b) => a.id.localeCompare(b.id));
return JSON.stringify(relations(draft)) === JSON.stringify(relations(remote));
}