feat(agent): 会话接续 - 首页创作带入画布原助手
This commit is contained in:
@@ -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,消费后移除,不自动发送模型请求、批准方案或重放任务。首页没有引入任何画布操作工具;需要继续执行时在画布原输入框输入要求。后续可在此基础上接电商工作台及更完整的角色上下文。
|
||||
|
||||
## 简化交互
|
||||
|
||||
|
||||
@@ -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<ResponseInputMessage[]> {
|
||||
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;不能仅凭素材名称断言画面内容。` },
|
||||
],
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<CreationMode, string> = { text: "文本", image: "图片", video: "视频" };
|
||||
const shotScriptLabels: Record<CreationMode, string> = { 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() {
|
||||
/>
|
||||
</main>
|
||||
</> : <div className="creation-thread-workbench">
|
||||
<CreationWorkspaceToolbar onNewConversation={startNewConversation} onOpenHistory={() => setHistoryOpen(true)} shots={videoShots} onJumpToShot={jumpToShot} />
|
||||
<CreationWorkspaceToolbar onNewConversation={startNewConversation} onOpenHistory={() => setHistoryOpen(true)} shots={videoShots} onJumpToShot={jumpToShot} onContinueCanvas={() => void continueOnCanvas()} openingCanvas={openingCanvas} />
|
||||
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-thread-scroll creation-scrollbar">
|
||||
<section className="creation-thread-stage"><div className="creation-results">{activeConversation.messages.map((item, index) => <div key={item.id} id={`creation-shot-${item.id}`} className="creation-thread-message"><CreationMessageView
|
||||
item={item}
|
||||
shotNumber={creationVideoShotOrdinal(videoShots, item)}
|
||||
onRetryFailure={() => retryFailedMessage(item, index)}
|
||||
onCreateVariant={() => createVariant(item, index)}
|
||||
onContinueCanvas={(ids) => void continueOnCanvas(ids)}
|
||||
openingCanvas={openingCanvas}
|
||||
onEditUserMessage={(text) => { setPrompt(text); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }}
|
||||
/></div>)}</div></section>
|
||||
</main>
|
||||
@@ -1179,7 +1222,7 @@ function CreationHistoryDrawer({ open, conversations, activeId, onNew, onClose,
|
||||
</Drawer>;
|
||||
}
|
||||
|
||||
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<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -1205,13 +1248,14 @@ function CreationWorkspaceToolbar({ shots, onJumpToShot, onNewConversation, onOp
|
||||
</div> : null}
|
||||
</div>
|
||||
<div className="creation-toolbar-actions">
|
||||
<Button size="small" loading={openingCanvas} onClick={onContinueCanvas}>画布中继续</Button>
|
||||
<Tooltip title="新建创作"><button type="button" aria-label="新建创作" className="creation-toolbar-action" onClick={onNewConversation}><Plus /></button></Tooltip>
|
||||
<Tooltip title="历史对话"><button type="button" aria-label="查看历史对话" className="creation-toolbar-action" onClick={onOpenHistory}><History /></button></Tooltip>
|
||||
</div>
|
||||
</header>;
|
||||
}
|
||||
|
||||
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 <CreationUserMessage item={item} shotNumber={shotNumber} onEditUserMessage={onEditUserMessage} />;
|
||||
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 <article className={`creation-assistant-message is-${mode}`}>
|
||||
{mode === "text" ? <><div className="creation-message-heading">{heading}</div>{item.reasoning ? <div className="creation-message-reasoning-wrap"><MessageReasoning reasoning={item.reasoning} isStreaming={item.status === "streaming"} /></div> : null}<div className="creation-message-content">{item.content ? <AIMessageMarkdown isStreaming={item.status === "streaming"}>{item.content}</AIMessageMarkdown> : <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}><WorkingDots dotSize={5} gap={2} /><span>正在生成…</span></span>}</div></> : <GenerationToolCard status={toolStatus} heading={heading}><MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} /></GenerationToolCard>}
|
||||
{mode === "text" ? <><div className="creation-message-heading">{heading}</div>{item.reasoning ? <div className="creation-message-reasoning-wrap"><MessageReasoning reasoning={item.reasoning} isStreaming={item.status === "streaming"} /></div> : null}<div className="creation-message-content">{item.content ? <AIMessageMarkdown isStreaming={item.status === "streaming"}>{item.content}</AIMessageMarkdown> : <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}><WorkingDots dotSize={5} gap={2} /><span>正在生成…</span></span>}</div></> : <GenerationToolCard status={toolStatus} heading={heading}><MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} onContinueCanvas={onContinueCanvas} openingCanvas={openingCanvas} /></GenerationToolCard>}
|
||||
{item.error && mode === "text" ? <div className="creation-message-error"><span>{generationErrorMessage(item.error)}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div> : null}
|
||||
</article>;
|
||||
}
|
||||
@@ -1253,21 +1297,20 @@ function CreationUserMessage({ item, shotNumber, onEditUserMessage }: { item: Cr
|
||||
</article>;
|
||||
}
|
||||
|
||||
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 <CreationMediaPending mode={item.mode || "image"} ratio={item.settings?.ratio} />;
|
||||
if ((item.status === "error" || item.status === "cancelled") && !resultUrls.length) return <div className="creation-media-error"><span>{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
|
||||
if (!resultUrls.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
|
||||
const isVideo = item.mode === "video";
|
||||
return <div className="creation-media-result">
|
||||
{isVideo ? <button type="button" className="creation-video-result" onClick={() => { setPreviewType("video"); setPreviewUrl(resultUrls[0]); }} aria-label="预览生成视频"><video muted preload="metadata" src={resultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{resultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => { setPreviewType("image"); setPreviewUrl(url); }} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}
|
||||
<div className="creation-media-actions"><span>{isVideo ? "视频结果" : `${resultUrls.length} 张图片`}</span><Link to={canvasPath}>{canvasHandoffPath ? "添加到画布" : "打开画布"}</Link>{resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
|
||||
<div className="creation-media-actions"><span>{isVideo ? "视频结果" : `${resultUrls.length} 张图片`}</span><button type="button" disabled={openingCanvas || !canTransferAssets} title={!canTransferAssets ? "素材保存完成后可转入画布" : "携带当前会话与这些素材进入画布"} onClick={() => onContinueCanvas(resultAssetIds)}>{openingCanvas ? "正在打开…" : "画布中继续"}</button>{resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
|
||||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
67
web/src/services/creation-canvas-conversation.ts
Normal file
67
web/src/services/creation-canvas-conversation.ts
Normal file
@@ -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 };
|
||||
}
|
||||
@@ -207,7 +207,7 @@ export function scheduleRemoteUserDataSync() {
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
export async function createCanvasProjectWithRemoteSync(title: string, projectId?: string, initialContent?: Partial<Pick<CanvasProject, "nodes" | "connections">>) {
|
||||
export async function createCanvasProjectWithRemoteSync(title: string, projectId?: string, initialContent?: Partial<Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId">>) {
|
||||
const id = useCanvasStore.getState().createProject(title, projectId);
|
||||
if (initialContent) useCanvasStore.getState().updateProject(id, initialContent);
|
||||
if (!activeRemoteUserId) return { id, syncError: new Error("尚未建立云端同步会话") };
|
||||
|
||||
Reference in New Issue
Block a user