Compare commits
17 Commits
codex/agen
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fa3210a367 | |||
| e335821ca8 | |||
| c2e8ff6eac | |||
| d26bec9864 | |||
| 24a3c930c2 | |||
| ebb0d1514b | |||
| 50ffc8d648 | |||
| ff6381d02e | |||
| feaa18ed3b | |||
| 2939ff8a1f | |||
| 5bffeaf94d | |||
| e221027b74 | |||
| 445d756a19 | |||
| 05c3d90708 | |||
| cea4cd01f5 | |||
| ea0351bb2c | |||
| 37406d7d6e |
8
app/api/app-version/route.ts
Normal file
8
app/api/app-version/route.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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,63 +100,71 @@ 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);
|
||||||
// Stage each image on disk, not all PNG buffers in the web process heap.
|
if (!reuseZip) {
|
||||||
// A restart resumes from these files using the current owner's session.
|
delete task.zipFile; delete task.contentDisposition; delete task.cdnUrl;
|
||||||
let cursor = 0;
|
// Stage each image on disk, not all PNG buffers in the web process heap.
|
||||||
let writes = Promise.resolve();
|
// A restart resumes from these files using the current owner's session.
|
||||||
const persist = () => { const current = task!; writes = writes.then(() => saveZipTask(current)); return writes; };
|
let cursor = 0;
|
||||||
const results = await Promise.allSettled(Array.from({ length: Math.min(2, task.files.length) }, async () => {
|
let writes = Promise.resolve();
|
||||||
while (cursor < task!.files.length) {
|
const persist = () => { const current = task!; writes = writes.then(() => saveZipTask(current)); return writes; };
|
||||||
const index = cursor++; const file = task!.files[index];
|
const results = await Promise.allSettled(Array.from({ length: Math.min(2, task.files.length) }, async () => {
|
||||||
const path = join(zipTaskPath(id), `${index}.image`);
|
while (cursor < task!.files.length) {
|
||||||
if (file.prepared && file.filename && (await stat(path).catch(() => null))?.size) continue;
|
const index = cursor++; const file = task!.files[index];
|
||||||
delete file.prepared; delete file.filename;
|
const path = join(zipTaskPath(id), `${index}.image`);
|
||||||
const result = await fetchCanvasDownloadAsset({ file, index, requestOrigin, cookie,
|
if (file.prepared && file.filename && (await stat(path).catch(() => null))?.size) continue;
|
||||||
signal: AbortSignal.timeout(120_000) }).catch(() => ({ ok: false as const, error: "原图读取超时或源地址暂时不可用" }));
|
delete file.prepared; delete file.filename;
|
||||||
if (result.ok) {
|
const result = await fetchCanvasDownloadAsset({ file, index, requestOrigin, cookie,
|
||||||
await assertZipDiskSpace(result.bytes.length);
|
signal: AbortSignal.timeout(120_000) }).catch(() => ({ ok: false as const, error: "原图读取超时或源地址暂时不可用" }));
|
||||||
await writeFile(path, result.bytes, { mode: 0o600 });
|
if (result.ok) {
|
||||||
file.prepared = `${index}.image`; file.filename = result.filename; delete file.error;
|
await assertZipDiskSpace(result.bytes.length);
|
||||||
} else file.error = result.error;
|
await writeFile(path, result.bytes, { mode: 0o600 });
|
||||||
task!.processedCount = task!.files.filter(f => f.prepared || f.error).length;
|
file.prepared = `${index}.image`; file.filename = result.filename; delete file.error;
|
||||||
task!.successCount = task!.files.filter(f => f.prepared).length;
|
} else file.error = result.error;
|
||||||
task!.failedCount = task!.files.filter(f => f.error).length;
|
task!.processedCount = task!.files.filter(f => f.prepared || f.error).length;
|
||||||
task!.stage = `正在准备原图 ${task!.processedCount}/${task!.totalCount}`;
|
task!.successCount = task!.files.filter(f => f.prepared).length;
|
||||||
task!.updatedAt = Date.now(); await persist();
|
task!.failedCount = task!.files.filter(f => f.error).length;
|
||||||
}
|
task!.stage = `正在准备原图 ${task!.processedCount}/${task!.totalCount}`;
|
||||||
}));
|
task!.updatedAt = Date.now(); await persist();
|
||||||
const rejected = results.find(result => result.status === "rejected");
|
}
|
||||||
if (rejected?.status === "rejected") throw rejected.reason;
|
}));
|
||||||
if (!task.successCount) throw new Error("所选原图暂时都无法读取,请稍后重试;画布图片不会被删除");
|
const rejected = results.find(result => result.status === "rejected");
|
||||||
const zip = new JSZip(); const folder = zip.folder(task.folderName) || zip;
|
if (rejected?.status === "rejected") throw rejected.reason;
|
||||||
for (const file of task.files) if (file.prepared && file.filename)
|
if (!task.successCount) throw new Error("所选原图暂时都无法读取,请稍后重试;画布图片不会被删除");
|
||||||
folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared))
|
const zip = new JSZip(); const folder = zip.folder(task.folderName) || zip;
|
||||||
.once("close", () => { void rm(join(zipTaskPath(id), file.prepared!), { force: true }).catch(() => undefined); }));
|
for (const file of task.files) if (file.prepared && file.filename)
|
||||||
if (task.failedCount) folder.file("_download-failures.txt", task.files.filter(f => f.error)
|
folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared))
|
||||||
.map(f => `${f.name || "图片"}:${f.error}`).join("\n"));
|
.once("close", () => { void rm(join(zipTaskPath(id), file.prepared!), { force: true }).catch(() => undefined); }));
|
||||||
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
|
if (task.failedCount) folder.file("_download-failures.txt", task.files.filter(f => f.error)
|
||||||
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
|
.map(f => `${f.name || "图片"}:${f.error}`).join("\n"));
|
||||||
task.contentDisposition = buildContentDisposition(task.folderName);
|
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
|
||||||
|
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
|
||||||
|
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 => {
|
||||||
if (cdn) {
|
task!.stage = `正在传送至七牛 ${Math.round(bytes / task!.zipFile!.bytes * 100)}%`;
|
||||||
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
|
task!.updatedAt = Date.now(); await saveZipTask(task!);
|
||||||
if (cdn.url) task.cdnUrl = cdn.url;
|
} });
|
||||||
}
|
if (cdn) {
|
||||||
} catch { console.warn("[CanvasDownloadZipTask] CDN delivery unavailable; complete local ZIP retained", { taskId: id }); }
|
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
|
||||||
|
if (!cdn.url) throw new Error(cdn.error || `ZIP 已打包,但加速下载未就绪,请再次点击下载继续传送原 ZIP。下载编号:${task.id}`);
|
||||||
|
task.cdnUrl = cdn.url;
|
||||||
|
}
|
||||||
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);
|
||||||
for (const file of task.files) delete file.error;
|
if (!task.zipFile) {
|
||||||
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length;
|
for (const file of task.files) delete file.error;
|
||||||
task.failedCount = 0; task.updatedAt = Date.now(); task.expiresAt = Date.now() + 6 * TASK_TTL_MS;
|
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length;
|
||||||
|
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);
|
||||||
|
|||||||
@ -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() }),
|
||||||
if (!externalCanvasConflictNotifiedRef.current) {
|
remoteSnapshot,
|
||||||
externalCanvasConflictNotifiedRef.current = true;
|
);
|
||||||
toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑");
|
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 (canvasPersistenceScope.current !== id) return false;
|
||||||
|
} else {
|
||||||
|
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 = () => {
|
||||||
|
if (canvasLeaveApprovedRef.current || !hasUnsavedCanvasChanges()) return null;
|
||||||
|
const canvasData = buildPersistedCanvasData();
|
||||||
|
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current!.snapshot, snapshot: JSON.stringify({ canvasData }) });
|
||||||
|
return canvasData;
|
||||||
|
};
|
||||||
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
if (!retainDraft()) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = "true";
|
||||||
|
};
|
||||||
|
const handlePageHide = () => {
|
||||||
try {
|
try {
|
||||||
if (isAgentOperationRefreshActive()) return;
|
const canvasData = retainDraft();
|
||||||
const canvasData = buildPersistedCanvasData();
|
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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
76
components/app-update-notice.tsx
Normal file
76
components/app-update-notice.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@ -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();
|
||||||
store.loadCanvas(result.data.canvasData);
|
if (applyCanvasRemoteSnapshot) {
|
||||||
store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
|
if (!applyCanvasRemoteSnapshot({ projectId: targetProjectId, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt || "") })) return false;
|
||||||
|
} else {
|
||||||
|
store.loadCanvas(result.data.canvasData);
|
||||||
|
store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
|
||||||
|
}
|
||||||
if (createdNodeIds.length > 0) {
|
if (createdNodeIds.length > 0) {
|
||||||
const refreshedStore = useCanvasStore.getState();
|
const refreshedStore = useCanvasStore.getState();
|
||||||
refreshedStore.selectNodes(createdNodeIds);
|
refreshedStore.selectNodes(createdNodeIds);
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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秒商品展示视频"
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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 小时。
|
||||||
|
|||||||
@ -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") {
|
||||||
|
|||||||
55
lib/client/canvas-draft.ts
Normal file
55
lib/client/canvas-draft.ts
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) };
|
||||||
}
|
}
|
||||||
|
|||||||
15
lib/client/page-refresh.ts
Normal file
15
lib/client/page-refresh.ts
Normal 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();
|
||||||
|
}
|
||||||
@ -293,7 +293,11 @@ export function getRequestFailureDetails(
|
|||||||
if (/AOS结果下载超时/.test(text)) {
|
if (/AOS结果下载超时/.test(text)) {
|
||||||
return detail("RMBG_RESULT_DOWNLOAD_TIMEOUT", "抠图已完成,但结果图片下载超时。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown");
|
return detail("RMBG_RESULT_DOWNLOAD_TIMEOUT", "抠图已完成,但结果图片下载超时。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown");
|
||||||
}
|
}
|
||||||
if ((isRecord(error) && (error.providerOutcomeUnknown === true || ["AbortError", "TimeoutError"].includes(String(error.name)))) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running|operation was aborted/i.test(text)) {
|
const errorName = isRecord(error) ? String(error.name || "") : "";
|
||||||
|
if (["AbortError", "TimeoutError"].includes(errorName) || /operation was aborted/i.test(text)) {
|
||||||
|
return detail("RESULT_UNCONFIRMED", errorName === "TimeoutError" ? "请求等待超时,任务结果尚未确认。" : "本次请求连接已中止,任务结果尚未确认。", "请先核对原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");
|
||||||
|
}
|
||||||
|
if ((isRecord(error) && error.providerOutcomeUnknown === true) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running/i.test(text)) {
|
||||||
return detail("RESULT_UNCONFIRMED", "任务结果尚未确认,可能仍在处理。", "请先查看原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");
|
return detail("RESULT_UNCONFIRMED", "任务结果尚未确认,可能仍在处理。", "请先查看原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");
|
||||||
}
|
}
|
||||||
if (isProviderBalanceError(error)) {
|
if (isProviderBalanceError(error)) {
|
||||||
|
|||||||
@ -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 }) {
|
||||||
|
|||||||
@ -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) };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"],
|
||||||
|
|||||||
5
proxy.ts
5
proxy.ts
@ -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();
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
624
tests/e2e/canvas-autosync.test.ts
Normal file
624
tests/e2e/canvas-autosync.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
198
tests/e2e/canvas-chat-scroll.test.ts
Normal file
198
tests/e2e/canvas-chat-scroll.test.ts
Normal 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\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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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});}
|
||||||
|
});
|
||||||
|
|||||||
@ -81,6 +81,9 @@ test("aborts and unconfirmed fallback results cannot start another paid generati
|
|||||||
expect(health.shouldFailoverAiModelError(error)).toBe(false);
|
expect(health.shouldFailoverAiModelError(error)).toBe(false);
|
||||||
}
|
}
|
||||||
expect(health.shouldFailoverAiModelError(new Error("Service unavailable (HTTP 503)"))).toBe(true);
|
expect(health.shouldFailoverAiModelError(new Error("Service unavailable (HTTP 503)"))).toBe(true);
|
||||||
|
expect(getRequestFailureDetails(Object.assign(new Error("This operation was aborted"), { name: "AbortError" })).message).toContain("连接已中止");
|
||||||
|
expect(getRequestFailureDetails("This operation was aborted").message).toContain("连接已中止");
|
||||||
|
expect(getRequestFailureDetails(Object.assign(new Error("timed out"), { name: "TimeoutError" })).message).toContain("等待超时");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the provider submits once when an HTTP 524 request cannot be located", async () => {
|
test("the provider submits once when an HTTP 524 request cannot be located", async () => {
|
||||||
|
|||||||
@ -53,6 +53,7 @@ test("an unconfirmed paid tool stops the planner and immediately preserves the r
|
|||||||
expect(submissions).toBe(1);
|
expect(submissions).toBe(1);
|
||||||
expect(emitted).toEqual([]);
|
expect(emitted).toEqual([]);
|
||||||
expect(snapshot.message).toContain("结果尚未确认");
|
expect(snapshot.message).toContain("结果尚未确认");
|
||||||
|
expect(snapshot.message).toContain("连接已中止");
|
||||||
expect(snapshot.message).toContain("不要换模型、新建对话或重新提交");
|
expect(snapshot.message).toContain("不要换模型、新建对话或重新提交");
|
||||||
expect(snapshot.message).toContain(ports.runId);
|
expect(snapshot.message).toContain(ports.runId);
|
||||||
ports.native = async () => { throw new Error("must not start a new attempt"); };
|
ports.native = async () => { throw new Error("must not start a new attempt"); };
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user