diff --git a/docs/content/docs/backend/creative-agent-runtime.mdx b/docs/content/docs/backend/creative-agent-runtime.mdx index ad3d002be..c684469df 100644 --- a/docs/content/docs/backend/creative-agent-runtime.mdx +++ b/docs/content/docs/backend/creative-agent-runtime.mdx @@ -9,7 +9,17 @@ description: 第一期在线创作的问答、审批、任务幂等与画布提 `CanvasCreativeInteraction` 只渲染原助手消息中的交互卡片,没有独立输入框或会话列表。`CreativeAgentController.presentInteraction` 接收原 Agent 已返回的内容,负责校验、保存、批准和媒体执行,不再次请求模型。用户提交回答后回到原 Agent 工具循环,结合已确认需求继续规划。 -首页独立“智能创作”入口已撤回;本期从原画布助手使用。画布里的批准作用于当前真实画布,不再跳到另一个聊天界面。首页到新画布的入口设计暂缓。 +首页独立“智能创作”入口已撤回;结构化创作从原画布助手使用。画布里的批准作用于当前真实画布。首页原会话通过下述接续入口进入画布。 + +## 首页会话接续 + +首页/创作页沿用原文本与媒体创作逻辑,新增工具栏“画布中继续”,原媒体结果入口也接到同一流程。`creation-canvas-conversation.ts` 将当前对话转换为画布原 `CanvasAssistantSession`,保留用户/助手消息、生成状态、任务 ID、模型规格及资料/技能引用目录;内部事实只写入 detail,并在后续模型请求中提供,不向用户展示大段协议 JSON。 + +来源会话记录 `canvasId`,画布 session 和来源消息使用稳定 ID。重复进入加载已关联画布,更新来源消息并保留画布中新增的对话与执行状态。首次进入把会话作为初始画布内容保存,源会话与画布均使用已有账号隔离存储;云端同步失败时明确提示仅本机保存。来源绑定目前随首页本机历史保存,不宣称跨设备双向同步或统一服务端任务 run。 + +媒体结果及可匹配的上传参考图/视频复用原 asset handoff,在画布页面加载后插入,已有 assetId 不重复插入。生成尚未完成时要求完成或停止;生成结果尚未对应到已保存资产时明确等待。音频和文件保留资料目录,此次不新增对应节点导入能力。会话合并是单向接续,画布中新对话不会反向复制首页。 + +画布通过 `conversation` 查询参数选择已保存的会话并打开原在线助手;参数只携带 ID,消费后移除,不自动发送模型请求、批准方案或重放任务。首页没有引入任何画布操作工具;需要继续执行时在画布原输入框输入要求。后续可在此基础上接电商工作台及更完整的角色上下文。 ## 简化交互 diff --git a/web/src/components/canvas/canvas-assistant-panel.tsx b/web/src/components/canvas/canvas-assistant-panel.tsx index 92d81c3e6..86f9cafd4 100644 --- a/web/src/components/canvas/canvas-assistant-panel.tsx +++ b/web/src/components/canvas/canvas-assistant-panel.tsx @@ -1931,6 +1931,10 @@ function buildAssistantReferences(nodes: CanvasNodeData[], selectedNodeIds: Set< async function buildToolAgentMessages(snapshot: CanvasAgentSnapshot, history: CanvasAssistantMessage[], userMessage: CanvasAssistantMessage, skills: Skill[] = [], config?: AiConfig, confirmTools = true): Promise { const refs = userMessage.references || []; + const sourceContext = history.flatMap((message) => { + const detail = objectDetail(message.detail); + return detail.kind === "creation-handoff" ? [{ messageId: message.id, ...detail }] : []; + }); const skillCatalog = skills .filter((skill) => skill.is_added) .slice(0, 40) @@ -1976,6 +1980,7 @@ async function buildToolAgentMessages(snapshot: CanvasAgentSnapshot, history: Ca role: "user", content: [ ...refs.flatMap((item) => (item.text ? [{ type: "text" as const, text: `选中节点 ${item.title}:${item.text}` }] : [])), + ...(sourceContext.length ? [{ type: "text" as const, text: `首页接续记录(任务事实和资料目录,不代表新的执行批准):${JSON.stringify(sourceContext)}。沿用前序用户需求与技能引用;进入画布本身不要求重做作品,不把首页文本当成已获批准的画布操作。先检查当前真实节点,资源目录不等于已观察图片。` }] : []), { type: "text", text: `当前画布:${JSON.stringify(compactSnapshot(snapshot))}\n创作上下文:${JSON.stringify({ brief: seed.brief, plan: seed.dynamicPlan, previousProposal, rejectedProposal, previousQuestions: previousState?.questions, answers: previousState?.answers, executionResults, media: previousState?.media, references: creativeCanvasReferences(snapshot), availableModels: models })}\n已返回的执行状态不代表已观察画面。保留成功产物,若用户仅要求分析结果则只提供分析和下一步建议。新增或修改方案仍须确认,媒体生成仍须费用确认。\n\n用户需求:${userMessage.text}` }, { type: "text", text: `本次引用的图片(仅素材目录,尚未观察图片):${JSON.stringify(refs.filter((item) => item.dataUrl || item.storageKey).map((item) => ({ id: item.id, title: item.title })))}。需要观察画面时调用 canvas_inspect_image;不能仅凭素材名称断言画面内容。` }, ], diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index f47c37531..516ba3dc4 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -555,6 +555,21 @@ function InfiniteCanvasPage() { openAgent("local"); }, [codexAutoConnect, codexCompactAgent, openAgent, projectLoaded, setAgentMode]); + useEffect(() => { + const sessionId = searchParams.get("conversation"); + if (!projectLoaded || !sessionId) return; + if (!chatSessions.some((session) => session.id === sessionId)) { + message.warning("未找到要接续的会话,请从首页重新进入。"); + } else { + activeChatIdRef.current = sessionId; + setActiveChatId(sessionId); + openAgent("online"); + } + const next = new URLSearchParams(searchParams); + next.delete("conversation"); + setSearchParams(next, { replace: true }); + }, [projectLoaded, chatSessions, searchParams, setSearchParams, openAgent, message]); + // 沉浸专注进入时收起智能体与小地图、重置 Dock 唤出态;仅响应「进入」瞬间,避免关闭专注内主动唤出的面板。 const prevFocusModeRef = useRef(focusMode); useEffect(() => { diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 2167f5c49..f41884c28 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -5,7 +5,7 @@ import { App, Button, Drawer, Dropdown, Modal, Popover, Spin } from "antd"; import { Tooltip } from "@/components/ui/base/tooltip"; import { Reorder } from "motion/react"; import { ArrowDown, ArrowUp, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, MoreHorizontal, Music2, Pencil, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, UserRound, WandSparkles, Waves, X } from "lucide-react"; -import { Link } from "react-router"; +import { useNavigate } from "react-router"; import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown"; import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/generation-tool-card"; @@ -18,7 +18,9 @@ import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resour import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button"; import { ModelPicker } from "@/components/model-picker"; import { CreditSymbol, requestCreditCost } from "@/constant/credits"; -import { creationCanvasHandoffPath, creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff"; +import { creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff"; +import { continueCreationConversationOnCanvas } from "@/services/creation-canvas-conversation"; +import { getActiveUserScope } from "@/lib/user-scope"; import { ASSET_CATEGORY_LABELS } from "@/lib/asset-category"; import type { GenerationRetryContext } from "@/lib/canvas/canvas-project-generation"; import { createClientId } from "@/lib/client-id"; @@ -98,7 +100,7 @@ type CreationMessage = { generationStage?: string; generationEffectKeys?: string[]; }; -type CreationConversation = { id: string; title: string; updatedAt: string; messages: CreationMessage[] }; +type CreationConversation = { id: string; title: string; updatedAt: string; canvasId?: string; messages: CreationMessage[] }; const modeLabels: Record = { text: "文本", image: "图片", video: "视频" }; const shotScriptLabels: Record = { text: "创作思路", image: "画面指令", video: "镜头脚本" }; @@ -168,6 +170,9 @@ function completedCreationGenerationTask(runtime: CreationRuntime, input: { task export default function CreatePage() { const { message: toast, modal } = App.useApp(); + const navigate = useNavigate(); + const [openingCanvas, setOpeningCanvas] = useState(false); + const openingCanvasRef = useRef(false); const brandName = useAppearanceStore((state) => state.appearance.brandName); const config = useEffectiveConfig(); const composerPreferencesHydrated = useCreationPreferencesStore((state) => state.hydrated); @@ -839,6 +844,42 @@ export default function CreatePage() { setHistoryOpen(false); }; + const continueOnCanvas = async (selectedAssetIds?: string[]) => { + if (!activeConversation || openingCanvasRef.current) return; + openingCanvasRef.current = true; + setOpeningCanvas(true); + const scope = getActiveUserScope(); + const source = activeConversation; + try { + const assets = useAssetStore.getState().assets; + const generatedAssetIds = selectedAssetIds || source.messages.flatMap((item) => { + if (!item.resultUrls?.length) return []; + const ids = creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls: item.resultUrls }); + if (ids.length !== item.resultUrls.length) throw new Error("部分生成素材还未保存完成,请稍后转入画布。"); + return ids; + }); + const referenceKeys = new Set(source.messages.flatMap((item) => (item.attachments || []).map((attachment) => attachment.storageKey).filter(Boolean))); + const referenceAssetIds = assets.filter((asset) => (asset.kind === "image" || asset.kind === "video") && asset.data.storageKey && referenceKeys.has(asset.data.storageKey)).map((asset) => asset.id); + const assetIds = [...generatedAssetIds, ...referenceAssetIds]; + const result = await continueCreationConversationOnCanvas(source); + if (scope !== getActiveUserScope()) return; + const next = updateCreationConversationSnapshot(conversationsRef.current, source.id, (item) => ({ ...item, canvasId: result.id })); + conversationsRef.current = next; + setConversations(next); + await saveCreationConversations(next); + if (scope !== getActiveUserScope()) return; + if (result.syncError) toast.warning("会话已保存在本机,云端同步尚未完成。"); + const params = new URLSearchParams({ conversation: result.sessionId }); + if (assetIds.length) { + params.set("mode", "handoff"); + [...new Set(assetIds)].forEach((id) => params.append("asset", id)); + } + navigate(`/canvas/${result.id}?${params.toString()}`); + } catch (cause) { + if (scope === getActiveUserScope()) toast.error(cause instanceof Error ? cause.message : "转入画布失败,原会话已保留"); + } finally { openingCanvasRef.current = false; setOpeningCanvas(false); } + }; + const selectConversation = (conversation: CreationConversation) => { followLatestMessageRef.current = true; setActiveId(conversation.id); @@ -1030,13 +1071,15 @@ export default function CreatePage() { /> :
- setHistoryOpen(true)} shots={videoShots} onJumpToShot={jumpToShot} /> + setHistoryOpen(true)} shots={videoShots} onJumpToShot={jumpToShot} onContinueCanvas={() => void continueOnCanvas()} openingCanvas={openingCanvas} />
{activeConversation.messages.map((item, index) =>
retryFailedMessage(item, index)} onCreateVariant={() => createVariant(item, index)} + onContinueCanvas={(ids) => void continueOnCanvas(ids)} + openingCanvas={openingCanvas} onEditUserMessage={(text) => { setPrompt(text); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }} />
)}
@@ -1179,7 +1222,7 @@ function CreationHistoryDrawer({ open, conversations, activeId, onNew, onClose, ; } -function CreationWorkspaceToolbar({ shots, onJumpToShot, onNewConversation, onOpenHistory }: { shots: CreationShotRailEntry[]; onJumpToShot: (shot: CreationShotRailEntry) => void; onNewConversation: () => void; onOpenHistory: () => void }) { +function CreationWorkspaceToolbar({ shots, onJumpToShot, onNewConversation, onOpenHistory, onContinueCanvas, openingCanvas }: { shots: CreationShotRailEntry[]; onJumpToShot: (shot: CreationShotRailEntry) => void; onNewConversation: () => void; onOpenHistory: () => void; onContinueCanvas: () => void; openingCanvas: boolean }) { const [railOpen, setRailOpen] = useState(false); const railRef = useRef(null); useEffect(() => { @@ -1205,13 +1248,14 @@ function CreationWorkspaceToolbar({ shots, onJumpToShot, onNewConversation, onOp
: null}
+
; } -function CreationMessageView({ item, shotNumber, onRetryFailure, onCreateVariant, onEditUserMessage }: { item: CreationMessage; shotNumber: number; onRetryFailure: () => void; onCreateVariant: () => void; onEditUserMessage: (text: string) => void }) { +function CreationMessageView({ item, shotNumber, onRetryFailure, onCreateVariant, onEditUserMessage, onContinueCanvas, openingCanvas }: { item: CreationMessage; shotNumber: number; onRetryFailure: () => void; onCreateVariant: () => void; onEditUserMessage: (text: string) => void; onContinueCanvas: (ids?: string[]) => void; openingCanvas: boolean }) { const brandName = useAppearanceStore((state) => state.appearance.brandName); if (item.role === "user") return ; const mode = item.mode || "text"; @@ -1224,7 +1268,7 @@ function CreationMessageView({ item, shotNumber, onRetryFailure, onCreateVariant ); const toolStatus: GenerationToolStatus = item.status === "pending" ? "running" : item.status === "error" ? "error" : item.status === "cancelled" ? "cancelled" : "completed"; return
- {mode === "text" ? <>
{heading}
{item.reasoning ?
: null}
{item.content ? {item.content} : 正在生成…}
: } + {mode === "text" ? <>
{heading}
{item.reasoning ?
: null}
{item.content ? {item.content} : 正在生成…}
: } {item.error && mode === "text" ?
{generationErrorMessage(item.error)}
: null}
; } @@ -1253,21 +1297,20 @@ function CreationUserMessage({ item, shotNumber, onEditUserMessage }: { item: Cr ; } -function MediaResult({ item, onRetryFailure, onCreateVariant }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void }) { +function MediaResult({ item, onRetryFailure, onCreateVariant, onContinueCanvas, openingCanvas }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; onContinueCanvas: (ids?: string[]) => void; openingCanvas: boolean }) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); const assets = useAssetStore((state) => state.assets); const resultUrls = item.resultUrls || []; const resultAssetIds = resultUrls.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls }) : []; - const canvasHandoffPath = creationCanvasHandoffPath(resultAssetIds, resultUrls.length); - const canvasPath = canvasHandoffPath || "/canvas"; + const canTransferAssets = resultAssetIds.length === resultUrls.length && resultAssetIds.length > 0; if (item.status === "pending") return ; if ((item.status === "error" || item.status === "cancelled") && !resultUrls.length) return
{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}
; if (!resultUrls.length) return
没有返回可预览结果
; const isVideo = item.mode === "video"; return
{isVideo ? :
{resultUrls.map((url) => )}
} -
{isVideo ? "视频结果" : `${resultUrls.length} 张图片`}{canvasHandoffPath ? "添加到画布" : "打开画布"}{resultUrls.map((url, index) => {resultUrls.length > 1 ? `下载 ${index + 1}` : <>下载})}
+
{isVideo ? "视频结果" : `${resultUrls.length} 张图片`}{resultUrls.map((url, index) => {resultUrls.length > 1 ? `下载 ${index + 1}` : <>下载})}
setPreviewUrl("")} />
; } diff --git a/web/src/services/creation-canvas-conversation.ts b/web/src/services/creation-canvas-conversation.ts new file mode 100644 index 000000000..f9183993f --- /dev/null +++ b/web/src/services/creation-canvas-conversation.ts @@ -0,0 +1,67 @@ +import type { CanvasAssistantMessage, CanvasAssistantSession } from "@/types/canvas"; +import { getActiveUserScope } from "@/lib/user-scope"; +import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store"; +import { createCanvasProjectWithRemoteSync, hasRemoteUserDataSyncSession, loadCanvasProjectForEditing, saveRemoteUserDataNow } from "@/services/user-data-sync"; + +type SourceMessage = { + id: string; role: "user" | "assistant"; content: string; createdAt: string; + mode?: string; status?: string; taskIds?: string[]; error?: string; model?: string; + settings?: unknown; attachments?: Array<{ name: string; storageKey?: string }>; + references?: Array<{ id: string; label: string; kind: string }>; +}; +type SourceConversation = { id: string; title: string; updatedAt: string; canvasId?: string; messages: SourceMessage[] }; + +// Same source IDs merge into the same canvas conversation, without replaying tasks +// or replacing messages produced by the canvas Agent after the handoff. +export async function continueCreationConversationOnCanvas(source: SourceConversation) { + const scope = getActiveUserScope(); + const assertScope = () => { if (scope !== getActiveUserScope()) throw new DOMException("账号已切换,请重新打开创作", "AbortError"); }; + if (!source.id || !source.messages.length) throw new Error("请先开始一段创作对话"); + if (source.messages.some((item) => item.status === "pending" || item.status === "streaming")) throw new Error("当前创作仍在生成,请完成或停止后再转入画布。"); + if (!useCanvasStore.getState().hydrated) throw new Error("画布资料正在加载,请稍后继续。"); + const sessionId = `creation:${source.id}`; + const messages: CanvasAssistantMessage[] = source.messages.map((item) => ({ + id: `creation:${source.id}:${item.id}`, role: item.role, + text: item.content || (item.status === "done" ? "首页作品已生成,可在画布中继续创作。" : item.status === "error" ? "首页生成未完成。" : item.status === "cancelled" ? "首页生成已停止。" : "来自首页的创作记录"), + meta: "来自首页", + detail: { kind: "creation-handoff", sourceConversationId: source.id, sourceMessageId: item.id, createdAt: item.createdAt, + status: item.status, mode: item.mode, model: item.model, taskIds: item.taskIds, error: item.error, settings: item.settings, + references: item.references?.map(({ id, label, kind }) => ({ id, label, kind })), + attachments: item.attachments?.map(({ name, storageKey }) => ({ name, storageKey })), + }, + })); + const local = useCanvasStore.getState().projects.find((item) => item.chatSessions?.some((session) => session.id === sessionId)); + const existingId = source.canvasId || local?.id; + let id: string; + let syncError: unknown; + if (existingId) { + const project = await loadCanvasProjectForEditing(existingId); + assertScope(); + if (!project) throw new Error("关联画布已不存在,请先恢复画布后继续。"); + const sessions = project.chatSessions || []; + const previous = sessions.find((item) => item.id === sessionId); + const incoming = new Map(messages.map((item) => [item.id, item])); + const merged = (previous?.messages || []).map((item) => { + const replacement = incoming.get(item.id); + incoming.delete(item.id); + return replacement || item; + }); + const session: CanvasAssistantSession = { ...previous, id: sessionId, title: source.title, createdAt: previous?.createdAt || source.messages[0].createdAt, updatedAt: new Date().toISOString(), messages: [...merged, ...incoming.values()] }; + useCanvasStore.getState().updateProject(project.id, { chatSessions: [...sessions.filter((item) => item.id !== sessionId), session], activeChatId: sessionId }); + id = project.id; + await flushCanvasStorePersistence(); + assertScope(); + try { + if (!hasRemoteUserDataSyncSession()) throw new Error("尚未建立云端同步会话"); + await saveRemoteUserDataNow(); + } catch (cause) { syncError = cause; } + } else { + const session: CanvasAssistantSession = { id: sessionId, title: source.title, createdAt: source.messages[0].createdAt, updatedAt: source.updatedAt, messages }; + const created = await createCanvasProjectWithRemoteSync(source.title || "创作画布", undefined, { chatSessions: [session], activeChatId: sessionId }); + id = created.id; + syncError = created.syncError; + await flushCanvasStorePersistence(); + } + assertScope(); + return { id, sessionId, syncError }; +} diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index b4c192a25..90dac4d09 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -207,7 +207,7 @@ export function scheduleRemoteUserDataSync() { }, 1200); } -export async function createCanvasProjectWithRemoteSync(title: string, projectId?: string, initialContent?: Partial>) { +export async function createCanvasProjectWithRemoteSync(title: string, projectId?: string, initialContent?: Partial>) { const id = useCanvasStore.getState().createProject(title, projectId); if (initialContent) useCanvasStore.getState().updateProject(id, initialContent); if (!activeRemoteUserId) return { id, syncError: new Error("尚未建立云端同步会话") };