修复画布自动同步并添加新版安全刷新提示 #52
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" },
|
||||
});
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
|
||||
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 } from "@/components/canvas/CanvasRemoteOperationContext";
|
||||
import {
|
||||
AlertCircle,
|
||||
@ -1929,6 +1932,11 @@ export default function CanvasEditorLegacyPage({
|
||||
() => !initialProjectForCurrentRoute
|
||||
);
|
||||
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 [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [isAddImageDialogOpen, setIsAddImageDialogOpen] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
@ -3365,6 +3373,28 @@ export default function CanvasEditorLegacyPage({
|
||||
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;
|
||||
const draft = readCanvasDraft(id);
|
||||
if (draft) {
|
||||
const merged = mergeCanvasSnapshots(draft.base, draft.snapshot, base);
|
||||
if (merged.conflicts.length) {
|
||||
externalCanvasConflictRef.current = true;
|
||||
useCanvasStore.getState().loadCanvas(JSON.parse(draft.snapshot).canvasData);
|
||||
toast.warning("同一画布内容在多处被修改,本地草稿已恢复;请保留此页并联系管理员核对冲突。");
|
||||
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(
|
||||
(project: CanvasInitialProject) => {
|
||||
externalCanvasConflictRef.current = false;
|
||||
@ -3374,9 +3404,9 @@ export default function CanvasEditorLegacyPage({
|
||||
useCanvasStore
|
||||
.getState()
|
||||
.setCanvasRevision(projectUpdatedAt, projectUpdatedAt);
|
||||
loadPersistedCanvasState(project.canvasData);
|
||||
|
||||
if (project.canvasData) {
|
||||
useCanvasStore.getState().loadCanvas(project.canvasData);
|
||||
const persistedWorkflow = project.canvasData.workflowState;
|
||||
if (!requestedAgentMode && persistedWorkflow) {
|
||||
const nextAppearanceRecognitionEnabled =
|
||||
@ -3479,7 +3509,7 @@ export default function CanvasEditorLegacyPage({
|
||||
|
||||
setWorkflowHydrationSource("empty");
|
||||
},
|
||||
[id, requestedAgentMode]
|
||||
[id, requestedAgentMode, loadPersistedCanvasState]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@ -3487,6 +3517,7 @@ export default function CanvasEditorLegacyPage({
|
||||
if (lastLoadedCanvasIdRef.current === id) return;
|
||||
|
||||
hydrateCanvasProject(initialProjectForCurrentRoute);
|
||||
canvasReadyRef.current = true;
|
||||
setIsLoading(false);
|
||||
lastLoadedCanvasIdRef.current = id;
|
||||
}, [hydrateCanvasProject, id, initialProjectForCurrentRoute]);
|
||||
@ -3494,6 +3525,7 @@ export default function CanvasEditorLegacyPage({
|
||||
// 加载画布数据
|
||||
useEffect(() => {
|
||||
console.log("[Canvas] Loading project:", id);
|
||||
let cancelled = false;
|
||||
|
||||
const loadProject = async () => {
|
||||
if (lastLoadedCanvasIdRef.current === id) {
|
||||
@ -3502,6 +3534,8 @@ export default function CanvasEditorLegacyPage({
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
canvasReadyRef.current = false;
|
||||
setCanvasLoadError("");
|
||||
if (!requestedAgentMode) {
|
||||
setWorkflowHydrationSource("pending");
|
||||
}
|
||||
@ -3534,18 +3568,16 @@ export default function CanvasEditorLegacyPage({
|
||||
setLoadedAgentProjectId(null);
|
||||
}
|
||||
|
||||
// 🆕 先清空当前画布状态,确保切换时不会显示旧数据
|
||||
useCanvasStore.getState().clearCanvas();
|
||||
|
||||
const result = await canvasService.getProject(id);
|
||||
if (cancelled) return;
|
||||
|
||||
if (result.success && result.data) {
|
||||
useCanvasStore.getState().setProject(id, result.data.title);
|
||||
useCanvasStore
|
||||
.getState()
|
||||
.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
|
||||
loadPersistedCanvasState(result.data.canvasData);
|
||||
if (result.data.canvasData) {
|
||||
useCanvasStore.getState().loadCanvas(result.data.canvasData);
|
||||
const persistedWorkflow = result.data.canvasData.workflowState;
|
||||
if (!requestedAgentMode && persistedWorkflow) {
|
||||
const nextAppearanceRecognitionEnabled =
|
||||
@ -3662,8 +3694,15 @@ export default function CanvasEditorLegacyPage({
|
||||
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);
|
||||
useCanvasStore.getState().clearCanvas();
|
||||
useCanvasStore
|
||||
.getState()
|
||||
.setProject(
|
||||
@ -3690,15 +3729,20 @@ export default function CanvasEditorLegacyPage({
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
canvasReadyRef.current = true;
|
||||
setIsLoading(false);
|
||||
lastLoadedCanvasIdRef.current = id;
|
||||
};
|
||||
|
||||
loadProject();
|
||||
return () => { cancelled = true; };
|
||||
}, [
|
||||
activeCanvasWorkspaceType,
|
||||
defaultCanvasTitle,
|
||||
id,
|
||||
canvasLoadAttempt,
|
||||
loadPersistedCanvasState,
|
||||
openLoginDialog,
|
||||
requestedAgentMode,
|
||||
]);
|
||||
@ -5074,12 +5118,18 @@ export default function CanvasEditorLegacyPage({
|
||||
canvasPersistenceScope.current = id;
|
||||
const persistCanvasStateNow = useCallback(
|
||||
async (reason: "autosave" | "delete" | "beforeunload") => {
|
||||
if (canvasPersistenceScope.current !== id) return false;
|
||||
if (canvasPersistenceScope.current !== id || !canvasReadyRef.current || useCanvasStore.getState().projectId !== id) return false;
|
||||
if (externalCanvasConflictRef.current) return false;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const canvasData = buildPersistedCanvasData();
|
||||
let canvasData = buildPersistedCanvasData();
|
||||
let snapshot = JSON.stringify({ canvasData });
|
||||
if (savedCanvasSnapshotRef.current?.id === id && savedCanvasSnapshotRef.current.snapshot === snapshot) {
|
||||
clearCanvasDraft(id);
|
||||
return true;
|
||||
}
|
||||
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
||||
const result = await canvasService.saveCanvasState(
|
||||
let result = await canvasService.saveCanvasState(
|
||||
id,
|
||||
canvasData,
|
||||
activeCanvasWorkspaceType,
|
||||
@ -5089,23 +5139,48 @@ export default function CanvasEditorLegacyPage({
|
||||
}
|
||||
);
|
||||
if (canvasPersistenceScope.current !== id) return false;
|
||||
if (!result.success) {
|
||||
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
||||
if (result.status === 409) {
|
||||
externalCanvasConflictRef.current = true;
|
||||
console.warn("[Canvas] stale save revision handled without user prompt", {
|
||||
reason,
|
||||
canvasUpdatedAt: result.canvasUpdatedAt,
|
||||
});
|
||||
if (!externalCanvasConflictNotifiedRef.current) {
|
||||
externalCanvasConflictNotifiedRef.current = true;
|
||||
toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑");
|
||||
if (result.status === 409 && savedCanvasSnapshotRef.current?.id === id) {
|
||||
const remote = await canvasService.getProject(id);
|
||||
if (canvasPersistenceScope.current !== id) return false;
|
||||
if (remote.success && remote.data?.canvasData) {
|
||||
const remoteSnapshot = JSON.stringify({ canvasData: remote.data.canvasData });
|
||||
const merged = mergeCanvasSnapshots(
|
||||
savedCanvasSnapshotRef.current.snapshot,
|
||||
JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
||||
remoteSnapshot,
|
||||
);
|
||||
if (!merged.conflicts.length) {
|
||||
canvasData = JSON.parse(merged.snapshot).canvasData;
|
||||
snapshot = merged.snapshot;
|
||||
savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot };
|
||||
useCanvasStore.getState().loadCanvas(canvasData);
|
||||
const revision = String(remote.data.updatedAt);
|
||||
useCanvasStore.getState().setCanvasRevision(revision, revision);
|
||||
result = await canvasService.saveCanvasState(id, canvasData, activeCanvasWorkspaceType, {
|
||||
baseCanvasRevision: revision, baseCanvasUpdatedAt: revision,
|
||||
});
|
||||
if (canvasPersistenceScope.current !== id) return false;
|
||||
} else {
|
||||
externalCanvasConflictRef.current = true;
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
externalCanvasConflictNotifiedRef.current = false;
|
||||
useCanvasStore
|
||||
if (result.data?.updatedAt) useCanvasStore
|
||||
.getState()
|
||||
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
||||
}
|
||||
@ -5144,14 +5219,42 @@ export default function CanvasEditorLegacyPage({
|
||||
[persistCanvasStateNow]
|
||||
);
|
||||
|
||||
const persistCanvasBeforeAgent = useCallback(() => {
|
||||
const persistCanvasBeforeAgent = useCallback(async () => {
|
||||
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || externalCanvasConflictRef.current)
|
||||
throw new Error(canvasSyncErrorRef.current || "画布尚未完成同步,本次任务未提交,请保留当前页面并重试同步。");
|
||||
if (savedCanvasSnapshotRef.current?.id === id) {
|
||||
const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData;
|
||||
const current = buildPersistedCanvasData();
|
||||
if (JSON.stringify(saved.nodes) === JSON.stringify(current.nodes) && JSON.stringify(saved.edges) === JSON.stringify(current.edges)) return true;
|
||||
}
|
||||
// Flush the pending local edit once instead of racing its scheduled autosave.
|
||||
if (canvasAutosaveTimer.current) {
|
||||
clearTimeout(canvasAutosaveTimer.current);
|
||||
canvasAutosaveTimer.current = null;
|
||||
}
|
||||
return persistCanvasState("autosave");
|
||||
}, [persistCanvasState]);
|
||||
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current || "画布尚未完成加载,本次任务未提交;请等待加载完成后再次发送。");
|
||||
return true;
|
||||
}, [persistCanvasState, buildPersistedCanvasData, id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVideoMode) return;
|
||||
const beforeRefresh = (event: Event) => {
|
||||
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
||||
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(() => {
|
||||
@ -5281,8 +5384,12 @@ export default function CanvasEditorLegacyPage({
|
||||
|
||||
const handleBeforeUnload = () => {
|
||||
try {
|
||||
if (isAgentOperationRefreshActive()) return;
|
||||
if (!canvasReadyRef.current || isAgentOperationRefreshActive()) return;
|
||||
const canvasData = buildPersistedCanvasData();
|
||||
writeCanvasDraft(id, {
|
||||
base: savedCanvasSnapshotRef.current?.id === id ? savedCanvasSnapshotRef.current.snapshot : JSON.stringify({ canvasData: { nodes: [], edges: [] } }),
|
||||
snapshot: JSON.stringify({ canvasData }),
|
||||
});
|
||||
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
||||
void fetch(`/api/canvas/${id}`, {
|
||||
method: "PUT",
|
||||
@ -5396,21 +5503,12 @@ export default function CanvasEditorLegacyPage({
|
||||
|
||||
// 保存画布 (手动触发保留作为备用,但UI上移除)
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
const canvasData = buildPersistedCanvasData();
|
||||
const result = await canvasService.saveCanvasState(
|
||||
id,
|
||||
canvasData,
|
||||
activeCanvasWorkspaceType
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
if (await persistCanvasState("autosave")) {
|
||||
toast.success("保存成功");
|
||||
} else {
|
||||
toast.error(result.error || "保存失败");
|
||||
toast.error(canvasSyncErrorRef.current || "画布同步失败,请检查网络后重试");
|
||||
}
|
||||
setIsSaving(false);
|
||||
}, [activeCanvasWorkspaceType, buildPersistedCanvasData, id]);
|
||||
}, [persistCanvasState]);
|
||||
|
||||
// 更新标题
|
||||
const handleUpdateTitle = async () => {
|
||||
@ -10204,6 +10302,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 (!hasActiveAgentProject) {
|
||||
return (
|
||||
|
||||
@ -3,6 +3,7 @@ import { Toaster } from "sonner";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { FaviconUpdater } from "@/components/favicon-updater";
|
||||
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
|
||||
import { AppUpdateNotice } from "@/components/app-update-notice";
|
||||
import { getSiteSettings } from "@/lib/db/queries";
|
||||
|
||||
import "./globals.css";
|
||||
@ -215,6 +216,7 @@ export default function RootLayout({
|
||||
enableSystem
|
||||
>
|
||||
<Toaster position="top-center" />
|
||||
<AppUpdateNotice />
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
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 { getSceneRunResult } from "@/lib/agents/scene-run-recovery";
|
||||
import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
|
||||
@ -4879,6 +4880,14 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
return recoverReplacementRun({ messages, ...pendingGeneration });
|
||||
}, [messages, pendingGeneration]);
|
||||
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(() => {
|
||||
if (!pendingGeneration) {
|
||||
return 0;
|
||||
|
||||
67
components/app-update-notice.tsx
Normal file
67
components/app-update-notice.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
"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(() => {
|
||||
const controller = new AbortController();
|
||||
let checking = false;
|
||||
const check = async () => {
|
||||
if (checking || document.visibilityState !== "visible") return;
|
||||
checking = true;
|
||||
try {
|
||||
const response = await fetch("/api/app-version", { cache: "no-store", signal: AbortSignal.any([controller.signal, AbortSignal.timeout(8000)]) });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (!controller.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 {
|
||||
checking = false;
|
||||
}
|
||||
};
|
||||
void check();
|
||||
const interval = window.setInterval(check, 60_000);
|
||||
window.addEventListener("focus", check);
|
||||
document.addEventListener("visibilitychange", check);
|
||||
return () => {
|
||||
controller.abort();
|
||||
clearInterval(interval);
|
||||
window.removeEventListener("focus", 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>
|
||||
<Button disabled={refreshing} onClick={() => void refresh()}>{refreshing ? "正在同步内容…" : "同步并刷新"}</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>;
|
||||
}
|
||||
@ -5,6 +5,9 @@ import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
|
||||
import { CanvasAgentProgressMessage } from "@/components/canvas/chat-panel/canvas-agent-progress";
|
||||
import { useCanvasRemoteOperation } 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 { useCanvasAgentRun } from "@/components/canvas/chat-panel/use-canvas-agent-run";
|
||||
import { canvasAgentProgress, type CanvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
|
||||
@ -3243,6 +3246,7 @@ export function CanvasChatPanel({
|
||||
onRequireLogin,
|
||||
onBeforeAgentRequest,
|
||||
}: CanvasChatPanelProps) {
|
||||
const runCanvasRemoteOperation = useCanvasRemoteOperation();
|
||||
const isWorkspacePresentation = presentation === "workspace";
|
||||
const isVideoWorkspace = workspaceVariant === "video";
|
||||
const propCanvasToolbarConfig = useMemo(
|
||||
@ -3371,7 +3375,7 @@ export function CanvasChatPanel({
|
||||
const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const [clawdTableFiles, setClawdTableFiles] = useState<File[]>([]);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const detailModeSwitchRef = useRef(false);
|
||||
const hiddenPromptRef = useRef<string | null>(null);
|
||||
const autoContextRef = useRef<string | null>(null);
|
||||
@ -5030,6 +5034,15 @@ export function CanvasChatPanel({
|
||||
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 payload = getUserMessageCopyPayload(message);
|
||||
if (!payload.text && payload.attachments.length === 0) {
|
||||
@ -8957,6 +8970,21 @@ export function CanvasChatPanel({
|
||||
});
|
||||
const activeCanvasAgentRun = canvasAgentTask.run;
|
||||
const isCanvasAgentRunning = activeCanvasAgentRun?.status === "running";
|
||||
useEffect(() => {
|
||||
const beforeRefresh = (event: Event) => {
|
||||
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
||||
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("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
|
||||
});
|
||||
};
|
||||
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||
}, [activeChatId, input, attachments, isCanvasAgentRunning, isClawdSubmitting, isDetailGenerating, status]);
|
||||
|
||||
const restorePendingClawdRequestToComposer = useCallback(
|
||||
(snapshot: PendingClawdRequestRestoreSnapshot) => {
|
||||
@ -9153,6 +9181,20 @@ export function CanvasChatPanel({
|
||||
}
|
||||
}, [activeChatId, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
const draft = readCanvasComposerDraft<PickedObjectAttachment>(activeChatId);
|
||||
if (!draft || (input && input !== draft.text)) return;
|
||||
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 (historyScrollTimerRef.current) {
|
||||
clearTimeout(historyScrollTimerRef.current);
|
||||
@ -13355,6 +13397,25 @@ export function CanvasChatPanel({
|
||||
inFlightChatIdRef.current = activeChatId;
|
||||
inFlightRawChatIdRef.current = rawChatId;
|
||||
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);
|
||||
clearAgentProgressStateForChat(activeChatId);
|
||||
if (shouldShowOptimisticImageGeneration) {
|
||||
@ -13417,6 +13478,8 @@ export function CanvasChatPanel({
|
||||
}
|
||||
}, [
|
||||
isCanvasAgentRunning,
|
||||
onBeforeAgentRequest,
|
||||
isVideoWorkspace,
|
||||
canvasAgentTask.control,
|
||||
activeChatId,
|
||||
activeComposerMode,
|
||||
@ -13554,23 +13617,19 @@ export function CanvasChatPanel({
|
||||
|
||||
console.log('[CanvasChat] 更新画布:', rawId);
|
||||
|
||||
const updateResponse = await fetch(`/api/canvas/${rawId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
thumbnail: firstImageUrl,
|
||||
}),
|
||||
if (!runCanvasRemoteOperation) return;
|
||||
await runCanvasRemoteOperation(async () => {
|
||||
if (useCanvasStore.getState().projectId !== rawId) return;
|
||||
const result = await canvasService.updateProject(rawId, { title, thumbnail: firstImageUrl });
|
||||
if (!result.success) throw new Error(result.error || "画布名称同步失败");
|
||||
});
|
||||
|
||||
console.log('[CanvasChat] 画布更新响应:', updateResponse.status);
|
||||
} else {
|
||||
console.error('[CanvasChat] auto-title API 请求失败:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CanvasChat] 自动命名失败:', error);
|
||||
}
|
||||
}, [chatId]);
|
||||
}, [chatId, runCanvasRemoteOperation]);
|
||||
|
||||
const addUploadedImageAttachmentToComposer = useCallback(
|
||||
(uploaded: ComposerUploadedImage) => {
|
||||
@ -18548,7 +18607,7 @@ export function CanvasChatPanel({
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={textareaRef as any}
|
||||
ref={setComposerEditorRef}
|
||||
contentEditable={!isSubmitting || (isCanvasAgentRunning && !canvasAgentTask.stopPending)}
|
||||
role="textbox"
|
||||
tabIndex={isSubmitting ? -1 : 0}
|
||||
@ -18582,6 +18641,7 @@ export function CanvasChatPanel({
|
||||
setTagPreview(null);
|
||||
}}
|
||||
onInput={(e) => {
|
||||
clearCanvasComposerDraft(activeChatId);
|
||||
// 从 contenteditable 中提取纯文本(排除附件标签文本)
|
||||
const target = e.target as HTMLDivElement;
|
||||
const clone = target.cloneNode(true) as HTMLDivElement;
|
||||
|
||||
@ -6,6 +6,8 @@ import { ArrowUp, MessageSquare, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
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 {
|
||||
videoAgentSettingsSchema,
|
||||
type VideoAgentSettings,
|
||||
@ -94,6 +96,22 @@ export function VideoCanvasAgentPanel(props: {
|
||||
};
|
||||
}, [props.projectId, setMessages]);
|
||||
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(
|
||||
async (resumeRunId?: string) => {
|
||||
if (busy || loading) return;
|
||||
@ -105,6 +123,7 @@ export function VideoCanvasAgentPanel(props: {
|
||||
try {
|
||||
await latest.current.props.beforeSend();
|
||||
if (!mounted.current) return;
|
||||
clearCanvasComposerDraft(`video-agent-${props.projectId}`);
|
||||
resumeRef.current = resumeRunId;
|
||||
setInput("");
|
||||
await sendMessage({ text });
|
||||
@ -298,7 +317,7 @@ export function VideoCanvasAgentPanel(props: {
|
||||
<textarea
|
||||
aria-label="视频创作要求"
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onChange={(event) => { clearCanvasComposerDraft(`video-agent-${props.projectId}`); setInput(event.target.value); }}
|
||||
maxLength={2000}
|
||||
rows={3}
|
||||
placeholder="例如:先分析选中素材,写分镜,再生成一条5秒商品展示视频"
|
||||
|
||||
@ -29,10 +29,10 @@ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> & { overlayClassName?: string }
|
||||
>(({ className, overlayClassName, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogOverlay className={overlayClassName} />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
||||
@ -92,6 +92,7 @@ public 先从当前运行版本创建写入时复制副本,保留未纳入 Git
|
||||
|
||||
原生产 checkout 的支线及未提交改动仍保留,不能据其 Git HEAD 判断已发布版本。
|
||||
实际发布证据是:运行 cwd、`.next/BUILD_ID`、该目录 `release.json` 和 `activation.json`。
|
||||
`next.config.ts` 将本流程的发布目录编号写入前后端版本标识;`/api/app-version` 返回该编号且不缓存,发布烟测会核对它与运行目录一致。网页每分钟及切回页面时检查更新,发现版本变化后提示同步并刷新;画布同步失败或当前任务仍在处理时保留页面。首次接入前已经打开的旧网页需先刷新一次,后续发布即可自动收到提示。
|
||||
维护电脑的 `.git/releases/<发布ID>/` 保留提交、构建日志、烟测、传输校验和及操作回执。
|
||||
服务器回滚配置可能含环境元数据,目录权限为 0700、文件为 0600,不放入公开目录或 Git。
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 {
|
||||
CanvasRevisionConflict,
|
||||
@ -149,6 +150,16 @@ export function useVideoCanvasPersistence(
|
||||
return !queue.dirty && !queue.failed;
|
||||
}, [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(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
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.
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
@ -1,4 +1,8 @@
|
||||
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() {
|
||||
const rawUrl = (
|
||||
@ -45,6 +49,7 @@ function resolveDevMediaOrigins(isDev: boolean) {
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
env: { NEXT_PUBLIC_APP_RELEASE: appRelease },
|
||||
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
|
||||
poweredByHeader: false,
|
||||
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;
|
||||
|
||||
return [
|
||||
"/api/app-version",
|
||||
"/api/ai-wash/config",
|
||||
"/api/models",
|
||||
"/api/canvas",
|
||||
@ -816,6 +817,10 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (pathname === "/api/app-version" && (request.method === "GET" || request.method === "HEAD")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Pascal 服务到服务调用由对应 Route 再次验证共享密钥。
|
||||
if (isPascalInternalProxyRequest(request)) {
|
||||
return NextResponse.next();
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { basename, dirname } from "node:path";
|
||||
|
||||
const DEFAULT_BASE_URL = `http://127.0.0.1:${process.env.PORT || "3001"}`;
|
||||
const BASE_URL = stripTrailingSlash(
|
||||
process.env.SMOKE_BASE_URL || process.argv[2] || DEFAULT_BASE_URL
|
||||
);
|
||||
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 = [
|
||||
{ path: "/api/app-version", method: "GET", expect: { status: [200] } },
|
||||
{
|
||||
path: "/",
|
||||
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 (
|
||||
check.expect.location &&
|
||||
!matchesLocation(location, check.expect.location)
|
||||
|
||||
@ -204,9 +204,9 @@ export const canvasService = {
|
||||
try {
|
||||
const response = await fetch(`/api/canvas/${id}`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
return { ...data, status: response.status };
|
||||
} catch (error) {
|
||||
return { success: false, error: '获取画布失败' };
|
||||
return { success: false, error: '画布读取失败,请检查网络后重试;现有内容未被覆盖', status: 0 };
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
273
tests/e2e/canvas-autosync.test.ts
Normal file
273
tests/e2e/canvas-autosync.test.ts
Normal file
@ -0,0 +1,273 @@
|
||||
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[],
|
||||
appVersion: "development", holdRequest: false, finishRequest: null as (() => void) | null,
|
||||
};
|
||||
const revision = () => new Date(Date.UTC(2026, 10, 6, 0, 0, state.revision)).toISOString();
|
||||
const project = () => ({ id: projectId, title: "同步验证", canvasData: state.canvasData, 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 === `/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/agent-chat" && request.method() === "POST") {
|
||||
state.submissions.push(request.postDataJSON());
|
||||
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\ndata: {"type":"text-start","id":"text"}\n\ndata: {"type":"text-delta","id":"text","delta":"已收到请求"}\n\ndata: {"type":"text-end","id":"text"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n' });
|
||||
return;
|
||||
} 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();
|
||||
}
|
||||
|
||||
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([]);
|
||||
});
|
||||
|
||||
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("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");
|
||||
expect(refreshes()).toBe(0);
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
state.failSave = false;
|
||||
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([]);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user