diff --git a/app/api/app-version/route.ts b/app/api/app-version/route.ts new file mode 100644 index 000000000..6e1f0c0c3 --- /dev/null +++ b/app/api/app-version/route.ts @@ -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" }, + }); +} diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index d4131dc95..1fb22fb55 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -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).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
+

{canvasLoadError}

+ + {loginDialogElement} +
; + } + if (isAgentMode) { if (!hasActiveAgentProject) { return ( diff --git a/app/layout.tsx b/app/layout.tsx index aca22ee6f..88e1262f3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 > + {children} diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index b4fddada6..5baac4e47 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -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).detail.block("当前生图任务仍在处理中,请等待结果返回后再刷新。原任务无需重新提交。"); + }; + window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh); + return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh); + }, [transportIsGenerating, pendingGeneration]); const pendingGenerationProgressCount = useMemo(() => { if (!pendingGeneration) { return 0; diff --git a/components/app-update-notice.tsx b/components/app-update-notice.tsx new file mode 100644 index 000000000..b9645e40b --- /dev/null +++ b/components/app-update-notice.tsx @@ -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 + event.preventDefault()}> + + 平台已更新 + 需要刷新页面后继续使用。点击后会先同步当前画布并保留输入;正在处理的任务完成后再刷新。 + + {error ?

{error}

: null} + + + +
+
; +} diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 12211f1b6..346e94a8a 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -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>([]); const detailSlotInputRefs = useRef>([]); const [clawdTableFiles, setClawdTableFiles] = useState([]); - const textareaRef = useRef(null); + const textareaRef = useRef(null); const detailModeSwitchRef = useRef(false); const hiddenPromptRef = useRef(null); const autoContextRef = useRef(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(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).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(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({ )} >
{ + clearCanvasComposerDraft(activeChatId); // 从 contenteditable 中提取纯文本(排除附件标签文本) const target = e.target as HTMLDivElement; const clone = target.cloneNode(true) as HTMLDivElement; diff --git a/components/canvas/chat-panel/video-canvas-agent-panel.tsx b/components/canvas/chat-panel/video-canvas-agent-panel.tsx index 36e3049b9..162fdadb0 100644 --- a/components/canvas/chat-panel/video-canvas-agent-panel.tsx +++ b/components/canvas/chat-panel/video-canvas-agent-panel.tsx @@ -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).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: {