feat(canvas): 画布工作区 - 整合侧栏与风格预设并修复提示词回填

选择性整合 #547,保留主分支节点与工具条设计。新增默认折叠的节点、资产、任务和历史面板,风格与预设保留在节点内,移除重复入口。

补齐工具权限、提示词解析、资源引用保护及数据库迁移,修复提示词清空和连续编辑后的生成配置同步。

验证:前端全量 1925 项测试、构建、类型检查及 lint 通过;后端 go test ./... 通过。真实模型、PostgreSQL 实库与登录态页面验收仍待进行。

Refs #547

Co-authored-by: bensharp <bensharp@qq.com>
This commit is contained in:
ddCatandbensharp committed 2026-09-20 16:40:08 +08:00
1 parent be5f2733b6
commit e93bf3ca39
66 files changed
+6127 -97

No files matched your search

@@ -9,7 +9,7 @@ test("connection menu omits the header and provides descriptions for every optio
expect(component).not.toContain(">创建下一步</span>");
expect(component).not.toContain("ChevronRight");
expect(component).not.toContain("absolute inset-x-8 top-0 h-px");
const options = component.match(/<ConnectionCreateOption [^\n]+/g) || [];
const options = component.match(/<ConnectionCreateOption\b[\s\S]*?\n\s*\/>/g) || [];
expect(options).toHaveLength(9);
for (const option of options) expect(option).toMatch(/description="[^"]+"/);
expect(component).toContain('title="批量创作表"');
@@ -130,9 +130,11 @@ describe("canvas resource mention editor", () => {
const chat = source("../src/components/canvas/canvas-cloud-agent-chat-ui.tsx");
const css = source("../src/components/canvas/canvas-cloud-agent.css");
expect(component).toContain('chip.className = `canvas-resource-inline-mention ${reference.kind === "skill" ? "is-skill" : ""}`');
expect(component).toContain('prefix.textContent = reference.kind === "skill" ? "✦" : "@"');
expect(component).toContain('if (reference.kind !== "skill") chip.appendChild(createInlinePreview(reference));');
expect(component).toContain('const isDecorated = reference.kind === "skill" || reference.kind === "tool"');
expect(component).toContain('chip.className = `canvas-resource-inline-mention ${isDecorated ? `is-${reference.kind}` : ""}`');
expect(component).toMatch(/if \(reference.kind === "skill"\)\s*\{\s*prefix.textContent = "✦"/);
expect(component).toContain('prefix.textContent = "@"');
expect(component).toContain("if (!isDecorated) chip.appendChild(createInlinePreview(reference));");
expect(component).toContain('chip.style.setProperty("--canvas-skill-mention-color", skillMentionColor(reference))');
expect(chat).toContain('sendOnEnter={canSubmit ? "both" : false}');
expect(chat).toContain("agent-composer-resize-handle");
+72
View File
@@ -0,0 +1,72 @@
import { expect, test } from "bun:test";
import { applyToolMention, parseToolMentionTokens, removeToolMentions } from "../src/lib/canvas/canvas-resource-references";
import { createNineGridNode } from "../src/lib/canvas/canvas-image-source";
import { CanvasNodeType } from "../src/types/canvas";
import type { CanvasNodeData } from "../src/types/canvas";
import "../src/lib/canvas/node-registry/definitions/index";
import { writeCanvasNodePrompt } from "../src/lib/canvas/canvas-node-prompt";
import { canonicalGenerationMetadata } from "../src/lib/canvas/generation-contract";
for (const type of ["style", "nine_grid"]) {
test(`${type} selection, replacement, removal and clearing persist across prompt panel reads`, () => {
for (const content of [undefined, "/generated-image.png"]) {
let node: CanvasNodeData = {
id: "image",
type: CanvasNodeType.Image,
title: "图片",
position: { x: 0, y: 0 },
width: 320,
height: 180,
metadata: { content, prompt: "原始生成记录" },
};
const editAndRead = (prompt: string) => {
node = writeCanvasNodePrompt(node, prompt);
node = JSON.parse(JSON.stringify(node)) as CanvasNodeData;
const metadata = canonicalGenerationMetadata(node, "image");
expect(metadata.composerContent).toBe(prompt);
expect(metadata.generationSpec?.prompt).toBe(prompt);
if (content) expect(node.metadata?.prompt).toBe("原始生成记录");
return metadata.composerContent!;
};
let prompt = editAndRead(applyToolMention("场景描述", { id: 1, type, label: "初始选择" }));
prompt = editAndRead(applyToolMention(prompt, { id: 2, type, label: "替换选择" }));
expect(parseToolMentionTokens(prompt).map((token) => token.toolId)).toEqual([2]);
prompt = editAndRead(`${prompt}\n连续编辑`);
prompt = editAndRead(removeToolMentions(prompt, type));
expect(prompt).toBe("场景描述 \n连续编辑");
expect(parseToolMentionTokens(prompt)).toEqual([]);
editAndRead(applyToolMention(prompt, { id: 3, type, label: "再次选择" }));
expect(parseToolMentionTokens(editAndRead(""))).toEqual([]);
editAndRead("清空后的新内容");
}
});
}
test("tool labels round trip; style replaces, motion deduplicates; clear preserves prose", () => {
const style = { id: 1, type: "style", label: "风格: [a] 中文" };
const first = applyToolMention("第一行\n第二行", style);
expect(parseToolMentionTokens(first)[0].label).toBe(style.label);
const changed = applyToolMention(first, { ...style, id: 2 });
expect(parseToolMentionTokens(changed).map((t) => t.toolId)).toEqual([2]);
expect(removeToolMentions(changed, "style")).toBe("第一行\n第二行");
const motion = { id: 46, type: "motion", label: "推镜" };
const combined = applyToolMention(changed, motion);
expect(applyToolMention(combined, motion)).toBe(combined);
expect(parseToolMentionTokens(combined)).toHaveLength(2);
});
test("nine grid creates idle child using stable source token, not an auto generation", () => {
const child = createNineGridNode({ id: "source", type: CanvasNodeType.Image, title: "图", position: { x: 0, y: 0 }, width: 320, height: 180, metadata: { content: "/image.png" } }, "child", 79, "九宫格", "nine_grid", "Grid3x3");
expect(child.metadata?.prompt).toContain("@[node:source]");
expect(parseToolMentionTokens(child.metadata?.prompt ?? "")[0].toolId).toBe(79);
expect(child.metadata?.content).toBeFalsy();
});
test("node style overrides project style and removing it restores inheritance", async () => {
const { resolveCanvasStyleExecution } = await import("../src/lib/canvas/canvas-style-execution");
const { defaultConfig } = await import("../src/stores/use-config-store");
const styleNode = { id: "style", type: CanvasNodeType.Config, title: "画风", position: { x: 0, y: 0 }, width: 300, height: 200, metadata: { workflowKind: "styleboard" as const, stylePresetId: "test", prompt: "project style" } };
const prompt = applyToolMention("scene", { id: 1, type: "style", label: "custom" });
expect(resolveCanvasStyleExecution([styleNode], undefined, prompt, defaultConfig, "image")).toBeNull();
expect(resolveCanvasStyleExecution([styleNode], undefined, removeToolMentions(prompt, "style"), defaultConfig, "image")).not.toBeNull();
});
+88
View File
@@ -0,0 +1,88 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { canvasDockStyle } from "../src/lib/canvas/canvas-aceternity-style";
import { canvasThemes } from "../src/lib/canvas-theme";
const component = (name: string) => readFileSync(new URL(`../src/components/canvas/${name}`, import.meta.url), "utf8");
test("canvas removes the standalone asset tray while retaining sidebar assets and zoom controls", () => {
const page = readFileSync(new URL("../src/pages/canvas/project.tsx", import.meta.url), "utf8");
expect(page).not.toContain("CanvasAssetTray");
expect(page).toContain("<CanvasZoomControls");
expect(page).toContain("<CanvasWorkspacePanel");
expect(component("canvas-workspace-panel.tsx")).toContain("<CanvasWorkspaceAssetPanel");
});
test("workspace keeps a permanent rail and independent task/history entries", () => {
const source = component("canvas-workspace-panel.tsx");
for (const label of ["主页", "节点", "资产", "任务", "历史"]) expect(source).toContain(label);
expect(source).not.toContain('id: "tools"');
expect(source).not.toContain("CanvasWorkspaceToolPanel");
expect(source).not.toContain("onInsertTool");
expect(source).toContain('aria-label="画布左侧菜单"');
expect(source).toContain("aria-pressed={open && tab === id}");
expect(source).toContain("<CanvasWorkspaceHistoryPanel");
expect(source).toContain("<CanvasWorkspaceAssetPanel");
expect(source).toContain("<AppDrawer");
});
test("asset panel uses scoped paginated data and existing insertion contract", () => {
const source = component("canvas-workspace-asset-panel.tsx");
expect(source).toContain('["canvas-workspace-assets", userId, category, kind, search]');
expect(source).toContain("loadAssetLibraryPage");
expect(source).toContain("assetPickerItemsToInsertPayloads");
expect(source).toContain("query.fetchNextPage()");
expect(source).toContain('role="alert"');
expect(source).toContain("disabled={!insertable || Boolean(inserting)}");
});
test("image style and presets remain visible without the prompt template picker", () => {
const source = component("canvas-node-prompt-panel.tsx");
expect(source).toContain("canvas-node-tool-controls-inline");
expect(source).toContain("<CanvasChooseImageStylePicker");
expect(source).toContain("<CanvasNineGridPicker");
expect(source).toContain('dense appearance="quiet"');
expect(source).toContain('const showPromptTemplates = !simpleMode && mode !== "image"');
expect(source).toContain("{showPromptTemplates ? <CanvasPresetPicker");
expect(source).toContain("if (showPromptTemplates &&");
expect(source).not.toContain('aria-label="生成工具"');
expect(component("canvas-choose-image-style-picker.tsx")).toContain('resolvedLabel || "风格"');
});
test("history explicitly describes its bounded dataset", () => {
expect(component("canvas-workspace-history-panel.tsx")).toContain("最近 30 条(含 Agent)");
});
test("cleared tool references do not fall back to the original generation prompt", () => {
const source = readFileSync(new URL("../src/pages/canvas/use-canvas-render-model.ts", import.meta.url), "utf8");
const toolReferences = source.split("const toolMentionReferencesByNodeId = useMemo")[1].split("const tokens =")[0];
expect(toolReferences).toContain('node.metadata?.composerContent ?? node.metadata?.prompt ?? ""');
expect(toolReferences).not.toContain("||");
});
test("workspace starts collapsed and keeps separated navigation and bottom toggle", () => {
const page = readFileSync(new URL("../src/pages/canvas/project.tsx", import.meta.url), "utf8");
expect(page).toContain("const [workspaceOpen, setWorkspaceOpen] = useState(false)");
const panel = component("canvas-workspace-panel.tsx");
expect(panel).toContain('className="canvas-workspace-rail-items"');
expect(panel.indexOf("canvas-workspace-rail-toggle")).toBeGreaterThan(panel.indexOf("entries.map"));
const css = component("canvas-workspace-panel.css");
expect(css).toContain("gap: var(--space-3)");
expect(css).toContain("margin-top: auto");
expect(css).toContain("prefers-reduced-motion: reduce");
});
test("rail shares canvas dock colors rather than muted workspace navigation colors", () => {
const panel = component("canvas-workspace-panel.tsx");
expect(panel).toContain("canvasThemes[useActiveTheme()]");
expect(panel).toContain('...canvasDockStyle(theme), boxShadow: "none"');
const railCss = component("canvas-workspace-panel.css").split(".canvas-workspace-rail {")[1];
expect(railCss).not.toContain("var(--muted-foreground)");
expect(railCss).not.toContain("var(--workspace-navigation)");
for (const token of ["--dock-command-hover", "--dock-command-active", "--dock-command-active-text", "--dock-tooltip-border"]) expect(railCss).toContain(`var(${token})`);
for (const theme of Object.values(canvasThemes)) {
const style = canvasDockStyle(theme);
expect(style.background).toBe("var(--dock-surface)");
expect(style.color).toBe(theme.toolbar.item);
}
});