Compare commits

..

15 Commits

Author SHA1 Message Date
fa3210a367 Merge pull request '修复画布保存状态判定和素材上传刷新保护' (#58) from codex/canvas-leave-pending-upload into main 2026-11-06 16:54:43 +08:00
e335821ca8 修复画布保存状态判定及上传刷新保护 2026-11-06 16:54:35 +08:00
c2e8ff6eac Merge pull request '合并页面关闭时的自动保存触发' (#57) from codex/canvas-leave-save-once into main 2026-11-06 16:11:47 +08:00
d26bec9864 合并画布关闭时的保存触发 2026-11-06 16:11:38 +08:00
24a3c930c2 Merge pull request '修复旧浏览器更新提醒并完善画布自动保存与离开保护' (#56) from codex/canvas-background-save-update-notice into main 2026-11-06 16:09:08 +08:00
ebb0d1514b 修复旧浏览器更新提醒及画布离开保存体验 2026-11-06 16:08:58 +08:00
50ffc8d648 Merge pull request '修复图片处理后画布同步冲突及旧草稿恢复' (#55) from codex/canvas-operation-sync-20261106 into main 2026-11-06 13:52:36 +08:00
ff6381d02e 修复图片处理后的画布同步基准和冲突恢复 2026-11-06 13:52:27 +08:00
feaa18ed3b Merge pull request '修复 Agent 对话上滑时被自动拉回底部' (#54) from codex/agent-chat-scroll-20261106 into main 2026-11-06 13:03:14 +08:00
2939ff8a1f 修复 Agent 对话上滑后的自动滚动 2026-11-06 13:03:07 +08:00
5bffeaf94d Merge pull request '修复大 ZIP 上传超时及慢速回退下载' (#53) from codex/zip-cdn-delivery-20261106 into main 2026-11-06 12:10:50 +08:00
e221027b74 修复大 ZIP 七牛交付与断点重试 2026-11-06 12:10:42 +08:00
445d756a19 Merge pull request '修复画布自动同步并添加新版安全刷新提示' (#52) from codex/canvas-auto-sync-20261106 into main 2026-11-06 11:11:34 +08:00
05c3d90708 fix(canvas): synchronize drafts and refresh safely for new releases 2026-11-06 11:10:33 +08:00
cea4cd01f5 Merge pull request '明确区分请求连接中止与等待超时' (#51) from codex/aborted-request-reason-20261106 into main 2026-11-06 09:44:18 +08:00
26 changed files with 1856 additions and 210 deletions

View File

@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export function GET() {
return NextResponse.json({ version: process.env.NEXT_PUBLIC_APP_RELEASE || "development" }, {
headers: { "Cache-Control": "no-store, max-age=0" },
});
}

View File

@ -10,11 +10,13 @@ import JSZip from "jszip";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download"; import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery"; import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery";
import { trackRuntimeWork } from "@/lib/observability/active-work";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 300; export const maxDuration = 300;
type DownloadFileItem = { url: string; name?: string }; type DownloadFileItem = { url: string; name?: string };
const MAX_DOWNLOAD_FILES = 200; const MAX_DOWNLOAD_FILES = 200;
const TASK_TTL_MS = 60 * 60_000; const TASK_TTL_MS = 24 * 60 * 60_000;
const PREPARATION_TTL_MS = 6 * 60 * 60_000;
const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> }); const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
const active = running.__canvasZipRunning ??= new Set<string>(); const active = running.__canvasZipRunning ??= new Set<string>();
function sanitizeFolderName(value: string): string { function sanitizeFolderName(value: string): string {
@ -98,8 +100,12 @@ async function processTask(id: string, userId: string, requestOrigin: string, co
task = await loadZipTask(id, userId); task = await loadZipTask(id, userId);
if (!task || task.status === "completed" || task.status === "failed") return; if (!task || task.status === "completed" || task.status === "failed") return;
await assertZipDiskSpace(); await assertZipDiskSpace();
task.status = "running"; task.stage = "正在准备原图"; task.updatedAt = Date.now(); const existingZip = task.zipFile && task.contentDisposition && await stat(task.zipFile.path).catch(() => null);
const reuseZip = existingZip && existingZip.size === task.zipFile!.bytes;
task.status = "running"; task.stage = reuseZip ? "正在准备加速下载" : "正在准备原图"; task.updatedAt = Date.now();
await saveZipTask(task); await saveZipTask(task);
if (!reuseZip) {
delete task.zipFile; delete task.contentDisposition; delete task.cdnUrl;
// Stage each image on disk, not all PNG buffers in the web process heap. // Stage each image on disk, not all PNG buffers in the web process heap.
// A restart resumes from these files using the current owner's session. // A restart resumes from these files using the current owner's session.
let cursor = 0; let cursor = 0;
@ -137,24 +143,28 @@ async function processTask(id: string, userId: string, requestOrigin: string, co
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task); task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; }); task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
task.contentDisposition = buildContentDisposition(task.folderName); task.contentDisposition = buildContentDisposition(task.folderName);
}
task.stage = "正在准备下载链接"; await saveZipTask(task); task.stage = "正在准备下载链接"; await saveZipTask(task);
try { const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile!, disposition: task.contentDisposition!,
const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile, disposition: task.contentDisposition }); onProgress: async bytes => {
task!.stage = `正在传送至七牛 ${Math.round(bytes / task!.zipFile!.bytes * 100)}%`;
task!.updatedAt = Date.now(); await saveZipTask(task!);
} });
if (cdn) { if (cdn) {
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key }; task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
if (cdn.url) task.cdnUrl = cdn.url; if (!cdn.url) throw new Error(cdn.error || `ZIP 已打包,但加速下载未就绪,请再次点击下载继续传送原 ZIP。下载编号:${task.id}`);
task.cdnUrl = cdn.url;
} }
} catch { console.warn("[CanvasDownloadZipTask] CDN delivery unavailable; complete local ZIP retained", { taskId: id }); }
task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成"; task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成";
task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS;
await saveZipTask(task); await saveZipTask(task);
// The complete ZIP is enough for retries; release staged duplicate bytes. // The complete ZIP is enough for retries; release staged duplicate bytes.
await Promise.all(task.files.filter(f => f.prepared).map(f => rm(join(zipTaskPath(id), f.prepared!), { force: true }).catch(() => undefined))); await Promise.all(task.files.filter(f => f.prepared).map(f => rm(join(zipTaskPath(id), f.prepared!), { force: true }).catch(() => undefined)));
console.info("[CanvasDownloadZipTask] Complete", { taskId: id, count: task.successCount, failed: task.failedCount, console.info("[CanvasDownloadZipTask] Complete", { taskId: id, count: task.successCount, failed: task.failedCount,
bytes: task.zipFile.bytes, delivery: task.cdnUrl ? "cdn" : "local" }); bytes: task.zipFile!.bytes, delivery: task.cdnUrl ? "cdn" : "local" });
} catch (error) { } catch (error) {
if (task) { if (task) {
task.status = "failed"; task.stage = "打包未完成"; task.status = "failed"; task.stage = task.zipFile ? "ZIP 已打包,加速下载未就绪" : "打包未完成";
task.error = error instanceof Error ? error.message : "打包失败,请稍后重试"; task.error = error instanceof Error ? error.message : "打包失败,请稍后重试";
task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined); task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined);
} }
@ -162,7 +172,7 @@ async function processTask(id: string, userId: string, requestOrigin: string, co
} finally { await release?.().catch(() => undefined); active.delete(id); } } finally { await release?.().catch(() => undefined); active.delete(id); }
} }
function schedule(task: ZipTask, request: NextRequest) { function schedule(task: ZipTask, request: NextRequest) {
const run = () => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || ""); const run = () => trackRuntimeWork(() => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || ""));
after(run); after(run);
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@ -187,14 +197,17 @@ export async function POST(request: NextRequest) {
const now = Date.now(); const now = Date.now();
task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files, task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files,
totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0, totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0,
status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + 6 * TASK_TTL_MS }; status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + PREPARATION_TTL_MS };
await saveZipTask(task); await saveZipTask(task);
} else if (task.status === "failed" && body.retry === true) { } else if ((task.status === "failed" || task.status === "completed" && task.cdnObject && !task.cdnUrl) && body.retry === true) {
task.status = "pending"; task.error = undefined; task.status = "pending"; task.error = undefined;
await assertZipTaskCapacity(session.user.id); await assertZipTaskCapacity(session.user.id);
if (!task.zipFile) {
for (const file of task.files) delete file.error; for (const file of task.files) delete file.error;
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length; task.processedCount = task.successCount = task.files.filter(file => file.prepared).length;
task.failedCount = 0; task.updatedAt = Date.now(); task.expiresAt = Date.now() + 6 * TASK_TTL_MS; task.failedCount = 0;
}
task.updatedAt = Date.now(); task.expiresAt = Date.now() + PREPARATION_TTL_MS;
await saveZipTask(task); await saveZipTask(task);
} }
if (task.status === "pending" || task.status === "running") schedule(task, request); if (task.status === "pending" || task.status === "running") schedule(task, request);

View File

@ -1,7 +1,11 @@
"use client"; "use client";
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue"; import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
import { CanvasRemoteOperationContext } from "@/components/canvas/CanvasRemoteOperationContext"; import { readCanvasDraft, writeCanvasDraft, clearCanvasDraft } from "@/lib/client/canvas-draft";
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
import { CanvasRemoteOperationContext, type CanvasRemoteOperation, type CanvasRemoteOperationResult } from "@/components/canvas/CanvasRemoteOperationContext";
import equal from "fast-deep-equal";
import { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
@ -168,6 +172,7 @@ 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";
@ -1929,6 +1934,12 @@ export default function CanvasEditorLegacyPage({
() => !initialProjectForCurrentRoute () => !initialProjectForCurrentRoute
); );
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [canvasLoadError, setCanvasLoadError] = useState("");
const [canvasLoadAttempt, setCanvasLoadAttempt] = useState(0);
const canvasReadyRef = useRef(Boolean(initialProjectForCurrentRoute));
const savedCanvasSnapshotRef = useRef<{ id: string; snapshot: string } | null>(null);
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("");
@ -3365,6 +3376,32 @@ export default function CanvasEditorLegacyPage({
return () => window.clearTimeout(timeoutId); return () => window.clearTimeout(timeoutId);
}, []); }, []);
const loadPersistedCanvasState = useCallback((input: CanvasState | null | undefined) => {
const canvasData = input || { nodes: [], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select" as const };
const base = JSON.stringify({ canvasData });
savedCanvasSnapshotRef.current = { id, snapshot: base };
externalCanvasConflictRef.current = false;
setCanvasSyncConflicts([]);
const draft = readCanvasDraft(id);
if (draft) {
const merged = mergeCanvasSnapshots(draft.base, draft.snapshot, base);
if (merged.conflicts.length) {
externalCanvasConflictRef.current = true;
savedCanvasSnapshotRef.current = { id, snapshot: draft.base };
setCanvasSyncConflicts(merged.conflicts);
canvasSyncErrorRef.current = "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。";
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
toast.warning(canvasSyncErrorRef.current);
return;
}
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
if (merged.snapshot !== base) writeCanvasDraft(id, { base, snapshot: merged.snapshot });
else clearCanvasDraft(id);
return;
}
useCanvasStore.getState().loadCanvas(canvasData);
}, [id]);
const hydrateCanvasProject = useCallback( const hydrateCanvasProject = useCallback(
(project: CanvasInitialProject) => { (project: CanvasInitialProject) => {
externalCanvasConflictRef.current = false; externalCanvasConflictRef.current = false;
@ -3374,9 +3411,9 @@ export default function CanvasEditorLegacyPage({
useCanvasStore useCanvasStore
.getState() .getState()
.setCanvasRevision(projectUpdatedAt, projectUpdatedAt); .setCanvasRevision(projectUpdatedAt, projectUpdatedAt);
loadPersistedCanvasState(project.canvasData);
if (project.canvasData) { if (project.canvasData) {
useCanvasStore.getState().loadCanvas(project.canvasData);
const persistedWorkflow = project.canvasData.workflowState; const persistedWorkflow = project.canvasData.workflowState;
if (!requestedAgentMode && persistedWorkflow) { if (!requestedAgentMode && persistedWorkflow) {
const nextAppearanceRecognitionEnabled = const nextAppearanceRecognitionEnabled =
@ -3479,7 +3516,7 @@ export default function CanvasEditorLegacyPage({
setWorkflowHydrationSource("empty"); setWorkflowHydrationSource("empty");
}, },
[id, requestedAgentMode] [id, requestedAgentMode, loadPersistedCanvasState]
); );
useLayoutEffect(() => { useLayoutEffect(() => {
@ -3487,6 +3524,7 @@ export default function CanvasEditorLegacyPage({
if (lastLoadedCanvasIdRef.current === id) return; if (lastLoadedCanvasIdRef.current === id) return;
hydrateCanvasProject(initialProjectForCurrentRoute); hydrateCanvasProject(initialProjectForCurrentRoute);
canvasReadyRef.current = true;
setIsLoading(false); setIsLoading(false);
lastLoadedCanvasIdRef.current = id; lastLoadedCanvasIdRef.current = id;
}, [hydrateCanvasProject, id, initialProjectForCurrentRoute]); }, [hydrateCanvasProject, id, initialProjectForCurrentRoute]);
@ -3494,6 +3532,7 @@ export default function CanvasEditorLegacyPage({
// 加载画布数据 // 加载画布数据
useEffect(() => { useEffect(() => {
console.log("[Canvas] Loading project:", id); console.log("[Canvas] Loading project:", id);
let cancelled = false;
const loadProject = async () => { const loadProject = async () => {
if (lastLoadedCanvasIdRef.current === id) { if (lastLoadedCanvasIdRef.current === id) {
@ -3502,6 +3541,8 @@ export default function CanvasEditorLegacyPage({
} }
setIsLoading(true); setIsLoading(true);
canvasReadyRef.current = false;
setCanvasLoadError("");
if (!requestedAgentMode) { if (!requestedAgentMode) {
setWorkflowHydrationSource("pending"); setWorkflowHydrationSource("pending");
} }
@ -3534,18 +3575,16 @@ export default function CanvasEditorLegacyPage({
setLoadedAgentProjectId(null); setLoadedAgentProjectId(null);
} }
// 🆕 先清空当前画布状态,确保切换时不会显示旧数据
useCanvasStore.getState().clearCanvas();
const result = await canvasService.getProject(id); const result = await canvasService.getProject(id);
if (cancelled) return;
if (result.success && result.data) { if (result.success && result.data) {
useCanvasStore.getState().setProject(id, result.data.title); useCanvasStore.getState().setProject(id, result.data.title);
useCanvasStore useCanvasStore
.getState() .getState()
.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || "")); .setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
loadPersistedCanvasState(result.data.canvasData);
if (result.data.canvasData) { if (result.data.canvasData) {
useCanvasStore.getState().loadCanvas(result.data.canvasData);
const persistedWorkflow = result.data.canvasData.workflowState; const persistedWorkflow = result.data.canvasData.workflowState;
if (!requestedAgentMode && persistedWorkflow) { if (!requestedAgentMode && persistedWorkflow) {
const nextAppearanceRecognitionEnabled = const nextAppearanceRecognitionEnabled =
@ -3662,8 +3701,15 @@ export default function CanvasEditorLegacyPage({
return; return;
} }
// 画布不存在时,创建一个新画布 if (result.status !== 404) {
setCanvasLoadError(result.error || "画布暂时无法读取,请检查网络后重试加载。现有内容未被覆盖。");
setIsLoading(false);
return;
}
// Only an explicit missing project can create a new empty canvas.
console.log("[Canvas] 画布不存在,创建新画布:", id); console.log("[Canvas] 画布不存在,创建新画布:", id);
useCanvasStore.getState().clearCanvas();
useCanvasStore useCanvasStore
.getState() .getState()
.setProject( .setProject(
@ -3690,15 +3736,20 @@ export default function CanvasEditorLegacyPage({
} }
} }
} }
if (cancelled) return;
canvasReadyRef.current = true;
setIsLoading(false); setIsLoading(false);
lastLoadedCanvasIdRef.current = id; lastLoadedCanvasIdRef.current = id;
}; };
loadProject(); loadProject();
return () => { cancelled = true; };
}, [ }, [
activeCanvasWorkspaceType, activeCanvasWorkspaceType,
defaultCanvasTitle, defaultCanvasTitle,
id, id,
canvasLoadAttempt,
loadPersistedCanvasState,
openLoginDialog, openLoginDialog,
requestedAgentMode, requestedAgentMode,
]); ]);
@ -5071,15 +5122,69 @@ 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 (canvasPersistenceScope.current !== id) return false; if (canvasLeaveApprovedRef.current || canvasPersistenceScope.current !== id || !canvasReadyRef.current || useCanvasStore.getState().projectId !== id) return false;
if (externalCanvasConflictRef.current) return false;
setIsSaving(true); setIsSaving(true);
try { try {
const canvasData = buildPersistedCanvasData(); let canvasData = buildPersistedCanvasData();
let snapshot = JSON.stringify({ canvasData });
if (savedCanvasSnapshotRef.current?.id === id && equal(JSON.parse(savedCanvasSnapshotRef.current.snapshot), JSON.parse(snapshot))) {
clearCanvasDraft(id);
return true;
}
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState(); const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
const result = await canvasService.saveCanvasState( let result = await canvasService.saveCanvasState(
id, id,
canvasData, canvasData,
activeCanvasWorkspaceType, activeCanvasWorkspaceType,
@ -5089,23 +5194,53 @@ export default function CanvasEditorLegacyPage({
} }
); );
if (canvasPersistenceScope.current !== id) return false; if (canvasPersistenceScope.current !== id) return false;
if (!result.success) { if (result.status === 409 && savedCanvasSnapshotRef.current?.id === id) {
console.error(`[Canvas] ${reason} save failed:`, result.error); const remote = await canvasService.getProject(id);
if (result.status === 409) { if (canvasPersistenceScope.current !== id) return false;
externalCanvasConflictRef.current = true; if (remote.success && remote.data?.canvasData) {
console.warn("[Canvas] stale save revision handled without user prompt", { const remoteSnapshot = JSON.stringify({ canvasData: remote.data.canvasData });
reason, const merged = mergeCanvasSnapshots(
canvasUpdatedAt: result.canvasUpdatedAt, savedCanvasSnapshotRef.current.snapshot,
JSON.stringify({ canvasData: buildPersistedCanvasData() }),
remoteSnapshot,
);
if (!merged.conflicts.length) {
canvasData = JSON.parse(merged.snapshot).canvasData;
snapshot = merged.snapshot;
savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot };
useCanvasStore.getState().loadCanvas(canvasData);
const revision = String(remote.data.updatedAt);
useCanvasStore.getState().setCanvasRevision(revision, revision);
result = await canvasService.saveCanvasState(id, canvasData, activeCanvasWorkspaceType, {
baseCanvasRevision: revision, baseCanvasUpdatedAt: revision,
}); });
if (!externalCanvasConflictNotifiedRef.current) { if (canvasPersistenceScope.current !== id) return false;
externalCanvasConflictNotifiedRef.current = true; } else {
toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑"); externalCanvasConflictRef.current = true;
setCanvasSyncConflicts(merged.conflicts);
} }
} }
} else if (result.data?.updatedAt) { }
if (!result.success) {
const retained = writeCanvasDraft(id, {
base: savedCanvasSnapshotRef.current?.id === id ? savedCanvasSnapshotRef.current.snapshot : JSON.stringify({ canvasData: { nodes: [], edges: [] } }),
snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }),
});
canvasSyncErrorRef.current = result.status === 409
? "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。"
: `画布自动同步失败${result.status ? `(HTTP ${result.status})` : "(网络连接失败)"}。${retained ? "本地草稿已保留,系统会继续自动同步,也可稍后重试。" : "请保留当前页面,检查网络后重试同步,暂勿刷新。"}`;
console.error(`[Canvas] ${reason} save failed:`, result.error);
} else {
canvasSyncErrorRef.current = "";
savedCanvasSnapshotRef.current = { id, snapshot };
clearCanvasDraft(id);
externalCanvasConflictRef.current = false; externalCanvasConflictRef.current = false;
setCanvasSyncConflicts([]);
externalCanvasConflictNotifiedRef.current = false; externalCanvasConflictNotifiedRef.current = false;
useCanvasStore 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
.getState() .getState()
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt)); .setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
} }
@ -5114,7 +5249,7 @@ export default function CanvasEditorLegacyPage({
if (canvasPersistenceScope.current === id) setIsSaving(false); if (canvasPersistenceScope.current === id) setIsSaving(false);
} }
}, },
[activeCanvasWorkspaceType, buildPersistedCanvasData, id] [activeCanvasWorkspaceType, applyCanvasRemoteSnapshot, buildPersistedCanvasData, id]
); );
const persistCanvasState = useCallback( const persistCanvasState = useCallback(
@ -5123,35 +5258,139 @@ 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: () => Promise<void>) => { (operation: CanvasRemoteOperation) => {
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(
() => persistCanvasStateNow("autosave"), async () => {
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 {
await operation(); const result = 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] [persistCanvasStateNow, id]
); );
const persistCanvasBeforeAgent = useCallback(() => { const persistCanvasBeforeAgent = useCallback(async () => {
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || externalCanvasConflictRef.current)
throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成同步,本次任务未提交,请保留当前页面并重试同步。");
if (savedCanvasSnapshotRef.current?.id === id) {
if (!hasUnsavedCanvasChanges()) 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;
} }
return persistCanvasState("autosave"); if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成加载,本次任务未提交;请等待加载完成后再次发送。");
}, [persistCanvasState]); return true;
}, [persistCanvasState, hasUnsavedCanvasChanges, id]);
const canvasRemoteOperationContext = useMemo(() => ({
run: runCanvasRemoteOperation,
applySnapshot: applyCanvasRemoteSnapshot,
}), [runCanvasRemoteOperation, applyCanvasRemoteSnapshot]);
useEffect(() => {
if (isVideoMode) return;
const beforeRefresh = (event: Event) => {
const request = (event as CustomEvent<PageRefreshRequest>).detail;
if (!isAgentMode && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) {
request.block("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再刷新。");
return;
}
if (useCanvasStore.getState().isGenerating) {
request.block("当前图片任务仍在处理中,请等待结果返回后再刷新。");
return;
}
request.save(async () => {
if (isAgentMode) {
if (agentProjectId && hasLoadedCurrentAgentProject && !(await persistAgentProject("switch"))) throw new Error("生图项目同步失败,请保留此页并重试刷新。");
} else if (canvasReadyRef.current && !(await persistCanvasState("autosave"))) {
throw new Error(canvasSyncErrorRef.current || "画布尚未完成同步,请保留此页并重试刷新。");
}
});
};
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
}, [isVideoMode, isAgentMode, agentProjectId, hasLoadedCurrentAgentProject, persistAgentProject, persistCanvasState]);
// 自动保存逻辑 // 自动保存逻辑
useEffect(() => { useEffect(() => {
@ -5172,7 +5411,32 @@ 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, isLoading, persistCanvasState]); }, [isAgentMode, isAgentOperationRefreshActive, nodes, edges, 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;
@ -5275,14 +5539,25 @@ 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 handleBeforeUnload = () => { const retainDraft = () => {
try { if (canvasLeaveApprovedRef.current || !hasUnsavedCanvasChanges()) return null;
if (isAgentOperationRefreshActive()) return;
const canvasData = buildPersistedCanvasData(); 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 {
const canvasData = retainDraft();
if (!canvasData || isSaving || canvasRemoteOperationActive.current || isAgentOperationRefreshActive() || externalCanvasConflictRef.current) return;
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState(); const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
void fetch(`/api/canvas/${id}`, { void fetch(`/api/canvas/${id}`, {
method: "PUT", method: "PUT",
@ -5301,18 +5576,20 @@ export default function CanvasEditorLegacyPage({
}; };
window.addEventListener("beforeunload", handleBeforeUnload); window.addEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("pagehide", handleBeforeUnload); window.addEventListener("pagehide", handlePageHide);
return () => { return () => {
window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("pagehide", handleBeforeUnload); window.removeEventListener("pagehide", handlePageHide);
}; };
}, [ }, [
activeCanvasWorkspaceType, activeCanvasWorkspaceType,
buildPersistedCanvasData, buildPersistedCanvasData,
hasUnsavedCanvasChanges,
id, id,
isAgentOperationRefreshActive, isAgentOperationRefreshActive,
isAgentMode, isAgentMode,
isLoading, isLoading,
isSaving,
]); ]);
// 智能命名与缩略图逻辑 // 智能命名与缩略图逻辑
@ -5396,21 +5673,12 @@ export default function CanvasEditorLegacyPage({
// 保存画布 (手动触发保留作为备用,但UI上移除) // 保存画布 (手动触发保留作为备用,但UI上移除)
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
setIsSaving(true); if (await persistCanvasState("autosave")) {
const canvasData = buildPersistedCanvasData();
const result = await canvasService.saveCanvasState(
id,
canvasData,
activeCanvasWorkspaceType
);
if (result.success) {
toast.success("保存成功"); toast.success("保存成功");
} else { } else {
toast.error(result.error || "保存失败"); toast.error(canvasSyncErrorRef.current || "画布同步失败,请检查网络后重试");
} }
setIsSaving(false); }, [persistCanvasState]);
}, [activeCanvasWorkspaceType, buildPersistedCanvasData, id]);
// 更新标题 // 更新标题
const handleUpdateTitle = async () => { const handleUpdateTitle = async () => {
@ -10204,6 +10472,14 @@ export default function CanvasEditorLegacyPage({
] ]
); );
if (canvasLoadError && !isAgentMode) {
return <div role="alert" className="flex h-dvh flex-col items-center justify-center gap-4">
<p>{canvasLoadError}</p>
<Button onClick={() => setCanvasLoadAttempt(value => value + 1)}>重新加载画布</Button>
{loginDialogElement}
</div>;
}
if (isAgentMode) { if (isAgentMode) {
if (!hasActiveAgentProject) { if (!hasActiveAgentProject) {
return ( return (
@ -10307,7 +10583,7 @@ export default function CanvasEditorLegacyPage({
} }
return ( return (
<CanvasRemoteOperationContext.Provider value={runCanvasRemoteOperation}> <CanvasRemoteOperationContext.Provider value={canvasRemoteOperationContext}>
<TooltipProvider> <TooltipProvider>
<div <div
data-canvas-panel-open={isPanelOpen ? "true" : "false"} data-canvas-panel-open={isPanelOpen ? "true" : "false"}
@ -10323,6 +10599,30 @@ 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"
@ -11032,14 +11332,12 @@ export default function CanvasEditorLegacyPage({
{isVideoMode ? brandEntry : ( {isVideoMode ? brandEntry : (
<CanvasSurfaceHeader <CanvasSurfaceHeader
isSaving={isSaving}
onAddImage={handleAddImageNode} onAddImage={handleAddImageNode}
onHome={goHome} onHome={() => requestCanvasLeave("/")}
onOpenFiles={() => { onOpenFiles={() => {
setIsPanelOpen(true); setIsPanelOpen(true);
setPanelMode("fileList"); setPanelMode("fileList");
}} }}
onSave={() => void handleSave()}
projectTitle={projectTitle} projectTitle={projectTitle}
rightInset={panelOcclusionRight} rightInset={panelOcclusionRight}
/> />
@ -11144,6 +11442,7 @@ 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

@ -3,6 +3,7 @@ import { Toaster } from "sonner";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { FaviconUpdater } from "@/components/favicon-updater"; import { FaviconUpdater } from "@/components/favicon-updater";
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer"; import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
import { AppUpdateNotice } from "@/components/app-update-notice";
import { getSiteSettings } from "@/lib/db/queries"; import { getSiteSettings } from "@/lib/db/queries";
import "./globals.css"; import "./globals.css";
@ -215,6 +216,7 @@ export default function RootLayout({
enableSystem enableSystem
> >
<Toaster position="top-center" /> <Toaster position="top-center" />
<AppUpdateNotice />
<SessionProvider>{children}</SessionProvider> <SessionProvider>{children}</SessionProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>

View File

@ -2,6 +2,7 @@
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download"; import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery"; import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
import { getSceneRunResult } from "@/lib/agents/scene-run-recovery"; import { getSceneRunResult } from "@/lib/agents/scene-run-recovery";
import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch"; import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
@ -4879,6 +4880,14 @@ export function AgentGenerationWorkspaceDarkV2({
return recoverReplacementRun({ messages, ...pendingGeneration }); return recoverReplacementRun({ messages, ...pendingGeneration });
}, [messages, pendingGeneration]); }, [messages, pendingGeneration]);
const transportIsGenerating = status === "streaming" || status === "submitted"; const transportIsGenerating = status === "streaming" || status === "submitted";
useEffect(() => {
const beforeRefresh = (event: Event) => {
if (transportIsGenerating || pendingGeneration)
(event as CustomEvent<PageRefreshRequest>).detail.block("当前生图任务仍在处理中,请等待结果返回后再刷新。原任务无需重新提交。");
};
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
}, [transportIsGenerating, pendingGeneration]);
const pendingGenerationProgressCount = useMemo(() => { const pendingGenerationProgressCount = useMemo(() => {
if (!pendingGeneration) { if (!pendingGeneration) {
return 0; return 0;

View File

@ -0,0 +1,76 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog";
import { preparePageRefresh } from "@/lib/client/page-refresh";
export function AppUpdateNotice() {
const [availableVersion, setAvailableVersion] = useState("");
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let disposed = false;
let controller: AbortController | undefined;
let checking = false;
const check = async () => {
if (disposed || checking || document.visibilityState !== "visible") return;
checking = true;
controller = new AbortController();
const current = controller;
const timeout = window.setTimeout(() => current.abort(), 8000);
try {
const response = await fetch("/api/app-version", { cache: "no-store", signal: current.signal });
if (!response.ok) return;
const data = await response.json();
if (!disposed && !current.signal.aborted && typeof data.version === "string" && data.version)
setAvailableVersion(data.version !== (process.env.NEXT_PUBLIC_APP_RELEASE || "development") ? data.version : "");
} catch {
// A failed version read leaves the current workspace available.
} finally {
window.clearTimeout(timeout);
checking = false;
}
};
void check();
const interval = window.setInterval(check, 60_000);
window.addEventListener("focus", check);
window.addEventListener("online", check);
document.addEventListener("visibilitychange", check);
return () => {
disposed = true;
controller?.abort();
clearInterval(interval);
window.removeEventListener("focus", check);
window.removeEventListener("online", check);
document.removeEventListener("visibilitychange", check);
};
}, []);
const refresh = async () => {
if (refreshing) return;
setRefreshing(true);
setError("");
try {
await preparePageRefresh();
window.location.reload();
setRefreshing(false);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "当前内容同步失败,请保留此页并重试。");
setRefreshing(false);
}
};
return <AlertDialog open={Boolean(availableVersion)}>
<AlertDialogContent className="z-[10001]" overlayClassName="z-[10000]" onEscapeKeyDown={event => event.preventDefault()}>
<AlertDialogHeader>
<AlertDialogTitle>平台已更新</AlertDialogTitle>
<AlertDialogDescription>需要刷新页面后继续使用。点击后会先同步当前画布并保留输入;正在处理的任务完成后再刷新。</AlertDialogDescription>
</AlertDialogHeader>
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
<AlertDialogFooter>
{error ? <Button variant="outline" disabled={refreshing} onClick={() => { setAvailableVersion(""); setError(""); }}>返回处理</Button> : null}
<Button disabled={refreshing} onClick={() => void refresh()}>{refreshing ? "正在同步内容…" : "同步并刷新"}</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>;
}

View File

@ -2,9 +2,13 @@
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 { readCanvasComposerDraft, writeCanvasComposerDraft, clearCanvasComposerDraft } from "@/lib/client/canvas-draft";
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";
import { useCanvasAgentRun } from "@/components/canvas/chat-panel/use-canvas-agent-run"; import { useCanvasAgentRun } from "@/components/canvas/chat-panel/use-canvas-agent-run";
import { canvasAgentProgress, type CanvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress"; import { canvasAgentProgress, type CanvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
@ -3243,6 +3247,8 @@ export function CanvasChatPanel({
onRequireLogin, onRequireLogin,
onBeforeAgentRequest, onBeforeAgentRequest,
}: CanvasChatPanelProps) { }: CanvasChatPanelProps) {
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(
@ -3363,15 +3369,15 @@ 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 [messageViewportKey, setMessageViewportKey] = useState(0); const { scrollRef: messagesScrollRef, contentRef: messagesContentRef, scrollToBottom: scrollMessagesToBottom, isAtBottom: isMessageListAtBottom } = useStickToBottom({ initial: false, resize: "instant" });
const messagesEndRef = useRef<HTMLDivElement>(null); const initialScrollChatRef = useRef<string | null>(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>>([]);
const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]); const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]); const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const [clawdTableFiles, setClawdTableFiles] = useState<File[]>([]); const [clawdTableFiles, setClawdTableFiles] = useState<File[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const detailModeSwitchRef = useRef(false); const detailModeSwitchRef = useRef(false);
const hiddenPromptRef = useRef<string | null>(null); const hiddenPromptRef = useRef<string | null>(null);
const autoContextRef = useRef<string | null>(null); const autoContextRef = useRef<string | null>(null);
@ -3400,7 +3406,6 @@ 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);
@ -5030,6 +5035,15 @@ export function CanvasChatPanel({
console.log('[CanvasChat] 已插入图片标签:', url.substring(0, 50)); console.log('[CanvasChat] 已插入图片标签:', url.substring(0, 50));
}, []); }, []);
const setComposerEditorRef = useCallback((editor: HTMLDivElement | null) => {
textareaRef.current = editor as unknown as HTMLTextAreaElement;
if (!editor) return;
const draft = readCanvasComposerDraft<PickedObjectAttachment>(activeChatId);
const text = activeChatIdRef.current === activeChatId ? inputRef.current || draft?.text : draft?.text;
if (text) editor.textContent = text;
draft?.attachments.forEach((attachment, index) => insertImageTagInEditor(attachment.url, attachment.name || "参考图片", index));
}, [activeChatId, insertImageTagInEditor]);
const handleCopyUserMessage = useCallback(async (message: ChatMessage) => { const handleCopyUserMessage = useCallback(async (message: ChatMessage) => {
const payload = getUserMessageCopyPayload(message); const payload = getUserMessageCopyPayload(message);
if (!payload.text && payload.attachments.length === 0) { if (!payload.text && payload.attachments.length === 0) {
@ -7977,8 +7991,12 @@ export function CanvasChatPanel({
const store = useCanvasStore.getState(); const store = useCanvasStore.getState();
store.markAgentOperationRefresh(); store.markAgentOperationRefresh();
if (applyCanvasRemoteSnapshot) {
if (!applyCanvasRemoteSnapshot({ projectId: targetProjectId, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt || "") })) return false;
} else {
store.loadCanvas(result.data.canvasData); store.loadCanvas(result.data.canvasData);
store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || "")); 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);
@ -8006,7 +8024,7 @@ export function CanvasChatPanel({
return false; return false;
} }
}, },
[applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate] [applyCanvasRemoteSnapshot, applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate]
); );
const { const {
@ -8824,12 +8842,6 @@ 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 () => {
@ -8942,7 +8954,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 !== activeChatIdRef.current) return; if (!run?.assistantMessageId || !run.assistantText || run.chatId !== toRawCanvasChatId(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);
@ -8957,6 +8969,33 @@ export function CanvasChatPanel({
}); });
const activeCanvasAgentRun = canvasAgentTask.run; const activeCanvasAgentRun = canvasAgentTask.run;
const isCanvasAgentRunning = activeCanvasAgentRun?.status === "running"; const isCanvasAgentRunning = activeCanvasAgentRun?.status === "running";
useEffect(() => {
const beforeRefresh = (event: Event) => {
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)) {
request.block("当前对话任务仍在处理中,请等待结果返回后再刷新。原任务会继续执行,无需重新提交。");
return;
}
request.save(async () => {
if ((input.trim() || attachments.length) && !writeCanvasComposerDraft(activeChatId, { text: input, attachments }))
throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
});
};
const beforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "true";
};
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
if (composerUploadProgress) window.addEventListener("beforeunload", beforeUnload);
return () => {
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) => {
@ -9100,6 +9139,7 @@ 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([
@ -9119,7 +9159,7 @@ export function CanvasChatPanel({
if (editableDiv) { if (editableDiv) {
editableDiv.innerHTML = ""; editableDiv.innerHTML = "";
} }
}, [activeChatId, rawChatId, selectedModelId, status, sendMessage, setMessages, clearSelection, launchAutoSendTick, activeComposerMode]); }, [activeChatId, rawChatId, selectedModelId, status, sendMessage, setMessages, clearSelection, launchAutoSendTick, activeComposerMode, scrollMessagesToBottom]);
// 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新) // 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新)
const loadedHistorySignatureRef = useRef<string>(""); const loadedHistorySignatureRef = useRef<string>("");
@ -9139,7 +9179,6 @@ 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([]);
@ -9154,11 +9193,20 @@ export function CanvasChatPanel({
}, [activeChatId, setMessages]); }, [activeChatId, setMessages]);
useEffect(() => { useEffect(() => {
if (historyScrollTimerRef.current) { const draft = readCanvasComposerDraft<PickedObjectAttachment>(activeChatId);
clearTimeout(historyScrollTimerRef.current); if (!draft || (input && input !== draft.text)) return;
historyScrollTimerRef.current = null; if (!input) setInput(draft.text);
} setAttachments(draft.attachments);
const frame = requestAnimationFrame(() => {
const editor = textareaRef.current as HTMLDivElement | null;
if (!editor || getComposerPlainTextFromElement(editor).trim()) return;
editor.textContent = draft.text;
draft.attachments.forEach((attachment, index) => insertImageTagInEditor(attachment.url, attachment.name || "参考图片", index));
});
return () => cancelAnimationFrame(frame);
}, [activeChatId, activeComposerMode, input, insertImageTagInEditor]);
useEffect(() => {
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);
@ -9199,24 +9247,9 @@ 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(() => {
@ -9796,11 +9829,13 @@ 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;
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); if (initialScrollChatRef.current === activeChatId) return;
}, [isClawdSubmitting, messages]); initialScrollChatRef.current = activeChatId;
void scrollMessagesToBottom({ animation: "instant" });
}, [activeChatId, isClawdSubmitting, messages.length, scrollMessagesToBottom]);
// 标记是否已经为当前画布设置过标题(避免重复调用) // 标记是否已经为当前画布设置过标题(避免重复调用)
const hasSetCanvasTitle = useRef(false); const hasSetCanvasTitle = useRef(false);
@ -12569,6 +12604,7 @@ 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 = "";
@ -12866,6 +12902,7 @@ 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([
@ -13355,6 +13392,25 @@ export function CanvasChatPanel({
inFlightChatIdRef.current = activeChatId; inFlightChatIdRef.current = activeChatId;
inFlightRawChatIdRef.current = rawChatId; inFlightRawChatIdRef.current = rawChatId;
imageGenerationUserMessageIdsRef.current[activeChatId] = userMessageId; imageGenerationUserMessageIdsRef.current[activeChatId] = userMessageId;
if (shouldUseCanvasAgentRuntime({
composerMode: effectiveComposerMode,
agentPresetId: effectiveAgentEntryMode === "preset" ? selectedAgentPreset?.id : undefined,
workspaceVariant: isVideoWorkspace ? "video" : "canvas",
contextPayload: contextPayloadRef.current || undefined,
}) && onBeforeAgentRequest) {
try {
if (!(await onBeforeAgentRequest())) throw new Error("画布自动同步失败,本次任务未提交。输入内容已保留,请检查网络后再次发送。");
} catch (error) {
releaseSendGuard(activeChatId);
inFlightChatIdRef.current = null;
inFlightRawChatIdRef.current = null;
contextPayloadRef.current = null;
writeCanvasComposerDraft(activeChatId, { text: composerInput, attachments: effectiveAttachments });
toast.error(error instanceof Error ? error.message : "画布自动同步失败,本次任务未提交,输入内容已保留。");
return;
}
}
clearCanvasComposerDraft(activeChatId);
clearStreamImageGenerationStateForChat(activeChatId); clearStreamImageGenerationStateForChat(activeChatId);
clearAgentProgressStateForChat(activeChatId); clearAgentProgressStateForChat(activeChatId);
if (shouldShowOptimisticImageGeneration) { if (shouldShowOptimisticImageGeneration) {
@ -13379,6 +13435,7 @@ 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([
@ -13417,6 +13474,9 @@ export function CanvasChatPanel({
} }
}, [ }, [
isCanvasAgentRunning, isCanvasAgentRunning,
scrollMessagesToBottom,
onBeforeAgentRequest,
isVideoWorkspace,
canvasAgentTask.control, canvasAgentTask.control,
activeChatId, activeChatId,
activeComposerMode, activeComposerMode,
@ -13554,23 +13614,19 @@ export function CanvasChatPanel({
console.log('[CanvasChat] 更新画布:', rawId); console.log('[CanvasChat] 更新画布:', rawId);
const updateResponse = await fetch(`/api/canvas/${rawId}`, { if (!runCanvasRemoteOperation) return;
method: 'PUT', await runCanvasRemoteOperation(async () => {
headers: { 'Content-Type': 'application/json' }, if (useCanvasStore.getState().projectId !== rawId) return;
body: JSON.stringify({ const result = await canvasService.updateProject(rawId, { title, thumbnail: firstImageUrl });
title, if (!result.success) throw new Error(result.error || "画布名称同步失败");
thumbnail: firstImageUrl,
}),
}); });
console.log('[CanvasChat] 画布更新响应:', updateResponse.status);
} else { } else {
console.error('[CanvasChat] auto-title API 请求失败:', response.status); console.error('[CanvasChat] auto-title API 请求失败:', response.status);
} }
} catch (error) { } catch (error) {
console.error('[CanvasChat] 自动命名失败:', error); console.error('[CanvasChat] 自动命名失败:', error);
} }
}, [chatId]); }, [chatId, runCanvasRemoteOperation]);
const addUploadedImageAttachmentToComposer = useCallback( const addUploadedImageAttachmentToComposer = useCallback(
(uploaded: ComposerUploadedImage) => { (uploaded: ComposerUploadedImage) => {
@ -15652,7 +15708,7 @@ export function CanvasChatPanel({
processedMessageIds.current.clear(); processedMessageIds.current.clear();
hasHydratedHistoryRef.current = true; hasHydratedHistoryRef.current = true;
setMessages(sortCanvasMessagesByCreatedAt(fresh.messages)); setMessages(sortCanvasMessagesByCreatedAt(fresh.messages));
setMessageViewportKey((previous) => previous + 1); void scrollMessagesToBottom({ animation: "instant" });
}).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("聊天记录暂时读取失败,请重试");
@ -15666,7 +15722,7 @@ export function CanvasChatPanel({
replaceChatSessionQuery(sessionId); replaceChatSessionQuery(sessionId);
setIsSessionMenuOpen(false); setIsSessionMenuOpen(false);
toast.success("已切换聊天窗口"); toast.success("已切换聊天窗口");
}, [activeChatId, isClawdSubmitting, mutateHistoryData, releaseSendGuard, replaceChatSessionQuery, setMessages, status]); }, [activeChatId, isClawdSubmitting, mutateHistoryData, releaseSendGuard, replaceChatSessionQuery, scrollMessagesToBottom, setMessages, status]);
const switchToClawdHistorySession = useCallback( const switchToClawdHistorySession = useCallback(
(historyItem: ClawdHistoryItem) => { (historyItem: ClawdHistoryItem) => {
@ -16083,11 +16139,17 @@ 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
key={`${activeChatId}-${messageViewportKey}`} ref={messagesScrollRef}
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 overflow-x-hidden", "flex-1 min-h-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-y-auto", isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-auto",
isWorkspacePresentation ? "p-5" : "p-3", isWorkspacePresentation ? "p-5" : "p-3",
isPlainCanvasEmptyState isPlainCanvasEmptyState
? "pt-0" ? "pt-0"
@ -16096,7 +16158,7 @@ export function CanvasChatPanel({
: "pt-3" : "pt-3"
)} )}
> >
<div className={cn( <div ref={messagesContentRef} 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"
)}> )}>
@ -17299,9 +17361,22 @@ 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(
@ -18548,7 +18623,7 @@ export function CanvasChatPanel({
)} )}
> >
<div <div
ref={textareaRef as any} ref={setComposerEditorRef}
contentEditable={!isSubmitting || (isCanvasAgentRunning && !canvasAgentTask.stopPending)} contentEditable={!isSubmitting || (isCanvasAgentRunning && !canvasAgentTask.stopPending)}
role="textbox" role="textbox"
tabIndex={isSubmitting ? -1 : 0} tabIndex={isSubmitting ? -1 : 0}
@ -18582,6 +18657,7 @@ export function CanvasChatPanel({
setTagPreview(null); setTagPreview(null);
}} }}
onInput={(e) => { onInput={(e) => {
clearCanvasComposerDraft(activeChatId);
// 从 contenteditable 中提取纯文本(排除附件标签文本) // 从 contenteditable 中提取纯文本(排除附件标签文本)
const target = e.target as HTMLDivElement; const target = e.target as HTMLDivElement;
const clone = target.cloneNode(true) as HTMLDivElement; const clone = target.cloneNode(true) as HTMLDivElement;

View File

@ -19,6 +19,7 @@ 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;
@ -28,6 +29,7 @@ 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",
@ -88,6 +90,10 @@ export function CanvasFileListPanel({
// 创建新画布 // 创建新画布
const handleCreateNew = async () => { const handleCreateNew = async () => {
if (onNavigate) {
onNavigate(createUrl);
return;
}
router.push(createUrl); router.push(createUrl);
}; };
@ -100,6 +106,10 @@ 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,11 +1,26 @@
"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); return useContext(CanvasRemoteOperationContext)?.run || null;
}
export function useCanvasRemoteSnapshot() {
return useContext(CanvasRemoteOperationContext)?.applySnapshot || null;
} }

View File

@ -2,14 +2,10 @@
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;
}; };
@ -18,11 +14,9 @@ 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) {
@ -77,19 +71,6 @@ 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

@ -6,6 +6,8 @@ import { ArrowUp, MessageSquare, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { CanvasAgentRunPanel } from "./canvas-agent-run-panel"; import { CanvasAgentRunPanel } from "./canvas-agent-run-panel";
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
import { readCanvasComposerDraft, writeCanvasComposerDraft, clearCanvasComposerDraft } from "@/lib/client/canvas-draft";
import { import {
videoAgentSettingsSchema, videoAgentSettingsSchema,
type VideoAgentSettings, type VideoAgentSettings,
@ -94,6 +96,22 @@ export function VideoCanvasAgentPanel(props: {
}; };
}, [props.projectId, setMessages]); }, [props.projectId, setMessages]);
const busy = backgroundRunning || preparing || status === "submitted" || status === "streaming"; const busy = backgroundRunning || preparing || status === "submitted" || status === "streaming";
useEffect(() => {
const draft = readCanvasComposerDraft(`video-agent-${props.projectId}`);
if (draft) setInput(draft.text);
}, [props.projectId]);
useEffect(() => {
const beforeRefresh = (event: Event) => {
const request = (event as CustomEvent<PageRefreshRequest>).detail;
if (busy) request.block("当前视频任务仍在处理中,请等待结果返回后再刷新。原任务无需重新提交。");
request.save(async () => {
if (input.trim() && !writeCanvasComposerDraft(`video-agent-${props.projectId}`, { text: input, attachments: [] }))
throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
});
};
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
}, [busy, input, props.projectId]);
const submit = useCallback( const submit = useCallback(
async (resumeRunId?: string) => { async (resumeRunId?: string) => {
if (busy || loading) return; if (busy || loading) return;
@ -105,6 +123,7 @@ export function VideoCanvasAgentPanel(props: {
try { try {
await latest.current.props.beforeSend(); await latest.current.props.beforeSend();
if (!mounted.current) return; if (!mounted.current) return;
clearCanvasComposerDraft(`video-agent-${props.projectId}`);
resumeRef.current = resumeRunId; resumeRef.current = resumeRunId;
setInput(""); setInput("");
await sendMessage({ text }); await sendMessage({ text });
@ -298,7 +317,7 @@ export function VideoCanvasAgentPanel(props: {
<textarea <textarea
aria-label="视频创作要求" aria-label="视频创作要求"
value={input} value={input}
onChange={(event) => setInput(event.target.value)} onChange={(event) => { clearCanvasComposerDraft(`video-agent-${props.projectId}`); setInput(event.target.value); }}
maxLength={2000} maxLength={2000}
rows={3} rows={3}
placeholder="例如:先分析选中素材,写分镜,再生成一条5秒商品展示视频" placeholder="例如:先分析选中素材,写分镜,再生成一条5秒商品展示视频"

View File

@ -29,10 +29,10 @@ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef< const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>, React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> & { overlayClassName?: string }
>(({ className, ...props }, ref) => ( >(({ className, overlayClassName, ...props }, ref) => (
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay /> <AlertDialogOverlay className={overlayClassName} />
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(

View File

@ -92,8 +92,16 @@ public 先从当前运行版本创建写入时复制副本,保留未纳入 Git
原生产 checkout 的支线及未提交改动仍保留,不能据其 Git HEAD 判断已发布版本。 原生产 checkout 的支线及未提交改动仍保留,不能据其 Git HEAD 判断已发布版本。
实际发布证据是:运行 cwd、`.next/BUILD_ID`、该目录 `release.json` 和 `activation.json`。 实际发布证据是:运行 cwd、`.next/BUILD_ID`、该目录 `release.json` 和 `activation.json`。
`next.config.ts` 将本流程的发布目录编号写入前后端版本标识;`/api/app-version` 返回该编号且不缓存,发布烟测会核对它与运行目录一致。网页每分钟及切回页面时检查更新,发现版本变化后提示同步并刷新;画布同步失败或当前任务仍在处理时保留页面。首次接入前已经打开的旧网页需先刷新一次,后续发布即可自动收到提示。
维护电脑的 `.git/releases/<发布ID>/` 保留提交、构建日志、烟测、传输校验和及操作回执。 维护电脑的 `.git/releases/<发布ID>/` 保留提交、构建日志、烟测、传输校验和及操作回执。
服务器回滚配置可能含环境元数据,目录权限为 0700、文件为 0600,不放入公开目录或 Git。 服务器回滚配置可能含环境元数据,目录权限为 0700、文件为 0600,不放入公开目录或 Git。
仓库历史全量 lint 仍有已记录的问题,不能声称全部质量门禁通过。 仓库历史全量 lint 仍有已记录的问题,不能声称全部质量门禁通过。
每次 PR 须记录本次改动的验证、历史失败和未覆盖项目;当前流程不会自动触发生产发布。 每次 PR 须记录本次改动的验证、历史失败和未覆盖项目;当前流程不会自动触发生产发布。
## ZIP 下载交付
默认存储为七牛时,完整 ZIP 通过分片上传至七牛,确认下载地址可用后才提供下载。
大文件使用三个并发分片,上传进度显示在原打包任务中。传送或链接检查失败会返回具体阶段、
下载编号及重试建议,保留已生成的 ZIP;再次点击下载复用原文件,已上传的完整对象不重复上传。
后台 ZIP 任务参与发布期间的工作等待。完成或失败的任务及文件保留 24 小时,待处理任务保留 6 小时。

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
import { mergeCanvasSnapshots, type CanvasConflictChoice } from "@/lib/video-canvas/merge-snapshot"; import { mergeCanvasSnapshots, type CanvasConflictChoice } from "@/lib/video-canvas/merge-snapshot";
import { import {
CanvasRevisionConflict, CanvasRevisionConflict,
@ -149,6 +150,16 @@ export function useVideoCanvasPersistence(
return !queue.dirty && !queue.failed; return !queue.dirty && !queue.failed;
}, [ready]); }, [ready]);
useEffect(() => {
const beforeRefresh = (event: Event) => {
(event as CustomEvent<PageRefreshRequest>).detail.save(async () => {
if (!(await saveAndConfirm())) throw new Error("视频画布尚未同步完成,本地草稿已保留,请重试刷新。");
});
};
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
}, [saveAndConfirm]);
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {

View File

@ -0,0 +1,55 @@
export type CanvasDraft = { base: string; snapshot: string };
const key = (projectId: string) => `canvas-unsynced:${projectId}`;
export function readCanvasDraft(projectId: string): CanvasDraft | null {
try {
const value = JSON.parse(localStorage.getItem(key(projectId)) || "null");
return typeof value?.base === "string" && typeof value?.snapshot === "string" ? value : null;
} catch {
return null;
}
}
export function writeCanvasDraft(projectId: string, draft: CanvasDraft): boolean {
try {
localStorage.setItem(key(projectId), JSON.stringify(draft));
return true;
} catch {
return false;
}
}
export function clearCanvasDraft(projectId: string) {
try {
localStorage.removeItem(key(projectId));
} catch {
// Keep the current in-memory canvas when browser storage is unavailable.
}
}
const composerKey = (chatId: string) => `canvas-unsent-message:${chatId}`;
export function writeCanvasComposerDraft(chatId: string, draft: { text: string; attachments: unknown[] }) {
try {
localStorage.setItem(composerKey(chatId), JSON.stringify(draft));
return true;
} catch {
return false;
}
}
export function readCanvasComposerDraft<T>(chatId: string): { text: string; attachments: T[] } | null {
try {
const value = JSON.parse(localStorage.getItem(composerKey(chatId)) || "null");
return typeof value?.text === "string" && Array.isArray(value?.attachments) ? value : null;
} catch {
return null;
}
}
export function clearCanvasComposerDraft(chatId: string) {
try {
localStorage.removeItem(composerKey(chatId));
} catch {
// Keep the current editor contents when browser storage is unavailable.
}
}

View File

@ -63,4 +63,5 @@ 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

@ -0,0 +1,15 @@
export const BEFORE_PAGE_REFRESH = "app:before-page-refresh";
export type PageRefreshRequest = {
save: (action: () => Promise<void>) => void;
block: (message: string) => void;
};
export async function preparePageRefresh() {
const actions: Array<() => Promise<void>> = [];
let blocked = "";
window.dispatchEvent(new CustomEvent<PageRefreshRequest>(BEFORE_PAGE_REFRESH, {
detail: { save: action => actions.push(action), block: message => { blocked ||= message; } },
}));
if (blocked) throw new Error(blocked);
for (const action of actions) await action();
}

View File

@ -1,11 +1,72 @@
import "server-only"; import "server-only";
import { createReadStream } from "node:fs"; import { createReadStream } from "node:fs";
import { PutObjectCommand } from "@aws-sdk/client-s3"; import { open } from "node:fs/promises";
import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, HeadObjectCommand, PutObjectCommand, UploadPartCommand, type S3Client } from "@aws-sdk/client-s3";
import { getDefaultStorageConfig, getStorageConfigById } from "@/lib/db/queries"; import { getDefaultStorageConfig, getStorageConfigById } from "@/lib/db/queries";
import { createS3Client, toDbS3UploadConfig, buildS3PublicUrl } from "./service"; import { createS3Client, toDbS3UploadConfig, buildS3PublicUrl } from "./service";
/** Only a completely written archive is uploaded. Local GET remains a fallback. */ type ZipPublication = { id: string; path: string; bytes: number; disposition: string; onProgress?: (bytes: number) => Promise<void> };
export async function publishCanvasZip(params: { id: string; path: string; bytes: number; disposition: string }) { const PART_BYTES = 8 * 1024 ** 2;
async function uploadZip(client: S3Client, bucket: string, key: string, params: ZipPublication) {
const metadata = { Bucket: bucket, Key: key, ContentType: "application/zip", ContentDisposition: params.disposition, CacheControl: "public, max-age=1800" };
if (params.bytes <= 2 * PART_BYTES) {
const body = createReadStream(params.path);
try {
await client.send(new PutObjectCommand({ ...metadata, Body: body, ContentLength: params.bytes }), { abortSignal: AbortSignal.timeout(90_000) });
await params.onProgress?.(params.bytes);
} finally { body.destroy(); }
return;
}
const initiated = await client.send(new CreateMultipartUploadCommand(metadata), { abortSignal: AbortSignal.timeout(15_000) });
if (!initiated.UploadId) throw new Error("七牛未返回分片上传编号");
const upload = { Bucket: bucket, Key: key, UploadId: initiated.UploadId };
const handle = await open(params.path, "r").catch(async error => {
await client.send(new AbortMultipartUploadCommand(upload), { abortSignal: AbortSignal.timeout(10_000) }).catch(() => undefined);
throw error;
});
const parts: Array<{ PartNumber: number; ETag: string }> = [];
let cursor = 0;
let uploaded = 0;
let progress = Promise.resolve();
const controller = new AbortController();
try {
const workers = await Promise.allSettled(Array.from({ length: 3 }, async () => {
try {
while (!controller.signal.aborted) {
const index = cursor++;
const start = index * PART_BYTES;
if (start >= params.bytes) return;
const body = Buffer.allocUnsafe(Math.min(PART_BYTES, params.bytes - start));
let read = 0;
while (read < body.length) {
const result = await handle.read(body, read, body.length - read, start + read);
if (!result.bytesRead) throw new Error("完整 ZIP 文件读取中断,请重试下载");
read += result.bytesRead;
}
const part = await client.send(new UploadPartCommand({ ...upload, PartNumber: index + 1, Body: body, ContentLength: body.length }), {
abortSignal: AbortSignal.any([controller.signal, AbortSignal.timeout(180_000)]),
});
if (!part.ETag) throw new Error("七牛未确认上传分片");
parts.push({ PartNumber: index + 1, ETag: part.ETag });
uploaded += body.length;
const bytes = uploaded;
progress = progress.then(() => params.onProgress?.(bytes));
await progress;
}
} catch (error) { controller.abort(); throw error; }
}));
const failure = workers.find(worker => worker.status === "rejected");
if (failure?.status === "rejected") throw failure.reason;
await client.send(new CompleteMultipartUploadCommand({ ...upload, MultipartUpload: { Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber) } }), { abortSignal: AbortSignal.timeout(30_000) });
} catch (error) {
await client.send(new AbortMultipartUploadCommand(upload), { abortSignal: AbortSignal.timeout(10_000) }).catch(() => undefined);
throw error;
} finally { await handle.close(); }
}
/** Publish the complete ZIP before offering the accelerated download. */
export async function publishCanvasZip(params: ZipPublication) {
const raw = await getDefaultStorageConfig(); const raw = await getDefaultStorageConfig();
const config = toDbS3UploadConfig(raw); const config = toDbS3UploadConfig(raw);
if (config?.provider !== "qiniu-kodo" || !config.publicUrl?.startsWith("https://")) return null; if (config?.provider !== "qiniu-kodo" || !config.publicUrl?.startsWith("https://")) return null;
@ -13,20 +74,37 @@ export async function publishCanvasZip(params: { id: string; path: string; bytes
const client = createS3Client(config); const client = createS3Client(config);
// Qiniu's S3 endpoint expects an ordinary known-length body, without AWS trailers. // Qiniu's S3 endpoint expects an ordinary known-length body, without AWS trailers.
client.config.requestChecksumCalculation = async () => "WHEN_REQUIRED"; client.config.requestChecksumCalculation = async () => "WHEN_REQUIRED";
const body = createReadStream(params.path); let phase: "check" | "upload" | "verify" = "check";
const startedAt = Date.now();
let uploadedBytes = 0;
try { try {
await client.send(new PutObjectCommand({ Bucket: config.bucket, Key: key, Body: body, const existing = await client.send(new HeadObjectCommand({ Bucket: config.bucket, Key: key }), { abortSignal: AbortSignal.timeout(15_000) }).catch(error => {
ContentLength: params.bytes, ContentType: "application/zip", ContentDisposition: params.disposition, if (error.$metadata?.httpStatusCode === 404) return null;
CacheControl: "public, max-age=1800" }), { abortSignal: AbortSignal.timeout(90_000) }); throw error;
const url = buildS3PublicUrl(config, key); });
if (existing?.ContentLength !== params.bytes) {
phase = "upload";
await uploadZip(client, config.bucket, key, { ...params, onProgress: async bytes => {
uploadedBytes = bytes;
await params.onProgress?.(bytes);
} });
}
phase = "verify";
const target = new URL(buildS3PublicUrl(config, key));
target.searchParams.set("v", String(params.bytes));
const url = target.toString();
const response = await fetch(url, { method: "HEAD", redirect: "error", signal: AbortSignal.timeout(10_000) }); const response = await fetch(url, { method: "HEAD", redirect: "error", signal: AbortSignal.timeout(10_000) });
const length = Number(response.headers.get("content-length")); const length = Number(response.headers.get("content-length"));
if (!response.ok || length !== params.bytes) throw new Error("CDN object is not ready"); if (!response.ok || length !== params.bytes) throw new Error("CDN object is not ready");
return { url, storageConfigId: raw!.id, key }; return { url, storageConfigId: raw!.id, key };
} catch (error) { } catch (error) {
console.warn("[CanvasDownloadZipTask] CDN upload/check failed; using complete local file", { taskId: params.id, kind: (error as Error).name }); const kind = (error as Error).name;
return { url: null, storageConfigId: raw!.id, key }; const status = (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode;
} finally { body.destroy(); client.destroy(); } console.warn("[CanvasDownloadZipTask] CDN publication failed", { taskId: params.id, phase, kind, status, bytes: params.bytes, uploadedBytes, elapsedMs: Date.now() - startedAt });
const reason = status === 401 || status === 403 ? `七牛拒绝访问(HTTP ${status}),请管理员检查存储权限` : phase === "check" ? "暂时无法确认七牛文件状态" : phase === "verify" ? "七牛下载链接尚未可用" : /AbortError|TimeoutError/.test(kind) ? "传送至七牛时超时" : "传送至七牛失败";
const advice = status === 401 || status === 403 ? "存储权限恢复后再次点击下载即可继续传送原 ZIP" : "请再次点击下载以继续传送原 ZIP";
return { url: null, storageConfigId: raw!.id, key, error: `ZIP 已打包完成,但${reason}。${advice};不会重新生成图片。若持续失败,请联系管理员并提供下载编号:${params.id}` };
} finally { client.destroy(); }
} }
export async function removeCanvasZipObject(object: { storageConfigId: string; key: string }) { export async function removeCanvasZipObject(object: { storageConfigId: string; key: string }) {

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 }; return { snapshot: JSON.stringify(snapshot), conflicts, changed: !same(snapshot, local) };
} }

View File

@ -1,4 +1,8 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import { basename, dirname } from "node:path";
const appRelease = [basename(process.cwd()), basename(dirname(process.cwd()))]
.find(name => /^\d{8}T\d{6}Z-[a-f0-9]{12}$/.test(name)) || "development";
function resolvePascalEditorFrameSource() { function resolvePascalEditorFrameSource() {
const rawUrl = ( const rawUrl = (
@ -45,6 +49,7 @@ function resolveDevMediaOrigins(isDev: boolean) {
} }
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
env: { NEXT_PUBLIC_APP_RELEASE: appRelease },
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next", distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
poweredByHeader: false, poweredByHeader: false,
allowedDevOrigins: ["127.0.0.1"], allowedDevOrigins: ["127.0.0.1"],

View File

@ -54,6 +54,7 @@ function isHighFrequencyReadApi(pathname: string, method: string) {
if (/^\/api\/canvas\/[^/]+$/.test(pathname)) return true; if (/^\/api\/canvas\/[^/]+$/.test(pathname)) return true;
return [ return [
"/api/app-version",
"/api/ai-wash/config", "/api/ai-wash/config",
"/api/models", "/api/models",
"/api/canvas", "/api/canvas",
@ -816,6 +817,10 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) {
return NextResponse.next(); return NextResponse.next();
} }
if (pathname === "/api/app-version" && (request.method === "GET" || request.method === "HEAD")) {
return NextResponse.next();
}
// Pascal 服务到服务调用由对应 Route 再次验证共享密钥。 // Pascal 服务到服务调用由对应 Route 再次验证共享密钥。
if (isPascalInternalProxyRequest(request)) { if (isPascalInternalProxyRequest(request)) {
return NextResponse.next(); return NextResponse.next();

View File

@ -1,12 +1,17 @@
#!/usr/bin/env node #!/usr/bin/env node
import { basename, dirname } from "node:path";
const DEFAULT_BASE_URL = `http://127.0.0.1:${process.env.PORT || "3001"}`; const DEFAULT_BASE_URL = `http://127.0.0.1:${process.env.PORT || "3001"}`;
const BASE_URL = stripTrailingSlash( const BASE_URL = stripTrailingSlash(
process.env.SMOKE_BASE_URL || process.argv[2] || DEFAULT_BASE_URL process.env.SMOKE_BASE_URL || process.argv[2] || DEFAULT_BASE_URL
); );
const REQUEST_TIMEOUT_MS = Number(process.env.SMOKE_TIMEOUT_MS || 20_000); const REQUEST_TIMEOUT_MS = Number(process.env.SMOKE_TIMEOUT_MS || 20_000);
const EXPECTED_APP_RELEASE = process.env.SMOKE_EXPECTED_APP_RELEASE ||
[basename(process.cwd()), basename(dirname(process.cwd()))].find(name => /^\d{8}T\d{6}Z-[a-f0-9]{12}$/.test(name));
const checks = [ const checks = [
{ path: "/api/app-version", method: "GET", expect: { status: [200] } },
{ {
path: "/", path: "/",
method: "HEAD", method: "HEAD",
@ -74,6 +79,12 @@ async function runCheck(check) {
}; };
} }
if (check.path === "/api/app-version") {
const data = await response.json();
if (!data.version || (EXPECTED_APP_RELEASE && data.version !== EXPECTED_APP_RELEASE) || !response.headers.get("cache-control")?.includes("no-store"))
return { ok: false, message: "deployed app version or cache policy does not match the release" };
}
if ( if (
check.expect.location && check.expect.location &&
!matchesLocation(location, check.expect.location) !matchesLocation(location, check.expect.location)

View File

@ -204,9 +204,9 @@ export const canvasService = {
try { try {
const response = await fetch(`/api/canvas/${id}`); const response = await fetch(`/api/canvas/${id}`);
const data = await response.json(); const data = await response.json();
return data; return { ...data, status: response.status };
} catch (error) { } catch (error) {
return { success: false, error: '获取画布失败' }; return { success: false, error: '画布读取失败,请检查网络后重试;现有内容未被覆盖', status: 0 };
} }
}, },

View File

@ -0,0 +1,624 @@
import { expect, test, type Page } from "@playwright/test";
test.setTimeout(60_000);
const projectId = "8d3b4bac-4539-4caf-8b49-b063f218de19";
const imageOrigin = "https://scqnycdn.jiansun.vip";
const imageNode = (id: string) => ({ id, type: "image", position: { x: 160, y: 130 }, size: { width: 300, height: 200 },
data: { status: "completed", src: `${imageOrigin}/uploads/${id}.png`, title: id } });
async function setup(page: Page, failRead = false) {
const state = {
canvasData: { nodes: [imageNode("source")], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select" },
revision: 1, failSave: false, failRead, conflictNextSave: false, conflictSameNode: false,
writes: [] as any[], metadataWrites: 0, submissions: [] as any[], capabilitySubmissions: [] as any[],
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 project = () => ({ id: projectId, title: "同步验证", canvasData: JSON.parse(JSON.stringify(state.canvasData, (_key, value) =>
value && typeof value === "object" && !Array.isArray(value)
? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))
: value)), updatedAt: revision() });
const errors: string[] = [];
page.on("pageerror", error => errors.push(error.message));
await page.context().addInitScript(() => localStorage.setItem("theme", "light"));
await page.context().route("**/api/**", async route => {
const request = route.request();
const path = new URL(request.url()).pathname;
let data: unknown = {};
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 (request.method() === "GET") {
status = state.failRead ? 500 : 200;
data = state.failRead ? { success: false, error: "画布暂时无法读取,现有内容未被覆盖。" } : { success: true, data: project() };
} else if (request.method() === "PUT") {
const body = request.postDataJSON();
state.writes.push(body);
const previousCanvasRevision = revision();
if (body.canvasData) {
if (state.conflictNextSave) {
state.conflictNextSave = false;
if (state.conflictSameNode) state.canvasData.nodes[0].position.x = 500;
else state.canvasData.nodes.push(imageNode("remote-result"));
state.revision++;
}
if (state.failSave) {
status = 503; data = { success: false, error: "同步服务暂时不可用" };
} else if ((body.baseCanvasUpdatedAt || body.baseCanvasRevision) !== revision()) {
status = 409; data = { success: false, code: "STALE_CANVAS_REVISION", error: "画布已更新", canvasUpdatedAt: revision() };
} else {
state.canvasData = body.canvasData;
state.revision++;
data = { success: true, previousCanvasRevision, data: project() };
}
} else {
state.metadataWrites++;
state.revision++;
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") {
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; });
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' });
return;
} else if (path === "/api/app-version") data = { version: state.appVersion };
else if (path === "/api/files/direct-upload") {
data = request.postDataJSON().action === "complete"
? { url: `${imageOrigin}/uploads/uploaded.png`, pathname: "uploads/uploaded.png" }
: { mode: "completed", key: "uploads/uploaded.png" };
} else if (path === "/api/files/upload") data = { url: `${imageOrigin}/uploads/uploaded.png` };
else if (path === "/api/canvas/auto-title") data = { title: "参考图片" };
else if (path.includes("validate-session")) data = { valid: true, success: true };
else if (path === "/api/auth/session") data = { user: { id: "sync-user", name: "同步验证", type: "admin" }, expires: "2099-01-01" };
else if (path === "/api/site-settings") data = { canvasEnabled: true, agentEnabled: true, membershipEnabled: false };
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.includes("messages")) data = { messages: [] };
else if (path.includes("sessions")) data = { sessions: [] };
else if (path.startsWith("/api/canvas/")) data = { success: true, runs: [] };
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(data) });
});
await page.context().route(`${imageOrigin}/**`, route => route.fulfill({ contentType: "image/svg+xml",
body: '<svg xmlns="http://www.w3.org/2000/svg" width="300" height="200"><rect width="300" height="200" fill="#43b9bd"/></svg>' }));
await page.goto(`/canvas/${projectId}`, { waitUntil: "domcontentloaded" });
if (!failRead) await expect(page.getByRole("heading", { name: /Hi, 同步验证/ })).toBeVisible();
return { state, errors };
}
async function moveImage(page: Page) {
const node = page.locator('[data-canvas-node-id="source"]');
await expect(node).toBeVisible();
const box = (await node.boundingBox())!;
await page.mouse.move(box.x + 100, box.y + 80);
await page.mouse.down();
await page.mouse.move(box.x + 190, box.y + 120, { steps: 5 });
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 }) => {
const { state, errors } = await setup(page);
const editor = page.getByLabel("描述你的设计需求", { exact: true });
await expect(editor).toBeVisible();
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.metadataWrites).toBe(1);
expect(state.canvasData.nodes).toHaveLength(2);
for (const prompt of ["请先分析参考图", "继续调整为白色背景"]) {
await editor.fill(prompt);
await editor.press("Enter");
await expect.poll(() => state.submissions.length).toBe(prompt.startsWith("请") ? 1 : 2);
await expect(editor).toBeEditable();
}
await page.reload();
await expect(page.locator('[data-canvas-node-id]')).toHaveCount(2);
expect(errors).toEqual([]);
});
test("a save conflict merges a remote result with local movement before one Agent submission", async ({ page }) => {
const { state, errors } = await setup(page);
await moveImage(page);
state.conflictNextSave = 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", "remote-result"]);
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
expect(errors).toEqual([]);
});
test("failed synchronization preserves the unsent message and canvas draft across refresh", async ({ page }) => {
const { state, errors } = await setup(page);
await moveImage(page);
state.failSave = true;
const editor = page.getByLabel("描述你的设计需求", { exact: true });
const prompt = "保持产品结构,只更换背景";
await editor.fill(prompt);
await editor.press("Enter");
await expect(page.getByText(/本地草稿已保留/).first()).toBeVisible();
expect(state.submissions).toHaveLength(0);
await expect(editor).toHaveText(prompt);
await page.reload();
await expect(editor).toHaveText(prompt);
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
await expect(page.getByRole("heading", { name: /Hi, 同步验证/ })).toBeVisible();
state.failSave = false;
await expect(page.getByRole("button", { name: "发送", exact: true })).toBeEnabled();
await editor.press("Enter");
await expect.poll(() => state.submissions.length).toBe(1);
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
expect(errors).toEqual([]);
});
test("conflicting edits keep both canvases and do not submit an Agent task", 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.getByText(/画布同步发生冲突/).first()).toBeVisible();
expect(state.submissions).toHaveLength(0);
expect(state.canvasData.nodes[0].position.x).toBe(500);
const draft = await page.evaluate(id => JSON.parse(localStorage.getItem(`canvas-unsynced:${id}`) || "null"), projectId);
expect(JSON.parse(draft.snapshot).canvasData.nodes[0].position.x).toBeGreaterThan(160);
expect(JSON.parse(draft.snapshot).canvasData.nodes[0].position.x).not.toBe(500);
await expect(editor).toHaveText("请处理这张图片");
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 }) => {
const { state, errors } = await setup(page, true);
await expect(page.getByRole("alert").filter({ hasText: "画布暂时无法读取" })).toBeVisible();
await page.reload();
await expect(page.getByRole("alert").filter({ hasText: "画布暂时无法读取" })).toBeVisible();
expect(state.writes).toHaveLength(0);
state.failRead = false;
await page.getByRole("button", { name: "重新加载画布" }).click();
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
expect(state.canvasData.nodes).toHaveLength(1);
expect(errors).toEqual([]);
});
async function showUpdate(page: Page, state: { appVersion: string }) {
state.appVersion = "20261106T120000Z-nextrelease1";
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
await expect(page.getByRole("alertdialog")).toContainText("平台已更新");
}
async function countRefreshes(page: Page, state: { appVersion: string }) {
let navigations = 0;
await page.route(url => url.pathname === `/canvas/${projectId}`, route => {
if (route.request().isNavigationRequest()) { navigations++; state.appVersion = "development"; }
return route.continue();
});
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 }) => {
const { state, errors } = await setup(page);
const refreshes = await countRefreshes(page, state);
await moveImage(page);
const editor = page.getByLabel("描述你的设计需求", { exact: true });
await editor.fill("刷新后还要保留这条输入");
await showUpdate(page, state);
await page.keyboard.press("Escape");
await expect(page.getByRole("alertdialog")).toBeVisible();
expect(refreshes()).toBe(0);
await page.screenshot({ path: test.info().outputPath("update-notice-desktop.png"), animations: "disabled" });
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({ path: test.info().outputPath("update-notice-mobile.png"), animations: "disabled" });
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
await expect.poll(refreshes).toBe(1);
await expect(editor).toHaveText("刷新后还要保留这条输入");
await expect(page.getByRole("alertdialog")).toHaveCount(0);
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
expect(state.submissions).toHaveLength(0);
expect(errors).toEqual([]);
});
test("an update cannot interrupt or resubmit a running Agent task", async ({ page }) => {
const { state, errors } = await setup(page);
const refreshes = await countRefreshes(page, state);
state.holdRequest = true;
const editor = page.getByLabel("描述你的设计需求", { exact: true });
await editor.fill("先分析这张图片");
await editor.press("Enter");
await expect.poll(() => state.submissions.length).toBe(1);
await showUpdate(page, state);
const saves = state.writes.length;
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
await expect(page.getByRole("alertdialog")).toContainText("当前对话任务仍在处理中");
expect(refreshes()).toBe(0);
expect(state.writes).toHaveLength(saves);
state.finishRequest?.();
await expect(editor).toBeEditable();
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
await expect.poll(refreshes).toBe(1);
expect(state.submissions).toHaveLength(1);
expect(errors).toEqual([]);
});
test("an update keeps the current page when automatic synchronization fails", async ({ page }) => {
const { state, errors } = await setup(page);
const refreshes = await countRefreshes(page, state);
state.failSave = true;
await moveImage(page);
await showUpdate(page, state);
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
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(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;
await showUpdate(page, state);
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
await expect.poll(refreshes).toBe(1);
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
expect(errors).toEqual([]);
});
test("a release refresh never saves an empty canvas after its initial read failed", async ({ page }) => {
const { state, errors } = await setup(page, true);
const refreshes = await countRefreshes(page, state);
await expect(page.getByRole("alert").filter({ hasText: "画布暂时无法读取" })).toBeVisible();
await showUpdate(page, state);
state.failRead = false;
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
await expect.poll(refreshes).toBe(1);
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
expect(state.canvasData.nodes).toHaveLength(1);
expect(state.writes.filter(body => body.canvasData?.nodes.length === 0)).toHaveLength(0);
expect(errors).toEqual([]);
});

View File

@ -0,0 +1,198 @@
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

@ -7,6 +7,9 @@ import { randomUUID } from "node:crypto";
import JSZip from "jszip"; import JSZip from "jszip";
import { serverLoader } from "../helpers/canvas-download-server-loader"; import { serverLoader } from "../helpers/canvas-download-server-loader";
import { parseDownloadByteRange } from "@/lib/canvas/download-byte-range"; import { parseDownloadByteRange } from "@/lib/canvas/download-byte-range";
import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, HeadObjectCommand, PutObjectCommand, UploadPartCommand } from "@aws-sdk/client-s3";
const runtimeWork = { trackRuntimeWork: async (run: () => Promise<void>) => run() };
test("ranges reject invalid offsets and preserve normal, open and suffix byte ranges", () => { test("ranges reject invalid offsets and preserve normal, open and suffix byte ranges", () => {
expect(parseDownloadByteRange("bytes=0-3", 20)).toEqual({start:0,end:3}); expect(parseDownloadByteRange("bytes=0-3", 20)).toEqual({start:0,end:3});
@ -20,7 +23,7 @@ test("multi-select ZIP admission is immediate; complete archives survive restart
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-test-')); let owner:string|null='owner';let fetches=0; const dir=await mkdtemp(join(tmpdir(),'canvas-zip-test-')); let owner:string|null='owner';let fetches=0;
const callbacks:Array<()=>Promise<void>>=[]; const callbacks:Array<()=>Promise<void>>=[];
const mocks={ const mocks={
'@/app/(auth)/auth': {auth:async()=>owner?{user:{id:owner}}:null}, '@/lib/observability/active-work':runtimeWork,'@/app/(auth)/auth': {auth:async()=>owner?{user:{id:owner}}:null},
'next/server': {NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)}, 'next/server': {NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)},
'@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async({index}:any)=>{ '@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async({index}:any)=>{
fetches++;return {ok:true,filename:`${index}-花瓶.png`,bytes:Buffer.from(`image-${index}`),contentType:'image/png'}; fetches++;return {ok:true,filename:`${index}-花瓶.png`,bytes:Buffer.from(`image-${index}`),contentType:'image/png'};
@ -56,7 +59,7 @@ test("multi-select ZIP admission is immediate; complete archives survive restart
test("all-source failures never produce an empty successful ZIP; retries reuse the task and resolve sources again", async()=>{ test("all-source failures never produce an empty successful ZIP; retries reuse the task and resolve sources again", async()=>{
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-retry-'));const callbacks:Array<()=>Promise<void>>=[];let available=false; const dir=await mkdtemp(join(tmpdir(),'canvas-zip-retry-'));const callbacks:Array<()=>Promise<void>>=[];let available=false;
const route=serverLoader({ const route=serverLoader({
'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})}, '@/lib/observability/active-work':runtimeWork,'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)}, 'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)},
'@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async()=>available?{ok:true,filename:'one.png',bytes:Buffer.from('one'),contentType:'image/png'}:{ok:false,error:'expired provider URL'}}, '@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async()=>available?{ok:true,filename:'one.png',bytes:Buffer.from('one'),contentType:'image/png'}:{ok:false,error:'expired provider URL'}},
'@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>null}, '@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>null},
@ -108,7 +111,7 @@ test("the browser polls a complete ZIP, preserves an idempotent task after a los
test("partial source failures are explicit; CDN redirects retain an authenticated local range fallback",async()=>{ test("partial source failures are explicit; CDN redirects retain an authenticated local range fallback",async()=>{
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-partial-'));const callbacks:Array<()=>Promise<void>>=[];let owner='owner'; const dir=await mkdtemp(join(tmpdir(),'canvas-zip-partial-'));const callbacks:Array<()=>Promise<void>>=[];let owner='owner';
const route=serverLoader({ const route=serverLoader({
'@/app/(auth)/auth':{auth:async()=>({user:{id:owner}})}, '@/lib/observability/active-work':runtimeWork,'@/app/(auth)/auth':{auth:async()=>({user:{id:owner}})},
'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)}, 'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)},
'@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async({index}:any)=>index?{ok:false,error:'source expired'}:{ok:true,filename:'original.png',bytes:Buffer.from('original'),contentType:'image/png'}}, '@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async({index}:any)=>index?{ok:false,error:'source expired'}:{ok:true,filename:'original.png',bytes:Buffer.from('original'),contentType:'image/png'}},
'@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>({url:'https://cdn.example/complete.zip',key:'canvas-downloads/x.zip',storageConfigId:'config'})}, '@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>({url:'https://cdn.example/complete.zip',key:'canvas-downloads/x.zip',storageConfigId:'config'})},
@ -128,7 +131,7 @@ test("partial source failures are explicit; CDN redirects retain an authenticate
test("recovery validates staged files instead of treating missing bytes as successful images",async()=>{ test("recovery validates staged files instead of treating missing bytes as successful images",async()=>{
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-missing-'));const callbacks:Array<()=>Promise<void>>=[];const id=randomUUID(); const dir=await mkdtemp(join(tmpdir(),'canvas-zip-missing-'));const callbacks:Array<()=>Promise<void>>=[];const id=randomUUID();
const load=serverLoader({ const load=serverLoader({
'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})}, '@/lib/observability/active-work':runtimeWork,'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)}, 'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)},
'@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async()=>({ok:false,error:'source expired'})}, '@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async()=>({ok:false,error:'source expired'})},
'@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>null}, '@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>null},
@ -157,7 +160,7 @@ test("large ZIP writing checks current free space as staged input bytes are rele
test("slow expired-object cleanup cannot keep the ZIP admission request open",async()=>{ test("slow expired-object cleanup cannot keep the ZIP admission request open",async()=>{
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-cleanup-'));const callbacks:Array<()=>Promise<void>>=[]; const dir=await mkdtemp(join(tmpdir(),'canvas-zip-cleanup-'));const callbacks:Array<()=>Promise<void>>=[];
const mocks:Record<string,unknown>={ const mocks:Record<string,unknown>={
'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})}, '@/lib/observability/active-work':runtimeWork,'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)}, 'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)},
'@/lib/canvas/photoshop-safe-download':{},'@/lib/storage/canvas-zip-delivery':{}, '@/lib/canvas/photoshop-safe-download':{},'@/lib/storage/canvas-zip-delivery':{},
}; };
@ -169,3 +172,127 @@ test("slow expired-object cleanup cannot keep the ZIP admission request open",as
expect(result).not.toBe('blocked');expect((result as Response).status).toBe(202); expect(result).not.toBe('blocked');expect((result as Response).status).toBe(202);
}finally{await rm(dir,{recursive:true,force:true});} }finally{await rm(dir,{recursive:true,force:true});}
}); });
test("large ZIPs use bounded parallel parts and publish only the complete ordered archive", async () => {
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-parts-'));
const bytes=Buffer.alloc(25*1024**2+19,0x5a);bytes[16*1024**2]=0xff;
const file=join(dir,'large.zip');await writeFile(file,bytes);
const parts=new Map<number,Buffer>();let inFlight=0;let peak=0;let finished=false;let destroyed=false;let trailers='';
const progress:number[]=[];
const client={config:{requestChecksumCalculation:async()=>''},destroy(){destroyed=true;},async send(command:any){
if(command instanceof HeadObjectCommand)throw Object.assign(new Error('missing'),{$metadata:{httpStatusCode:404}});
if(command instanceof CreateMultipartUploadCommand){expect(command.input.ContentDisposition).toContain('attachment');return {UploadId:'upload'};}
if(command instanceof UploadPartCommand){
const input=command.input;inFlight++;peak=Math.max(peak,inFlight);expect(input.ContentLength).toBeLessThanOrEqual(8*1024**2);
await new Promise(resolve=>setTimeout(resolve,input.PartNumber===1?30:20));
expect(Buffer.isBuffer(input.Body)).toBe(true);
parts.set(input.PartNumber!,Buffer.from(input.Body as Buffer));inFlight--;return {ETag:`part-${input.PartNumber}`};
}
expect(command).toBeInstanceOf(CompleteMultipartUploadCommand);
expect(command.input.MultipartUpload.Parts.map((p:any)=>p.PartNumber)).toEqual([1,2,3,4]);
expect(Buffer.concat(command.input.MultipartUpload.Parts.map((p:any)=>parts.get(p.PartNumber)!)).equals(bytes)).toBe(true);
finished=true;trailers=await client.config.requestChecksumCalculation();return {};
}};
const publish=serverLoader({
'@/lib/db/queries':{getDefaultStorageConfig:async()=>({id:'storage'})},
'./service':{toDbS3UploadConfig:()=>({provider:'qiniu-kodo',bucket:'bucket',publicUrl:'https://cdn.example'}),createS3Client:()=>client,buildS3PublicUrl:(_:any,key:string)=>'https://cdn.example/'+key},
fetch:async(url:string,init:any)=>{expect(finished).toBe(true);expect(init.method).toBe('HEAD');expect(new URL(url).searchParams.get('v')).toBe(String(bytes.length));return new Response(null,{headers:{'content-length':String(bytes.length)}});},
})('lib/storage/canvas-zip-delivery.ts').publishCanvasZip;
try{
const result=await publish({id:randomUUID(),path:file,bytes:bytes.length,disposition:'attachment; filename="large.zip"',onProgress:async(n:number)=>{progress.push(n);}});
expect(result.url).toContain('https://cdn.example/canvas-downloads/');expect(peak).toBeGreaterThan(1);expect(peak).toBeLessThanOrEqual(3);expect(progress.at(-1)).toBe(bytes.length);
expect(progress).toEqual([...progress].sort((a,b)=>a-b));expect(trailers).toBe('WHEN_REQUIRED');expect(destroyed).toBe(true);
}finally{await rm(dir,{recursive:true,force:true});}
});
test("failed multipart uploads abort their parts and retain the original ZIP with actionable advice",async()=>{
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-abort-'));const file=join(dir,'large.zip');const bytes=17*1024**2;
const handle=await (await import('node:fs/promises')).open(file,'w');await handle.truncate(bytes);await handle.close();
let aborted=false;let completed=false;
const client={config:{},destroy(){},async send(command:any){
if(command instanceof HeadObjectCommand)throw Object.assign(new Error('missing'),{$metadata:{httpStatusCode:404}});
if(command instanceof CreateMultipartUploadCommand)return {UploadId:'upload'};
if(command instanceof UploadPartCommand){if(command.input.PartNumber===2)throw new DOMException('part timeout','TimeoutError');return {ETag:'part'};}
if(command instanceof AbortMultipartUploadCommand){aborted=true;return {};}
completed=true;return {};
}};
const publish=serverLoader({
'@/lib/db/queries':{getDefaultStorageConfig:async()=>({id:'storage'})},
'./service':{toDbS3UploadConfig:()=>({provider:'qiniu-kodo',bucket:'bucket',publicUrl:'https://cdn.example'}),createS3Client:()=>client},
})('lib/storage/canvas-zip-delivery.ts').publishCanvasZip;
try{
const result=await publish({id:randomUUID(),path:file,bytes,disposition:'attachment'});
expect(result.url).toBeNull();expect(result.error).toContain('传送至七牛时超时');expect(result.error).toContain('继续传送原 ZIP');
expect(aborted).toBe(true);expect(completed).toBe(false);expect((await (await import('node:fs/promises')).stat(file)).size).toBe(bytes);
}finally{await rm(dir,{recursive:true,force:true});}
});
test("retrying CDN verification reuses an already uploaded ZIP without uploading it again",async()=>{
let uploads=0;let available=false;
const client={config:{},destroy(){},async send(command:any){
if(command instanceof HeadObjectCommand)return {ContentLength:100};
if(command instanceof PutObjectCommand||command instanceof CreateMultipartUploadCommand)uploads++;
throw new Error('Unexpected upload');
}};
const publish=serverLoader({
'@/lib/db/queries':{getDefaultStorageConfig:async()=>({id:'storage'})},
'./service':{toDbS3UploadConfig:()=>({provider:'qiniu-kodo',bucket:'bucket',publicUrl:'https://cdn.example'}),createS3Client:()=>client,buildS3PublicUrl:(_:any,key:string)=>'https://cdn.example/'+key},
fetch:async()=>new Response(null,{status:available?200:404,headers:{'content-length':available?'100':'0'}}),
})('lib/storage/canvas-zip-delivery.ts').publishCanvasZip;
const params={id:randomUUID(),path:'unused-already-published.zip',bytes:100,disposition:'attachment'};
const first=await publish(params);expect(first.url).toBeNull();expect(first.error).toContain('七牛下载链接尚未可用');
available=true;expect((await publish(params)).url).toContain('https://cdn.example/');expect(uploads).toBe(0);
});
test("CDN failures never silently start local downloads; retries reuse the ZIP and keep it resumable beyond one hour",async()=>{
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-cdn-retry-'));const callbacks:Array<()=>Promise<void>>=[];
let fetches=0;let publications=0;let available=false;let now=Date.now();
let activeWork=0;
const runtimeWork={trackRuntimeWork:async(run:()=>Promise<void>)=>{activeWork++;try{return await run();}finally{activeWork--;}}};
class Clock extends Date{static now(){return now;}}
const load=serverLoader({
Date:Clock,'@/lib/observability/active-work':runtimeWork,'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)},
'@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async()=>{fetches++;return {ok:true,filename:'original.png',bytes:Buffer.from('original'),contentType:'image/png'};}},
'@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>{expect(activeWork).toBe(1);publications++;return {url:available?'https://cdn.example/complete.zip':null,key:'canvas-downloads/key.zip',storageConfigId:'storage',error:'ZIP 已打包,传送至七牛时超时;请再次点击下载继续原 ZIP,不会重新生成图片。'};},removeCanvasZipObject:async()=>{}},
},{CANVAS_DOWNLOAD_ZIP_DIR:dir});
const route=load('app/api/canvas/download-zip-task/route.ts');const id=randomUUID();
const post=()=>new NextRequest('http://localhost/api/canvas/download-zip-task',{method:'POST',body:JSON.stringify({taskId:id,retry:true,files:[{url:'https://cdn.example/original.png'}]})});
const request=(suffix='')=>new NextRequest(`http://localhost/api/canvas/download-zip-task?taskId=${id}${suffix}`,{headers:{range:'bytes=0-3'}});
try{
await route.POST(post());while(callbacks.length)await callbacks.shift()!();
const failed=(await(await route.GET(request())).json()).task;
expect(failed).toMatchObject({id,status:'failed',successCount:1,downloadUrl:''});expect(failed.error).toContain('不会重新生成图片');
expect((await route.GET(request('&download=1'))).status).toBe(409);
const before=await readFile(join(dir,id,'task.json'),'utf8');const original=Buffer.from(await readFile(JSON.parse(before).zipFile.path));
available=true;await route.POST(post());while(callbacks.length)await callbacks.shift()!();
expect(fetches).toBe(1);expect(publications).toBe(2);
const manifest=JSON.parse(await readFile(join(dir,id,'task.json'),'utf8'));expect(await readFile(manifest.zipFile.path)).toEqual(original);
delete manifest.cdnUrl;
await load('lib/canvas/download-zip-task-store.ts').saveZipTask(manifest);
await route.POST(post());while(callbacks.length)await callbacks.shift()!();
expect(fetches).toBe(1);expect(publications).toBe(3);
expect(activeWork).toBe(0);
now+=2*60*60_000;
expect((await route.GET(request('&download=1'))).status).toBe(307);
const local=await route.GET(request('&download=1&local=1'));expect(local.status).toBe(206);expect(Buffer.from(await local.arrayBuffer())).toEqual(original.subarray(0,4));
}finally{await rm(dir,{recursive:true,force:true});}
});
test("small ZIP delivery preserves the complete file and download filename",async()=>{
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-small-'));const file=join(dir,'small.zip');const bytes=Buffer.from('PK small complete archive');await writeFile(file,bytes);
let written=false;
const client={config:{},destroy(){},async send(command:any){
if(command instanceof HeadObjectCommand)throw Object.assign(new Error('missing'),{$metadata:{httpStatusCode:404}});
expect(command).toBeInstanceOf(PutObjectCommand);expect(command.input.ContentLength).toBe(bytes.length);expect(command.input.ContentDisposition).toContain('small.zip');
const chunks:Buffer[]=[];for await(const chunk of command.input.Body)chunks.push(Buffer.from(chunk));
expect(Buffer.concat(chunks).equals(bytes)).toBe(true);written=true;return {};
}};
const publish=serverLoader({
'@/lib/db/queries':{getDefaultStorageConfig:async()=>({id:'storage'})},
'./service':{toDbS3UploadConfig:()=>({provider:'qiniu-kodo',bucket:'bucket',publicUrl:'https://cdn.example'}),createS3Client:()=>client,buildS3PublicUrl:(_:any,key:string)=>'https://cdn.example/'+key},
fetch:async()=>{expect(written).toBe(true);return new Response(null,{headers:{'content-length':String(bytes.length)}});},
})('lib/storage/canvas-zip-delivery.ts').publishCanvasZip;
try{expect((await publish({id:randomUUID(),path:file,bytes:bytes.length,disposition:'attachment; filename="small.zip"'})).url).toContain('https://cdn.example/');}
finally{await rm(dir,{recursive:true,force:true});}
});