feat(web): 角色与音频工作台 - 完善素材选择、生成参数与同步交互
This commit is contained in:
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 {
|
||||
|
||||
@@ -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 只读到清单,说成"读取了图片节点"会被读成看过画面。
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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() },
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user