Compare commits

..

No commits in common. "main" and "codex/zip-cdn-delivery-20261106" have entirely different histories.

19 changed files with 140 additions and 1033 deletions

View File

@ -329,7 +329,6 @@ export async function POST(request: Request) {
withModel, withModel,
imageSize: snapshot.imagePreferences?.parameters?.imageSize, imageSize: snapshot.imagePreferences?.parameters?.imageSize,
imagePreferences: snapshot.imagePreferences, imagePreferences: snapshot.imagePreferences,
priorCalls: snapshot.calls,
preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined), preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined),
operationSelectedNodeIds: selectedNodeIds, operationSelectedNodeIds: selectedNodeIds,
objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join( objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join(

View File

@ -4,8 +4,7 @@ import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
import { readCanvasDraft, writeCanvasDraft, clearCanvasDraft } from "@/lib/client/canvas-draft"; import { readCanvasDraft, writeCanvasDraft, clearCanvasDraft } from "@/lib/client/canvas-draft";
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot"; import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh"; import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
import { CanvasRemoteOperationContext, type CanvasRemoteOperation, type CanvasRemoteOperationResult } from "@/components/canvas/CanvasRemoteOperationContext"; import { CanvasRemoteOperationContext } from "@/components/canvas/CanvasRemoteOperationContext";
import equal from "fast-deep-equal";
import { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
@ -172,7 +171,6 @@ import {
type VideoCanvasSymbolMarkerId, type VideoCanvasSymbolMarkerId,
} from "@/lib/canvas/video-canvas-markers"; } from "@/lib/canvas/video-canvas-markers";
import { import {
isCanvasImageUploadPending,
LOCAL_UPLOAD_PENDING_METADATA_KEY, LOCAL_UPLOAD_PENDING_METADATA_KEY,
LOCAL_UPLOAD_PREVIEW_METADATA_KEY, LOCAL_UPLOAD_PREVIEW_METADATA_KEY,
} from "@/lib/canvas/local-upload-state"; } from "@/lib/canvas/local-upload-state";
@ -1939,7 +1937,6 @@ export default function CanvasEditorLegacyPage({
const canvasReadyRef = useRef(Boolean(initialProjectForCurrentRoute)); const canvasReadyRef = useRef(Boolean(initialProjectForCurrentRoute));
const savedCanvasSnapshotRef = useRef<{ id: string; snapshot: string } | null>(null); const savedCanvasSnapshotRef = useRef<{ id: string; snapshot: string } | null>(null);
const canvasSyncErrorRef = useRef(""); const canvasSyncErrorRef = useRef("");
const [canvasSyncConflicts, setCanvasSyncConflicts] = useState<string[]>([]);
const [isEditingTitle, setIsEditingTitle] = useState(false); const [isEditingTitle, setIsEditingTitle] = useState(false);
const [isAddImageDialogOpen, setIsAddImageDialogOpen] = useState(false); const [isAddImageDialogOpen, setIsAddImageDialogOpen] = useState(false);
const [editTitle, setEditTitle] = useState(""); const [editTitle, setEditTitle] = useState("");
@ -3381,17 +3378,13 @@ export default function CanvasEditorLegacyPage({
const base = JSON.stringify({ canvasData }); const base = JSON.stringify({ canvasData });
savedCanvasSnapshotRef.current = { id, snapshot: base }; savedCanvasSnapshotRef.current = { id, snapshot: base };
externalCanvasConflictRef.current = false; externalCanvasConflictRef.current = false;
setCanvasSyncConflicts([]);
const draft = readCanvasDraft(id); const draft = readCanvasDraft(id);
if (draft) { if (draft) {
const merged = mergeCanvasSnapshots(draft.base, draft.snapshot, base); const merged = mergeCanvasSnapshots(draft.base, draft.snapshot, base);
if (merged.conflicts.length) { if (merged.conflicts.length) {
externalCanvasConflictRef.current = true; externalCanvasConflictRef.current = true;
savedCanvasSnapshotRef.current = { id, snapshot: draft.base }; useCanvasStore.getState().loadCanvas(JSON.parse(draft.snapshot).canvasData);
setCanvasSyncConflicts(merged.conflicts); toast.warning("同一画布内容在多处被修改,本地草稿已恢复;请保留此页并联系管理员核对冲突。");
canvasSyncErrorRef.current = "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。";
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
toast.warning(canvasSyncErrorRef.current);
return; return;
} }
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData); useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
@ -5122,64 +5115,16 @@ export default function CanvasEditorLegacyPage({
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const canvasPersistenceScope = useRef(id); const canvasPersistenceScope = useRef(id);
const canvasRemoteOperationActive = useRef(false); const canvasRemoteOperationActive = useRef(false);
const canvasLeaveApprovedRef = useRef(false);
const [canvasLeaveUrl, setCanvasLeaveUrl] = useState("");
const [isSavingBeforeLeave, setIsSavingBeforeLeave] = useState(false);
const [canvasLeaveError, setCanvasLeaveError] = useState("");
canvasPersistenceScope.current = id; canvasPersistenceScope.current = id;
const hasUnsavedCanvasChanges = useCallback(() => {
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || savedCanvasSnapshotRef.current?.id !== id) return false;
if (useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) return true;
const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData as CanvasState;
const current = JSON.parse(JSON.stringify(buildPersistedCanvasData())) as CanvasState;
const content = (value: CanvasState) => [value.nodes.map(node => {
const { updatedAt: _updatedAt, ...nodeData } = node;
return { ...nodeData, connections: node.connections || [] };
}), value.edges || [], value.suppressedMediaKeys || [], normalizeStyleProPersistedState(value.styleProState)];
return !equal(content(saved), content(current));
}, [buildPersistedCanvasData, id]);
const requestCanvasLeave = useCallback((url: string) => {
if (hasUnsavedCanvasChanges()) {
setCanvasLeaveError("");
setCanvasLeaveUrl(url);
return;
}
canvasLeaveApprovedRef.current = true;
window.location.assign(url);
}, [hasUnsavedCanvasChanges]);
const applyCanvasRemoteSnapshot = useCallback((result: CanvasRemoteOperationResult) => {
if (result.projectId !== id || canvasPersistenceScope.current !== id || useCanvasStore.getState().projectId !== id) return false;
const remoteSnapshot = JSON.stringify({ canvasData: result.canvasData });
const merged = mergeCanvasSnapshots(
savedCanvasSnapshotRef.current?.id === id ? savedCanvasSnapshotRef.current.snapshot : remoteSnapshot,
JSON.stringify({ canvasData: buildPersistedCanvasData() }),
remoteSnapshot,
);
if (merged.changed) useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
useCanvasStore.getState().setCanvasRevision(result.updatedAt, result.updatedAt);
if (merged.conflicts.length) {
canvasSyncErrorRef.current = "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。";
if (!externalCanvasConflictRef.current) toast.warning(canvasSyncErrorRef.current);
externalCanvasConflictRef.current = true;
setCanvasSyncConflicts(merged.conflicts);
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current?.snapshot || remoteSnapshot, snapshot: merged.snapshot });
return false;
}
savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot };
externalCanvasConflictRef.current = false;
setCanvasSyncConflicts([]);
canvasSyncErrorRef.current = "";
return true;
}, [buildPersistedCanvasData, id]);
const persistCanvasStateNow = useCallback( const persistCanvasStateNow = useCallback(
async (reason: "autosave" | "delete" | "beforeunload") => { async (reason: "autosave" | "delete" | "beforeunload") => {
if (canvasLeaveApprovedRef.current || canvasPersistenceScope.current !== id || !canvasReadyRef.current || useCanvasStore.getState().projectId !== id) return false; if (canvasPersistenceScope.current !== id || !canvasReadyRef.current || useCanvasStore.getState().projectId !== id) return false;
if (externalCanvasConflictRef.current) return false; if (externalCanvasConflictRef.current) return false;
setIsSaving(true); setIsSaving(true);
try { try {
let canvasData = buildPersistedCanvasData(); let canvasData = buildPersistedCanvasData();
let snapshot = JSON.stringify({ canvasData }); let snapshot = JSON.stringify({ canvasData });
if (savedCanvasSnapshotRef.current?.id === id && equal(JSON.parse(savedCanvasSnapshotRef.current.snapshot), JSON.parse(snapshot))) { if (savedCanvasSnapshotRef.current?.id === id && savedCanvasSnapshotRef.current.snapshot === snapshot) {
clearCanvasDraft(id); clearCanvasDraft(id);
return true; return true;
} }
@ -5217,7 +5162,6 @@ export default function CanvasEditorLegacyPage({
if (canvasPersistenceScope.current !== id) return false; if (canvasPersistenceScope.current !== id) return false;
} else { } else {
externalCanvasConflictRef.current = true; externalCanvasConflictRef.current = true;
setCanvasSyncConflicts(merged.conflicts);
} }
} }
} }
@ -5227,19 +5171,15 @@ export default function CanvasEditorLegacyPage({
snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }), snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }),
}); });
canvasSyncErrorRef.current = result.status === 409 canvasSyncErrorRef.current = result.status === 409
? "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。" ? "画布同步发生冲突,本次任务未提交。当前改动未覆盖服务端内容,请保留此页并联系管理员核对。"
: `画布自动同步失败${result.status ? `(HTTP ${result.status})` : "(网络连接失败)"}。${retained ? "本地草稿已保留,系统会继续自动同步,也可稍后重试。" : "请保留当前页面,检查网络后重试同步,暂勿刷新。"}`; : `画布自动同步失败${result.status ? `(HTTP ${result.status})` : "(网络连接失败)"},本次任务未提交。${retained ? "本地草稿已保留,请检查网络后再次发送。" : "请保留当前页面,检查网络后再次发送,暂勿刷新。"}`;
console.error(`[Canvas] ${reason} save failed:`, result.error); console.error(`[Canvas] ${reason} save failed:`, result.error);
} else { } else {
canvasSyncErrorRef.current = ""; canvasSyncErrorRef.current = "";
savedCanvasSnapshotRef.current = { id, snapshot }; savedCanvasSnapshotRef.current = { id, snapshot };
clearCanvasDraft(id); clearCanvasDraft(id);
externalCanvasConflictRef.current = false; externalCanvasConflictRef.current = false;
setCanvasSyncConflicts([]);
externalCanvasConflictNotifiedRef.current = false; externalCanvasConflictNotifiedRef.current = false;
if (result.data?.canvasData && result.data?.updatedAt) {
return applyCanvasRemoteSnapshot({ projectId: id, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt) });
}
if (result.data?.updatedAt) useCanvasStore if (result.data?.updatedAt) useCanvasStore
.getState() .getState()
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt)); .setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
@ -5249,7 +5189,7 @@ export default function CanvasEditorLegacyPage({
if (canvasPersistenceScope.current === id) setIsSaving(false); if (canvasPersistenceScope.current === id) setIsSaving(false);
} }
}, },
[activeCanvasWorkspaceType, applyCanvasRemoteSnapshot, buildPersistedCanvasData, id] [activeCanvasWorkspaceType, buildPersistedCanvasData, id]
); );
const persistCanvasState = useCallback( const persistCanvasState = useCallback(
@ -5258,124 +5198,48 @@ export default function CanvasEditorLegacyPage({
[persistCanvasStateNow] [persistCanvasStateNow]
); );
const finishCanvasLeave = useCallback(async (save: boolean) => {
if (!canvasLeaveUrl || isSavingBeforeLeave) return;
setCanvasLeaveError("");
if (save && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) {
setCanvasLeaveError("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再保存并离开。");
return;
}
if (canvasAutosaveTimer.current) {
clearTimeout(canvasAutosaveTimer.current);
canvasAutosaveTimer.current = null;
}
if (save) {
setIsSavingBeforeLeave(true);
try {
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current || "画布暂时无法同步,请保留此页并稍后重试。");
if (hasUnsavedCanvasChanges() && !(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current);
if (hasUnsavedCanvasChanges()) throw new Error("还有改动正在同步,请稍后重试离开。");
} catch (error) {
setCanvasLeaveError(error instanceof Error ? error.message : "画布暂时无法同步,请保留此页并稍后重试。");
setIsSavingBeforeLeave(false);
return;
}
}
canvasLeaveApprovedRef.current = true;
clearCanvasDraft(id);
window.location.assign(canvasLeaveUrl);
}, [canvasLeaveUrl, hasUnsavedCanvasChanges, id, isSavingBeforeLeave, persistCanvasState]);
const resolveCanvasSyncConflict = useCallback((choice: "local" | "remote") => {
return canvasSaveQueue.current!.enqueue(async () => {
setIsSaving(true);
try {
const remote = await canvasService.getProject(id);
if (canvasPersistenceScope.current !== id || useCanvasStore.getState().projectId !== id) return;
if (!remote.success || !remote.data?.canvasData || savedCanvasSnapshotRef.current?.id !== id) {
throw new Error(remote.error || "画布暂时无法读取,改动已保留,请稍后重试同步。");
}
const remoteSnapshot = JSON.stringify({ canvasData: remote.data.canvasData });
const merged = mergeCanvasSnapshots(
savedCanvasSnapshotRef.current.snapshot,
JSON.stringify({ canvasData: buildPersistedCanvasData() }),
remoteSnapshot,
choice,
);
savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot };
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
const revision = String(remote.data.updatedAt);
useCanvasStore.getState().setCanvasRevision(revision, revision);
externalCanvasConflictRef.current = false;
canvasSyncErrorRef.current = "";
setCanvasSyncConflicts([]);
if (!(await persistCanvasStateNow("autosave"))) throw new Error(canvasSyncErrorRef.current);
toast.success("画布已合并同步,可以继续操作");
} catch (error) {
toast.error(error instanceof Error ? error.message : "画布同步失败,改动已保留,请稍后重试。");
} finally {
if (canvasPersistenceScope.current === id) setIsSaving(false);
}
});
}, [buildPersistedCanvasData, id, persistCanvasStateNow]);
const runCanvasRemoteOperation = useCallback( const runCanvasRemoteOperation = useCallback(
(operation: CanvasRemoteOperation) => { (operation: () => Promise<void>) => {
if (canvasAutosaveTimer.current) { if (canvasAutosaveTimer.current) {
clearTimeout(canvasAutosaveTimer.current); clearTimeout(canvasAutosaveTimer.current);
canvasAutosaveTimer.current = null; canvasAutosaveTimer.current = null;
} }
return canvasSaveQueue.current!.enqueueOperation( return canvasSaveQueue.current!.enqueueOperation(
async () => { () => persistCanvasStateNow("autosave"),
const saved = await persistCanvasStateNow("autosave");
if (!saved) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次处理未提交。` : "画布尚未完成加载,本次处理未提交,请等待加载完成后重试。");
return true;
},
async () => { async () => {
canvasRemoteOperationActive.current = true; canvasRemoteOperationActive.current = true;
try { try {
const result = await operation(); await operation();
if (result && result.projectId === id && canvasPersistenceScope.current === id) {
savedCanvasSnapshotRef.current = { id, snapshot: JSON.stringify({ canvasData: result.canvasData }) };
canvasSyncErrorRef.current = "";
}
} finally { } finally {
canvasRemoteOperationActive.current = false; canvasRemoteOperationActive.current = false;
} }
} }
); );
}, },
[persistCanvasStateNow, id] [persistCanvasStateNow]
); );
const persistCanvasBeforeAgent = useCallback(async () => { const persistCanvasBeforeAgent = useCallback(async () => {
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || externalCanvasConflictRef.current) if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || externalCanvasConflictRef.current)
throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成同步,本次任务未提交,请保留当前页面并重试同步。"); throw new Error(canvasSyncErrorRef.current || "画布尚未完成同步,本次任务未提交,请保留当前页面并重试同步。");
if (savedCanvasSnapshotRef.current?.id === id) { if (savedCanvasSnapshotRef.current?.id === id) {
if (!hasUnsavedCanvasChanges()) return true; const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData;
const current = buildPersistedCanvasData();
if (JSON.stringify(saved.nodes) === JSON.stringify(current.nodes) && JSON.stringify(saved.edges) === JSON.stringify(current.edges)) return true;
} }
// Flush the pending local edit once instead of racing its scheduled autosave. // Flush the pending local edit once instead of racing its scheduled autosave.
if (canvasAutosaveTimer.current) { if (canvasAutosaveTimer.current) {
clearTimeout(canvasAutosaveTimer.current); clearTimeout(canvasAutosaveTimer.current);
canvasAutosaveTimer.current = null; canvasAutosaveTimer.current = null;
} }
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成加载,本次任务未提交;请等待加载完成后再次发送。"); if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current || "画布尚未完成加载,本次任务未提交;请等待加载完成后再次发送。");
return true; return true;
}, [persistCanvasState, hasUnsavedCanvasChanges, id]); }, [persistCanvasState, buildPersistedCanvasData, id]);
const canvasRemoteOperationContext = useMemo(() => ({
run: runCanvasRemoteOperation,
applySnapshot: applyCanvasRemoteSnapshot,
}), [runCanvasRemoteOperation, applyCanvasRemoteSnapshot]);
useEffect(() => { useEffect(() => {
if (isVideoMode) return; if (isVideoMode) return;
const beforeRefresh = (event: Event) => { const beforeRefresh = (event: Event) => {
const request = (event as CustomEvent<PageRefreshRequest>).detail; const request = (event as CustomEvent<PageRefreshRequest>).detail;
if (!isAgentMode && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) {
request.block("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再刷新。");
return;
}
if (useCanvasStore.getState().isGenerating) { if (useCanvasStore.getState().isGenerating) {
request.block("当前图片任务仍在处理中,请等待结果返回后再刷新。"); request.block("当前图片任务仍在处理中,请等待结果返回后再刷新。");
return; return;
@ -5411,32 +5275,7 @@ export default function CanvasEditorLegacyPage({
clearTimeout(saveTimer); clearTimeout(saveTimer);
if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null; if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null;
}; };
}, [isAgentMode, isAgentOperationRefreshActive, nodes, edges, isLoading, persistCanvasState]); }, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
useEffect(() => {
if (isLoading || isAgentMode) return;
const retrySync = () => {
if (canvasLeaveApprovedRef.current || externalCanvasConflictRef.current || canvasRemoteOperationActive.current || isAgentOperationRefreshActive() || !hasUnsavedCanvasChanges()) return;
void persistCanvasState("autosave").catch(error => console.error("[Canvas] background sync failed:", error));
};
const onVisibilityChange = () => {
if (!canvasLeaveApprovedRef.current && document.visibilityState === "hidden" && hasUnsavedCanvasChanges()) {
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current!.snapshot, snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }) });
}
};
const retryTimer = window.setInterval(() => {
if (document.visibilityState === "visible" && navigator.onLine && canvasSyncErrorRef.current) retrySync();
}, 20_000);
window.addEventListener("online", retrySync);
window.addEventListener("focus", retrySync);
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
window.clearInterval(retryTimer);
window.removeEventListener("online", retrySync);
window.removeEventListener("focus", retrySync);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [buildPersistedCanvasData, hasUnsavedCanvasChanges, id, isAgentMode, isAgentOperationRefreshActive, isLoading, persistCanvasState]);
useEffect(() => { useEffect(() => {
if (isLoading || isAgentOperationRefreshActive()) return; if (isLoading || isAgentOperationRefreshActive()) return;
@ -5539,25 +5378,18 @@ export default function CanvasEditorLegacyPage({
} }
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]); }, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
// 浏览器离开确认只保护尚未同步的内容;真正离开时保留草稿并发送一次保存。 // 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率
useEffect(() => { useEffect(() => {
if (isLoading || isAgentMode) return; if (isLoading || isAgentMode) return;
const retainDraft = () => { const handleBeforeUnload = () => {
if (canvasLeaveApprovedRef.current || !hasUnsavedCanvasChanges()) return null;
const canvasData = buildPersistedCanvasData();
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current!.snapshot, snapshot: JSON.stringify({ canvasData }) });
return canvasData;
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!retainDraft()) return;
event.preventDefault();
event.returnValue = "true";
};
const handlePageHide = () => {
try { try {
const canvasData = retainDraft(); if (!canvasReadyRef.current || isAgentOperationRefreshActive()) return;
if (!canvasData || isSaving || canvasRemoteOperationActive.current || isAgentOperationRefreshActive() || externalCanvasConflictRef.current) return; const canvasData = buildPersistedCanvasData();
writeCanvasDraft(id, {
base: savedCanvasSnapshotRef.current?.id === id ? savedCanvasSnapshotRef.current.snapshot : JSON.stringify({ canvasData: { nodes: [], edges: [] } }),
snapshot: JSON.stringify({ canvasData }),
});
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState(); const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
void fetch(`/api/canvas/${id}`, { void fetch(`/api/canvas/${id}`, {
method: "PUT", method: "PUT",
@ -5576,20 +5408,18 @@ export default function CanvasEditorLegacyPage({
}; };
window.addEventListener("beforeunload", handleBeforeUnload); window.addEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("pagehide", handlePageHide); window.addEventListener("pagehide", handleBeforeUnload);
return () => { return () => {
window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("pagehide", handlePageHide); window.removeEventListener("pagehide", handleBeforeUnload);
}; };
}, [ }, [
activeCanvasWorkspaceType, activeCanvasWorkspaceType,
buildPersistedCanvasData, buildPersistedCanvasData,
hasUnsavedCanvasChanges,
id, id,
isAgentOperationRefreshActive, isAgentOperationRefreshActive,
isAgentMode, isAgentMode,
isLoading, isLoading,
isSaving,
]); ]);
// 智能命名与缩略图逻辑 // 智能命名与缩略图逻辑
@ -10583,7 +10413,7 @@ export default function CanvasEditorLegacyPage({
} }
return ( return (
<CanvasRemoteOperationContext.Provider value={canvasRemoteOperationContext}> <CanvasRemoteOperationContext.Provider value={runCanvasRemoteOperation}>
<TooltipProvider> <TooltipProvider>
<div <div
data-canvas-panel-open={isPanelOpen ? "true" : "false"} data-canvas-panel-open={isPanelOpen ? "true" : "false"}
@ -10599,30 +10429,6 @@ export default function CanvasEditorLegacyPage({
} }
> >
<CanvasAgentUiListener projectId={id} workspace="canvas" /> <CanvasAgentUiListener projectId={id} workspace="canvas" />
<Dialog open={Boolean(canvasLeaveUrl)} onOpenChange={open => { if (!open && !isSavingBeforeLeave) setCanvasLeaveUrl(""); }}>
<DialogContent onEscapeKeyDown={event => { if (isSavingBeforeLeave) event.preventDefault(); }} onPointerDownOutside={event => event.preventDefault()}>
<DialogHeader>
<DialogTitle>离开前保存画布?</DialogTitle>
<DialogDescription>平时会自动保存。当前还有改动尚未同步到云端,可以保存后离开,或放弃这些未同步的改动。</DialogDescription>
</DialogHeader>
{canvasLeaveError ? <p role="alert" className="text-sm text-destructive">{canvasLeaveError}</p> : null}
<DialogFooter className="gap-2">
<Button disabled={isSavingBeforeLeave} variant="ghost" onClick={() => setCanvasLeaveUrl("")}>继续编辑</Button>
<Button disabled={isSavingBeforeLeave} variant="outline" onClick={() => void finishCanvasLeave(false)}>不保存并离开</Button>
<Button disabled={isSavingBeforeLeave} onClick={() => void finishCanvasLeave(true)}>{isSavingBeforeLeave ? "正在保存…" : "保存并离开"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{canvasSyncConflicts.length > 0 ? (
<div role="alert" className="absolute inset-x-3 top-28 z-[60] mx-auto flex max-w-2xl flex-wrap items-center gap-3 rounded-lg border bg-background p-4 text-sm shadow-md">
<div className="min-w-0 flex-1 basis-64">
<p className="font-medium">画布同步发生冲突</p>
<p className="mt-1 text-muted-foreground">本页和云端的同一内容有不同修改。请选择保留哪边的冲突内容,其余图片和修改会合并保留。</p>
</div>
<Button disabled={isSaving} onClick={() => void resolveCanvasSyncConflict("local")}>保留本页修改</Button>
<Button disabled={isSaving} variant="outline" onClick={() => void resolveCanvasSyncConflict("remote")}>保留云端修改</Button>
</div>
) : null}
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */} {/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
<div <div
className="absolute inset-0 z-0" className="absolute inset-0 z-0"
@ -11332,12 +11138,14 @@ export default function CanvasEditorLegacyPage({
{isVideoMode ? brandEntry : ( {isVideoMode ? brandEntry : (
<CanvasSurfaceHeader <CanvasSurfaceHeader
isSaving={isSaving}
onAddImage={handleAddImageNode} onAddImage={handleAddImageNode}
onHome={() => requestCanvasLeave("/")} onHome={goHome}
onOpenFiles={() => { onOpenFiles={() => {
setIsPanelOpen(true); setIsPanelOpen(true);
setPanelMode("fileList"); setPanelMode("fileList");
}} }}
onSave={() => void handleSave()}
projectTitle={projectTitle} projectTitle={projectTitle}
rightInset={panelOcclusionRight} rightInset={panelOcclusionRight}
/> />
@ -11442,7 +11250,6 @@ export default function CanvasEditorLegacyPage({
createUrl={createNewCanvasUrl} createUrl={createNewCanvasUrl}
currentId={id} currentId={id}
onClose={() => setPanelMode("chat")} onClose={() => setPanelMode("chat")}
onNavigate={requestCanvasLeave}
workspaceLabel={workspaceLabel} workspaceLabel={workspaceLabel}
workspaceType={activeCanvasWorkspaceType} workspaceType={activeCanvasWorkspaceType}
/> />

View File

@ -10,39 +10,31 @@ export function AppUpdateNotice() {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
useEffect(() => { useEffect(() => {
let disposed = false; const controller = new AbortController();
let controller: AbortController | undefined;
let checking = false; let checking = false;
const check = async () => { const check = async () => {
if (disposed || checking || document.visibilityState !== "visible") return; if (checking || document.visibilityState !== "visible") return;
checking = true; checking = true;
controller = new AbortController();
const current = controller;
const timeout = window.setTimeout(() => current.abort(), 8000);
try { try {
const response = await fetch("/api/app-version", { cache: "no-store", signal: current.signal }); const response = await fetch("/api/app-version", { cache: "no-store", signal: AbortSignal.any([controller.signal, AbortSignal.timeout(8000)]) });
if (!response.ok) return; if (!response.ok) return;
const data = await response.json(); const data = await response.json();
if (!disposed && !current.signal.aborted && typeof data.version === "string" && data.version) if (!controller.signal.aborted && typeof data.version === "string" && data.version)
setAvailableVersion(data.version !== (process.env.NEXT_PUBLIC_APP_RELEASE || "development") ? data.version : ""); setAvailableVersion(data.version !== (process.env.NEXT_PUBLIC_APP_RELEASE || "development") ? data.version : "");
} catch { } catch {
// A failed version read leaves the current workspace available. // A failed version read leaves the current workspace available.
} finally { } finally {
window.clearTimeout(timeout);
checking = false; checking = false;
} }
}; };
void check(); void check();
const interval = window.setInterval(check, 60_000); const interval = window.setInterval(check, 60_000);
window.addEventListener("focus", check); window.addEventListener("focus", check);
window.addEventListener("online", check);
document.addEventListener("visibilitychange", check); document.addEventListener("visibilitychange", check);
return () => { return () => {
disposed = true; controller.abort();
controller?.abort();
clearInterval(interval); clearInterval(interval);
window.removeEventListener("focus", check); window.removeEventListener("focus", check);
window.removeEventListener("online", check);
document.removeEventListener("visibilitychange", check); document.removeEventListener("visibilitychange", check);
}; };
}, []); }, []);
@ -68,7 +60,6 @@ export function AppUpdateNotice() {
</AlertDialogHeader> </AlertDialogHeader>
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null} {error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
<AlertDialogFooter> <AlertDialogFooter>
{error ? <Button variant="outline" disabled={refreshing} onClick={() => { setAvailableVersion(""); setError(""); }}>返回处理</Button> : null}
<Button disabled={refreshing} onClick={() => void refresh()}>{refreshing ? "正在同步内容…" : "同步并刷新"}</Button> <Button disabled={refreshing} onClick={() => void refresh()}>{refreshing ? "正在同步内容…" : "同步并刷新"}</Button>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View File

@ -2,11 +2,10 @@
import { advancePastAtomicRanges, scanMarkdownImageStreaming } from "@/lib/canvas/markdown-image-streaming"; import { advancePastAtomicRanges, scanMarkdownImageStreaming } from "@/lib/canvas/markdown-image-streaming";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { useStickToBottom } from "use-stick-to-bottom";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing"; import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
import { CanvasAgentProgressMessage } from "@/components/canvas/chat-panel/canvas-agent-progress"; import { CanvasAgentProgressMessage } from "@/components/canvas/chat-panel/canvas-agent-progress";
import { useCanvasRemoteOperation, useCanvasRemoteSnapshot } from "@/components/canvas/CanvasRemoteOperationContext"; import { useCanvasRemoteOperation } from "@/components/canvas/CanvasRemoteOperationContext";
import { readCanvasComposerDraft, writeCanvasComposerDraft, clearCanvasComposerDraft } from "@/lib/client/canvas-draft"; import { readCanvasComposerDraft, writeCanvasComposerDraft, clearCanvasComposerDraft } from "@/lib/client/canvas-draft";
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh"; import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
import { canvasHistoryForChat } from "@/lib/client/canvas-history-response"; import { canvasHistoryForChat } from "@/lib/client/canvas-history-response";
@ -3248,7 +3247,6 @@ export function CanvasChatPanel({
onBeforeAgentRequest, onBeforeAgentRequest,
}: CanvasChatPanelProps) { }: CanvasChatPanelProps) {
const runCanvasRemoteOperation = useCanvasRemoteOperation(); const runCanvasRemoteOperation = useCanvasRemoteOperation();
const applyCanvasRemoteSnapshot = useCanvasRemoteSnapshot();
const isWorkspacePresentation = presentation === "workspace"; const isWorkspacePresentation = presentation === "workspace";
const isVideoWorkspace = workspaceVariant === "video"; const isVideoWorkspace = workspaceVariant === "video";
const propCanvasToolbarConfig = useMemo( const propCanvasToolbarConfig = useMemo(
@ -3369,8 +3367,8 @@ export function CanvasChatPanel({
const latestCanvasSkillTagsRef = useRef<CanvasSkillTag[]>([]); const latestCanvasSkillTagsRef = useRef<CanvasSkillTag[]>([]);
const pendingCanvasSkillTagOrderSaveRef = useRef<string[] | null>(null); const pendingCanvasSkillTagOrderSaveRef = useRef<string[] | null>(null);
const suppressSkillTagClickRef = useRef(false); const suppressSkillTagClickRef = useRef(false);
const { scrollRef: messagesScrollRef, contentRef: messagesContentRef, scrollToBottom: scrollMessagesToBottom, isAtBottom: isMessageListAtBottom } = useStickToBottom({ initial: false, resize: "instant" }); const [messageViewportKey, setMessageViewportKey] = useState(0);
const initialScrollChatRef = useRef<string | null>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const clawdFolderInputRef = useRef<HTMLInputElement | null>(null); const clawdFolderInputRef = useRef<HTMLInputElement | null>(null);
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]); const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
@ -3406,6 +3404,7 @@ export function CanvasChatPanel({
const hasInitializedVideoWorkspaceRef = useRef(false); const hasInitializedVideoWorkspaceRef = useRef(false);
const sessionMenuRef = useRef<HTMLDivElement>(null); const sessionMenuRef = useRef<HTMLDivElement>(null);
const clawdHistoryMenuRef = useRef<HTMLDivElement>(null); const clawdHistoryMenuRef = useRef<HTMLDivElement>(null);
const historyScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const forceHistoryHydrationRef = useRef<string | null>(null); const forceHistoryHydrationRef = useRef<string | null>(null);
const copiedUserMessageRef = useRef<CopiedUserMessagePayload | null>(null); const copiedUserMessageRef = useRef<CopiedUserMessagePayload | null>(null);
const composerUserEditedAtRef = useRef(0); const composerUserEditedAtRef = useRef(0);
@ -7991,12 +7990,8 @@ export function CanvasChatPanel({
const store = useCanvasStore.getState(); const store = useCanvasStore.getState();
store.markAgentOperationRefresh(); store.markAgentOperationRefresh();
if (applyCanvasRemoteSnapshot) { store.loadCanvas(result.data.canvasData);
if (!applyCanvasRemoteSnapshot({ projectId: targetProjectId, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt || "") })) return false; store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
} else {
store.loadCanvas(result.data.canvasData);
store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
}
if (createdNodeIds.length > 0) { if (createdNodeIds.length > 0) {
const refreshedStore = useCanvasStore.getState(); const refreshedStore = useCanvasStore.getState();
refreshedStore.selectNodes(createdNodeIds); refreshedStore.selectNodes(createdNodeIds);
@ -8024,7 +8019,7 @@ export function CanvasChatPanel({
return false; return false;
} }
}, },
[applyCanvasRemoteSnapshot, applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate] [applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate]
); );
const { const {
@ -8842,6 +8837,12 @@ export function CanvasChatPanel({
hasHydratedHistoryRef.current = true; hasHydratedHistoryRef.current = true;
setMessages(sortCanvasMessagesByCreatedAt(dbMessages)); setMessages(sortCanvasMessagesByCreatedAt(dbMessages));
if (dbMessages.length > 0) {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
});
}
}; };
const reloadMessagesOnce = async () => { const reloadMessagesOnce = async () => {
@ -8954,7 +8955,7 @@ export function CanvasChatPanel({
onRunChange: (run) => { onRunChange: (run) => {
// The live stream owns its message; polling only recovers disconnected replies. // The live stream owns its message; polling only recovers disconnected replies.
if (status === "submitted" || status === "streaming") return; if (status === "submitted" || status === "streaming") return;
if (!run?.assistantMessageId || !run.assistantText || run.chatId !== toRawCanvasChatId(activeChatIdRef.current)) return; if (!run?.assistantMessageId || !run.assistantText || run.chatId !== activeChatIdRef.current) return;
// Recover the same assistant reply after a stream disconnect or refresh. // Recover the same assistant reply after a stream disconnect or refresh.
setMessages(current => { setMessages(current => {
const index = current.findIndex(message => message.id === run.assistantMessageId); const index = current.findIndex(message => message.id === run.assistantMessageId);
@ -8972,10 +8973,6 @@ export function CanvasChatPanel({
useEffect(() => { useEffect(() => {
const beforeRefresh = (event: Event) => { const beforeRefresh = (event: Event) => {
const request = (event as CustomEvent<PageRefreshRequest>).detail; const request = (event as CustomEvent<PageRefreshRequest>).detail;
if (composerUploadProgress) {
request.block("素材仍在上传,请等待上传完成后再刷新。当前页面和输入已保留。");
return;
}
if (isCanvasAgentRunning || isClawdSubmitting || isDetailGenerating || status === "streaming" || status === "submitted" || Object.values(sendGuardRef.current).some(guard => guard.inFlight)) { if (isCanvasAgentRunning || isClawdSubmitting || isDetailGenerating || status === "streaming" || status === "submitted" || Object.values(sendGuardRef.current).some(guard => guard.inFlight)) {
request.block("当前对话任务仍在处理中,请等待结果返回后再刷新。原任务会继续执行,无需重新提交。"); request.block("当前对话任务仍在处理中,请等待结果返回后再刷新。原任务会继续执行,无需重新提交。");
return; return;
@ -8985,17 +8982,9 @@ export function CanvasChatPanel({
throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。"); throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
}); });
}; };
const beforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "true";
};
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh); window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
if (composerUploadProgress) window.addEventListener("beforeunload", beforeUnload); return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
return () => { }, [activeChatId, input, attachments, isCanvasAgentRunning, isClawdSubmitting, isDetailGenerating, status]);
window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
window.removeEventListener("beforeunload", beforeUnload);
};
}, [activeChatId, input, attachments, composerUploadProgress, isCanvasAgentRunning, isClawdSubmitting, isDetailGenerating, status]);
const restorePendingClawdRequestToComposer = useCallback( const restorePendingClawdRequestToComposer = useCallback(
(snapshot: PendingClawdRequestRestoreSnapshot) => { (snapshot: PendingClawdRequestRestoreSnapshot) => {
@ -9139,7 +9128,6 @@ export function CanvasChatPanel({
}, },
} as ChatMessage; } as ChatMessage;
void scrollMessagesToBottom({ animation: "instant" });
sendMessage(launchUserMessage as any); sendMessage(launchUserMessage as any);
setMessages((previousMessages) => setMessages((previousMessages) =>
sortCanvasMessagesByCreatedAt([ sortCanvasMessagesByCreatedAt([
@ -9159,7 +9147,7 @@ export function CanvasChatPanel({
if (editableDiv) { if (editableDiv) {
editableDiv.innerHTML = ""; editableDiv.innerHTML = "";
} }
}, [activeChatId, rawChatId, selectedModelId, status, sendMessage, setMessages, clearSelection, launchAutoSendTick, activeComposerMode, scrollMessagesToBottom]); }, [activeChatId, rawChatId, selectedModelId, status, sendMessage, setMessages, clearSelection, launchAutoSendTick, activeComposerMode]);
// 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新) // 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新)
const loadedHistorySignatureRef = useRef<string>(""); const loadedHistorySignatureRef = useRef<string>("");
@ -9179,6 +9167,7 @@ export function CanvasChatPanel({
hiddenPromptRef.current = null; hiddenPromptRef.current = null;
autoContextRef.current = null; autoContextRef.current = null;
pendingLaunchRef.current = null; pendingLaunchRef.current = null;
setMessageViewportKey((prev) => prev + 1);
setMessages([]); setMessages([]);
setInput(""); setInput("");
setAttachments([]); setAttachments([]);
@ -9207,6 +9196,11 @@ export function CanvasChatPanel({
}, [activeChatId, activeComposerMode, input, insertImageTagInEditor]); }, [activeChatId, activeComposerMode, input, insertImageTagInEditor]);
useEffect(() => { useEffect(() => {
if (historyScrollTimerRef.current) {
clearTimeout(historyScrollTimerRef.current);
historyScrollTimerRef.current = null;
}
if (!historyData || !rawChatId) return; if (!historyData || !rawChatId) return;
const historyMessages = historyData.messages || []; const historyMessages = historyData.messages || [];
const isClawdHistory = isClawdMode || historyMessages.some(isClawdChatMessage); const isClawdHistory = isClawdMode || historyMessages.some(isClawdChatMessage);
@ -9247,9 +9241,24 @@ export function CanvasChatPanel({
missingPendingMessage ? [...historyMessages, pendingMessage] : historyMessages missingPendingMessage ? [...historyMessages, pendingMessage] : historyMessages
); );
}); });
setMessageViewportKey((prev) => prev + 1);
console.log("[CanvasChat] 已同步历史消息:", historyMessages.length, signature); console.log("[CanvasChat] 已同步历史消息:", historyMessages.length, signature);
if (historyMessages.length > 0) {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
});
historyScrollTimerRef.current = setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
historyScrollTimerRef.current = null;
}, 160);
return () => {
if (historyScrollTimerRef.current) {
clearTimeout(historyScrollTimerRef.current);
historyScrollTimerRef.current = null;
}
};
}
}, [activeChatId, activeClawdRequestUi?.userMessageId, historyData, isClawdMode, isClawdSubmitting, rawChatId, setMessages, status]); }, [activeChatId, activeClawdRequestUi?.userMessageId, historyData, isClawdMode, isClawdSubmitting, rawChatId, setMessages, status]);
useEffect(() => { useEffect(() => {
@ -9829,13 +9838,11 @@ export function CanvasChatPanel({
}); });
}, [activeChatSessionMode, messages, nodes, updateNode]); }, [activeChatSessionMode, messages, nodes, updateNode]);
// Open each conversation at its latest message; later updates respect manual scrolling. // 滚动到底部
useEffect(() => { useEffect(() => {
if (messages.length === 0 && !isClawdSubmitting) return; if (messages.length === 0 && !isClawdSubmitting) return;
if (initialScrollChatRef.current === activeChatId) return; messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
initialScrollChatRef.current = activeChatId; }, [isClawdSubmitting, messages]);
void scrollMessagesToBottom({ animation: "instant" });
}, [activeChatId, isClawdSubmitting, messages.length, scrollMessagesToBottom]);
// 标记是否已经为当前画布设置过标题(避免重复调用) // 标记是否已经为当前画布设置过标题(避免重复调用)
const hasSetCanvasTitle = useRef(false); const hasSetCanvasTitle = useRef(false);
@ -12604,7 +12611,6 @@ export function CanvasChatPanel({
const supplement = typeof overrideInput === "string" ? overrideInput.trim() : input.trim(); const supplement = typeof overrideInput === "string" ? overrideInput.trim() : input.trim();
if (isCanvasAgentRunning && !isClawdMode && imageAgentEnabled) { if (isCanvasAgentRunning && !isClawdMode && imageAgentEnabled) {
if (supplement && await canvasAgentTask.control("steer", supplement)) { if (supplement && await canvasAgentTask.control("steer", supplement)) {
void scrollMessagesToBottom({ animation: "instant" });
setInput(""); setInput("");
const editor = textareaRef.current as HTMLDivElement | null; const editor = textareaRef.current as HTMLDivElement | null;
if (editor) editor.innerHTML = ""; if (editor) editor.innerHTML = "";
@ -12902,7 +12908,6 @@ export function CanvasChatPanel({
requestChatId, requestChatId,
clawdRequestRestoreSnapshot clawdRequestRestoreSnapshot
); );
void scrollMessagesToBottom({ animation: "instant" });
startClawdRequestUi(requestChatId, userMessageId, nextProcessingHint); startClawdRequestUi(requestChatId, userMessageId, nextProcessingHint);
setMessages((previousMessages) => setMessages((previousMessages) =>
sortCanvasMessagesByCreatedAt([ sortCanvasMessagesByCreatedAt([
@ -13435,7 +13440,6 @@ export function CanvasChatPanel({
// 使用与 multimodal-input 相同的格式发送消息 // 使用与 multimodal-input 相同的格式发送消息
try { try {
void scrollMessagesToBottom({ animation: "instant" });
sendMessage(optimisticUserMessage as any); sendMessage(optimisticUserMessage as any);
setMessages((previousMessages) => setMessages((previousMessages) =>
sortCanvasMessagesByCreatedAt([ sortCanvasMessagesByCreatedAt([
@ -13474,7 +13478,6 @@ export function CanvasChatPanel({
} }
}, [ }, [
isCanvasAgentRunning, isCanvasAgentRunning,
scrollMessagesToBottom,
onBeforeAgentRequest, onBeforeAgentRequest,
isVideoWorkspace, isVideoWorkspace,
canvasAgentTask.control, canvasAgentTask.control,
@ -15708,7 +15711,7 @@ export function CanvasChatPanel({
processedMessageIds.current.clear(); processedMessageIds.current.clear();
hasHydratedHistoryRef.current = true; hasHydratedHistoryRef.current = true;
setMessages(sortCanvasMessagesByCreatedAt(fresh.messages)); setMessages(sortCanvasMessagesByCreatedAt(fresh.messages));
void scrollMessagesToBottom({ animation: "instant" }); setMessageViewportKey((previous) => previous + 1);
}).catch((error) => { }).catch((error) => {
console.error("[CanvasChat] Failed to reopen current conversation:", error); console.error("[CanvasChat] Failed to reopen current conversation:", error);
toast.error("聊天记录暂时读取失败,请重试"); toast.error("聊天记录暂时读取失败,请重试");
@ -15722,7 +15725,7 @@ export function CanvasChatPanel({
replaceChatSessionQuery(sessionId); replaceChatSessionQuery(sessionId);
setIsSessionMenuOpen(false); setIsSessionMenuOpen(false);
toast.success("已切换聊天窗口"); toast.success("已切换聊天窗口");
}, [activeChatId, isClawdSubmitting, mutateHistoryData, releaseSendGuard, replaceChatSessionQuery, scrollMessagesToBottom, setMessages, status]); }, [activeChatId, isClawdSubmitting, mutateHistoryData, releaseSendGuard, replaceChatSessionQuery, setMessages, status]);
const switchToClawdHistorySession = useCallback( const switchToClawdHistorySession = useCallback(
(historyItem: ClawdHistoryItem) => { (historyItem: ClawdHistoryItem) => {
@ -16139,17 +16142,11 @@ export function CanvasChatPanel({
{isDetailEmptyState ? ( {isDetailEmptyState ? (
<div className="flex-1 min-h-0" /> <div className="flex-1 min-h-0" />
) : ( ) : (
<div key={activeChatId} className="relative flex min-h-0 flex-1 flex-col">
<div <div
ref={messagesScrollRef} key={`${activeChatId}-${messageViewportKey}`}
data-testid="canvas-chat-messages"
role="region"
aria-label="对话消息"
// biome-ignore lint/a11y/noNoninteractiveTabindex: The scroll region needs PageUp/PageDown keyboard input.
tabIndex={0}
className={cn( className={cn(
"flex-1 min-h-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", "flex-1 min-h-0 overflow-x-hidden",
isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-auto", isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-y-auto",
isWorkspacePresentation ? "p-5" : "p-3", isWorkspacePresentation ? "p-5" : "p-3",
isPlainCanvasEmptyState isPlainCanvasEmptyState
? "pt-0" ? "pt-0"
@ -16158,7 +16155,7 @@ export function CanvasChatPanel({
: "pt-3" : "pt-3"
)} )}
> >
<div ref={messagesContentRef} className={cn( <div className={cn(
"space-y-5 overflow-x-hidden pr-1", "space-y-5 overflow-x-hidden pr-1",
isPlainCanvasEmptyState ? "h-full pb-0" : "pb-24" isPlainCanvasEmptyState ? "h-full pb-0" : "pb-24"
)}> )}>
@ -17361,22 +17358,9 @@ export function CanvasChatPanel({
/> />
) : null} ) : null}
<div ref={messagesEndRef} />
</div> </div>
</div> </div>
{!isMessageListAtBottom && renderableVisibleMessages.length > 0 ? (
<Button
type="button"
variant="outline"
size="icon"
aria-label="回到最新消息"
title="回到最新消息"
className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full shadow-sm"
onClick={() => void scrollMessagesToBottom({ animation: "instant" })}
>
<ChevronDown className="h-4 w-4" />
</Button>
) : null}
</div>
)} )}
{fullscreenPreview && createPortal( {fullscreenPreview && createPortal(

View File

@ -19,7 +19,6 @@ const PROJECT_LIST_PAGE_SIZE = 24;
interface CanvasFileListPanelProps { interface CanvasFileListPanelProps {
currentId: string; currentId: string;
onClose: () => void; onClose: () => void;
onNavigate?: (url: string) => void;
workspaceType?: CanvasWorkspaceType; workspaceType?: CanvasWorkspaceType;
workspaceLabel?: string; workspaceLabel?: string;
createUrl?: string; createUrl?: string;
@ -29,7 +28,6 @@ interface CanvasFileListPanelProps {
export function CanvasFileListPanel({ export function CanvasFileListPanel({
currentId, currentId,
onClose, onClose,
onNavigate,
workspaceType = "canvas", workspaceType = "canvas",
workspaceLabel = "画布", workspaceLabel = "画布",
createUrl = "/canvas?new=1", createUrl = "/canvas?new=1",
@ -90,10 +88,6 @@ export function CanvasFileListPanel({
// 创建新画布 // 创建新画布
const handleCreateNew = async () => { const handleCreateNew = async () => {
if (onNavigate) {
onNavigate(createUrl);
return;
}
router.push(createUrl); router.push(createUrl);
}; };
@ -106,10 +100,6 @@ export function CanvasFileListPanel({
} }
const targetUrl = buildProjectUrl(id); const targetUrl = buildProjectUrl(id);
onClose(); // 关闭文件列表面板 onClose(); // 关闭文件列表面板
if (onNavigate) {
onNavigate(targetUrl);
return;
}
// 强制整页跳转,避免仅更新 URL 不刷新 // 强制整页跳转,避免仅更新 URL 不刷新
setTimeout(() => { setTimeout(() => {
window.location.assign(targetUrl); window.location.assign(targetUrl);

View File

@ -1,26 +1,11 @@
"use client"; "use client";
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import type { CanvasState } from "@/types/canvas";
export type CanvasRemoteOperationResult = {
projectId: string;
canvasData: CanvasState;
updatedAt: string;
};
export type CanvasRemoteOperation = (() => Promise<void>) | (() => Promise<CanvasRemoteOperationResult | undefined>);
export const CanvasRemoteOperationContext = createContext< export const CanvasRemoteOperationContext = createContext<
{ ((operation: () => Promise<void>) => Promise<void>) | null
run: (operation: CanvasRemoteOperation) => Promise<void>;
applySnapshot: (result: CanvasRemoteOperationResult) => boolean;
} | null
>(null); >(null);
export function useCanvasRemoteOperation() { export function useCanvasRemoteOperation() {
return useContext(CanvasRemoteOperationContext)?.run || null; return useContext(CanvasRemoteOperationContext);
}
export function useCanvasRemoteSnapshot() {
return useContext(CanvasRemoteOperationContext)?.applySnapshot || null;
} }

View File

@ -2,10 +2,14 @@
import { ReferenceIcon } from "@/components/lovart/reference-icon"; import { ReferenceIcon } from "@/components/lovart/reference-icon";
import { Loader2, Save } from "lucide-react";
type CanvasSurfaceHeaderProps = { type CanvasSurfaceHeaderProps = {
isSaving: boolean;
onAddImage: () => void; onAddImage: () => void;
onHome: () => void; onHome: () => void;
onOpenFiles: () => void; onOpenFiles: () => void;
onSave: () => void;
projectTitle: string; projectTitle: string;
rightInset: number; rightInset: number;
}; };
@ -14,9 +18,11 @@ const iconButtonClass =
"inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] p-0 text-[#858585] transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/10"; "inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] p-0 text-[#858585] transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/10";
export function CanvasSurfaceHeader({ export function CanvasSurfaceHeader({
isSaving,
onAddImage, onAddImage,
onHome, onHome,
onOpenFiles, onOpenFiles,
onSave,
projectTitle, projectTitle,
rightInset, rightInset,
}: CanvasSurfaceHeaderProps) { }: CanvasSurfaceHeaderProps) {
@ -71,6 +77,19 @@ export function CanvasSurfaceHeader({
<ReferenceIcon name="plus" size={16} /> <ReferenceIcon name="plus" size={16} />
</button> </button>
</div> </div>
<button
className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[8px] border border-[#e5e5e5] px-3 text-[12px] text-[#333333] transition-colors hover:bg-black/5 disabled:opacity-50 dark:border-white/10 dark:text-white"
disabled={isSaving}
onClick={onSave}
type="button"
>
{isSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
保存
</button>
</div> </div>
); );
} }

View File

@ -13,18 +13,13 @@ export type AgentImageParameters = {
}; };
export type AgentImagePreferences = { modelId?: string; parameters?: AgentImageParameters }; export type AgentImagePreferences = { modelId?: string; parameters?: AgentImageParameters };
export function selectedAgentImageCount(preferences?: AgentImagePreferences) {
const count = preferences?.parameters?.imageCount ?? preferences?.parameters?.n;
return count === undefined ? undefined : Math.floor(count);
}
export function applyAgentImagePreferences<T extends Record<string, unknown>>( export function applyAgentImagePreferences<T extends Record<string, unknown>>(
input: T, input: T,
preferences?: AgentImagePreferences, preferences?: AgentImagePreferences,
): T & AgentImageParameters & { modelId?: string } { ): T & AgentImageParameters & { modelId?: string } {
const parameters = Object.fromEntries(Object.entries(preferences?.parameters || {}) const parameters = Object.fromEntries(Object.entries(preferences?.parameters || {})
.filter(([, value]) => value !== undefined)); .filter(([, value]) => value !== undefined));
const count = selectedAgentImageCount(preferences); const count = preferences?.parameters?.imageCount ?? preferences?.parameters?.n;
return { return {
...input, ...input,
...parameters, ...parameters,

View File

@ -1,14 +1,5 @@
import type { AgentCallRecord, AgentRunSnapshot } from "./protocol"; import type { AgentCallRecord, AgentRunSnapshot } from "./protocol";
export function countAgentGeneratedImages(calls: AgentCallRecord[]) {
return calls.reduce((count, call) => {
const output = call.output;
if (call.status !== "succeeded" || !output?.appliedImagePreferences ||
!Array.isArray(output.createdNodeIds)) return count;
return count + output.createdNodeIds.length;
}, 0);
}
// A spontaneous download command is not an unfinished image edit. Keep genuine // A spontaneous download command is not an unfinished image edit. Keep genuine
// download requests and all editor panels waiting for their actual interaction. // download requests and all editor panels waiting for their actual interaction.
export function isAncillaryDownload(snapshot: AgentRunSnapshot, call: AgentCallRecord) { export function isAncillaryDownload(snapshot: AgentRunSnapshot, call: AgentCallRecord) {

View File

@ -5,8 +5,7 @@ import sharp from "sharp";
import { z } from "zod"; import { z } from "zod";
import { readCanvasImages, readOwnedCanvas, type RuntimeImageNode } from "./canvas"; import { readCanvasImages, readOwnedCanvas, type RuntimeImageNode } from "./canvas";
import type { AgentRunSnapshot } from "./protocol"; import type { AgentRunSnapshot } from "./protocol";
import { countAgentGeneratedImages, finalResultNodeIds, originalSourceNodeIds, requiresCanvasInteraction } from "./results"; import { finalResultNodeIds, originalSourceNodeIds, requiresCanvasInteraction } from "./results";
import { selectedAgentImageCount } from "./image-preferences";
import { isCompletedSimpleCutout } from "./cutout-completion"; import { isCompletedSimpleCutout } from "./cutout-completion";
export async function inspectImageAlpha(source: Buffer) { export async function inspectImageAlpha(source: Buffer) {
@ -125,16 +124,14 @@ export async function verifyCanvasAgentResults(params: {
(call) => (call) =>
call.status !== "succeeded" || call.status !== "succeeded" ||
requiresCanvasInteraction(snapshot, call) || requiresCanvasInteraction(snapshot, call) ||
(typeof call.output?.status === "string" && call.output.status !== "succeeded" && (typeof call.output?.status === "string" && call.output.status !== "succeeded"),
!(call.output.status === "partial" && call.output.appliedImagePreferences)),
); );
if (incomplete.length) return { passed: false, issues: ["部分操作仍在等待交互或未完成"] }; if (incomplete.length) return { passed: false, issues: ["部分操作仍在等待交互或未完成"] };
const plan = calls.filter((call) => call.name === "planCanvasTask").at(-1)?.output; const plan = calls.filter((call) => call.name === "planCanvasTask").at(-1)?.output;
if (!plan) return { passed: false, issues: ["缺少明确的任务验收计划"] }; if (!plan) return { passed: false, issues: ["缺少明确的任务验收计划"] };
// Intermediate images consumed by later write tools do not count as final deliverables. // Intermediate images consumed by later write tools do not count as final deliverables.
const finalIds = finalResultNodeIds(snapshot); const finalIds = finalResultNodeIds(snapshot);
const expectedCount = snapshot.requirements?.expectedImageCount ?? plan.expectedImageCount ?? const expectedCount = snapshot.requirements?.expectedImageCount ?? plan.expectedImageCount;
(countAgentGeneratedImages(calls) > 0 ? selectedAgentImageCount(snapshot.imagePreferences) : undefined);
if (expectedCount && finalIds.length !== expectedCount) { if (expectedCount && finalIds.length !== expectedCount) {
return { return {
passed: false, passed: false,

View File

@ -1,11 +1,5 @@
import type { AgentVisionExecutor } from "../runtime/agent-vision-failover"; import type { AgentVisionExecutor } from "../runtime/agent-vision-failover";
import { import { resolveUserImageModel, applyAgentImagePreferences, agentProviderImageParameters, type AgentImagePreferences } from "./image-preferences";
agentProviderImageParameters,
applyAgentImagePreferences,
resolveUserImageModel,
selectedAgentImageCount,
type AgentImagePreferences,
} from "./image-preferences";
import { tool, type LanguageModel, type ToolSet } from "ai"; import { tool, type LanguageModel, type ToolSet } from "ai";
import { z } from "zod"; import { z } from "zod";
import { createCanvasCapabilityTools } from "@/lib/canvas/capabilities/ai-sdk-tools"; import { createCanvasCapabilityTools } from "@/lib/canvas/capabilities/ai-sdk-tools";
@ -24,8 +18,6 @@ import {
sortStyleProImageModelEntries, sortStyleProImageModelEntries,
} from "@/lib/style-pro/model-options"; } from "@/lib/style-pro/model-options";
import type { CanvasAgentLoopPorts, CanvasAgentTool } from "./loop"; import type { CanvasAgentLoopPorts, CanvasAgentTool } from "./loop";
import { AgentToolRejectedError, type AgentCallRecord } from "./protocol";
import { countAgentGeneratedImages } from "./results";
import { readCanvasImages, readOwnedCanvas } from "./canvas"; import { readCanvasImages, readOwnedCanvas } from "./canvas";
import { analyzeRuntimeImages } from "./review"; import { analyzeRuntimeImages } from "./review";
import { isExplicitDestructiveIntent } from "./capability-access"; import { isExplicitDestructiveIntent } from "./capability-access";
@ -45,13 +37,11 @@ export async function buildCanvasRuntimeTools(params: {
imageSize?: string; imageSize?: string;
preferredModelId?: string; preferredModelId?: string;
imagePreferences?: AgentImagePreferences; imagePreferences?: AgentImagePreferences;
priorCalls?: AgentCallRecord[];
emit: (type: string, data: unknown) => void; emit: (type: string, data: unknown) => void;
objective?: string; objective?: string;
operationSelectedNodeIds?: string[]; operationSelectedNodeIds?: string[];
}) { }) {
const { nodes } = await readOwnedCanvas(params.userId, params.projectId); const { nodes } = await readOwnedCanvas(params.userId, params.projectId);
const selectedImageCount = selectedAgentImageCount(params.imagePreferences);
const imageNodeIds = nodes.filter((node) => node.type === "image").map((node) => node.id); const imageNodeIds = nodes.filter((node) => node.type === "image").map((node) => node.id);
const defaults = params.selectedNodeIds.filter((id) => imageNodeIds.includes(id)); const defaults = params.selectedNodeIds.filter((id) => imageNodeIds.includes(id));
const sdk = await createCanvasCapabilityTools({ const sdk = await createCanvasCapabilityTools({
@ -138,8 +128,8 @@ export async function buildCanvasRuntimeTools(params: {
visualCriteria: z.array(z.string().max(200)).max(10), visualCriteria: z.array(z.string().max(200)).max(10),
}), }),
async (input) => ({ ...input, async (input) => ({ ...input,
...(input.expectedImageCount !== undefined && selectedImageCount !== undefined ...(input.expectedImageCount !== undefined && params.imagePreferences?.parameters?.imageCount !== undefined
? { expectedImageCount: selectedImageCount } : {}), ? { expectedImageCount: params.imagePreferences.parameters.imageCount } : {}),
...(params.imagePreferences?.parameters?.aspectRatio && params.imagePreferences.parameters.aspectRatio !== "auto" ...(params.imagePreferences?.parameters?.aspectRatio && params.imagePreferences.parameters.aspectRatio !== "auto"
? { aspectRatio: params.imagePreferences.parameters.aspectRatio } : {}), ? { aspectRatio: params.imagePreferences.parameters.aspectRatio } : {}),
}), }),
@ -184,9 +174,7 @@ export async function buildCanvasRuntimeTools(params: {
async (input) => analyzeRuntimeImages({ ...params, ...input }), async (input) => analyzeRuntimeImages({ ...params, ...input }),
); );
let submittedCount = 0; let generatedCount = 0;
let occupiedImageSlots = countAgentGeneratedImages(params.priorCalls ?? []);
const countLimitMessage = "本次设定的生成张数已占满,请先查看正在生成或已经生成的结果。";
register( register(
"generateCanvasImages", "generateCanvasImages",
"生成图片", "生成图片",
@ -201,8 +189,9 @@ export async function buildCanvasRuntimeTools(params: {
}), }),
async (toolInput, callId) => { async (toolInput, callId) => {
const input = applyAgentImagePreferences(toolInput, params.imagePreferences); const input = applyAgentImagePreferences(toolInput, params.imagePreferences);
if (selectedImageCount !== undefined && occupiedImageSlots >= selectedImageCount) // Bound per-task spend without introducing any global/model concurrency queue.
throw new AgentToolRejectedError(countLimitMessage); if (generatedCount + input.imageCount > 24)
throw new Error("本次任务已达到 24 张生成预算,请检查结果后继续。");
const sources = await readCanvasImages( const sources = await readCanvasImages(
params.userId, params.userId,
params.projectId, params.projectId,
@ -225,17 +214,10 @@ export async function buildCanvasRuntimeTools(params: {
if (!chosen) throw new Error("系统未配置可用的图片生成模型"); if (!chosen) throw new Error("系统未配置可用的图片生成模型");
const imageSize = input.imageSize || params.imageSize || "2K"; const imageSize = input.imageSize || params.imageSize || "2K";
const quota = await checkUserImageQuota(params.userId); const quota = await checkUserImageQuota(params.userId);
const points = await getPointsSystemConfig();
const user = await getUserById({ id: params.userId });
// Reserve the remaining slots before dispatch, including calls still in flight.
if (selectedImageCount !== undefined) {
input.imageCount = Math.min(input.imageCount, selectedImageCount - occupiedImageSlots);
if (input.imageCount < 1) throw new AgentToolRejectedError(countLimitMessage);
}
if (submittedCount + input.imageCount > 24)
throw new AgentToolRejectedError("本次任务已达到 24 张生成预算,请检查结果后继续。");
if (!quota.allowed || (quota.remaining !== null && input.imageCount > quota.remaining)) if (!quota.allowed || (quota.remaining !== null && input.imageCount > quota.remaining))
throw new Error("本月图片生成配额不足。"); throw new Error("本月图片生成配额不足。");
const points = await getPointsSystemConfig();
const user = await getUserById({ id: params.userId });
const cost = const cost =
getModelGenerationCreditCost( getModelGenerationCreditCost(
{ imageGenConfig: chosen.model.imageGenConfig as DiscountableImageGenConfigLike | null }, { imageGenConfig: chosen.model.imageGenConfig as DiscountableImageGenConfigLike | null },
@ -249,8 +231,7 @@ export async function buildCanvasRuntimeTools(params: {
(user?.pointsBalance ?? 0) < cost (user?.pointsBalance ?? 0) < cost
) )
throw new Error("积分不足,请充值或联系管理员。"); throw new Error("积分不足,请充值或联系管理员。");
submittedCount += input.imageCount; generatedCount += input.imageCount;
occupiedImageSlots += input.imageCount;
const result = await generateCanvasImage({ const result = await generateCanvasImage({
prompt: input.prompt, prompt: input.prompt,
model: chosen.model.id, model: chosen.model.id,
@ -330,7 +311,6 @@ export async function buildCanvasRuntimeTools(params: {
}, },
); );
} }
occupiedImageSlots += result.images.length - input.imageCount;
return { return {
status: result.images.length < input.imageCount ? "partial" : "succeeded", status: result.images.length < input.imageCount ? "partial" : "succeeded",
appliedImagePreferences: { modelId: result.modelRef, imageSize, aspectRatio: input.aspectRatio, imageCount: input.imageCount }, appliedImagePreferences: { modelId: result.modelRef, imageSize, aspectRatio: input.aspectRatio, imageCount: input.imageCount },

View File

@ -7,8 +7,7 @@ import type { CanvasAgentTool } from "@/lib/ai/canvas-agent-runtime/loop";
import { AgentToolRejectedError, publicAgentError, type AgentCallRecord, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol"; import { AgentToolRejectedError, publicAgentError, type AgentCallRecord, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error"; import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion"; import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion";
import { countAgentGeneratedImages, requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results"; import { requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results";
import { selectedAgentImageCount } from "@/lib/ai/canvas-agent-runtime/image-preferences";
import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error"; import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
import { publicAssistantText } from "./assistant-output"; import { publicAssistantText } from "./assistant-output";
@ -50,16 +49,6 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
if (!details.recommendation) details.recommendation = "请联系管理员并提供请求编号,核对该步骤的错误。"; if (!details.recommendation) details.recommendation = "请联系管理员并提供请求编号,核对该步骤的错误。";
return `${call.title || "创作工具"}:${formatRequestFailure(details, ports.runId)}`; return `${call.title || "创作工具"}:${formatRequestFailure(details, ports.runId)}`;
} }
function imageCountReached() {
const count = snapshot.workspace === "video" ? undefined : selectedAgentImageCount(snapshot.imagePreferences);
return count !== undefined && countAgentGeneratedImages(snapshot.calls) >= count;
}
function reviewMessage(issues: string[]) {
const summary = `结果待核对:${issues.join(";")}`;
return imageCountReached()
? `${summary}\n\n已保留本次生成的图片。如需再生成,请发送新指令。`
: summary;
}
let modelError = ""; let modelError = "";
let toolFailure = ""; let toolFailure = "";
let nativeFailed = false; let nativeFailed = false;
@ -95,7 +84,6 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
`可用业务工具:${tools.join("、")}。工具名称带 mcp__canvas__ 前缀。`, `可用业务工具:${tools.join("、")}。工具名称带 mcp__canvas__ 前缀。`,
"原生文件、Bash、记忆及子任务能力仅作用于此任务的私有工作区,不能通过 shell 直接访问模型服务商或本站后台;业务操作使用 MCP。", "原生文件、Bash、记忆及子任务能力仅作用于此任务的私有工作区,不能通过 shell 直接访问模型服务商或本站后台;业务操作使用 MCP。",
"用户只要求生图或改图时,把结果写入画布并验收即可。除非用户明确要求下载或导出,不要主动调用 image_download,也不要为收尾打开交互面板。", "用户只要求生图或改图时,把结果写入画布并验收即可。除非用户明确要求下载或导出,不要主动调用 image_download,也不要为收尾打开交互面板。",
"用户设置的生成张数是本次任务的总数,包括验收未通过的已生成图片。达到张数后,保留结果并说明问题;如需再次生成,等待用户发送新指令。",
"用户指定的生图模型被明确拒绝时,停止本轮收费操作,询问用户希望切换生图模型还是稍后重试。切换需用户确认并更新模型选择;结果未知的超时必须先核对原任务,不可直接换模型重发。不要为普通图片编辑调用 appearance.post 或其他无关业务接口。", "用户指定的生图模型被明确拒绝时,停止本轮收费操作,询问用户希望切换生图模型还是稍后重试。切换需用户确认并更新模型选择;结果未知的超时必须先核对原任务,不可直接换模型重发。不要为普通图片编辑调用 appearance.post 或其他无关业务接口。",
"imagePreferences 是用户在输入框选择的生图模型和图片参数,与当前规划模型不同。生成和编辑图片优先用 generateCanvasImages;用户选定的模型不得被技能默认值、你选的其他模型或重试覆盖。自动选择生图模型时,图片工具内部会在明确失败后切换备用模型并补齐结果,不要自行重放付费操作;用户明确指定模型时禁止故障切换。比例、尺寸、数量、反向提示词等控件必须传入;不要通过其他生成接口绕过用户选择。分析、问答、排版等不需要出图的任务无需生成图片。", "imagePreferences 是用户在输入框选择的生图模型和图片参数,与当前规划模型不同。生成和编辑图片优先用 generateCanvasImages;用户选定的模型不得被技能默认值、你选的其他模型或重试覆盖。自动选择生图模型时,图片工具内部会在明确失败后切换备用模型并补齐结果,不要自行重放付费操作;用户明确指定模型时禁止故障切换。比例、尺寸、数量、反向提示词等控件必须传入;不要通过其他生成接口绕过用户选择。分析、问答、排版等不需要出图的任务无需生成图片。",
"转正面、换角度、旋转主体视角时,直接用 generateCanvasImages,referenceNodeIds 指向要编辑的真实图片,提示词描述目标视角并保留产品结构和未要求修改的细节。旧多视角功能、angleEditImage 和 image.multi_angle 已废弃,即使历史对话用过也不得再调用或打开旧面板。", "转正面、换角度、旋转主体视角时,直接用 generateCanvasImages,referenceNodeIds 指向要编辑的真实图片,提示词描述目标视角并保留产品结构和未要求修改的细节。旧多视角功能、angleEditImage 和 image.multi_angle 已废弃,即使历史对话用过也不得再调用或打开旧面板。",
@ -289,12 +277,12 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
const verification = await ports.verify(reviewSnapshot); snapshot.verification = verification; const verification = await ports.verify(reviewSnapshot); snapshot.verification = verification;
snapshot.inputTokens = reviewSnapshot.inputTokens; snapshot.outputTokens = reviewSnapshot.outputTokens; snapshot.inputTokens = reviewSnapshot.inputTokens; snapshot.outputTokens = reviewSnapshot.outputTokens;
if (verification.passed) return await save(result.response || "任务完成,结果已写入画布。", "completed"); if (verification.passed) return await save(result.response || "任务完成,结果已写入画布。", "completed");
if (verification.retryable && snapshot.repairs < 2 && !imageCountReached()) { if (verification.retryable && snapshot.repairs < 2) {
snapshot.repairs++; snapshot.repairs++;
prompt = `验收需要定点修复:${JSON.stringify(verification)}。只处理失败结果,禁止整批重新生成;保留所有通过结果。`; prompt = `验收需要定点修复:${JSON.stringify(verification)}。只处理失败结果,禁止整批重新生成;保留所有通过结果。`;
continue; continue;
} }
return await save(reviewMessage(verification.issues), "needs_review"); return await save(`结果待核对:${verification.issues.join(";")}`, "needs_review");
} }
} catch (error) { } catch (error) {
if (snapshot.nativeRuntime) snapshot.nativeRuntime.state = "stopped"; if (snapshot.nativeRuntime) snapshot.nativeRuntime.state = "stopped";
@ -318,7 +306,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
if (control.cancelled || ports.signal.aborted) return await save("已停止后续步骤,完成的结果保留在画布。", "cancelled"); if (control.cancelled || ports.signal.aborted) return await save("已停止后续步骤,完成的结果保留在画布。", "cancelled");
if (inputs !== JSON.stringify(snapshot.inputs)) return await save("已收到新的补充要求,图片已保留,请继续处理。", "needs_review"); if (inputs !== JSON.stringify(snapshot.inputs)) return await save("已收到新的补充要求,图片已保留,请继续处理。", "needs_review");
return await save(verification.passed ? "图片已完成并通过检查,结果已保留在画布。" return await save(verification.passed ? "图片已完成并通过检查,结果已保留在画布。"
: `图片已返回画布,${reviewMessage(verification.issues)}`, verification.passed ? "completed" : "needs_review"); : `图片已返回画布,结果待核对:${verification.issues.join(";")}`, verification.passed ? "completed" : "needs_review");
} catch { /* Keep the original interruption if independent inspection also fails. */ } } catch { /* Keep the original interruption if independent inspection also fails. */ }
} }
return await save(cancelled ? "已停止后续步骤,完成的结果保留在画布。" : toolFailure || modelError || publicAgentError(error), cancelled ? "cancelled" : "needs_review"); return await save(cancelled ? "已停止后续步骤,完成的结果保留在画布。" : toolFailure || modelError || publicAgentError(error), cancelled ? "cancelled" : "needs_review");

View File

@ -63,5 +63,4 @@ export async function executeCanvasToolbarCapability(
} }
current.loadCanvas(JSON.parse(merged.snapshot).canvasData); current.loadCanvas(JSON.parse(merged.snapshot).canvasData);
current.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt)); current.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
return { projectId, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt) };
} }

View File

@ -107,5 +107,5 @@ export function mergeCanvasSnapshots(
(e: any) => ids.has(e.source) && ids.has(e.target), (e: any) => ids.has(e.source) && ids.has(e.target),
); );
} }
return { snapshot: JSON.stringify(snapshot), conflicts, changed: !same(snapshot, local) }; return { snapshot: JSON.stringify(snapshot), conflicts };
} }

View File

@ -10,16 +10,11 @@ async function setup(page: Page, failRead = false) {
const state = { const state = {
canvasData: { nodes: [imageNode("source")], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select" }, canvasData: { nodes: [imageNode("source")], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select" },
revision: 1, failSave: false, failRead, conflictNextSave: false, conflictSameNode: false, revision: 1, failSave: false, failRead, conflictNextSave: false, conflictSameNode: false,
writes: [] as any[], metadataWrites: 0, submissions: [] as any[], capabilitySubmissions: [] as any[], writes: [] as any[], metadataWrites: 0, submissions: [] as any[],
appVersion: "development", holdRequest: false, finishRequest: null as (() => void) | null, appVersion: "development", holdRequest: false, finishRequest: null as (() => void) | null,
nativeResultNext: false,
holdUpload: false, uploadStarted: 0, finishUpload: null as (() => void) | null,
}; };
const revision = () => new Date(Date.UTC(2026, 10, 6, 0, 0, state.revision)).toISOString(); const revision = () => new Date(Date.UTC(2026, 10, 6, 0, 0, state.revision)).toISOString();
const project = () => ({ id: projectId, title: "同步验证", canvasData: JSON.parse(JSON.stringify(state.canvasData, (_key, value) => const project = () => ({ id: projectId, title: "同步验证", canvasData: state.canvasData, updatedAt: revision() });
value && typeof value === "object" && !Array.isArray(value)
? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))
: value)), updatedAt: revision() });
const errors: string[] = []; const errors: string[] = [];
page.on("pageerror", error => errors.push(error.message)); page.on("pageerror", error => errors.push(error.message));
await page.context().addInitScript(() => localStorage.setItem("theme", "light")); await page.context().addInitScript(() => localStorage.setItem("theme", "light"));
@ -28,10 +23,6 @@ async function setup(page: Page, failRead = false) {
const path = new URL(request.url()).pathname; const path = new URL(request.url()).pathname;
let data: unknown = {}; let data: unknown = {};
let status = 200; let status = 200;
if (path.startsWith("/api/files/") && request.method() === "POST" && state.holdUpload) {
state.uploadStarted++;
await new Promise<void>(resolve => { state.finishUpload = resolve; });
}
if (path === `/api/canvas/${projectId}`) { if (path === `/api/canvas/${projectId}`) {
if (request.method() === "GET") { if (request.method() === "GET") {
status = state.failRead ? 500 : 200; status = state.failRead ? 500 : 200;
@ -62,23 +53,11 @@ async function setup(page: Page, failRead = false) {
data = { success: true, previousCanvasRevision, data: project() }; data = { success: true, previousCanvasRevision, data: project() };
} }
} }
} else if (path === `/api/canvas/${projectId}/capabilities` && request.method() === "POST") {
state.capabilitySubmissions.push(request.postDataJSON());
state.canvasData.nodes.push({ ...imageNode(`enhanced-${state.capabilitySubmissions.length}`), position: { x: 520, y: 130 } });
state.revision++;
data = { success: true, task: { status: "succeeded" } };
} else if (path === "/api/canvas/agent-chat" && request.method() === "POST") { } else if (path === "/api/canvas/agent-chat" && request.method() === "POST") {
state.submissions.push(request.postDataJSON()); state.submissions.push(request.postDataJSON());
let operation = "";
if (state.nativeResultNext) {
state.nativeResultNext = false;
state.canvasData.nodes.push({ ...imageNode("agent-result"), position: { x: 520, y: 130 } });
state.revision++;
operation = `data: ${JSON.stringify({ type: "data-canvasOperation", data: { status: "applied", projectId, createdNodeIds: ["agent-result"] } })}\n\n`;
}
if (state.holdRequest) await new Promise<void>(resolve => { state.finishRequest = resolve; }); if (state.holdRequest) await new Promise<void>(resolve => { state.finishRequest = resolve; });
await route.fulfill({ headers: { "content-type": "text/event-stream", "x-vercel-ai-ui-message-stream": "v1" }, body: await route.fulfill({ headers: { "content-type": "text/event-stream", "x-vercel-ai-ui-message-stream": "v1" }, body:
'data: {"type":"start","messageId":"sync-assistant"}\n\n' + operation + 'data: {"type":"text-start","id":"text"}\n\ndata: {"type":"text-delta","id":"text","delta":"已收到请求"}\n\ndata: {"type":"text-end","id":"text"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n' }); 'data: {"type":"start","messageId":"sync-assistant"}\n\ndata: {"type":"text-start","id":"text"}\n\ndata: {"type":"text-delta","id":"text","delta":"已收到请求"}\n\ndata: {"type":"text-end","id":"text"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n' });
return; return;
} else if (path === "/api/app-version") data = { version: state.appVersion }; } else if (path === "/api/app-version") data = { version: state.appVersion };
else if (path === "/api/files/direct-upload") { else if (path === "/api/files/direct-upload") {
@ -114,207 +93,6 @@ async function moveImage(page: Page) {
await page.mouse.up(); await page.mouse.up();
} }
const unloadWouldBeBlocked = (page: Page) => page.evaluate(() => {
const event = new Event("beforeunload", { cancelable: true });
Object.defineProperty(event, "returnValue", { value: "", writable: true });
window.dispatchEvent(event);
return event.defaultPrevented && Boolean(event.returnValue);
});
test("background saving recovers when the network returns without a manual save", async ({ page }) => {
const { state, errors } = await setup(page);
await expect(page.getByRole("button", { name: "保存", exact: true })).toHaveCount(0);
state.failSave = true;
await moveImage(page);
await expect.poll(() => state.writes.filter(body => body.canvasData).length).toBeGreaterThan(0);
expect(state.canvasData.nodes[0].position.x).toBe(160);
state.failSave = false;
await page.evaluate(() => window.dispatchEvent(new Event("online")));
await expect.poll(() => state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
await expect.poll(() => unloadWouldBeBlocked(page)).toBe(false);
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("selecting a saved image does not require another save before an Agent message", async ({ page }) => {
const { state, errors } = await setup(page);
await page.locator('[data-canvas-node-id="source"]').click();
state.failSave = true;
const editor = page.getByLabel("描述你的设计需求", { exact: true });
await editor.fill("分析这张已经保存的图片");
await editor.press("Enter");
await expect.poll(() => state.submissions.length).toBe(1);
expect(state.canvasData.nodes.map(node => node.id)).toEqual(["source"]);
expect(errors).toEqual([]);
});
test("only unsaved content blocks closing and beforeunload does not issue duplicate saves", async ({ page }) => {
const { state, errors } = await setup(page);
expect(await unloadWouldBeBlocked(page)).toBe(false);
await page.locator('[data-canvas-node-id="source"]').click();
expect(await unloadWouldBeBlocked(page)).toBe(false);
state.failSave = true;
await moveImage(page);
const writes = state.writes.length;
expect(await unloadWouldBeBlocked(page)).toBe(true);
expect(state.writes).toHaveLength(writes);
const draft = await page.evaluate(id => localStorage.getItem(`canvas-unsynced:${id}`), projectId);
expect(JSON.parse(JSON.parse(draft!).snapshot).canvasData.nodes[0].position.x).toBeGreaterThan(160);
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("pagehide and visibilitychange issue only one closing save", async ({ page }) => {
const { state, errors } = await setup(page);
state.failSave = true;
await moveImage(page);
const writes = state.writes.length;
await page.evaluate(() => {
window.dispatchEvent(new Event("pagehide"));
Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
document.dispatchEvent(new Event("visibilitychange"));
});
await expect.poll(() => state.writes.length).toBe(writes + 1);
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("leaving with pending changes can continue editing or save before navigating", async ({ page }) => {
const { state, errors } = await setup(page);
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
await moveImage(page);
await page.getByRole("button", { name: "返回首页", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
await expect(dialog).toBeVisible();
await page.screenshot({ path: test.info().outputPath("leave-dialog-desktop.png"), animations: "disabled" });
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({ path: test.info().outputPath("leave-dialog-mobile.png"), animations: "disabled" });
await page.setViewportSize({ width: 1440, height: 900 });
await dialog.getByRole("button", { name: "继续编辑", exact: true }).click();
await expect(dialog).toHaveCount(0);
await moveImage(page);
await page.getByRole("button", { name: "返回首页", exact: true }).click();
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
await expect(page.getByText("已返回首页")).toBeVisible();
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("failed saving keeps the leave dialog and canvas until a successful retry", async ({ page }) => {
const { state, errors } = await setup(page);
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
state.failSave = true;
await moveImage(page);
await page.getByRole("button", { name: "返回首页", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
await expect(dialog.getByRole("alert")).toContainText("HTTP 503");
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
expect(state.submissions).toHaveLength(0);
state.failSave = false;
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
await expect(page.getByText("已返回首页")).toBeVisible();
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
expect(errors).toEqual([]);
});
test("discarding pending changes leaves without saving or restoring the discarded draft", async ({ page }) => {
const { state, errors } = await setup(page);
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
state.failSave = true;
await moveImage(page);
expect(await unloadWouldBeBlocked(page)).toBe(true);
const writes = state.writes.length;
await page.getByRole("button", { name: "返回首页", exact: true }).click();
await page.getByRole("button", { name: "不保存并离开", exact: true }).click();
await expect(page.getByText("已返回首页")).toBeVisible();
expect(state.writes).toHaveLength(writes);
expect(state.canvasData.nodes[0].position.x).toBe(160);
expect(await page.evaluate(id => localStorage.getItem(`canvas-unsynced:${id}`), projectId)).toBeNull();
expect(errors).toEqual([]);
});
test("creating another project also offers to preserve pending canvas changes", async ({ page }) => {
const { state, errors } = await setup(page);
state.failSave = true;
await moveImage(page);
await page.getByRole("button", { name: "查看项目文件", exact: true }).click();
await page.getByRole("button", { name: "新建画布", exact: true }).first().click();
await expect(page.getByRole("dialog", { name: "离开前保存画布?" })).toBeVisible();
await page.getByRole("button", { name: "继续编辑", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/canvas/${projectId}$`));
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("an enhanced result can be moved and archived before the next save without blocking Agent", async ({ page }) => {
const { state, errors } = await setup(page);
await page.locator('[data-canvas-node-id="source"]').click();
await page.locator('[data-tool-key="enhance"]').click();
const result = page.locator('[data-canvas-node-id="enhanced-1"]');
await expect(result).toBeVisible();
const box = (await result.boundingBox())!;
await page.mouse.move(box.x + 100, box.y + 80);
await page.mouse.down();
await page.mouse.move(box.x + 165, box.y + 110, { steps: 5 });
await page.mouse.up();
state.canvasData.nodes.find(node => node.id === "enhanced-1")!.data.src = `${imageOrigin}/uploads/archived-result.png`;
state.revision++;
const editor = page.getByLabel("描述你的设计需求", { exact: true });
await editor.fill("保留增强结果的位置,继续分析");
await editor.press("Enter");
await expect.poll(() => state.submissions.length).toBe(1);
const saved = state.canvasData.nodes.find(node => node.id === "enhanced-1")!;
expect(saved.position.x).toBeGreaterThan(520);
expect(saved.data.src).toContain("archived-result.png");
expect(state.capabilitySubmissions).toHaveLength(1);
await page.reload();
await expect(page.locator('[data-canvas-node-id="enhanced-1"]')).toBeVisible();
expect(errors).toEqual([]);
});
test("toolbar save failures show the cause and recover without a paid submission", async ({ page }) => {
const { state, errors } = await setup(page);
await moveImage(page);
state.failSave = true;
await page.locator('[data-tool-key="enhance"]').click();
await expect(page.getByText(/画布自动同步失败(HTTP 503)/)).toBeVisible();
await expect(page.getByText(/本次处理未提交/)).toBeVisible();
expect(state.capabilitySubmissions).toHaveLength(0);
state.failSave = false;
await page.locator('[data-tool-key="enhance"]').click();
await expect(page.locator('[data-canvas-node-id="enhanced-1"]')).toBeVisible();
expect(state.capabilitySubmissions).toHaveLength(1);
expect(errors).toEqual([]);
});
test("native Agent results update the save baseline before moving and archiving the result", async ({ page }) => {
const { state, errors } = await setup(page);
state.nativeResultNext = true;
const editor = page.getByLabel("描述你的设计需求", { exact: true });
await editor.fill("先分析再处理图片");
await editor.press("Enter");
const result = page.locator('[data-canvas-node-id="agent-result"]');
await expect(result).toBeVisible();
await expect(editor).toBeEditable();
const box = (await result.boundingBox())!;
await page.mouse.move(box.x + 100, box.y + 80);
await page.mouse.down();
await page.mouse.move(box.x + 165, box.y + 110, { steps: 5 });
await page.mouse.up();
state.canvasData.nodes.find(node => node.id === "agent-result")!.data.src = `${imageOrigin}/uploads/agent-archived.png`;
state.revision++;
await editor.fill("保留位置,继续分析");
await editor.press("Enter");
await expect.poll(() => state.submissions.length).toBe(2);
const saved = state.canvasData.nodes.find(node => node.id === "agent-result")!;
expect(saved.position.x).toBeGreaterThan(520);
expect(saved.data.src).toContain("agent-archived.png");
expect(state.capabilitySubmissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("upload naming and consecutive Agent messages sync automatically without manual saves", async ({ page }) => { test("upload naming and consecutive Agent messages sync automatically without manual saves", async ({ page }) => {
const { state, errors } = await setup(page); const { state, errors } = await setup(page);
const editor = page.getByLabel("描述你的设计需求", { exact: true }); const editor = page.getByLabel("描述你的设计需求", { exact: true });
@ -391,39 +169,6 @@ test("conflicting edits keep both canvases and do not submit an Agent task", asy
expect(errors).toEqual([]); expect(errors).toEqual([]);
}); });
for (const choice of ["local", "remote"] as const) {
test(`an existing conflicting draft can keep the ${choice} edit and resume without losing other images`, async ({ page }) => {
const { state, errors } = await setup(page);
await moveImage(page);
state.conflictSameNode = true;
state.conflictNextSave = true;
const editor = page.getByLabel("描述你的设计需求", { exact: true });
await editor.fill("继续分析图片");
await editor.press("Enter");
await expect(page.getByRole("button", { name: "保留本页修改", exact: true })).toBeVisible();
state.canvasData.nodes.push(imageNode("remote-addition"));
state.revision++;
await page.reload();
await expect(page.locator('[data-canvas-node-id="remote-addition"]')).toBeVisible();
if (choice === "local") {
await page.screenshot({ path: test.info().outputPath("conflict-recovery-desktop.png"), animations: "disabled" });
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({ path: test.info().outputPath("conflict-recovery-mobile.png"), animations: "disabled" });
await page.setViewportSize({ width: 1440, height: 900 });
}
await page.getByRole("button", { name: choice === "local" ? "保留本页修改" : "保留云端修改", exact: true }).click();
await expect(page.getByRole("button", { name: "保留本页修改", exact: true })).toHaveCount(0);
await editor.press("Enter");
await expect.poll(() => state.submissions.length).toBe(1);
const x = state.canvasData.nodes.find(node => node.id === "source")!.position.x;
if (choice === "remote") expect(x).toBe(500);
else { expect(x).toBeGreaterThan(160); expect(x).not.toBe(500); }
expect(state.canvasData.nodes.map(node => node.id)).toEqual(["source", "remote-addition"]);
expect(state.capabilitySubmissions).toHaveLength(0);
expect(errors).toEqual([]);
});
}
test("a failed canvas read cannot create or save an empty replacement", async ({ page }) => { test("a failed canvas read cannot create or save an empty replacement", async ({ page }) => {
const { state, errors } = await setup(page, true); const { state, errors } = await setup(page, true);
await expect(page.getByRole("alert").filter({ hasText: "画布暂时无法读取" })).toBeVisible(); await expect(page.getByRole("alert").filter({ hasText: "画布暂时无法读取" })).toBeVisible();
@ -452,92 +197,6 @@ async function countRefreshes(page: Page, state: { appVersion: string }) {
return () => navigations; return () => navigations;
} }
test("unfinished image uploads remain on the page even after their canvas placeholder was saved", async ({ page }) => {
const { state, errors } = await setup(page);
const refreshes = await countRefreshes(page, state);
state.holdUpload = true;
await page.mouse.click(100, 90);
await page.evaluate(() => {
const bytes = Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j2d0AAAAASUVORK5CYII="), c => c.charCodeAt(0));
const transfer = new DataTransfer();
transfer.items.add(new File([bytes], "上传保护.png", { type: "image/png" }));
window.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }));
});
await expect.poll(() => state.uploadStarted).toBeGreaterThan(0);
await expect.poll(() => state.canvasData.nodes.filter(node => node.data.src === "").length).toBe(1);
expect(await unloadWouldBeBlocked(page)).toBe(true);
await page.getByRole("button", { name: "返回首页", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
await expect(dialog.getByRole("alert")).toContainText("还有图片未上传成功");
await dialog.getByRole("button", { name: "继续编辑", exact: true }).click();
await showUpdate(page, state);
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
await expect(page.getByRole("alertdialog")).toContainText("还有图片未上传成功");
expect(refreshes()).toBe(0);
state.holdUpload = false;
state.finishUpload?.();
await expect.poll(() => state.canvasData.nodes.some(node => node.data.src.includes("uploaded.png"))).toBe(true);
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
await expect.poll(refreshes).toBe(1);
expect(state.canvasData.nodes).toHaveLength(2);
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("a composer upload blocks release refresh and closing until the image and input are retained", async ({ page }) => {
const { state, errors } = await setup(page);
const refreshes = await countRefreshes(page, state);
state.holdUpload = true;
const editor = page.getByLabel("描述你的设计需求", { exact: true });
await editor.fill("上传后保留这条输入");
await editor.evaluate(element => {
const transfer = new DataTransfer();
transfer.items.add(new File([new Uint8Array([137, 80, 78, 71])], "聊天上传保护.png", { type: "image/png" }));
element.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }));
});
await expect.poll(() => state.uploadStarted).toBeGreaterThan(0);
await expect(page.getByText("聊天上传保护.png", { exact: true })).toBeVisible();
expect(await unloadWouldBeBlocked(page)).toBe(true);
await showUpdate(page, state);
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
await expect(page.getByRole("alertdialog")).toContainText("素材仍在上传");
expect(refreshes()).toBe(0);
state.holdUpload = false;
state.finishUpload?.();
await expect.poll(() => state.canvasData.nodes.some(node => node.data.src.includes("uploaded.png"))).toBe(true);
await expect(page.getByText("聊天上传保护.png", { exact: true })).toHaveCount(1);
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
await expect.poll(refreshes).toBe(1);
await expect(editor).toContainText("上传后保留这条输入");
await expect(editor.getByText("聊天上传保护.png", { exact: true })).toBeVisible();
expect(state.canvasData.nodes).toHaveLength(2);
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("an older browser without AbortSignal static helpers receives the release notice", async ({ page }) => {
await page.context().addInitScript(() => {
Object.defineProperty(AbortSignal, "any", { value: undefined, configurable: true });
Object.defineProperty(AbortSignal, "timeout", { value: undefined, configurable: true });
});
const { state, errors } = await setup(page);
await showUpdate(page, state);
await expect(page.getByRole("button", { name: "同步并刷新", exact: true })).toBeVisible();
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("a release is detected by the minute timer without requiring a focus change", async ({ page }) => {
await page.clock.install();
const { state, errors } = await setup(page);
state.appVersion = "20261106T120000Z-nextrelease1";
await page.clock.fastForward(60_001);
await expect(page.getByRole("alertdialog")).toContainText("平台已更新");
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("a new release requires confirmation and refreshes after preserving canvas and input", async ({ page }) => { test("a new release requires confirmation and refreshes after preserving canvas and input", async ({ page }) => {
const { state, errors } = await setup(page); const { state, errors } = await setup(page);
const refreshes = await countRefreshes(page, state); const refreshes = await countRefreshes(page, state);
@ -590,19 +249,9 @@ test("an update keeps the current page when automatic synchronization fails", as
await showUpdate(page, state); await showUpdate(page, state);
await page.getByRole("button", { name: "同步并刷新", exact: true }).click(); await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
await expect(page.getByRole("alertdialog")).toContainText("HTTP 503"); await expect(page.getByRole("alertdialog")).toContainText("HTTP 503");
await expect(page.getByRole("alertdialog")).not.toContainText("本次任务未提交");
await expect(page.getByRole("alertdialog")).not.toContainText("再次发送");
expect(refreshes()).toBe(0); expect(refreshes()).toBe(0);
expect(state.submissions).toHaveLength(0); expect(state.submissions).toHaveLength(0);
await page.screenshot({ path: test.info().outputPath("update-error-desktop.png"), animations: "disabled" });
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({ path: test.info().outputPath("update-error-mobile.png"), animations: "disabled" });
await page.setViewportSize({ width: 1440, height: 900 });
await page.getByRole("button", { name: "返回处理", exact: true }).click();
await expect(page.getByRole("alertdialog")).toHaveCount(0);
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
state.failSave = false; state.failSave = false;
await showUpdate(page, state);
await page.getByRole("button", { name: "同步并刷新", exact: true }).click(); await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
await expect.poll(refreshes).toBe(1); await expect.poll(refreshes).toBe(1);
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160); expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);

View File

@ -1,198 +0,0 @@
import { expect, test, type Page } from "@playwright/test";
const projectId="f1827b8f-b810-44d8-9927-c2ef255eed78";
const paragraph="保持产品结构与材质一致,先分析参考图片,再安排场景与光线,逐项确认处理要求。";
const makeMessage=(id:string,role:string,text:string,offset:number)=>({id,role,parts:[{type:"text",text}],createdAt:new Date(Date.UTC(2026,10,6,0,0,offset)).toISOString()});
async function setup(page:Page) {
const state={history:Array.from({length:16},(_,i)=>makeMessage(`history-${i}`,i%2?"assistant":"user",`历史记录 ${i+1}\n\n${new Array(4).fill(paragraph).join("\n\n")}`,i)),historyReads:0,run:null as any};
const errors:string[]=[];page.on("pageerror",error=>errors.push(error.message));
await page.context().addInitScript(()=>{
localStorage.setItem("theme","light");
const originalFetch=window.fetch.bind(window);
(window as any).agentScrollStream={};
window.fetch=async(input,init)=>{
const url=typeof input==="string"?input:input instanceof URL?input.href:input.url;
if(new URL(url,location.origin).pathname!=="/api/canvas/agent-chat"||init?.method!=="POST")return originalFetch(input,init);
const request=JSON.parse(String(init.body));
const stream=(window as any).agentScrollStream;
stream.request=request;stream.text="";
const encoder=new TextEncoder();
return new Response(new ReadableStream({start(controller){
const emit=(value:unknown)=>controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`));
emit({type:"start",messageId:"stream-assistant"});emit({type:"text-start",id:"text"});
stream.append=(text:string)=>{stream.text+=text;emit({type:"text-delta",id:"text",delta:text});};
stream.finish=()=>{emit({type:"text-end",id:"text"});emit({type:"finish"});controller.enqueue(encoder.encode("data: [DONE]\n\n"));controller.close();};
}}),{headers:{"Content-Type":"text/event-stream","x-vercel-ai-ui-message-stream":"v1"}});
};
});
await page.context().route("**/api/**",async route=>{
const request=route.request(),url=new URL(request.url()),path=url.pathname;
let data:unknown={};
if(path===`/api/canvas/${projectId}`)data={success:true,data:{id:projectId,title:"滚动验证",updatedAt:"2026-11-06T00:00:00.000Z",canvasData:{nodes:[],edges:[],viewport:{x:0,y:0,zoom:1}}}};
else if(path==="/api/auth/session")data={user:{id:"scroll-user",name:"滚动验证",type:"admin"},expires:"2099-01-01"};
else if(path.includes("validate-session"))data={success:true,valid:true};
else if(path==="/api/site-settings")data={canvasEnabled:true,agentEnabled:true,membershipEnabled:false};
else if(path==="/api/app-version")data={version:"development"};
else if(path==="/api/models")data={models:[{id:"image-test",modelId:"gpt-image-2",providerType:"openai",name:"GPT Image",type:"image",enabled:true,supportsImageGen:true}]};
else if(path==="/api/vote")data=[];
else if(path==="/api/canvas/agent-runs")data={run:state.run};
else if(path.includes("messages")){state.historyReads++;data={messages:url.searchParams.get("chatId")===projectId?state.history:[]};}
else if(path.includes("sessions"))data={sessions:[]};
else if(path.startsWith("/api/canvas/"))data={success:true,runs:[]};
await route.fulfill({contentType:"application/json",body:JSON.stringify(data)});
});
await page.goto(`/canvas/${projectId}`,{waitUntil:"domcontentloaded"});
await expect(page.getByTestId("canvas-chat-messages")).toContainText("历史记录 16");
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
return {state,errors};
}
const metrics=(page:Page)=>page.getByTestId("canvas-chat-messages").evaluate(element=>({top:element.scrollTop,bottom:element.scrollHeight-element.clientHeight-element.scrollTop}));
async function startStream(page:Page){
const editor=page.getByLabel("描述你的设计需求",{exact:true});await editor.fill("先分析和规划,再处理图片。");await editor.press("Enter");
await expect.poll(()=>page.evaluate(()=>Boolean((window as any).agentScrollStream.append))).toBe(true);
await page.evaluate(text=>(window as any).agentScrollStream.append(text),`开始分析\n\n${new Array(8).fill(paragraph).join("\n\n")}\n\n首次输出已显示\n\n`);
await expect(page.getByTestId("canvas-chat-messages")).toContainText("首次输出已显示");
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
}
async function scrollUp(page:Page){
const box=(await page.getByTestId("canvas-chat-messages").boundingBox())!;
await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.wheel(0,-650);
await expect.poll(async()=>(await metrics(page)).bottom).toBeGreaterThan(350);
await page.waitForTimeout(100);
return (await metrics(page)).top;
}
test("streaming Agent output preserves the position after the reader scrolls up",async({page})=>{
const {errors}=await setup(page);await startStream(page);const top=await scrollUp(page);
await page.evaluate(text=>(window as any).agentScrollStream.append(text),`新增分析步骤\n\n${new Array(5).fill(paragraph).join("\n\n")}`);
await expect(page.getByTestId("canvas-chat-messages")).toContainText("新增分析步骤");
await page.waitForTimeout(400);
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
expect(errors).toEqual([]);
});
test("returning to the latest message resumes following streamed output",async({page})=>{
const {errors}=await setup(page);await startStream(page);await scrollUp(page);
await expect(page.getByRole("button",{name:"回到最新消息",exact:true})).toBeVisible();
await page.screenshot({path:test.info().outputPath("reading-history-desktop.png"),animations:"disabled"});
await page.getByRole("button",{name:"回到最新消息",exact:true}).click();
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
await page.evaluate(text=>(window as any).agentScrollStream.append(text),`继续处理\n\n${new Array(5).fill(paragraph).join("\n\n")}`);
await expect(page.getByTestId("canvas-chat-messages")).toContainText("继续处理");
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
await scrollUp(page);
const box=(await page.getByTestId("canvas-chat-messages").boundingBox())!;
await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.wheel(0,20000);
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
await page.evaluate(text=>(window as any).agentScrollStream.append(text),`已回到最新消息\n\n${paragraph}`);
await expect(page.getByTestId("canvas-chat-messages")).toContainText("已回到最新消息");
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
expect(errors).toEqual([]);
});
test("finishing a streamed request and reloading its history preserve the reader and viewport",async({page})=>{
const {state,errors}=await setup(page);await startStream(page);const top=await scrollUp(page);
await page.getByTestId("canvas-chat-messages").evaluate(element=>(window as any).readingViewport=element);
const stream=await page.evaluate(()=>({request:(window as any).agentScrollStream.request,text:(window as any).agentScrollStream.text}));
state.history.push(stream.request.message,{...makeMessage("stream-assistant","assistant",stream.text,100),createdAt:new Date(Date.now()+100).toISOString()});
const reads=state.historyReads;
await page.evaluate(()=>(window as any).agentScrollStream.finish());
await expect(page.getByLabel("描述你的设计需求",{exact:true})).toBeEditable();
await expect.poll(()=>state.historyReads).toBeGreaterThan(reads);
await page.waitForTimeout(3800);
expect(await page.getByTestId("canvas-chat-messages").evaluate(element=>element===(window as any).readingViewport)).toBe(true);
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
expect(errors).toEqual([]);
});
test("background Agent progress restores text in the original chat without taking over scrolling",async({page})=>{
const {state,errors}=await setup(page);const top=await scrollUp(page);
const text=state.history.at(-1)!.parts[0].text+"\n\n后台任务继续分析参考图片。";
state.run={id:"native-scroll-run",chatId:projectId,projectId,assistantMessageId:state.history.at(-1)!.id,assistantText:text,status:"running",message:"正在分析图片",steps:2,toolCount:2,resultNodeIds:[],inputs:[],updatedAt:new Date().toISOString(),cancelRequested:false,resumable:false};
await page.evaluate(run=>window.dispatchEvent(new CustomEvent("canvas-agent-run:update",{detail:run})),state.run);
await expect(page.getByTestId("canvas-chat-messages")).toContainText("后台任务继续分析参考图片。");
await page.waitForTimeout(100);
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
state.run={...state.run,status:"completed",assistantText:text+"\n\n后台任务处理结束。"};
await page.evaluate(run=>window.dispatchEvent(new CustomEvent("canvas-agent-run:update",{detail:run})),state.run);
await expect(page.getByTestId("canvas-chat-messages")).toContainText("后台任务处理结束。");
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
expect(errors).toEqual([]);
});
test("background history refresh preserves the same scroll container and reading position",async({page})=>{
await page.clock.install();
const {state,errors}=await setup(page);const top=await scrollUp(page);
await page.getByTestId("canvas-chat-messages").evaluate(element=>(window as any).readingViewport=element);
state.history.at(-1)!.parts[0].text+="\n\n历史记录已从服务端更新。";
const reads=state.historyReads;
await page.clock.fastForward(11000);
await page.evaluate(()=>window.dispatchEvent(new Event("focus")));
await expect.poll(()=>state.historyReads).toBeGreaterThan(reads);
await expect(page.getByTestId("canvas-chat-messages")).toContainText("历史记录已从服务端更新。");
expect(await page.getByTestId("canvas-chat-messages").evaluate(element=>element===(window as any).readingViewport)).toBe(true);
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
expect(errors).toEqual([]);
});
test("a generated preview finishing its load does not pull a reader back to the bottom",async({page})=>{
let ready=false;let requests=0;const waiting:Array<()=>void>=[];
await page.context().route('https://scqnycdn.jiansun.vip/**',async route=>{
requests++;
if(!ready)await new Promise<void>(resolve=>waiting.push(resolve));
await route.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1200"><rect width="800" height="1200" fill="#43b9bd"/></svg>'});
});
const {errors}=await setup(page);await startStream(page);
await page.evaluate(()=>(window as any).agentScrollStream.append('\n\n![生成结果](https://scqnycdn.jiansun.vip/uploads/scroll-result.png)\n\n'));
await expect.poll(()=>requests).toBeGreaterThan(0);
const top=await scrollUp(page);ready=true;waiting.forEach(release=>release());
await expect.poll(()=>page.getByTestId('canvas-chat-messages').locator('img').evaluateAll(images=>images.some(image=>(image as HTMLImageElement).naturalWidth>0))).toBe(true);
await page.waitForTimeout(200);
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
expect(errors).toEqual([]);
});
test("keyboard scrolling pauses following and a new conversation starts at its own latest message",async({page})=>{
const {errors}=await setup(page);await startStream(page);
await page.getByTestId("canvas-chat-messages").press("PageUp");
await expect.poll(async()=>(await metrics(page)).bottom).toBeGreaterThan(100);
const top=(await metrics(page)).top;
await page.evaluate(()=>(window as any).agentScrollStream.append("\n\n键盘上翻后的新进度。"));
await expect(page.getByTestId("canvas-chat-messages")).toContainText("键盘上翻后的新进度。");
await page.waitForTimeout(200);
expect((await metrics(page)).top).toBeLessThanOrEqual(top+4);
await page.getByRole("button",{name:"新建聊天窗口",exact:true}).click();
await expect(page.getByTestId("canvas-chat-messages")).not.toContainText("历史记录 1");
await expect(page.getByLabel("描述你的设计需求",{exact:true})).toBeEditable();
await startStream(page);
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
expect(errors).toEqual([]);
});
test.describe("mobile touch reading",()=>{
test.use({viewport:{width:390,height:844},isMobile:true,hasTouch:true});
test("touch scrolling keeps history readable while the Agent continues streaming",async({page})=>{
const {errors}=await setup(page);await startStream(page);
const box=(await page.getByTestId("canvas-chat-messages").boundingBox())!;
const cdp=await page.context().newCDPSession(page);
const x=box.x+box.width/2,y=box.y+70,delta=Math.min(260,box.height-120);
await cdp.send("Input.dispatchTouchEvent",{type:"touchStart",touchPoints:[{x,y}]});
for(let i=1;i<=8;i++){
await cdp.send("Input.dispatchTouchEvent",{type:"touchMove",touchPoints:[{x,y:y+delta*i/8}]});
await page.waitForTimeout(25);
}
await cdp.send("Input.dispatchTouchEvent",{type:"touchEnd",touchPoints:[]});
await expect.poll(async()=>(await metrics(page)).bottom).toBeGreaterThan(100);
const top=(await metrics(page)).top;
await page.evaluate(()=>(window as any).agentScrollStream.append("\n\n手机查看历史时的新进度。"));
await expect(page.getByTestId("canvas-chat-messages")).toContainText("手机查看历史时的新进度。");
await page.waitForTimeout(300);
expect((await metrics(page)).top).toBeLessThanOrEqual(top+4);
await expect(page.getByRole("button",{name:"回到最新消息",exact:true})).toBeVisible();
await page.screenshot({path:test.info().outputPath("reading-history-mobile.png"),animations:"disabled"});
await page.getByRole("button",{name:"回到最新消息",exact:true}).click();
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
expect(errors).toEqual([]);await cdp.detach();
});
});

View File

@ -1,36 +1,21 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { serverLoader } from "../helpers/canvas-download-server-loader"; import { serverLoader } from "../helpers/canvas-download-server-loader";
import { applyAgentImagePreferences, agentProviderImageParameters } from "@/lib/ai/canvas-agent-runtime/image-preferences"; import { applyAgentImagePreferences, agentProviderImageParameters } from "@/lib/ai/canvas-agent-runtime/image-preferences";
import type { AgentCallRecord } from "@/lib/ai/canvas-agent-runtime/protocol";
const preferences = { const preferences = {
modelId: "user-image-model", modelId: "user-image-model",
parameters: { imageCount: 3, aspectRatio: "3:4", imageSize: "4K", negativePrompt: "不要文字", parameters: { imageCount: 3, aspectRatio: "3:4", imageSize: "4K", negativePrompt: "不要文字",
quality: "high" as const, size: "1536*2048", promptExtend: false, watermark: false, seed: 0 }, quality: "high" as const, size: "1536*2048", promptExtend: false, watermark: false, seed: 0 },
}; };
function harness(results?: any[] | ((count: number) => any[])) { function harness(results?: any[]) {
const saved: any[] = []; const saved: any[] = [];
const generated: any[] = []; const generated: any[] = [];
const operations: any[] = []; const operations: any[] = [];
const mock = serverLoader({ const mock = serverLoader({
"@/lib/canvas/capabilities/ai-sdk-tools": { createCanvasCapabilityTools: async () => ({ editTargetImage: {} }) }, "@/lib/canvas/capabilities/ai-sdk-tools": { createCanvasCapabilityTools: async () => ({ editTargetImage: {} }) },
"@/lib/canvas/capabilities/registry": { CANVAS_CAPABILITY_REGISTRY: [{ id: "image.prompt_regenerate", title: "重绘", risk: "external", agent: { toolName: "editTargetImage" } }] }, "@/lib/canvas/capabilities/registry": { CANVAS_CAPABILITY_REGISTRY: [{ id: "image.prompt_regenerate", title: "重绘", risk: "external", agent: { toolName: "editTargetImage" } }] },
"@/lib/canvas/agent-mutations": { "@/lib/canvas/agent-mutations": { applyCanvasOperationPlan: async (value: any) => { operations.push(value); return { createdNodeIds: ["new-image"] }; } },
applyCanvasOperationPlan: async (value: any) => { "@/lib/canvas/generate-image": { generateCanvasImage: async (value: any) => { generated.push(value); return { modelRef: results ? "backup" : value.model, images: results || [{ originalUrl: "https://test.example/result.png", thumbnailUrl: "https://test.example/thumb.png" }] }; } },
operations.push(value);
return { createdNodeIds: value.plan.mediaItems.map((item: { mediaItemId: string }) => item.mediaItemId) };
},
},
"@/lib/canvas/generate-image": {
generateCanvasImage: async (value: any) => {
generated.push(value);
const images = typeof results === "function" ? results(value.imageCount) : results;
return {
modelRef: Array.isArray(results) ? "backup" : value.model,
images: images ?? [{ originalUrl: "https://test.example/result.png", thumbnailUrl: "https://test.example/thumb.png" }],
};
},
},
"@/lib/ai/runtime/generated-image-persistence": { saveGeneratedImages: async (...args: any[]) => { saved.push(args); } }, "@/lib/ai/runtime/generated-image-persistence": { saveGeneratedImages: async (...args: any[]) => { saved.push(args); } },
"@/lib/ai/model-router": { routeToModel: async () => ({ id: "system-default" }) }, "@/lib/ai/model-router": { routeToModel: async () => ({ id: "system-default" }) },
"@/lib/db/queries": { checkUserImageQuota: async () => ({ allowed: true, remaining: 10 }), getPointsSystemConfig: async () => ({ enabled: false }), getUserById: async () => ({}) }, "@/lib/db/queries": { checkUserImageQuota: async () => ({ allowed: true, remaining: 10 }), getPointsSystemConfig: async () => ({ enabled: false }), getUserById: async () => ({}) },
@ -42,10 +27,10 @@ function harness(results?: any[] | ((count: number) => any[])) {
"./review": {}, "./review": {},
"./image-access-tools": { registerImageCanvasAccessTools() {} }, "./image-access-tools": { registerImageCanvasAccessTools() {} },
}); });
const build = async (imagePreferences: any = preferences, priorCalls?: AgentCallRecord[]) => mock("lib/ai/canvas-agent-runtime/tools.ts").buildCanvasRuntimeTools({ const build = async (imagePreferences: any = preferences) => mock("lib/ai/canvas-agent-runtime/tools.ts").buildCanvasRuntimeTools({
userId: "u", chatId: "c", projectId: "p", runId: "r", assistantMessageId: "a", selectedNodeIds: ["reference"], userId: "u", chatId: "c", projectId: "p", runId: "r", assistantMessageId: "a", selectedNodeIds: ["reference"],
request: new Request("https://test.example/api/canvas/agent-chat"), model: {}, supportsVision: true, request: new Request("https://test.example/api/canvas/agent-chat"), model: {}, supportsVision: true,
preferredModelId: "system-default", imagePreferences, priorCalls, emit() {}, preferredModelId: "system-default", imagePreferences, emit() {},
}); });
return { build, generated, operations, saved }; return { build, generated, operations, saved };
} }
@ -62,35 +47,12 @@ test("real generation tool honors the chosen image model and all parameters desp
providerImageParams: { quality: "high", size: "1536*2048", watermark: false, seed: 0 } }); providerImageParams: { quality: "high", size: "1536*2048", watermark: false, seed: 0 } });
expect(output.appliedImagePreferences).toEqual({ modelId: "user-image-model", imageCount: 3, imageSize: "4K", aspectRatio: "3:4" }); expect(output.appliedImagePreferences).toEqual({ modelId: "user-image-model", imageCount: 3, imageSize: "4K", aspectRatio: "3:4" });
}); });
test("redraw and generation share the task limit and only fill the missing images", async () => { test("redraw and a corrective retry inherit the same user model, batch and image controls", async () => {
const image = { originalUrl: "https://test.example/result.png", thumbnailUrl: "https://test.example/thumb.png" }; const h = harness(); const tools = await h.build();
const h = harness(count => Array.from({ length: count === 3 ? 1 : count }, () => image)); await tools.executable.editTargetImage.execute({ nodeIds: ["reference"], args: { prompt: "换背景", modelId: "system-default" } }, "edit-1");
const tools = await h.build(); await tools.executable.generateCanvasImages.execute({ prompt: "修正背景", modelId: "system-default" }, "retry-1");
const first = await tools.executable.editTargetImage.execute({ nodeIds: ["reference"], args: { prompt: "换背景", modelId: "system-default" } }, "edit-1");
expect(first.status).toBe("partial");
const filled = await tools.executable.generateCanvasImages.execute({ prompt: "补齐图片", modelId: "system-default" }, "fill-1");
expect(filled.status).toBe("succeeded");
await expect(tools.executable.generateCanvasImages.execute({ prompt: "再次修正" }, "retry-1")).rejects.toThrow("张数已占满");
expect(h.generated).toHaveLength(2);
expect(h.generated.map(call => call.imageCount)).toEqual([3, 2]);
for (const invocation of h.generated) expect(invocation).toMatchObject({ model: preferences.modelId, imageSize: "4K", aspectRatio: "3:4", allowFailover: false });
});
test("one selected image allows only one submission even when calls overlap or the task resumes", async () => {
const h = harness();
const selection = { parameters: { imageCount: 1 } };
const tools = await h.build(selection);
const generate = (callId: string) => tools.executable.generateCanvasImages.execute({ prompt: "修改图片" }, callId);
const [first, duplicate] = await Promise.allSettled([generate("original"), generate("parallel")]);
expect(first.status).toBe("fulfilled");
expect(duplicate.status).toBe("rejected");
if (first.status !== "fulfilled") throw first.reason;
const resumed = await h.build(selection, [{ id: "original", name: "generateCanvasImages", input: {}, status: "succeeded", output: first.value }]);
await expect(resumed.executable.generateCanvasImages.execute({ prompt: "继续" }, "resume")).rejects.toThrow("张数已占满");
expect(h.generated).toHaveLength(1);
const next = await h.build(selection);
await next.executable.generateCanvasImages.execute({ prompt: "用户新指令" }, "next");
expect(h.generated).toHaveLength(2); expect(h.generated).toHaveLength(2);
for (const invocation of h.generated) expect(invocation).toMatchObject({ model: preferences.modelId, imageSize: "4K", imageCount: 3, aspectRatio: "3:4", allowFailover: false });
}); });
test("an unavailable explicit choice never silently generates or charges through the default", async () => { test("an unavailable explicit choice never silently generates or charges through the default", async () => {
const h = harness(); const tools = await h.build({ ...preferences, modelId: "disabled-or-missing" }); const h = harness(); const tools = await h.build({ ...preferences, modelId: "disabled-or-missing" });

View File

@ -94,17 +94,6 @@ test("checks real image bytes and compares results to their source images", asyn
expect(result.issues).toEqual([]); expect(result.issues).toEqual([]);
}); });
test("a partial image batch can be filled and verified without discarding the first result", async () => {
const f = await fixture('{"passed":true,"issues":[],"failedNodeIds":[]}');
const { snapshot } = f.params;
snapshot.requirements = { expectedImageCount: 2 };
snapshot.calls[1].output = { status: "partial", createdNodeIds: ["result"], appliedImagePreferences: { imageCount: 2 } };
await expect(verifyCanvasAgentResults(f.params)).resolves.toMatchObject({ passed: false, retryable: true });
snapshot.resultNodeIds.push("remaining-result");
snapshot.calls.push({ id: "fill", name: "generateCanvasImages", input: {}, status: "succeeded", output: { status: "succeeded", createdNodeIds: ["remaining-result"] } });
await expect(verifyCanvasAgentResults(f.params)).resolves.toMatchObject({ passed: true });
});
test("malformed vision JSON switches models, while a valid failed review never triggers failover", async () => { test("malformed vision JSON switches models, while a valid failed review never triggers failover", async () => {
const bad = await fixture("not JSON"); const bad = await fixture("not JSON");
const validNegative = await fixture('{"passed":false,"issues":["主体不一致"],"failedNodeIds":["result"]}'); const validNegative = await fixture('{"passed":false,"issues":["主体不一致"],"failedNodeIds":["result"]}');

View File

@ -30,26 +30,6 @@ function fixture() {
const result = { type: "result", sessionId: "test-session", response: "已完成" }; const result = { type: "result", sessionId: "test-session", response: "已完成" };
test("a failed quality check keeps the single generated image without opening a repair round", async () => {
const { ports, run, snapshot } = fixture();
snapshot.imagePreferences = { parameters: { imageCount: 1 } };
let attempts = 0;
ports.tools.generateCanvasImages.execute = async () => ({
createdNodeIds: ["generated-image"], appliedImagePreferences: { imageCount: 1 },
});
ports.native = async options => {
attempts++;
await options.execute("planCanvasTask", {}, "plan", options.signal!);
await options.execute("generateCanvasImages", { referenceNodeIds: ["original"] }, "gen", options.signal!);
return { ...result, response: "图片已生成,正在检查" };
};
ports.verify = async () => ({ passed: false, issues: ["主体细节不符"], retryable: true, failedNodeIds: ["generated-image"] });
expect(await run(ports)).toBe("needs_review");
expect(attempts).toBe(1);
expect(snapshot.resultNodeIds).toEqual(["generated-image"]);
expect(snapshot.message).toMatch(/主体细节不符[\s\S]*如需再生成,请发送新指令/);
});
test("an unconfirmed paid tool stops the planner and immediately preserves the reason and original request ID", async () => { test("an unconfirmed paid tool stops the planner and immediately preserves the reason and original request ID", async () => {
const { ports, run, snapshot } = fixture(); const { ports, run, snapshot } = fixture();
const emitted: string[] = [], saved: string[] = []; const emitted: string[] = [], saved: string[] = [];