feat(agent): 会话接续 - 首页创作带入画布原助手

This commit is contained in:
王志伟
2026-09-08 18:24:37 +08:00
parent e782009db5
commit 88f4b9ccdf
6 changed files with 153 additions and 13 deletions

View File

@@ -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,消费后移除,不自动发送模型请求、批准方案或重放任务。首页没有引入任何画布操作工具;需要继续执行时在画布原输入框输入要求。后续可在此基础上接电商工作台及更完整的角色上下文。
## 简化交互

View File

@@ -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;不能仅凭素材名称断言画面内容。` },
],

View File

@@ -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(() => {

View File

@@ -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>;
}

View 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 };
}

View File

@@ -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("尚未建立云端同步会话") };