feat(canvas): 画布工作区 - 整合侧栏与风格预设并修复提示词回填
选择性整合 #547,保留主分支节点与工具条设计。新增默认折叠的节点、资产、任务和历史面板,风格与预设保留在节点内,移除重复入口。 补齐工具权限、提示词解析、资源引用保护及数据库迁移,修复提示词清空和连续编辑后的生成配置同步。 验证:前端全量 1925 项测试、构建、类型检查及 lint 通过;后端 go test ./... 通过。真实模型、PostgreSQL 实库与登录态页面验收仍待进行。 Refs #547 Co-authored-by: bensharp <bensharp@qq.com>
This commit is contained in:
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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user