feat(web): 角色与音频工作台 - 完善素材选择、生成参数与同步交互

This commit is contained in:
ddcat666 committed 2026-10-01 09:43:02 +08:00
1 parent 3aa874a70c
commit 12125e18f6
77 files changed
+3641 -721

No files matched your search

@@ -31,7 +31,7 @@ function shortReference(value: unknown): string {
function nodeTypeLabel(value: unknown): string {
const type = text(value, 32).toLowerCase();
return ({ text: "文本", markdown: "Markdown", image: "图片", video: "视频", audio: "音频", frame: "画框", script: "脚本" } as Record<string, string>)[type] || (type ? "节点" : "");
return ({ text: "文本", markdown: "Markdown", image: "图片", video: "视频", audio: "音频", frame: "画框", script: "脚本", character: "角色卡" } as Record<string, string>)[type] || (type ? "节点" : "");
}
function fieldLabel(key: string, nodeType: string): string {
@@ -41,7 +41,7 @@ function fieldLabel(key: string, nodeType: string): string {
}
function operation(value: unknown): AgentApprovalPreviewOperation | null {
return value === "add_node" || value === "update_node" || value === "connect_nodes" || value === "arrange_nodes" || value === "generate_media" || value === "create_storyboard" || value === "edit_storyboard" || value === "plan_step" ? value : null;
return value === "add_node" || value === "update_node" || value === "connect_nodes" || value === "arrange_nodes" || value === "generate_media" || value === "create_storyboard" || value === "edit_storyboard" || value === "create_character" || value === "plan_step" ? value : null;
}
function normalizeServerPreview(value: unknown): AgentApprovalPresentation | null {
+2 -2
View File
@@ -67,9 +67,9 @@ export function agentCanvasActions(toolName: string, detail: unknown, references
}
export function agentCanvasActionLabel(action: AgentCanvasAction) {
const kind = ({ image: "图片", video: "视频", audio: "音频", text: "文本", markdown: "Markdown", script: "分镜" } as Record<string, string>)[action.nodeType] || "";
const kind = ({ image: "图片", video: "视频", audio: "音频", text: "文本", markdown: "Markdown", script: "分镜", character: "角色卡" } as Record<string, string>)[action.nodeType] || "";
if (action.action === "referenced" && action.targetTitle) {
const targetKind = ({ image: "图片", video: "视频", audio: "音频", text: "文本", markdown: "Markdown", script: "分镜" } as Record<string, string>)[action.targetNodeType || ""] || "";
const targetKind = ({ image: "图片", video: "视频", audio: "音频", text: "文本", markdown: "Markdown", script: "分镜", character: "角色卡" } as Record<string, string>)[action.targetNodeType || ""] || "";
return `建立引用:${kind}节点《${action.title}》 → ${targetKind}节点《${action.targetTitle}》`;
}
// read 的动词必须是"存在式":canvas_get_state 只读到清单,说成"读取了图片节点"会被读成看过画面。
+5 -4
View File
@@ -83,14 +83,15 @@ export function applyAgentCanvasPatch(project: CanvasProject, patch: AgentCanvas
return { ...project, nodes, connections, updatedAt: patch.updatedAt || project.updatedAt };
}
export function mergeAgentCanvasEditor(previous: CanvasProject, incoming: CanvasProject, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
export function mergeAgentCanvasEditor(previous: CanvasProject, incoming: CanvasProject, nodes: CanvasNodeData[], connections: CanvasConnection[], editorProject?: CanvasProject) {
const changes = <T extends { id: string }>(before: T[], after: T[]): Change<T>[] => {
const byId = new Map(before.map((item) => [item.id, item]));
const afterIds = new Set(after.map((item) => item.id));
return [...after.filter((item) => !equal(item, byId.get(item.id))).map((item) => ({ before: byId.get(item.id) ?? null, after: item })), ...before.filter((item) => !afterIds.has(item.id)).map((item) => ({ before: item, after: null }))];
};
const editorState = editorProject || { ...previous, nodes, connections };
const projected = applyAgentCanvasPatch(
{ ...previous, nodes, connections },
{ ...previous, nodes: editorState.nodes, connections: editorState.connections },
{
canvasId: previous.id,
updatedAt: incoming.updatedAt,
@@ -101,12 +102,12 @@ export function mergeAgentCanvasEditor(previous: CanvasProject, incoming: Canvas
const merged = { ...projected } as CanvasProject & Record<string, unknown>;
const before = previous as CanvasProject & Record<string, unknown>;
const after = incoming as CanvasProject & Record<string, unknown>;
const editor = { ...previous, nodes, connections } as CanvasProject & Record<string, unknown>;
const editor = editorState as CanvasProject & Record<string, unknown>;
for (const key of new Set([...Object.keys(before), ...Object.keys(after), ...Object.keys(editor)])) {
if (["id", "revision", "updatedAt", "remoteContentHash", "viewport", "nodes", "connections"].includes(key)) continue;
const value = mergeValue(editor[key], before[key], after[key]);
if (value === undefined) delete merged[key];
else merged[key] = value;
}
return { ...merged, revision: incoming.revision, updatedAt: incoming.updatedAt, viewport: editor.viewport } as CanvasProject;
return { ...merged, revision: incoming.revision, updatedAt: incoming.updatedAt, viewport: editorState.viewport } as CanvasProject;
}
@@ -13,6 +13,7 @@ export const AGENT_TOOL_NAMES = [
"ask_user",
"canvas_apply_ops",
"canvas_arrange_nodes",
"canvas_create_character",
"canvas_create_storyboard",
"canvas_edit_batch_table",
"canvas_edit_storyboard",
@@ -50,6 +51,7 @@ export const AGENT_TOOL_METADATA: Record<string, AgentToolMetadataEntry> = {
task_get: { summary: "已查询任务状态", failureMessage: "查询任务状态失败" },
canvas_apply_ops: { summary: ({ pending }) => (pending ? "准备更新画布内容" : "画布内容已保存至服务端"), failureMessage: "更新画布内容失败" },
model_list: { summary: "已获取可用模型", failureMessage: "获取可用模型失败" },
canvas_create_character: { summary: ({ pending }) => (pending ? "准备创建角色卡" : "角色卡已创建并放到画布"), failureMessage: "创建角色卡失败" },
generate_media: { summary: ({ pending, detail }) => (pending ? "准备创建媒体节点并生成" : field(detail, "eventType") === "tool_completed" ? "生成结果已回写画布节点" : "媒体节点已创建,生成任务已提交"), failureMessage: "媒体生成未完成" },
// 看图:只有这个工具会把图片字节真的交给模型,所以只有它可以说"画面/看图"。
// 回执字段来源:`backend/internal/app/cloud_agent_runtime.go`(payload["result"] = 回执)。
@@ -77,7 +79,7 @@ const AGENT_CANVAS_READ_TOOLS = new Set(["canvas_get_state", "canvas_list_node_t
/** 非画布信息的只读工具(模型、任务、技能、偏好)。`skills_load` 是历史事件名,保留兼容。 */
const AGENT_INFO_READ_TOOLS = new Set(["model_list", "task_get", "skill_search", "skill_read_file", "skills_load", "agent_profile_read"]);
const AGENT_VISION_TOOLS = new Set(["canvas_inspect_image"]);
const AGENT_CREATE_TOOLS = new Set(["generate_media", "canvas_create_storyboard", "image_annotation_render"]);
const AGENT_CREATE_TOOLS = new Set(["generate_media", "canvas_create_storyboard", "canvas_create_character", "image_annotation_render"]);
const AGENT_OPERATE_TOOLS = new Set(["canvas_apply_ops", "canvas_arrange_nodes", "canvas_edit_storyboard", "canvas_edit_batch_table", "image_layer_split", "image_text_detect"]);
export type AgentToolCategory = "read" | "vision" | "create" | "operate" | "other";
+21 -3
View File
@@ -1,5 +1,6 @@
import { maxModelInputCapacity, type ModelInputSummary } from "@/lib/model-selection";
import { audioModelForConnection, audioReferenceCapacity, doubaoAudioInputError, isDoubaoAudioModel, maxModelInputCapacity, resolveCompatibleModel, type ModelInputSummary } from "@/lib/model-selection";
import { getNodeAcceptedInputKinds, getNodeGenerationMode, getNodeInputKind, getNodeMaxInputCount } from "@/lib/canvas/node-registry";
import { readNodeGenerationSpec, resolveGenerationSelection } from "@/lib/canvas/generation-contract";
import type { AiConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
@@ -49,11 +50,28 @@ export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[],
return options.ignoreCapacity ? "" : capacityError(config, mode, "image", visualInputCount, "参考图") || capacityError(config, mode, "video", input.videoCount, "参考视频") || capacityError(config, mode, "audio", input.audioCount, "参考音频");
}
if (mode === "text" && input.audioCount > 0) return "文本生成节点不能连接参考音频";
if (mode === "audio" && input.characterCount > 1) return "角色配音一次只能连接一个角色卡";
if (mode === "audio" && (input.imageCount > 0 || input.videoCount > 0 || input.audioCount > 0)) return "音频生成节点只接受文本或单个角色卡输入";
if (mode === "audio") {
const model = routedAudioModel(config, target, input);
if (isDoubaoAudioModel(config, model)) return doubaoAudioInputError(input);
if (input.imageCount > 0 || input.videoCount > 0) return "音频生成节点不能连接参考图片或参考视频";
if (input.characterCount > 1) return "角色配音一次只能连接一个角色卡";
const maxAudios = audioReferenceCapacity(config, model);
if (input.audioCount > maxAudios) {
return maxAudios > 0 ? `音频生成节点最多连接 ${maxAudios} 个参考音频` : "当前音频模型不支持参考音频";
}
}
return "";
}
function routedAudioModel(config: AiConfig, target: CanvasNodeData, input: ModelInputSummary) {
const generationSpec = readNodeGenerationSpec(target);
const selectedModel = generationSpec?.mode === "audio" ? resolveGenerationSelection(config, generationSpec.modelSelection) : "";
const explicit = audioModelForConnection(config, selectedModel || target.metadata?.model || "");
// 同名模型组会在生成时改选到能接受当前参考的成员。连线必须按这个结果判断,
// 不能拿组里一个不支持图片的模型把豆包音频挡掉。
return resolveCompatibleModel(config, explicit, { capability: "audio", input }) || explicit;
}
function acceptedInputKindLabel(kind: "image" | "video" | "audio" | "text" | "table_data") {
if (kind === "image") return "图片";
if (kind === "video") return "视频";
@@ -19,6 +19,8 @@ export type CanvasGenerationRequestFingerprintInput = {
parameters?: Record<string, unknown>;
};
operation?: string;
audioLanguage?: string;
audioDialect?: string;
audioInstructions?: string;
audioEmotionControlMethod?: string;
audioEmotionRandom?: string;
@@ -54,6 +56,8 @@ export function canvasGenerationRequestFingerprint(input: CanvasGenerationReques
options: input.options,
workflow: input.workflow,
operation: input.operation,
audioLanguage: input.audioLanguage,
audioDialect: input.audioDialect,
audioInstructions: input.audioInstructions,
audioEmotionControlMethod: input.audioEmotionControlMethod,
audioEmotionRandom: input.audioEmotionRandom,
@@ -241,6 +241,8 @@ const NODE_MODEL_GENERATION_PARAMS: ReadonlyArray<keyof CanvasNodeMetadata> = [
"audioVoice",
"audioFormat",
"audioSpeed",
"audioLanguage",
"audioDialect",
"audioInstructions",
];
@@ -241,6 +241,8 @@ export function buildAudioGenerationMetadata(config: AiConfig): CanvasNodeMetada
audioVoice: config.audioVoice,
audioFormat: config.audioFormat,
audioSpeed: config.audioSpeed,
audioLanguage: config.audioLanguage,
audioDialect: config.audioDialect,
audioInstructions: config.audioInstructions,
audioEmotionControlMethod: config.audioEmotionControlMethod,
audioEmotionRandom: config.audioEmotionRandom,
@@ -447,6 +449,8 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
audioVoice: node?.metadata?.audioVoice ?? config.audioVoice ?? defaultConfig.audioVoice,
audioFormat: node?.metadata?.audioFormat ?? config.audioFormat ?? defaultConfig.audioFormat,
audioSpeed: node?.metadata?.audioSpeed ?? config.audioSpeed ?? defaultConfig.audioSpeed,
audioLanguage: node?.metadata?.audioLanguage ?? config.audioLanguage ?? defaultConfig.audioLanguage,
audioDialect: node?.metadata?.audioDialect ?? config.audioDialect ?? defaultConfig.audioDialect,
audioInstructions: node?.metadata?.audioInstructions ?? config.audioInstructions ?? defaultConfig.audioInstructions,
count: String(node?.metadata?.count ?? (mode === "image" ? config.canvasImageCount || config.count || defaultConfig.count : config.count || defaultConfig.count)),
};
@@ -16,6 +16,8 @@ export type Options = {
audioVoice?: string;
audioFormat?: string;
audioSpeed?: string;
audioLanguage?: string;
audioDialect?: string;
audioInstructions?: string;
};
@@ -141,6 +143,24 @@ export const GENERATION_OPTION_FIELDS = [
"node": "audioSpeed",
"task": "audioSpeed"
},
{
"kind": "string",
"modes": [
"audio"
],
"name": "audioLanguage",
"node": "audioLanguage",
"task": "audioLanguage"
},
{
"kind": "string",
"modes": [
"audio"
],
"name": "audioDialect",
"node": "audioDialect",
"task": "audioDialect"
},
{
"kind": "string",
"modes": [
@@ -91,7 +91,7 @@ const BUILTIN_NODE_TRAITS = {
icon: <Music2 />,
minSize: DEFAULT_MIN_SIZE,
showInCreateMenu: true,
resourceKind: (node: CanvasNodeData) => (node.metadata?.content ? "audio" : null),
resourceKind: (node: CanvasNodeData) => (node.metadata?.content || node.metadata?.storageKey ? "audio" : null),
generationMode: () => "audio",
inputKind: "audio",
},
@@ -19,6 +19,7 @@ export const addNodeMenuCommands: AddNodeMenuCommand[] = [
nodeCommand(CanvasNodeType.Frame, { defaultOrder: 40, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddFrame() }),
{ id: "folder", label: "文件夹", icon: <Folder />, badge: "6 款", section: "node", defaultOrder: 45, run: (ctx) => ctx.handlers.onAddFolder() },
nodeCommand(CanvasNodeType.Image, { defaultOrder: 50, run: (ctx) => ctx.handlers.onAddImage() }),
{ id: "project-character", label: "角色卡", icon: <UserRound />, section: "node", defaultOrder: 55, run: (ctx) => ctx.handlers.onOpenProjectCharacters() },
nodeCommand(CanvasNodeType.Video, { defaultOrder: 60, run: (ctx) => ctx.handlers.onAddVideo() }),
nodeCommand(CanvasNodeType.BatchTable, { defaultOrder: 66, run: (ctx) => ctx.handlers.onAddExtensionNode(CanvasNodeType.BatchTable) }),
nodeCommand(CanvasNodeType.MediaConversion, { badge: "本地", defaultOrder: 65, run: (ctx) => ctx.handlers.onAddExtensionNode(CanvasNodeType.MediaConversion) }),
@@ -29,7 +30,6 @@ export const addNodeMenuCommands: AddNodeMenuCommand[] = [
{ id: "workflow", label: "工作流", icon: <Workflow />, section: "workflow", defaultOrder: 10, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddWorkflow() },
// 导入资源
{ id: "upload", label: "上传文件", icon: <UploadCloud />, section: "resource", defaultOrder: 10, run: (ctx) => ctx.handlers.onUpload() },
{ id: "project-character", label: "添加角色卡", icon: <UserRound />, section: "resource", defaultOrder: 20, applicable: (ctx) => ctx.isProjectLinked, run: (ctx) => ctx.handlers.onOpenProjectCharacters() },
{ id: "assets", label: "素材库", icon: <FolderOpen />, section: "resource", defaultOrder: 30, applicable: (ctx) => !ctx.isProjectLinked, run: (ctx) => ctx.handlers.onOpenMyAssets() },
];