| null;
+ if (!response.ok && response.status !== 409) throw new Error("Canvas Agent 状态同步请求失败");
+ if (body?.accepted !== true || typeof body.revision !== "number" || typeof body.stateHash !== "string") {
+ throw new CanvasRuntimeStreamError("canvas_state_conflict", body?.reason === "stale_revision" || body?.reason === "revision_conflict" ? "画布状态已被其他操作更新,请重新读取后再试" : "画布状态同步被拒绝");
+ }
+ return { accepted: true, revision: body.revision, stateHash: body.stateHash, idempotent: body.idempotent, reason: body.reason };
+ } catch (error) {
+ if (error instanceof CanvasRuntimeStreamError) throw error;
throw new CanvasRuntimeStreamError("canvas_state_sync_failed", "画布状态同步失败");
}
}
diff --git a/web/src/pages/canvas/canvas-project-top-bar.tsx b/web/src/pages/canvas/canvas-project-top-bar.tsx
index 220269af9..9483e4838 100644
--- a/web/src/pages/canvas/canvas-project-top-bar.tsx
+++ b/web/src/pages/canvas/canvas-project-top-bar.tsx
@@ -325,7 +325,7 @@ export function CanvasWorkspaceModeSwitch({ mode, onChange }: { mode: CanvasWork
whileHover={reducedMotion ? undefined : { y: -1 }}
whileTap={reducedMotion ? undefined : { scale: 0.97 }}
transition={aceternityMotion.spring.dock}
- className="canvas-mode-switch-trigger flex h-10 min-w-28 items-center gap-2 rounded-xl border px-2.5 text-left outline-none backdrop-blur-2xl focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
+ className="canvas-mode-switch-trigger flex h-10 min-w-28 items-center gap-2 rounded-xl px-2.5 text-left outline-none backdrop-blur-2xl focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
style={{ ...canvasDockStyle(theme, theme.node.text), background: open ? "var(--dock-command-active)" : "var(--dock-surface)", outlineColor: theme.accent.primary }}
aria-haspopup="listbox"
aria-expanded={open}
@@ -351,7 +351,7 @@ export function CanvasWorkspaceModeSwitch({ mode, onChange }: { mode: CanvasWork
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.95 }}
transition={aceternityMotion.spring.panel}
- className="canvas-mode-switch-menu aceternity-floating-panel w-full overflow-hidden rounded-[var(--r-lg)] border p-1.5 backdrop-blur-2xl"
+ className="canvas-mode-switch-menu aceternity-floating-panel w-full overflow-hidden rounded-[var(--r-lg)] p-1.5 backdrop-blur-2xl"
style={{ ...canvasDockStyle(theme, theme.node.text), background: "var(--dock-surface)" }}
>
} title="简洁模式" description="保留核心创作路径,降低参数密度" theme={theme} onClick={() => selectMode("simple")} />
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index 381b8cd6d..ef42f2a92 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -1836,7 +1836,8 @@ function InfiniteCanvasPage() {
{!focusMode ? (
event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
diff --git a/web/src/stores/canvas/use-canvas-agent-store.ts b/web/src/stores/canvas/use-canvas-agent-store.ts
index b9f8a0be2..b02752f9e 100644
--- a/web/src/stores/canvas/use-canvas-agent-store.ts
+++ b/web/src/stores/canvas/use-canvas-agent-store.ts
@@ -59,7 +59,7 @@ export function writeCanvasAgentEnabledPreference(enabled: boolean, storage: Pic
}
export function canvasAgentConnectionStartingPatch() {
- return { enabled: true, connected: false, activity: "连接中", connectError: "", activeTab: "setup" as const };
+ return { enabled: true, connected: false, activity: "连接中", connectError: "", activeTab: "chat" as const };
}
export function canvasAgentTransientDisconnectPatch(activity: string, connectError: string) {
@@ -89,7 +89,7 @@ export const useCanvasAgentStore = create((set) => ({
activeThreadId: "",
workspacePath: "",
loadingThreads: false,
- activeTab: "setup",
+ activeTab: "chat",
confirmTools: true,
activity: "就绪",
connectError: "",
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index 2696fc707..57f31a803 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -1426,9 +1426,9 @@
}
.canvas-mode-switch-trigger {
- border-color: var(--dock-border);
- box-shadow: var(--elevation-overlay);
- transition: background-color var(--motion-instant) ease-out, color var(--motion-instant) ease-out;
+ border: 0;
+ box-shadow: 0 14px 34px color-mix(in srgb, var(--cn-spatial-shadow) 72%, transparent), inset 0 1px 0 rgba(255,255,255,.045);
+ transition: background-color var(--motion-instant) ease-out, color var(--motion-instant) ease-out, box-shadow var(--motion-instant) ease-out;
}
.canvas-mode-switch-trigger:hover:not(:disabled) {
@@ -1436,7 +1436,8 @@
}
.canvas-mode-switch-menu {
- border-color: var(--dock-border);
+ border: 0;
+ box-shadow: 0 22px 56px color-mix(in srgb, var(--cn-spatial-shadow) 80%, transparent), inset 0 1px 0 rgba(255,255,255,.04);
transform-origin: bottom right;
}
@@ -6284,8 +6285,9 @@
}
.canvas-mode-switch-trigger {
+ border: 0 !important;
border-color: transparent !important;
- box-shadow: var(--elevation-overlay);
+ box-shadow: 0 14px 34px color-mix(in srgb, var(--cn-spatial-shadow) 72%, transparent), inset 0 1px 0 rgba(255,255,255,.045);
}
.canvas-tool-button,
diff --git a/web/test/canvas-agent-context.test.ts b/web/test/canvas-agent-context.test.ts
new file mode 100644
index 000000000..c71978f80
--- /dev/null
+++ b/web/test/canvas-agent-context.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it } from "bun:test";
+
+import { getCanvasAgentConnection, getCanvasAgentGenerationTasks, getCanvasAgentNode, validateCanvasAgentOps } from "@/lib/canvas/canvas-agent-context";
+import { canvasAgentPostconditionMessage, type CanvasAgentSnapshot, verifyCanvasAgentOps } from "@/lib/canvas/canvas-agent-ops";
+import { CanvasNodeType } from "@/types/canvas";
+
+const snapshot: CanvasAgentSnapshot = {
+ projectId: "canvas-1",
+ domainProjectId: "project-1",
+ title: "测试画布",
+ nodes: [
+ { id: "prompt-1", type: CanvasNodeType.Text, title: "提示词", position: { x: 0, y: 0 }, width: 320, height: 240 },
+ { id: "ref-1", type: CanvasNodeType.Image, title: "参考图", position: { x: 420, y: 0 }, width: 320, height: 320, metadata: { status: "success", storageKey: "resource:ref-1", taskId: "task-1", taskStatus: "succeeded", taskProgress: 100, taskStage: "completed" } },
+ ],
+ connections: [{ id: "connection-1", fromNodeId: "prompt-1", toNodeId: "ref-1" }],
+ selectedNodeIds: [],
+ viewport: { x: 0, y: 0, k: 1 },
+};
+
+describe("validateCanvasAgentOps", () => {
+ it("allows deleting and recreating a connection in one batch", () => {
+ const result = validateCanvasAgentOps(snapshot, [
+ { type: "delete_connections", id: "connection-1" },
+ { type: "connect_nodes", id: "connection-2", fromNodeId: "prompt-1", toNodeId: "ref-1" },
+ ]);
+ expect(result.ok).toBe(true);
+ });
+
+ it("rejects direct media status forgery", () => {
+ const result = validateCanvasAgentOps(snapshot, [
+ { type: "update_node", id: "ref-1", patch: { metadata: { status: "success" } } },
+ ]);
+ expect(result.ok).toBe(false);
+ expect(result.issues[0]?.severity).toBe("error");
+ });
+});
+
+describe("precise canvas reads", () => {
+ it("reads one node with resource and connections", () => {
+ const result = getCanvasAgentNode(snapshot, { id: "ref-1" });
+ expect(result.found).toBe(true);
+ expect(result.node?.id).toBe("ref-1");
+ expect(result.resource?.ready).toBe(true);
+ expect(result.connections[0]?.id).toBe("connection-1");
+ });
+
+ it("reads one connection and returns explicit misses", () => {
+ const result = getCanvasAgentConnection(snapshot, { id: "connection-1" });
+ expect(result.found).toBe(true);
+ expect(result.connection?.fromTitle).toBe("提示词");
+ expect(result.toNode?.id).toBe("ref-1");
+ expect(getCanvasAgentConnection(snapshot, { id: "missing" }).found).toBe(false);
+ });
+
+ it("projects generation task observation from node metadata", () => {
+ const result = getCanvasAgentGenerationTasks(snapshot, {});
+ expect(result.total).toBe(1);
+ expect(result.tasks[0]?.taskId).toBe("task-1");
+ expect(result.tasks[0]?.status).toBe("succeeded");
+ expect(result.tasks[0]?.resourceReady).toBe(true);
+ });
+});
+
+describe("canvas write postconditions", () => {
+ it("confirms created nodes and connections exist after the batch", () => {
+ const after: CanvasAgentSnapshot = {
+ ...snapshot,
+ nodes: [...snapshot.nodes, { id: "text-2", type: CanvasNodeType.Text, title: "输出", position: { x: 0, y: 420 }, width: 320, height: 240 }],
+ connections: [...snapshot.connections, { id: "connection-2", fromNodeId: "ref-1", toNodeId: "text-2" }],
+ };
+ const result = verifyCanvasAgentOps(snapshot, after, [
+ { type: "add_node", id: "text-2", nodeType: CanvasNodeType.Text },
+ { type: "connect_nodes", id: "connection-2", fromNodeId: "ref-1", toNodeId: "text-2" },
+ ]);
+ expect(result.ok).toBe(true);
+ expect(result.createdNodeIds).toEqual(["text-2"]);
+ expect(result.missingConnectionIds).toEqual([]);
+ });
+
+ it("reports submitted generation without claiming completion", () => {
+ const before: CanvasAgentSnapshot = {
+ ...snapshot,
+ nodes: [{ ...snapshot.nodes[0], id: "image-1", type: CanvasNodeType.Image, title: "待生成", metadata: { status: "idle", generationMode: "image" } }],
+ connections: [],
+ };
+ const after: CanvasAgentSnapshot = {
+ ...before,
+ nodes: [{ ...before.nodes[0], metadata: { ...before.nodes[0].metadata, taskId: "task-2", taskStatus: "running", taskOfficialStatus: "processing" } }],
+ };
+ const result = verifyCanvasAgentOps(before, after, [{ type: "run_generation", nodeId: "image-1", mode: "image" }]);
+ expect(result.ok).toBe(true);
+ expect(result.generation[0]?.outcome).toBe("running");
+ expect(canvasAgentPostconditionMessage(result)).toContain("尚未完成");
+ });
+
+ it("fails the tool result when generation failed or was never submitted", () => {
+ const before: CanvasAgentSnapshot = {
+ ...snapshot,
+ nodes: [{ ...snapshot.nodes[0], id: "image-2", type: CanvasNodeType.Image, title: "失败生成", metadata: { status: "idle" } }],
+ connections: [],
+ };
+ const failed: CanvasAgentSnapshot = {
+ ...before,
+ nodes: [{ ...before.nodes[0], metadata: { status: "error", taskId: "task-3", taskStatus: "failed" } }],
+ };
+ const result = verifyCanvasAgentOps(before, failed, [{ type: "run_generation", nodeId: "image-2", mode: "image" }]);
+ expect(result.ok).toBe(false);
+ expect(canvasAgentPostconditionMessage(result)).toContain("失败");
+ });
+
+ it("does not accept a silent update no-op as a successful write", () => {
+ const result = verifyCanvasAgentOps(snapshot, snapshot, [{ type: "update_node", id: "prompt-1", patch: { title: "新标题" } }]);
+ expect(result.ok).toBe(false);
+ expect(result.warnings.join(" ")).toContain("未达到预期");
+ });
+});
diff --git a/web/test/canvas-agent-workflow.test.ts b/web/test/canvas-agent-workflow.test.ts
new file mode 100644
index 000000000..67c07e5cc
--- /dev/null
+++ b/web/test/canvas-agent-workflow.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from "bun:test";
+
+import { buildCanvasWorkflowOps, looksLikeWorkflowRequest } from "@/lib/canvas/canvas-agent-workflow";
+import { applyCanvasAgentOps, canvasAgentPostconditionMessage, verifyCanvasAgentOps, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
+import { extractCanvasAgentQuickActions } from "@/components/canvas/canvas-agent-chat-ui";
+import { CanvasNodeType } from "@/types/canvas";
+
+const config = { imageModel: "image-model", videoModel: "video-model", audioModel: "audio-model" } as never;
+const snapshot: CanvasAgentSnapshot = { projectId: "p", title: "空画布", nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
+
+describe("canvas agent workflow builder", () => {
+ it("creates semantic media nodes, non-overlapping layout and real edges", () => {
+ const ops = buildCanvasWorkflowOps({
+ title: "搞笑修仙小说流水线",
+ nodes: [
+ { ref: "characters", kind: "character_cards", title: "角色拆分图片卡片", prompt: "拆分搞笑修仙小说中的主要角色,并生成角色图片卡片" },
+ { ref: "views", kind: "character_three_view", title: "角色三视图", prompt: "基于角色卡片生成正面、侧面、背面三视图", referenceRefs: ["characters"] },
+ { ref: "video", kind: "storyboard_video", title: "分镜剧情视频", prompt: "基于角色三视图制作分镜剧情视频", referenceRefs: ["views"] },
+ ],
+ }, snapshot, config);
+ const after = applyCanvasAgentOps(snapshot, ops);
+ const added = after.nodes.filter((node) => node.id.startsWith("agent-workflow-"));
+ expect(added.map((node) => node.type)).toEqual([CanvasNodeType.Image, CanvasNodeType.Image, CanvasNodeType.Video]);
+ expect(after.connections).toHaveLength(2);
+ expect(added[1].position.x).toBeGreaterThan(added[0].position.x + added[0].width);
+ expect(added[2].position.x).toBeGreaterThan(added[1].position.x + added[1].width);
+ const result = verifyCanvasAgentOps(snapshot, after, ops);
+ expect(result.ok).toBe(true);
+ expect(result.connectionCount).toBe(2);
+ expect(result.expectedConnectionCount).toBe(2);
+ expect(result.overlapWarnings).toEqual([]);
+ expect(canvasAgentPostconditionMessage(result)).toContain("实际新增 2 条");
+ });
+
+ it("turns numbered assistant choices into clickable prompts", () => {
+ expect(extractCanvasAgentQuickActions("请选择下一步:\n1. 继续细化角色\n2、增加故事元素\n3) 调整布局")).toEqual([
+ { label: "继续细化角色", prompt: "继续细化角色" },
+ { label: "增加故事元素", prompt: "增加故事元素" },
+ { label: "调整布局", prompt: "调整布局" },
+ ]);
+ expect(extractCanvasAgentQuickActions("```json\n1. not an action\n```")).toEqual([]);
+ });
+
+ it("rejects workflow-shaped text batches", () => {
+ expect(looksLikeWorkflowRequest("创建一个工作流并连线")).toBe(true);
+ expect(looksLikeWorkflowRequest("写一段角色介绍")).toBe(false);
+ });
+
+ it("infers useful prompts for semantic media stages when the model omits them", () => {
+ const ops = buildCanvasWorkflowOps({
+ title: "搞笑修仙小说流水线",
+ nodes: [{ ref: "cards", kind: "character_cards", title: "角色拆分图片卡片" }],
+ }, snapshot, config);
+ const node = ops.find((op) => op.type === "add_node");
+ expect(node?.metadata?.prompt).toContain("拆分主要角色");
+ });
+
+ it("supports per-node generation without running every media node", () => {
+ const ops = buildCanvasWorkflowOps({
+ nodes: [
+ { ref: "cards", kind: "character_cards", title: "角色卡片", prompt: "生成角色卡片" },
+ { ref: "views", kind: "character_three_view", title: "角色三视图", prompt: "生成三视图", runGeneration: true },
+ { ref: "video", kind: "storyboard_video", title: "分镜视频", prompt: "生成分镜视频" },
+ ],
+ }, snapshot, config);
+ expect(ops.filter((op) => op.type === "run_generation")).toHaveLength(1);
+ expect(ops.find((op) => op.type === "run_generation")?.nodeId).toBe(ops[1].id);
+ });
+
+ it("can connect a new workflow node to an existing canvas resource", () => {
+ const existing: CanvasAgentSnapshot = {
+ ...snapshot,
+ nodes: [{ id: "asset-1", type: CanvasNodeType.Image, title: "角色原画", position: { x: 0, y: 0 }, width: 560, height: 380, metadata: { status: "success", storageKey: "resource:character-1" } }],
+ };
+ const ops = buildCanvasWorkflowOps({
+ nodes: [{ ref: "views", kind: "character_three_view", title: "角色三视图", prompt: "基于已有角色原画生成正面、侧面、背面三视图", referenceNodeIds: ["asset-1"] }],
+ }, existing, config);
+ const after = applyCanvasAgentOps(existing, ops);
+ expect(after.connections).toHaveLength(1);
+ expect(after.connections[0]).toMatchObject({ fromNodeId: "asset-1", toNodeId: ops[0].id });
+ expect(after.nodes[1]?.metadata?.referenceNodeIds).toEqual(["asset-1"]);
+ });
+});
diff --git a/web/test/canvas-skill-mentions.test.ts b/web/test/canvas-skill-mentions.test.ts
new file mode 100644
index 000000000..ccc7d3aed
--- /dev/null
+++ b/web/test/canvas-skill-mentions.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from "bun:test";
+
+import { buildSkillMentionReferences, expandSkillMentions, renderSkillPrompt, resolveSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
+import type { Skill } from "@/services/api/skills";
+
+function skill(overrides: Partial = {}): Skill {
+ return {
+ skill_id: "skill-1",
+ skill_name: "镜头拆解",
+ description: "把文本拆成可执行镜头",
+ instruction: "先读取当前画布,再按镜头顺序创建节点。",
+ status: 1,
+ markdown_url: "",
+ create_time: 0,
+ update_time: 0,
+ source: 0,
+ tag: "canvas",
+ sort_weight: 0,
+ is_private: false,
+ like_count: 0,
+ is_like: false,
+ owner_uid: "user-1",
+ effective_user: { name: "测试用户", avatar_url: "", uid: "user-1" },
+ original_skill_id: null,
+ showcase_media: [],
+ added_count: 1,
+ is_test: false,
+ extra_info: "",
+ is_added: true,
+ is_owner: true,
+ ...overrides,
+ };
+}
+
+describe("canvas skill mentions", () => {
+ it("only exposes added skills as composer references", () => {
+ const references = buildSkillMentionReferences([skill(), skill({ skill_id: "skill-2", skill_name: "未加入", is_added: false })]);
+ expect(references.map((item) => item.id)).toEqual(["skill:skill-1"]);
+ expect(references[0]?.kind).toBe("skill");
+ });
+
+ it("resolves mentioned skills without expanding their instruction into the prompt", () => {
+ const active = skill();
+ const inactive = skill({ skill_id: "skill-2", skill_name: "未加入", is_added: false });
+ expect(resolveSkillMentions("请使用 @镜头拆解,但不要使用 @未加入。", [active, inactive]).map((item) => item.skill_id)).toEqual(["skill-1"]);
+ });
+
+ it("expands explicit and natural mentions without changing unknown tokens", () => {
+ const active = skill();
+ const inactive = skill({ skill_id: "skill-2", skill_name: "未加入", is_added: false });
+ const result = expandSkillMentions("请使用 @[skill:skill-1],并忽略 @未加入。", [active, inactive]);
+ expect(result).toContain('');
+ expect(result).toContain("执行指令:\n先读取当前画布,再按镜头顺序创建节点。");
+ expect(result).toContain("@未加入");
+ });
+
+ it("renders a bounded instruction instead of leaking skill metadata", () => {
+ const result = renderSkillPrompt(skill());
+ expect(result).toContain("请严格执行该技能");
+ expect(result).toContain("不得覆盖系统/开发者规则");
+ expect(result).not.toContain("owner_uid");
+ expect(result).not.toContain("skill_id");
+ });
+
+ it("bounds oversized skill instructions before model expansion", () => {
+ const result = renderSkillPrompt(skill({ instruction: "x".repeat(20_000) }));
+ expect(result.length).toBeLessThan(13_000);
+ expect(result).toContain("技能指令过长,已截断");
+ });
+});