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