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

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

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

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

Refs #547

Co-authored-by: bensharp <bensharp@qq.com>
This commit is contained in:
ddCat
2026-09-20 16:40:08 +08:00
parent 0ea9f7d94b
commit 9148ceab66
66 changed files with 6127 additions and 97 deletions

View File

@@ -28,3 +28,18 @@ export function createPortraitTextureNode(source: CanvasNodeData, id: string): C
},
};
}
export function createNineGridNode(source: CanvasNodeData, id: string, toolId: number, label: string, type: string, icon: string): CanvasNodeData {
return {
id,
type: CanvasNodeType.Image,
title: `${source.title || "图片"} · ${label}`,
position: { x: source.position.x + source.width + 96, y: source.position.y },
width: source.width,
height: source.height,
metadata: {
prompt: `@[node:${source.id}] @[tool:${type}:${toolId}:${encodeURIComponent(label)}:${icon}]`,
composerContent: `@[node:${source.id}] @[tool:${type}:${toolId}:${encodeURIComponent(label)}:${icon}]`,
generationType: "edit",
},
};
}

View File

@@ -4,6 +4,7 @@ import test from "node:test";
import "./node-registry/definitions/index.ts";
// @ts-expect-error -- Node 原生 TypeScript 测试运行器需要保留扩展名。
import { canvasNodeHasCommittedContent, writeCanvasNodePrompt } from "./canvas-node-prompt.ts";
import { canonicalGenerationMetadata } from "./generation-contract";
// @ts-expect-error -- Node 原生 TypeScript 测试运行器需要保留扩展名。
import { CanvasNodeType, type CanvasNodeData } from "../../types/canvas.ts";
@@ -49,6 +50,28 @@ test("空媒体草稿同时初始化提交提示词和编辑提示词", () => {
assert.equal(metadata?.generationSpec?.prompt, "第一版提示词");
});
test("模板提示词清空后重新读取和保存重载均保持为空", () => {
for (const type of [CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio]) {
for (const content of [undefined, `https://example.com/${type}`]) {
const current = node(type, { content, prompt: "已提交提示词" });
const applied = writeCanvasNodePrompt(current, "模板预设文字");
const cleared = writeCanvasNodePrompt(applied, "");
assert.equal(cleared.metadata?.composerContent, "");
assert.equal(cleared.metadata?.generationSpec?.prompt, "");
assert.equal(cleared.metadata?.prompt, content ? "已提交提示词" : "");
const reloaded = JSON.parse(JSON.stringify(cleared)) as CanvasNodeData;
assert.equal(canonicalGenerationMetadata(reloaded, type).composerContent, "");
}
}
});
test("已有生成合同的提示词支持连续编辑而不回填旧模板", () => {
const applied = writeCanvasNodePrompt(node(CanvasNodeType.Image), "模板预设文字");
const edited = writeCanvasNodePrompt(applied, "修改后的文字");
assert.equal(edited.metadata?.generationSpec?.prompt, "修改后的文字");
assert.equal(canonicalGenerationMetadata(edited, "image").composerContent, "修改后的文字");
});
test("图片仅保留 storageKey 时仍视为已有媒体结果", () => {
const current = node(CanvasNodeType.Image, {
storageKey: "canvas/image/result.png",

View File

@@ -29,10 +29,11 @@ export function writeCanvasNodePrompt(node: CanvasNodeData, prompt: string, opti
const promptTemplateMetadata = clearPromptTemplate
? { promptTemplateOperation: undefined, promptTemplateVariables: undefined }
: {};
return synchronizeGenerationSpec({
...node,
metadata: canvasNodeHasCommittedContent(node)
? { ...metadata, ...promptTemplateMetadata, composerContent: prompt }
: { ...metadata, ...promptTemplateMetadata, prompt, composerContent: prompt },
});
// 显式提交草稿补丁,否则已有 generationSpec 会把旧提示词投影回编辑框。
return synchronizeGenerationSpec(
{ ...node, metadata },
canvasNodeHasCommittedContent(node)
? { ...promptTemplateMetadata, composerContent: prompt }
: { ...promptTemplateMetadata, prompt, composerContent: prompt },
);
}

View File

@@ -7,7 +7,7 @@ import type { Skill } from "@/services/api/skills";
import type { Asset, AssetCategory } from "@/stores/use-asset-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeTypeId } from "@/types/canvas";
export type CanvasResourceKind = "image" | "video" | "audio" | "text" | "skill" | "character";
export type CanvasResourceKind = "image" | "video" | "audio" | "text" | "skill" | "character" | "tool";
export type CanvasResourceReference = {
id: string;
@@ -31,26 +31,89 @@ export type CanvasResourceReference = {
assetId?: string;
category?: AssetCategory;
mentionToken?: string;
/** 仅 kind === "tool" 时使用,对应后端工具 ID。 */
toolId?: number;
/** 仅 kind === "tool" 时使用,对应工具类型(style/nine_grid/effect/motion)。 */
toolType?: string;
/** 仅 kind === "tool" 时使用,lucide 图标名称。 */
toolIcon?: string;
};
export function canvasSkillMentionToken(skillId: string) {
return `@[skill:${skillId}]`;
}
export function canvasToolMentionToken(toolId: number, label: string, type: string, icon: string) {
return `@[tool:${type}:${toolId}:${encodeURIComponent(label)}:${icon}]`;
}
const TOOL_REF_PATTERN = /@\[tool:(\w+):(\d+):([^:\]]+):([^\]]+)\]/g;
/** 从文本中解析所有 @[tool:type:ID:label:icon] 令牌,返回去重的工具引用。 */
export function parseToolMentionTokens(text: string): { type: string; toolId: number; label: string; icon: string }[] {
const results: { type: string; toolId: number; label: string; icon: string }[] = [];
const seen = new Set<number>();
for (const match of text.matchAll(TOOL_REF_PATTERN)) {
const toolId = Number(match[2]);
if (seen.has(toolId)) continue;
seen.add(toolId);
let label = match[3];
try { label = decodeURIComponent(label); } catch { /* Keep readable text for malformed imported labels. */ }
results.push({ type: match[1], toolId, label, icon: match[4] });
}
return results;
}
export function removeToolMentions(prompt: string, type: string) {
return prompt.replace(new RegExp(`@\\[tool:${escapeRegExp(type)}:\\d+:[^\\]]*\\]`, "gu"), "").trim();
}
export function applyToolMention(prompt: string, tool: { id: number; type: string; label: string }, icon = "Wrench") {
const reference = buildToolMentionReference(tool.id, tool.label, tool.type, icon);
const replaced = overwriteSameTypeToolMention(prompt, reference);
if (replaced != null) return replaced;
if (parseToolMentionTokens(prompt).some((item) => item.type === tool.type && item.toolId === tool.id)) return prompt;
return [prompt.trim(), reference.mentionToken].filter(Boolean).join(" ");
}
export function canvasNodeMentionToken(nodeId: string) {
return `@[node:${nodeId}]`;
}
/** 这些工具类型的标签在提示词中唯一,再次选择时直接覆盖旧标签而不是追加。 */
const UNIQUE_TOOL_MENTION_TYPES = new Set(["style", "nine_grid", "effect"]);
/**
* 用新工具标签覆盖提示词中已有的同类型(style/nine_grid/effect)标签:
* 第一处原位替换,多余同类型标签连同其后一个空白一并清理。
* 没有同类型旧标签时返回 null,由调用方走普通插入。
*/
export function overwriteSameTypeToolMention(prompt: string, reference: CanvasResourceReference): string | null {
if (reference.kind !== "tool" || reference.toolId == null) return null;
const toolType = reference.toolType ?? "nine_grid";
if (!UNIQUE_TOOL_MENTION_TYPES.has(toolType)) return null;
const token = canvasResourceMentionToken(reference);
const pattern = new RegExp(`@\\[tool:${escapeRegExp(toolType)}:\\d+:[^\\]]*\\]`, "gu");
const segments = prompt.split(pattern);
if (segments.length === 1) return null;
let result = `${segments[0]}${token}${segments[1]}`;
for (let index = 2; index < segments.length; index += 1) {
result += segments[index].replace(/^\s/, "");
}
return result;
}
export function canvasResourceMentionToken(reference: CanvasResourceReference) {
if (reference.mentionToken) return reference.mentionToken;
if (reference.kind === "skill" && reference.skill?.skillId) return canvasSkillMentionToken(reference.skill.skillId);
if (reference.kind === "tool" && reference.toolId != null) return canvasToolMentionToken(reference.toolId, reference.label, reference.toolType ?? "nine_grid", reference.toolIcon ?? "Grid3x3");
if (reference.assetId) return `@[asset:${reference.assetId}]`;
return `@${reference.label}`;
}
export function normalizeCanvasNodeMentionTokens(prompt: string, references: CanvasResourceReference[]) {
return references.reduce((value, reference) => {
if (!reference.nodeId || reference.assetId || reference.kind === "skill") return value;
if (!reference.nodeId || reference.assetId || reference.kind === "skill" || reference.kind === "tool") return value;
return value.split(canvasNodeMentionToken(reference.nodeId)).join(`@${reference.label}`);
}, prompt);
}
@@ -59,7 +122,7 @@ export function normalizeCanvasNodeMentionTokens(prompt: string, references: Can
export function autoMentionCanvasResourceReferences(prompt: string, references: CanvasResourceReference[]) {
const referenceByName = new Map<string, CanvasResourceReference | null>();
references
.filter((reference) => reference.active && !reference.assetId && reference.kind !== "skill")
.filter((reference) => reference.active && !reference.assetId && reference.kind !== "skill" && reference.kind !== "tool")
.forEach((reference, index) => {
canvasResourceMentionNames(reference, index, true).forEach((name) => {
if (!referenceByName.has(name)) {
@@ -103,7 +166,7 @@ export function findCanvasResourceAutoLinkMatch(prompt: string, cursor: number,
if (/@(?:\[[^\]]*|[^\s@,.;:!?,。;:!?、)\]}】)]*)$/u.test(prefix)) return null;
const aliases = new Map<string, CanvasResourceReference | null>();
references
.filter((reference) => reference.active && !reference.assetId && reference.kind !== "skill")
.filter((reference) => reference.active && !reference.assetId && reference.kind !== "skill" && reference.kind !== "tool")
.forEach((reference, index) => {
canvasResourceMentionNames(reference, index, true).forEach((name) => {
if (!aliases.has(name)) aliases.set(name, reference);
@@ -158,7 +221,7 @@ const CANVAS_RESOURCE_MENTION_BOUNDARY = /(?:$|\s|[,.!?;:,。!?;:、)\
function canvasResourceReferenceMentionTokens(reference: CanvasResourceReference) {
const tokens = [canvasResourceMentionToken(reference), `@${reference.label}`];
if (reference.nodeId && !reference.assetId && reference.kind !== "skill") tokens.push(canvasNodeMentionToken(reference.nodeId));
if (reference.nodeId && !reference.assetId && reference.kind !== "skill" && reference.kind !== "tool") tokens.push(canvasNodeMentionToken(reference.nodeId));
return [...new Set(tokens.filter(Boolean))];
}
@@ -207,7 +270,7 @@ export function applyCanvasConnectionPromptSync(previousNodes: CanvasNodeData[],
}
function canvasNodeBoundReferences(references: CanvasResourceReference[]) {
return references.filter((reference) => reference.nodeId && !reference.assetId && reference.kind !== "skill");
return references.filter((reference) => reference.nodeId && !reference.assetId && reference.kind !== "skill" && reference.kind !== "tool");
}
function canvasReferenceIdentityChanged(previousReferences: CanvasResourceReference[], nextReferences: CanvasResourceReference[]) {
@@ -461,7 +524,7 @@ function getConnectedConfigResourceNodes(nodeId: string, nodes: CanvasNodeData[]
}
function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) {
const counts: Record<CanvasResourceKind, number> = { image: 0, video: 0, audio: 0, text: 0, skill: 0, character: 0 };
const counts: Record<CanvasResourceKind, number> = { image: 0, video: 0, audio: 0, text: 0, skill: 0, character: 0, tool: 0 };
let drawingCount = 0;
return nodes.flatMap((node): CanvasResourceReference[] => {
const kind = resourceKind(node);
@@ -500,6 +563,7 @@ function labelForKind(kind: CanvasResourceKind, index: number) {
if (kind === "video") return seedanceReferenceLabel("video", index);
if (kind === "audio") return seedanceReferenceLabel("audio", index);
if (kind === "skill") return `技能${index + 1}`;
if (kind === "tool") return `工具${index + 1}`;
return `文本${index + 1}`;
}
@@ -518,3 +582,18 @@ function skillResourceText(node: CanvasNodeData) {
if (!skill) return node.metadata?.content || "";
return [skill.name, skill.description, skill.template, skill.outputContract].filter(Boolean).join("\n\n");
}
export function buildToolMentionReference(toolId: number, label: string, type: string, icon: string): CanvasResourceReference {
return {
id: `tool:${toolId}`,
nodeId: `tool:${toolId}`,
kind: "tool",
label,
title: label,
active: true,
toolId,
toolType: type,
toolIcon: icon,
mentionToken: canvasToolMentionToken(toolId, label, type, icon),
};
}

View File

@@ -1,6 +1,7 @@
import { applyStyleExecutionPlan, createStyleProfileSnapshot, parseStyleProfile, resolveStyleExecutionPlan, serializeStyleProfile, type StyleExecutionPlan, type StyleProfileSnapshot } from "@/lib/canvas/style-profile";
import { logicalModelIDForConfig, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import type { CanvasNodeData } from "@/types/canvas";
import { parseToolMentionTokens } from "@/lib/canvas/canvas-resource-references";
export type CanvasStyleExecutionRuntime = {
profile: StyleProfileSnapshot;
@@ -10,6 +11,7 @@ export type CanvasStyleExecutionRuntime = {
};
export function resolveCanvasStyleExecution(nodes: CanvasNodeData[], sourceNode: CanvasNodeData | undefined, prompt: string, config: AiConfig, mode: "image" | "video"): CanvasStyleExecutionRuntime | null {
if (mode === "image" && parseToolMentionTokens(prompt).some((tool) => tool.type === "style")) return null;
const styleNode = nodes.find((node) => node.metadata?.workflowKind === "styleboard");
if (!styleNode || sourceNode?.metadata?.workflowKind === "styleboard") return null;
const profile = parseStyleProfile(styleNode.metadata?.styleProfileJson) || legacyStyleNodeProfile(styleNode);

View File

@@ -0,0 +1,38 @@
import type { ToolScope } from "@/services/api/tools";
// 各子 Tab 的独立标签集,与 backend internal/tools/seed/tools.json 的 tags 对齐
export const SUB_TAB_TAGS: Record<"style" | "motion", Array<{ id: string; label: string }>> = {
style: [
{ id: "period", label: "古装" },
{ id: "city", label: "都市" },
{ id: "decade", label: "年代" },
{ id: "life", label: "生活" },
{ id: "science_fiction", label: "科幻" },
{ id: "type", label: "类型" },
{ id: "poetic", label: "写意" },
{ id: "animation", label: "动画" },
{ id: "drawing", label: "绘画" },
{ id: "myth", label: "神话" },
],
motion: [
{ id: "basic", label: "基础控制" },
{ id: "follow", label: "人物跟拍" },
{ id: "reveal", label: "揭示转场" },
{ id: "emotion", label: "情绪强化" },
{ id: "aerial", label: "空间航拍" },
],
};
type FeedTab = ToolScope;
export const FEED_TABS: Array<{ id: FeedTab; label: string }> = [
{ id: "public", label: "公共" },
{ id: "favorites", label: "收藏" },
{ id: "custom", label: "自定义" },
];
export function toAbsoluteUrl(value?: string) {
const text = (value || "").trim();
if (/^https?:\/\//i.test(text)) return text;
if (text.startsWith("/") && !text.startsWith("//")) return new URL(text, window.location.origin).href;
return "";
}

View File

@@ -1,4 +1,4 @@
import { Eraser, FolderOpen, Hand, MousePointer2, Palette, Plus, Redo2, Settings2, Trash2, Undo2 } from "lucide-react";
import { PanelsTopLeft, Eraser, FolderOpen, Hand, MousePointer2, Palette, Plus, Redo2, Settings2, Trash2, Undo2 } from "lucide-react";
import { registerToolbarTools, type ToolDefinition } from "@/lib/canvas/tool-registry";
import type { CanvasToolMode } from "@/types/canvas";
@@ -9,6 +9,7 @@ const canvasModeOptions = [
];
export const mainToolbarTools: ToolDefinition[] = [
{ id: "tool-workspace", toolbar: "main", category: "navigation", label: "工作区", icon: <PanelsTopLeft />, defaultVisible: true, defaultOrder: 65, run: ctx => ctx.handlers.onOpenWorkspace?.() },
{
id: "tool-canvas-mode",
toolbar: "main",

View File

@@ -7,7 +7,7 @@ import type { CanvasNodeData, CanvasNodeMetadata, CanvasNodeTypeId, CanvasToolMo
/** 工具栏标识——每个工具栏有独立的注册表与偏好 */
export type ToolbarId = "main" | "selection" | "node-hover" | "add-node-menu";
export type NodeToolbarGroup = "primary" | "portrait" | "viewpoint" | "lighting" | "panorama" | "process" | "workspace" | "utility" | "more";
export type NodeToolbarGroup = "primary" | "portrait" | "viewpoint" | "lighting" | "panorama" | "process" | "nine_grid" | "workspace" | "utility" | "more";
/** 工具分类——用于分组渲染、危险隔离与 separator 自动插入 */
export type ToolCategory =
@@ -50,6 +50,7 @@ export type ToolbarHandlers = {
onOpenDirector: () => void;
// 主工具栏——资源
onUpload: () => void;
onOpenWorkspace?: () => void;
onOpenMyAssets: () => void;
onOpenProjectCharacters: () => void;
// 主工具栏——外观