refactor(*): 稳定期治理 - 收敛核心架构、错误语义并修复跨项目数据竞态

This commit is contained in:
ddCat
2026-09-09 22:02:10 +08:00
parent bacd502f5c
commit b5f3413ded
298 changed files with 12681 additions and 26428 deletions

View File

@@ -1,5 +1,6 @@
import { App, Button, Dropdown, Modal, Popconfirm } from "antd";
import { App, Button, Dropdown, Popconfirm } from "antd";
import type { MenuProps } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { Check, ChevronDown, FileText, FolderOpen, HardDrive, Image as ImageIcon, LoaderCircle, Music2, Puzzle, RotateCcw, Search, Trash2, Upload, UserRound, Video } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
@@ -12,7 +13,7 @@ import { PaginationBar } from "@/components/layout/workspace-page";
import { cn } from "@/lib/utils";
import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/use-asset-store";
import { deleteAssetWithRemoteSync, loadAssetLibraryPage, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { deleteAssetWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
export type AssetPickerMediaKind = "image" | "video" | "audio" | "text";
@@ -271,8 +272,8 @@ export function AssetLibraryPickerModal({
setSelected(new Set());
message.success(`已还原 ${archivedSelectedIds.length} 个素材至素材库`);
setCategory("all");
} catch {
message.warning("已在本地还原,稍后自动同步至云端");
} catch (error) {
message.warning(localSavedRemotePendingMessage("已在本地还原", error));
} finally {
setWorking(false);
if (remoteEnabled) void remoteQuery.refetch();
@@ -380,7 +381,7 @@ export function AssetLibraryPickerModal({
const uploading = uploadingCount > 0;
return (
<Modal
<AppModal
centered
open={open}
footer={null}
@@ -393,7 +394,7 @@ export function AssetLibraryPickerModal({
if (!working) onClose();
}}
className="workspace-modal workspace-modal-wide asset-library-picker-modal"
styles={{ container: { padding: 0 }, body: { padding: 0 } }}
flush
>
<div className="asset-picker-shell">
<header className="asset-picker-toolbar">
@@ -559,7 +560,7 @@ export function AssetLibraryPickerModal({
</div>
</footer>
</div>
</Modal>
</AppModal>
);
}

View File

@@ -1,4 +1,5 @@
import { Button, Modal, Tag } from "antd";
import { Button, Tag } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { EmptyState } from "@/components/ui/product/empty-state";
import { ArrowLeft, ArrowRight, CheckCircle2, ChevronDown, ChevronRight, Copy, FileText, Image as ImageIcon, LoaderCircle, RefreshCw, Sparkles, Target, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -256,7 +257,7 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
const modelInlineLabel = selectedCritiqueModel || visibleState.report?.modelLabel ? `${modelStatusLabel} · ${displayedModelLabel}` : "请到设置中选择支持图片理解的文本模型";
return (
<Modal
<AppModal
open={open}
title={null}
closable={false}
@@ -266,7 +267,7 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
onCancel={close}
footer={null}
className="art-critique-modal"
styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}
flush
>
<div className="flex h-[min(820px,calc(100dvh-32px))] max-h-[calc(100dvh-32px)] min-h-0 flex-col overflow-hidden rounded-[var(--r-lg)]" style={{ background: theme.canvas.background, color: theme.node.text }}>
<header className="flex shrink-0 items-center gap-3 border-b px-5 py-3.5" style={{ borderColor: theme.node.edge }}>
@@ -465,7 +466,7 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
</aside>
</div>
</div>
</Modal>
</AppModal>
);
}

View File

@@ -340,7 +340,7 @@ export function AgentChatComposer({
};
const applySlashSkill = (skill: Skill) => {
const token = `@[skill:${skill.skill_id}] `;
const token = `@[skill:${skill.skillId}] `;
const next = slash ? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + slash.query.length)}` : prompt ? `${prompt.replace(/\s+$/u, "")} ${token}` : token;
setSlash(null);
setSlashIndex(0);
@@ -463,7 +463,7 @@ export function AgentChatComposer({
>
{availableSlashSkills.map((skill, index) => (
<button
key={skill.skill_id}
key={skill.skillId}
type="button"
className="flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs"
style={{ background: index === activeSlashIndex ? theme.toolbar.itemHover : "transparent", color: theme.node.text }}
@@ -471,7 +471,7 @@ export function AgentChatComposer({
onClick={() => applySlashSkill(skill)}
>
<Sparkles className="size-3.5 shrink-0 opacity-70" />
<span className="min-w-0 truncate font-medium">{skill.skill_name}</span>
<span className="min-w-0 truncate font-medium">{skill.skillName}</span>
{skill.description ? <span className="min-w-0 flex-1 truncate opacity-50">{skill.description}</span> : null}
</button>
))}

View File

@@ -0,0 +1,573 @@
import { nanoid } from "nanoid";
import { type ResponseInputMessage, type ResponseToolCall } from "@/services/api/image";
import { runBackendToolGenerationTask } from "@/services/api/generation-task";
import { imageToDataUrl } from "@/services/image-storage";
import { normalizeModelOptionValue, resolveModelRequestConfig, selectableModelsByCapability, type AiConfig } from "@/stores/use-config-store";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { CanvasNodeType, type CanvasAssistantMessage, type CanvasAssistantReference, type CanvasAssistantSession, type CanvasNodeData } from "@/types/canvas";
import { previewCanvasAgentOps, type CanvasAgentOp, type CanvasAgentOperationImpact, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { isWritableToolCall, buildCanvasOnlineAgentSystemContent } from "@/lib/canvas/canvas-agent-protocol";
import { CANVAS_ONLINE_AGENT_TOOLS } from "@/lib/canvas/canvas-agent-tools";
import { buildOrderedCanvasResourceReferences, canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references";
import { buildCanvasWorkflowOps, looksLikeWorkflowRequest, type CanvasWorkflowInput } from "@/lib/canvas/canvas-agent-workflow";
import type { Skill } from "@/services/api/skills";
export type OnlineToolResult = { ok: true; message: string; data?: unknown } | { ok: false; message: string };
export function objectDetail(value: unknown) {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
export function describeCanvasSnapshot(snapshot: CanvasAgentSnapshot) {
const counts = snapshot.nodes.reduce<Record<string, number>>((acc, node) => {
acc[node.type] = (acc[node.type] || 0) + 1;
return acc;
}, {});
return `当前画布有 ${snapshot.nodes.length} 个节点、${snapshot.connections.length} 条连线。背板 ${counts[CanvasNodeType.Frame] || 0} 个,文本 ${counts[CanvasNodeType.Text] || 0} 个,绘图 ${counts[CanvasNodeType.Drawing] || 0} 个,分镜脚本 ${counts[CanvasNodeType.Script] || 0} 个,技能 ${counts[CanvasNodeType.Skill] || 0} 个,图片 ${counts[CanvasNodeType.Image] || 0} 个,生成配置 ${counts[CanvasNodeType.Config] || 0} 个,视频 ${counts[CanvasNodeType.Video] || 0} 个,音频 ${counts[CanvasNodeType.Audio] || 0} 个。`;
}
export function parseToolArguments(value: string) {
try {
const parsed = JSON.parse(value || "{}");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("工具参数必须是 JSON 对象");
return parsed as Record<string, unknown>;
} catch {
throw new Error("工具参数不是合法 JSON 对象");
}
}
/**
* 把在线 Agent 的工具调用转换为可审计的画布操作,不在这里直接修改 store。
* 只读工具不会进入该函数;写操作先由上层确认,并以 snapshot 生成的 revision/hash 做并发保护,执行失败时保留原始工具消息和错误。
*/
export function onlineToolToOps(name: string, input: Record<string, unknown>, snapshot: CanvasAgentSnapshot, config: AiConfig): CanvasAgentOp[] {
if (name === "canvas_apply_ops") return requireOps(input.ops);
if (name === "canvas_create_workflow") return buildCanvasWorkflowOps(input as unknown as CanvasWorkflowInput, snapshot, config);
if (name === "canvas_create_node") {
const nodeType = requireNodeType(input.nodeType);
const x = numberOr(input.x, nextCanvasX(snapshot));
const y = numberOr(input.y, 0);
return [{ type: "add_node", nodeType, title: stringOptional(input.title), position: { x, y }, width: numberOptional(input.width), height: numberOptional(input.height), metadata: recordOptional(input.metadata) as CanvasNodeData["metadata"] }];
}
if (name === "canvas_create_text_node") return [textNodeOp(input, numberOr(input.x, nextCanvasX(snapshot)), numberOr(input.y, 0))];
if (name === "canvas_create_text_nodes") {
const items = requireRecordArray(input.items, "items");
const textBatch = items.map((item) => `${String(item.title || "")} ${String(item.text || "")}`).join(" ");
if (looksLikeWorkflowRequest(textBatch)) throw new Error("检测到流水线/工作流意图,请使用 canvas_create_workflow 创建真实类型节点和连线。" );
const x = numberOr(input.x, nextCanvasX(snapshot));
const y = numberOr(input.y, 0);
const gap = numberOr(input.gap, 40);
const direction = input.direction === "row" ? "row" : "column";
return items.map((item, index) =>
textNodeOp(
{ ...item, text: requireString(item.text, "text") },
numberOr(item.x, direction === "row" ? x + index * (NODE_DEFAULT_SIZE[CanvasNodeType.Text].width + gap) : x),
numberOr(item.y, direction === "row" ? y : y + index * (NODE_DEFAULT_SIZE[CanvasNodeType.Text].height + gap)),
),
);
}
if (name === "canvas_create_image_prompt_flow") return generationFlowOps({ ...input, mode: "image" }, snapshot, config);
if (name === "canvas_create_generation_flow") return generationFlowOps(input, snapshot, config);
if (name === "canvas_generate_text") return generationFlowOps({ ...input, mode: "text", autoRun: true }, snapshot, config);
if (name === "canvas_generate_image") return generationFlowOps({ ...input, mode: "image", autoRun: true }, snapshot, config);
if (name === "canvas_generate_video") return generationFlowOps({ ...input, mode: "video", autoRun: true }, snapshot, config);
if (name === "canvas_generate_audio") return generationFlowOps({ ...input, mode: "audio", autoRun: true }, snapshot, config);
if (name === "canvas_update_node") return [{ type: "update_node", id: requireString(input.id, "id"), patch: recordOptional(input.patch) as Partial<CanvasNodeData> | undefined, metadata: recordOptional(input.metadata) as CanvasNodeData["metadata"] }];
if (name === "canvas_update_node_text")
return [{ type: "update_node", id: requireString(input.id, "id"), patch: stringOptional(input.title) ? { title: stringOptional(input.title) } : undefined, metadata: { content: requireString(input.text, "text"), status: "success" } }];
if (name === "canvas_move_nodes") {
return requireRecordArray(input.items, "items").map((item) => {
const id = requireString(item.id, "id");
const current = snapshot.nodes.find((node) => node.id === id);
return { type: "update_node", id, patch: { position: { x: numberOr(item.x, (current?.position.x || 0) + numberOr(item.dx, 0)), y: numberOr(item.y, (current?.position.y || 0) + numberOr(item.dy, 0)) } } };
});
}
if (name === "canvas_resize_node")
return [
{
type: "update_node",
id: requireString(input.id, "id"),
patch: { width: requireNumber(input.width, "width"), height: requireNumber(input.height, "height") },
metadata: typeof input.freeResize === "boolean" ? { freeResize: input.freeResize } : undefined,
},
];
if (name === "canvas_delete_nodes") return [{ type: "delete_node", ids: requireStringArray(input.ids, "ids") }];
if (name === "canvas_connect_nodes")
return requireRecordArray(input.connections, "connections").map((connection) => ({ type: "connect_nodes", fromNodeId: requireString(connection.fromNodeId, "fromNodeId"), toNodeId: requireString(connection.toNodeId, "toNodeId") }));
if (name === "canvas_select_nodes") return [{ type: "select_nodes", ids: requireStringArray(input.ids, "ids") }];
if (name === "canvas_set_viewport") return [{ type: "set_viewport", viewport: requireViewport(input.viewport) }];
if (name === "canvas_run_generation") return [runGenerationOp(requireString(input.nodeId, "nodeId"), generationMode(input.mode), stringOptional(input.prompt), input.retry === true)];
throw new Error(`不支持的工具:${name}`);
}
function generationFlowOps(input: Record<string, unknown>, snapshot: CanvasAgentSnapshot, config: AiConfig): CanvasAgentOp[] {
const mode = generationMode(input.mode);
const prompt = requireString(input.prompt, "prompt");
const x = numberOr(input.x, nextCanvasX(snapshot));
const y = numberOr(input.y, 0);
const textId = `text-${nanoid()}`;
const targetId = `${mode}-${nanoid()}`;
const referenceNodeIds = Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds.filter((id): id is string => typeof id === "string") : [];
const promptNode: CanvasNodeData = {
id: textId,
type: CanvasNodeType.Text,
title: stringOptional(input.title) || "提示词",
position: { x, y },
width: NODE_DEFAULT_SIZE[CanvasNodeType.Text].width,
height: NODE_DEFAULT_SIZE[CanvasNodeType.Text].height,
metadata: { content: prompt },
};
const referenceNodes = referenceNodeIds.flatMap((id) => {
const node = snapshot.nodes.find((candidate) => candidate.id === id);
return node ? [node] : [];
});
const tokens = buildOrderedCanvasResourceReferences([promptNode, ...referenceNodes]).map(canvasResourceMentionToken);
return [
textNodeOp({ id: textId, text: prompt, title: stringOptional(input.title) || "提示词" }, x, y),
generationTargetNodeOp(targetId, { ...input, prompt: tokens.join("\n") }, x + NODE_DEFAULT_SIZE[CanvasNodeType.Text].width + 80, y, config),
{ type: "connect_nodes", fromNodeId: textId, toNodeId: targetId },
...referenceNodeIds.map((fromNodeId) => ({ type: "connect_nodes" as const, fromNodeId, toNodeId: targetId })),
{ type: "select_nodes", ids: [targetId] },
...(input.autoRun ? [runGenerationOp(targetId, mode, tokens.join("\n"))] : []),
];
}
function textNodeOp(input: Record<string, unknown>, x: number, y: number): CanvasAgentOp {
return {
type: "add_node",
id: stringOptional(input.id),
nodeType: CanvasNodeType.Text,
title: stringOptional(input.title),
position: { x, y },
width: numberOptional(input.width),
height: numberOptional(input.height),
metadata: { content: stringOptional(input.text), status: "success", fontSize: 14 },
};
}
function generationTargetNodeOp(id: string, input: Record<string, unknown>, x: number, y: number, config: AiConfig): CanvasAgentOp {
const mode = generationMode(input.mode);
const prompt = stringOptional(input.prompt);
const nodeType = generationNodeType(mode);
return {
type: "add_node",
id,
nodeType,
title: stringOptional(input.title) || generationTitle(mode),
position: { x, y },
width: numberOptional(input.width),
height: numberOptional(input.height),
metadata: cleanRecord({
content: "",
fontSize: nodeType === CanvasNodeType.Text ? 14 : undefined,
generationMode: mode,
composerContent: prompt,
prompt,
status: "idle",
model: resolveGenerationModel(config, mode, stringOptional(input.model)),
size: stringOptional(input.size) || config.size,
quality: stringOptional(input.quality) || config.quality,
transparentBackground: stringOptional(input.transparentBackground) || config.transparentBackground,
count: numberOptional(input.count) ?? generationCount(mode === "image" ? config.canvasImageCount || config.count : config.count),
seconds: stringOptional(input.seconds) || config.videoSeconds,
vquality: stringOptional(input.vquality) || config.vquality,
generateAudio: stringOptional(input.generateAudio) || config.videoGenerateAudio,
watermark: stringOptional(input.watermark) || config.videoWatermark,
audioVoice: stringOptional(input.audioVoice) || config.audioVoice,
audioFormat: stringOptional(input.audioFormat) || config.audioFormat,
audioSpeed: stringOptional(input.audioSpeed) || config.audioSpeed,
audioInstructions: stringOptional(input.audioInstructions) || config.audioInstructions,
}) as CanvasNodeData["metadata"],
};
}
function generationNodeType(mode: "text" | "image" | "video" | "audio") {
if (mode === "text") return CanvasNodeType.Text;
if (mode === "video") return CanvasNodeType.Video;
if (mode === "audio") return CanvasNodeType.Audio;
return CanvasNodeType.Image;
}
function runGenerationOp(nodeId: string, mode: "text" | "image" | "video" | "audio", prompt?: string, retry?: boolean): CanvasAgentOp {
return { type: "run_generation", nodeId, mode, prompt, ...(retry ? { retry: true } : {}) };
}
export function toolCallsFromDetail(detail: Record<string, unknown>): ResponseToolCall[] {
return Array.isArray(detail.toolCalls) ? (detail.toolCalls.filter(isResponseToolCall) as ResponseToolCall[]) : [];
}
function isResponseToolCall(value: unknown): value is ResponseToolCall {
const item = objectDetail(value);
const fn = objectDetail(item.function);
return typeof item.id === "string" && item.type === "function" && typeof fn.name === "string" && typeof fn.arguments === "string";
}
export function toolCallToResponseInput(call: ResponseToolCall): ResponseInputMessage {
return { type: "function_call", call_id: call.id, name: call.function.name, arguments: call.function.arguments, ...(call.thoughtSignature ? { thoughtSignature: call.thoughtSignature } : {}) };
}
// 上游兼容兜底:DeepSeek 等 OpenAI 兼容渠道的思考/推理模式不允许
// tool_choice=required(后端归类文案见 providerPayloadErrorCategory)。
// required 只用于对话首步把智能体拖入工具循环;遇到该类拒绝时降级为 auto
// 重试一次——auto 在思考模式下可用,对话与工具调用均不受影响。
// 语义与 image.ts 对非 auto tool_choice 的兼容降级策略一致。
const THINKING_MODE_TOOL_CHOICE_REJECTION = "不支持强制工具调用";
export async function requestOnlineAgentModel(config: AiConfig, messages: ResponseInputMessage[], toolChoice: "auto" | "required", prompt: string, onDelta: (text: string) => void) {
const submit = (choice: "auto" | "required") => runBackendToolGenerationTask({ prompt, config, messages, tools: CANVAS_ONLINE_AGENT_TOOLS, toolChoice: choice, onDelta });
try {
return await submit(toolChoice);
} catch (error) {
if (toolChoice !== "required" || !(error instanceof Error) || !error.message.includes(THINKING_MODE_TOOL_CHOICE_REJECTION)) {
throw error;
}
return submit("auto");
}
}
export function summarizeToolCalls(calls: ResponseToolCall[]) {
return calls.map((call) => toolCallLabel(call.function.name)).join(",") || "工具调用";
}
export function previewOnlineToolCalls(calls: ResponseToolCall[], snapshot: CanvasAgentSnapshot, config: AiConfig): CanvasAgentOperationImpact {
const ops: CanvasAgentOp[] = [];
let deferredCinematicCount = 0;
calls.filter(isWritableToolCall).forEach((call) => {
if (call.function.name === "canvas_create_cinematic_session") {
deferredCinematicCount += 1;
return;
}
try {
ops.push(...onlineToolToOps(call.function.name, parseToolArguments(call.function.arguments), snapshot, config));
} catch {
// 参数错误会在真正执行时显式失败;预览阶段只展示可确定的影响。
}
});
const impact = previewCanvasAgentOps(ops, snapshot);
if (!deferredCinematicCount) return impact;
return {
...impact,
operationCount: impact.operationCount + deferredCinematicCount,
items: [...impact.items, "启动影视 Agent,会话完成后将剧本、分镜和生成节点写回当前画布"].slice(0, 8),
warning: [impact.warning, "影视 Agent 的具体写回范围将在后端完成拆解后确定。"].filter(Boolean).join(" "),
};
}
function toolCallLabel(name: string) {
if (name === "canvas_list_skills") return "列出技能";
if (name === "canvas_get_skill") return "读取技能入口";
if (name === "canvas_list_skill_files") return "列出技能文件";
if (name === "canvas_read_skill_file") return "读取技能文件";
if (name === "canvas_search_skill_files") return "搜索技能文件";
if (name === "canvas_apply_ops") return "画布操作";
if (name === "canvas_get_state") return "读取画布";
if (name === "canvas_get_context") return "读取上下文";
if (name === "canvas_find_nodes") return "检索节点";
if (name === "canvas_get_node") return "读取节点";
if (name === "canvas_get_connection") return "读取连线";
if (name === "canvas_get_generation_tasks") return "读取生成任务";
if (name === "canvas_get_resources") return "读取资源";
if (name === "canvas_validate_ops") return "校验操作";
if (name === "canvas_get_selection") return "读取选区";
if (name === "canvas_export_snapshot") return "导出快照";
if (name === "canvas_create_cinematic_session") return "创建影视项目";
if (name === "canvas_create_workflow") return "创建工作流";
if (name === "canvas_create_node") return "创建节点";
if (name === "canvas_create_text_node") return "创建文本";
if (name === "canvas_create_text_nodes") return "批量创建文本";
if (name === "canvas_create_image_prompt_flow") return "创建生图流程";
if (name === "canvas_create_generation_flow") return "创建生成流程";
if (name === "canvas_generate_text") return "生成文本";
if (name === "canvas_generate_image") return "生成图片";
if (name === "canvas_generate_video") return "生成视频";
if (name === "canvas_generate_audio") return "生成音频";
if (name === "canvas_update_node") return "更新节点";
if (name === "canvas_update_node_text") return "更新文本";
if (name === "canvas_move_nodes") return "移动节点";
if (name === "canvas_resize_node") return "调整节点尺寸";
if (name === "canvas_delete_nodes") return "删除节点";
if (name === "canvas_connect_nodes") return "连接节点";
if (name === "canvas_select_nodes") return "选择节点";
if (name === "canvas_set_viewport") return "调整视口";
if (name === "canvas_run_generation") return "触发生成";
return name;
}
export function toolResultText(result: OnlineToolResult) {
return result.message;
}
function requireStringArray(value: unknown, field: string): string[] {
if (!Array.isArray(value)) throw new Error(`${field} 必须是字符串数组`);
if (!value.every((item) => typeof item === "string" && Boolean(item))) throw new Error(`${field} 必须只包含非空字符串`);
return value as string[];
}
export function requireOps(value: unknown): CanvasAgentOp[] {
if (!Array.isArray(value)) throw new Error("ops 必须是数组");
return value.map(toCanvasAgentOp);
}
function toCanvasAgentOp(value: unknown): CanvasAgentOp {
const item = objectDetail(value);
const type = item.type;
if (type === "add_node") {
return {
type,
id: stringOptional(item.id),
nodeType: item.nodeType ? requireNodeType(item.nodeType) : undefined,
title: stringOptional(item.title),
position: recordOptional(item.position) ? { x: requireNumber(objectDetail(item.position).x, "position.x"), y: requireNumber(objectDetail(item.position).y, "position.y") } : undefined,
x: numberOptional(item.x),
y: numberOptional(item.y),
width: numberOptional(item.width),
height: numberOptional(item.height),
metadata: recordOptional(item.metadata) as CanvasNodeData["metadata"],
};
}
if (type === "update_node") return { type, id: requireString(item.id, "id"), patch: recordOptional(item.patch) as Partial<CanvasNodeData> | undefined, metadata: recordOptional(item.metadata) as CanvasNodeData["metadata"] };
if (type === "delete_node") return { type, id: stringOptional(item.id), ids: Array.isArray(item.ids) ? requireStringArray(item.ids, "ids") : undefined };
if (type === "delete_connections") return { type, id: stringOptional(item.id), ids: Array.isArray(item.ids) ? requireStringArray(item.ids, "ids") : undefined, all: typeof item.all === "boolean" ? item.all : undefined };
if (type === "connect_nodes") return { type, id: stringOptional(item.id), fromNodeId: requireString(item.fromNodeId, "fromNodeId"), toNodeId: requireString(item.toNodeId, "toNodeId") };
if (type === "set_viewport") return { type, viewport: requireViewport(item.viewport) };
if (type === "select_nodes") return { type, ids: requireStringArray(item.ids, "ids") };
if (type === "run_generation") return { type, nodeId: requireString(item.nodeId, "nodeId"), mode: generationMode(item.mode), prompt: stringOptional(item.prompt), ...(item.retry === true ? { retry: true } : {}) };
throw new Error("不支持的画布操作类型");
}
function requireRecordArray(value: unknown, field: string): Record<string, unknown>[] {
if (!Array.isArray(value)) throw new Error(`${field} 必须是数组`);
return value.map((item) => {
const record = objectDetail(item);
if (!Object.keys(record).length) throw new Error(`${field} 必须只包含对象`);
return record;
});
}
export function requireString(value: unknown, field: string) {
if (typeof value !== "string" || !value) throw new Error(`${field} 必须是非空字符串`);
return value;
}
function requireNumber(value: unknown, field: string) {
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${field} 必须是数字`);
return value;
}
function requireNodeType(value: unknown): CanvasNodeType {
if (Object.values(CanvasNodeType).includes(value as CanvasNodeType)) return value as CanvasNodeType;
throw new Error("节点类型必须是 text、image、config、video 或 audio");
}
function requireViewport(value: unknown) {
const item = objectDetail(value);
return { x: requireNumber(item.x, "viewport.x"), y: requireNumber(item.y, "viewport.y"), k: requireNumber(item.k, "viewport.k") };
}
function recordOptional(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
}
function stringOptional(value: unknown) {
return typeof value === "string" ? value : "";
}
function numberOptional(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function numberOr(value: unknown, fallback: number) {
return numberOptional(value) ?? fallback;
}
function nextCanvasX(snapshot: CanvasAgentSnapshot) {
return snapshot.nodes.length ? Math.max(...snapshot.nodes.map((node) => node.position.x + node.width)) + 80 : 0;
}
function generationMode(value: unknown): "text" | "image" | "video" | "audio" {
return value === "text" || value === "video" || value === "audio" ? value : "image";
}
function generationTitle(mode: "text" | "image" | "video" | "audio") {
if (mode === "text") return "文本生成";
if (mode === "video") return "视频生成";
if (mode === "audio") return "音频生成";
return "图片生成";
}
function defaultGenerationModel(config: AiConfig, mode: "text" | "image" | "video" | "audio") {
if (mode === "image") return config.imageModel || config.model;
if (mode === "video") return config.videoModel || config.model;
if (mode === "audio") return config.audioModel || config.model;
return config.textModel || config.model;
}
function resolveGenerationModel(config: AiConfig, mode: "text" | "image" | "video" | "audio", model?: string) {
const normalized = normalizeModelOptionValue(model, config.channels);
return normalized && selectableModelsByCapability(config, mode).includes(normalized) ? normalized : defaultGenerationModel(config, mode);
}
function generationCount(value: string) {
return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1)));
}
function cleanRecord(value: Record<string, unknown>) {
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== ""));
}
export function snapshotSignature(snapshot: CanvasAgentSnapshot) {
return JSON.stringify({ nodes: snapshot.nodes, connections: snapshot.connections, selectedNodeIds: snapshot.selectedNodeIds, viewport: snapshot.viewport });
}
export function explainNoop(ops: CanvasAgentOp[], snapshot: CanvasAgentSnapshot) {
if (!ops.length) return "模型没有返回可执行的画布操作。";
const nodeIds = new Set(snapshot.nodes.map((node) => node.id));
const connectionIds = new Set(snapshot.connections.map((conn) => conn.id));
const deleteConnectionOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "delete_connections" }> => op.type === "delete_connections");
const connectOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "connect_nodes" }> => op.type === "connect_nodes");
const deleteNodeOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "delete_node" }> => op.type === "delete_node");
const updateOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "update_node" }> => op.type === "update_node");
const selectOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "select_nodes" }> => op.type === "select_nodes");
const generationOps = ops.filter((op): op is Extract<CanvasAgentOp, { type: "run_generation" }> => op.type === "run_generation");
if (deleteConnectionOps.length && !snapshot.connections.length) return "画布当前没有连线可删除。";
if (deleteConnectionOps.length && deleteConnectionOps.every((op) => !op.all && [...(op.ids || []), ...(op.id ? [op.id] : [])].every((id) => !connectionIds.has(id)))) return "没有找到要删除的连线。";
if (connectOps.length && connectOps.every((op) => snapshot.connections.some((conn) => conn.fromNodeId === op.fromNodeId && conn.toNodeId === op.toNodeId))) return "这些节点已经存在对应连线,无需重复连接。";
if (connectOps.length && connectOps.every((op) => !nodeIds.has(op.fromNodeId) || !nodeIds.has(op.toNodeId))) return "没有找到要连接的节点。";
if (deleteNodeOps.length && deleteNodeOps.every((op) => op.nodeType === CanvasNodeType.Config) && !snapshot.nodes.some((node) => node.type === CanvasNodeType.Config)) return "画布当前没有生成配置节点可删除。";
if (deleteNodeOps.length && deleteNodeOps.every((op) => [...(op.ids || []), ...(op.id ? [op.id] : [])].every((id) => !nodeIds.has(id)))) return "没有找到要删除的节点。";
if (updateOps.length && updateOps.every((op) => !nodeIds.has(op.id))) return "没有找到要更新的节点。";
if (selectOps.length && selectOps.every((op) => !(op.ids || []).some((id) => nodeIds.has(id)))) return "没有找到要选择的节点。";
if (generationOps.length && generationOps.every((op) => !nodeIds.has(op.nodeId))) return "没有找到要触发生成的节点。";
if (ops.every((op) => op.type === "set_viewport")) return "视图已经是目标状态。";
if (selectOps.length && selectOps.every((op) => JSON.stringify(op.ids || []) === JSON.stringify(snapshot.selectedNodeIds))) return "选区已经是目标状态。";
return "工具已执行,但画布状态没有变化;请在日志 tab 查看工具参数和执行前后状态。";
}
export function nodeToReference(node: CanvasNodeData): CanvasAssistantReference | null {
if (node.type === CanvasNodeType.Image && (node.metadata?.content || node.metadata?.storageKey)) {
return { id: node.id, type: node.type, title: node.title, dataUrl: node.metadata.content, storageKey: node.metadata.storageKey };
}
if (node.type === CanvasNodeType.Text && node.metadata?.content) {
return { id: node.id, type: node.type, title: node.title, text: node.metadata.content };
}
if (node.type === CanvasNodeType.Skill && node.metadata?.skillSnapshot) {
return { id: node.id, type: node.type, title: node.title, text: [node.metadata.skillSnapshot.name, node.metadata.skillSnapshot.template, node.metadata.skillSnapshot.outputContract].filter(Boolean).join("\n\n") };
}
return null;
}
export function buildAssistantReferences(nodes: CanvasNodeData[], selectedNodeIds: Set<string>) {
const nodeById = new Map(nodes.map((node) => [node.id, node]));
return Array.from(selectedNodeIds)
.map((id) => nodeById.get(id))
.filter((node): node is CanvasNodeData => Boolean(node))
.map(nodeToReference)
.filter((item): item is CanvasAssistantReference => Boolean(item));
}
export async function buildToolAgentMessages(snapshot: CanvasAgentSnapshot, history: CanvasAssistantMessage[], userMessage: CanvasAssistantMessage, skills: Skill[] = []): Promise<ResponseInputMessage[]> {
const refs = userMessage.references || [];
const skillCatalog = skills
.filter((skill) => skill.isAdded)
.slice(0, 40)
.map((skill) => `- ${skill.skillName}(${skill.skillId},v${skill.version || "1"},${skill.fileCount || 1} 文件):${skill.description}`)
.join("\n");
const systemContent = buildCanvasOnlineAgentSystemContent(skillCatalog);
return [
{ role: "system", content: systemContent },
...history
.filter((message) => message.role === "user" || message.role === "assistant" || message.role === "system")
.slice(-8)
.map((message): ResponseInputMessage => ({ role: message.role as "system" | "user" | "assistant", content: message.text })),
{
role: "user",
content: [
...refs.flatMap((item) => (item.text ? [{ type: "text" as const, text: `选中节点 ${item.title}:${item.text}` }] : [])),
{ type: "text", text: `当前画布:${JSON.stringify(compactSnapshot(snapshot))}\n\n用户需求:${userMessage.text}` },
...(await Promise.all(refs.filter((item) => item.dataUrl).map(async (item) => ({ type: "image_url" as const, image_url: { url: await imageToDataUrl(item) } })))),
],
},
];
}
export function compactSnapshot(snapshot: CanvasAgentSnapshot) {
return {
title: snapshot.title,
viewport: snapshot.viewport,
selectedNodeIds: snapshot.selectedNodeIds,
nodes: snapshot.nodes.map((node) => ({
id: node.id,
type: node.type,
title: node.title,
position: node.position,
width: node.width,
height: node.height,
metadata: compactMetadata(node.metadata || {}),
})),
connections: snapshot.connections,
};
}
function compactMetadata(metadata: CanvasNodeData["metadata"]) {
return {
content: String(metadata?.content || "").slice(0, 500),
prompt: String(metadata?.prompt || metadata?.composerContent || "").slice(0, 500),
status: metadata?.status,
skillName: metadata?.skillSnapshot?.name,
skillVersion: metadata?.skillSnapshot?.version,
generationMode: metadata?.generationMode,
model: metadata?.model,
size: metadata?.size,
assetTags: metadata?.assetTags,
workflowKind: metadata?.workflowKind,
workflowTitle: metadata?.workflowTitle,
workflowDescription: metadata?.workflowDescription,
characterName: metadata?.characterName,
characterAssetId: metadata?.characterAssetId,
characterVersionId: metadata?.characterVersionId,
chapterId: metadata?.chapterId,
chapterTitle: metadata?.chapterTitle,
shotIndex: metadata?.shotIndex,
};
}
export function backendAgentProviderConfig(config: ReturnType<typeof resolveModelRequestConfig>) {
return {
channelId: config.channelId,
apiFormat: config.apiFormat,
interfaceType: config.interfaceType,
baseUrl: config.baseUrl,
allowLocalChannel: config.allowLocalChannel === true,
apiKey: config.apiKey,
secretKey: config.secretKey,
model: config.model,
size: config.size,
quality: config.quality,
transparentBackground: config.transparentBackground,
count: config.count,
videoSeconds: config.videoSeconds,
vquality: config.vquality,
videoGenerateAudio: config.videoGenerateAudio,
videoWatermark: config.videoWatermark,
audioVoice: config.audioVoice,
audioFormat: config.audioFormat,
audioSpeed: config.audioSpeed,
audioInstructions: config.audioInstructions,
systemPrompt: config.systemPrompt,
};
}
export function cinematicSessionMessageId(backendSessionId: string) {
return `cinematic-session:${backendSessionId}`;
}
export function upsertAssistantMessage(messages: CanvasAssistantMessage[], message: CanvasAssistantMessage) {
const exists = messages.some((item) => item.id === message.id);
return exists ? messages.map((item) => (item.id === message.id ? { ...item, ...message } : item)) : [...messages, message];
}
export function createSession(): CanvasAssistantSession {
const now = new Date().toISOString();
return { id: nanoid(), title: "新对话", messages: [], createdAt: now, updatedAt: now };
}

View File

@@ -0,0 +1,251 @@
import { Button, Select } from "antd";
import { Tooltip } from "@/components/ui/base/tooltip";
import { useMemo, useState } from "react";
import { AtSign, Cpu, Trash2, X } from "lucide-react";
import { modelDisplayName, modelIcon, resolveModelChannel, selectableModelsByCapability, type AiConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme";
import { AgentImagePreview, type CanvasAgentChatMessage } from "./canvas-agent-chat-ui";
import { ModelLogo } from "@/components/model-logo";
import { useResolvedCanvasResourceReferences } from "./use-resolved-canvas-resource-references";
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { CanvasNodeType, type CanvasAssistantMessage, type CanvasAssistantReference, type CanvasAssistantSession } from "@/types/canvas";
import { useThemeStore } from "@/stores/use-theme-store";
export function AgentTextModelPicker({ config, value, onChange }: { config: AiConfig; value: string; onChange: (model: string) => void }) {
const options = useMemo(() => Array.from(new Set([value, ...selectableModelsByCapability(config, "text")].filter(Boolean))), [config, value]);
const current = value || "";
return (
<div className="min-w-0 max-w-[240px]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<Select<string>
size="small"
variant="borderless"
value={current || undefined}
className="agent-text-model-select w-full"
popupMatchSelectWidth={288}
options={options.map((model) => ({ value: model, label: agentModelLabel(config, model) }))}
notFoundContent={<span className="block py-2 text-center text-xs text-foreground/48">暂无文本模型</span>}
optionRender={(option) => {
const model = String(option.value);
return (
<span className="flex min-w-0 items-center gap-2">
<AgentModelIcon config={config} model={model} />
<span className="min-w-0 flex-1 truncate">{modelDisplayName(config, model)}</span>
{agentModelSource(config, model) ? <span className="shrink-0 text-xs opacity-55">{agentModelSource(config, model)}</span> : null}
</span>
);
}}
labelRender={() => (
<span className="flex min-w-0 items-center gap-1.5">
<AgentModelIcon config={config} model={current} />
<span className="min-w-0 truncate">{current ? modelDisplayName(config, current) : "选择文本模型"}</span>
{current && agentModelSource(config, current) ? <span className="shrink-0 opacity-55">{agentModelSource(config, current)}</span> : null}
</span>
)}
onChange={onChange}
aria-label="选择 Agent 文本模型"
title={current ? agentModelLabel(config, current) : "选择文本模型"}
/>
</div>
);
}
export function agentModelSource(config: AiConfig, model: string) {
const channel = resolveModelChannel(config, model);
return channel.scope === "system" ? "" : channel.name;
}
export function agentModelLabel(config: AiConfig, model: string) {
const source = agentModelSource(config, model);
return source ? `${modelDisplayName(config, model)} · ${source}` : modelDisplayName(config, model);
}
export function AgentModelIcon({ config, model }: { config: AiConfig; model: string }) {
const icon = modelIcon(config, model);
return icon ? <span className="inline-flex size-4 shrink-0 items-center justify-center"><ModelLogo icon={icon} size={16} /></span> : <Cpu className="size-4 shrink-0 opacity-70" />;
}
export function AssistantHistory({ sessions, activeSession, onOpen, onDelete }: { sessions: CanvasAssistantSession[]; activeSession: CanvasAssistantSession | null; onOpen: (id: string) => void; onDelete: (id: string) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<div className="space-y-3">
<div className="text-sm" style={{ color: theme.node.muted }}>
{sessions.length ? `${sessions.length} 条历史` : "暂无历史"}
</div>
{sessions.map((session) => (
<div key={session.id} className="rounded-md px-2.5 py-2 transition-colors" style={{ background: session.id === activeSession?.id ? theme.accent.primarySoft : "transparent", color: theme.node.text }}>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
{session.id === activeSession?.id ? (
<span className="shrink-0 text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.text }}>
当前
</span>
) : null}
<div className="truncate text-sm font-medium leading-5">{session.title}</div>
</div>
<div className="truncate text-[var(--fs-label)] leading-4 opacity-65">{sessionPreview(session)}</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<span className="text-[var(--fs-tiny)] opacity-55">{formatSessionTime(session.updatedAt || session.createdAt)}</span>
<Button size="small" className="!h-6 !px-2" onClick={() => onOpen(session.id)}>
进入
</Button>
<Tooltip title="删除记录">
<Button size="small" danger type="text" className="!h-6 !w-6 !min-w-6" icon={<Trash2 className="size-3.5" />} onClick={() => onDelete(session.id)} />
</Tooltip>
</div>
</div>
</div>
))}
{!sessions.length ? (
<div className="px-3 py-8 text-center text-sm" style={{ color: theme.node.muted }}>
网站 Agent 的对话记录会显示在这里
</div>
) : null}
</div>
);
}
export function MessageReferences({ message }: { message: CanvasAssistantMessage }) {
return (
<div className={`flex max-w-[88%] flex-wrap gap-2 ${message.role === "user" ? "ml-auto justify-end" : "ml-11 justify-start"}`}>
{message.references?.map((item, index, references) => (
<AssistantReferenceChip key={item.id} item={item} label={assistantImageReferenceLabel(references, index)} />
))}
</div>
);
}
export function promptAlreadyHasMention(prompt: string, label: string) {
const token = `@${label}`;
let from = 0;
while (from <= prompt.length) {
const index = prompt.indexOf(token, from);
if (index < 0) return false;
const next = prompt[index + token.length];
if (!next || /\s|[,.!?;:,。!?;:、)\]}】)]/.test(next)) return true;
from = index + 1;
}
return false;
}
export function AssistantReferenceChip({ item, label, previewUrl, onRemove, onInsertMention }: { item: CanvasAssistantReference; label?: string; previewUrl?: string; onRemove?: () => void; onInsertMention?: (label: string) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [previewOpen, setPreviewOpen] = useState(false);
const mentionReference = useMemo(() => assistantReferencesToMentionReferences([item]), [item]);
const resolvedReference = useResolvedCanvasResourceReferences(mentionReference)[0];
const text = (item.text || item.title).replace(/\s+/g, " ").trim().slice(0, 1) || "文";
const imageUrl = previewUrl || resolvedReference?.previewUrl || item.dataUrl;
const hasImage = Boolean(imageUrl || item.storageKey);
const chipStyle = { background: theme.spatial.surface, color: theme.node.text };
const actionStyle = { background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text };
if (hasImage) {
return (
<>
<span className="group/chip relative inline-flex h-12 shrink-0 items-center overflow-visible rounded-md" style={chipStyle}>
<button
type="button"
className="grid size-12 shrink-0 overflow-hidden rounded-l-md border-0 p-0"
style={{ background: theme.toolbar.itemHover }}
title="点击放大预览"
aria-label={`预览 ${label || item.title}`}
disabled={!imageUrl}
onClick={() => imageUrl && setPreviewOpen(true)}
onDoubleClick={() => imageUrl && setPreviewOpen(true)}
>
{imageUrl ? (
<img src={imageUrl} alt="" className="size-full object-cover" />
) : (
<span className="text-[var(--fs-micro)] opacity-50">…</span>
)}
</button>
{label && onInsertMention ? (
<button
type="button"
className="inline-flex h-12 max-w-[112px] min-w-0 items-center gap-1 border-0 bg-transparent px-2.5 text-[var(--fs-tiny)] opacity-80 hover:opacity-100"
title={`插入 @${label}`}
onClick={() => onInsertMention(label)}
>
<AtSign className="size-2.5 shrink-0" />
<span className="truncate">{label}</span>
</button>
) : label ? (
<span className="inline-flex h-12 max-w-[112px] min-w-0 items-center gap-1 px-2.5 text-[var(--fs-tiny)] opacity-80">
<AtSign className="size-2.5 shrink-0" />
<span className="truncate">{label}</span>
</span>
) : null}
{onRemove ? (
<button
type="button"
className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover/chip:opacity-100 group-focus-within/chip:opacity-100"
style={actionStyle}
onClick={onRemove}
aria-label="移除引用"
>
<X className="size-3" />
</button>
) : null}
</span>
{previewOpen && imageUrl ? <AgentImagePreview attachment={{ id: item.id, url: imageUrl, name: item.title || label || "图片" }} onClose={() => setPreviewOpen(false)} /> : null}
</>
);
}
return (
<span className="group/chip relative inline-flex h-12 max-w-[168px] shrink-0 items-center gap-2 overflow-hidden rounded-md px-2.5 text-sm" style={chipStyle}>
<span className="grid size-8 shrink-0 place-items-center rounded-md text-sm font-medium" style={{ background: theme.toolbar.itemHover }}>
{text}
</span>
<span className="min-w-0 truncate text-[var(--fs-tiny)] opacity-80">{item.title}</span>
{onRemove ? (
<button
type="button"
className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover/chip:opacity-100"
style={actionStyle}
onClick={onRemove}
aria-label="移除引用"
>
<X className="size-3" />
</button>
) : null}
</span>
);
}
export function assistantReferencesToMentionReferences(references: CanvasAssistantReference[]): CanvasResourceReference[] {
return references.flatMap((item, index): CanvasResourceReference[] => {
if (item.dataUrl || item.storageKey) {
const label = assistantImageReferenceLabel(references, index) || item.title;
return [{ id: item.id, nodeId: item.id, kind: "image", label, title: item.title, previewUrl: item.dataUrl, storageKey: item.storageKey, active: true }];
}
if (item.text) {
const label = item.type === CanvasNodeType.Skill ? `技能${index + 1}` : `文本${index + 1}`;
return [{ id: item.id, nodeId: item.id, kind: item.type === CanvasNodeType.Skill ? "skill" : "text", label, title: item.title, text: item.text, active: true }];
}
return [];
});
}
export function assistantImageReferenceLabel(references: CanvasAssistantReference[], index: number) {
if (!references[index]?.dataUrl && !references[index]?.storageKey) return undefined;
const imageIndex = references.slice(0, index + 1).filter((item) => item.dataUrl || item.storageKey).length - 1;
return imageIndex >= 0 ? imageReferenceLabel(imageIndex) : undefined;
}
export function assistantMessageToChatMessage(message: CanvasAssistantMessage): CanvasAgentChatMessage {
return { id: message.id, role: message.role, title: message.title, text: message.text, meta: message.meta, detail: message.detail };
}
export function formatSessionTime(value?: string) {
return value ? new Date(value).toLocaleString() : "";
}
export function sessionPreview(session: CanvasAssistantSession) {
return session.messages.at(-1)?.text || `${session.messages.length} 条消息`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { Modal } from "antd";
import { AudioLines, BadgeCheck, Image as ImageIcon, UserRound, Volume2 } from "lucide-react";
import { AppModal } from "@/components/ui/product/app-modal";
import type { ReactNode } from "react";
import type { CanvasNodeData } from "@/types/canvas";
@@ -15,14 +16,14 @@ export function CanvasCharacterReferenceModal({ node, open, onClose }: { node: C
const voiceProfile = metadata?.characterVoiceProfile;
return (
<Modal
<AppModal
open={open}
title={null}
footer={null}
destroyOnHidden
width="min(1180px, calc(100vw - 32px))"
onCancel={onClose}
styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}
flush
>
<div className="grid h-[min(720px,calc(100dvh-48px))] min-h-0 grid-rows-[minmax(240px,42vh)_minmax(0,1fr)] overflow-hidden bg-background text-foreground md:grid-cols-[minmax(0,1.55fr)_minmax(360px,.85fr)] md:grid-rows-1">
<section className="relative min-h-0 overflow-hidden border-b border-border bg-foreground/[.035] md:border-b-0 md:border-r" aria-label="角色三视图">
@@ -91,7 +92,7 @@ export function CanvasCharacterReferenceModal({ node, open, onClose }: { node: C
</div>
</aside>
</div>
</Modal>
</AppModal>
);
}

View File

@@ -0,0 +1,71 @@
import { isCanvasGenerationDurableAckError, persistCanvasCinematicSessionContinuationEffect } from "@/services/canvas-generation-consumer";
import { isAgentSessionPollingAbort } from "@/lib/canvas/canvas-agent-session";
import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import type { CanvasAssistantSession } from "@/types/canvas";
export type CinematicContinuationFailureDisposition = "abort" | "durable-ack" | "provider-failed";
export type CinematicContinuationLiveSessionState = { sessions: CanvasAssistantSession[]; activeChatId: string | null };
type CanvasCinematicContinuationBoundaryInput<T> = {
projectId: string;
effectKey?: string;
signal?: AbortSignal;
readSnapshot: () => CanvasAgentSnapshot;
executeOps: () => Promise<T>;
completeSession: (effectKey?: string) => CanvasAssistantSession[];
readLiveSessionState: () => CinematicContinuationLiveSessionState;
restoreLiveSessions: (sessions: CanvasAssistantSession[], activeChatId: string | null) => void;
restoreLiveSnapshot: (state: Pick<CanvasAgentSnapshot, "nodes" | "connections">) => void;
failProvider: (error: unknown) => void;
onFailureDisposition?: (disposition: CinematicContinuationFailureDisposition, error: unknown) => void;
persistContinuation?: typeof persistCanvasCinematicSessionContinuationEffect;
};
export function handleCinematicContinuationFailure(error: unknown, failProvider: (error: unknown) => void): CinematicContinuationFailureDisposition {
if (isAgentSessionPollingAbort(error)) return "abort";
if (isCanvasGenerationDurableAckError(error)) return "durable-ack";
failProvider(error);
return "provider-failed";
}
export async function runCanvasCinematicContinuationBoundary<T>(input: CanvasCinematicContinuationBoundaryInput<T>) {
const previousSnapshot = input.readSnapshot();
const previousSessionState = input.readLiveSessionState();
try {
if (input.signal?.aborted) throw new DOMException("The operation was aborted", "AbortError");
const result = await input.executeOps();
const attemptedSnapshot = input.readSnapshot();
input.completeSession(input.effectKey);
const attemptedSessionState = input.readLiveSessionState();
if (input.effectKey) {
await (input.persistContinuation ?? persistCanvasCinematicSessionContinuationEffect)({
projectId: input.projectId,
effectKey: input.effectKey,
previousNodes: previousSnapshot.nodes,
nodes: attemptedSnapshot.nodes,
previousConnections: previousSnapshot.connections,
connections: attemptedSnapshot.connections,
previousChatSessions: previousSessionState.sessions,
chatSessions: attemptedSessionState.sessions,
previousActiveChatId: previousSessionState.activeChatId,
activeChatId: attemptedSessionState.activeChatId,
signal: input.signal,
readLiveSessionState: input.readLiveSessionState,
restoreLiveSessions: input.restoreLiveSessions,
restoreLiveSnapshot: input.restoreLiveSnapshot,
});
}
return result;
} catch (error) {
const disposition = handleCinematicContinuationFailure(error, input.failProvider);
input.onFailureDisposition?.(disposition, error);
throw error;
}
}
export const canvasCinematicContinuationEntryAdapters = {
"online-tool": runCanvasCinematicContinuationBoundary,
"submit-cinematic": runCanvasCinematicContinuationBoundary,
"resume-cinematic": runCanvasCinematicContinuationBoundary,
} as const;

View File

@@ -1,5 +1,6 @@
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { App, Modal } from "antd";
import { App } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { Check, Maximize2, X } from "lucide-react";
import type { CanvasDrawingEditorHandle } from "@/components/canvas/canvas-drawing-editor-types";
@@ -80,7 +81,7 @@ export function CanvasDrawingEditorModal({ open, projectId, node, onClose, onSav
const unavailable = !isDrawingEngineAvailable(engine, tldrawLicenseKey);
return (
<Modal open={open} onCancel={() => void handleClose()} footer={null} closable={false} destroyOnHidden width="100vw" centered styles={{ body: { padding: 0 }, container: { padding: 0, overflow: "hidden" } }} className="canvas-drawing-editor-modal">
<AppModal flush open={open} onCancel={() => void handleClose()} footer={null} closable={false} destroyOnHidden width="100vw" centered className="canvas-drawing-editor-modal">
<div className="flex h-[min(92dvh,980px)] flex-col">
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4" style={{ background: "var(--background)", borderColor: "var(--border)" }}>
<div className="flex min-w-0 items-center gap-2"><Maximize2 className="size-4 opacity-55" /><span className="truncate text-sm font-semibold">{node?.title || "绘图"}</span><span className="text-[var(--fs-label)] opacity-45">{drawingEngineLabel(engine)} · {ready ? "已加载" : "正在加载"}</span></div>
@@ -99,7 +100,7 @@ export function CanvasDrawingEditorModal({ open, projectId, node, onClose, onSav
) : <EditorState title="正在准备绘图画布" />}
</div>
</div>
</Modal>
</AppModal>
);
}

View File

@@ -26,6 +26,7 @@ import {
} from "@/stores/canvas/use-canvas-agent-store";
import { canvasAgentPostconditionMessage, hashCanvasAgentSnapshot, previewCanvasAgentOps, summarizeCanvasAgentOps, verifyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { buildCanvasAgentContext, findCanvasAgentNodes, getCanvasAgentConnection, getCanvasAgentGenerationTasks, getCanvasAgentNode, getCanvasAgentResources, validateCanvasAgentOps } from "@/lib/canvas/canvas-agent-context";
import { isCanvasReadTool } from "@/lib/canvas/canvas-agent-protocol";
import { buildCanvasResourceReferences } from "@/lib/canvas/canvas-resource-references";
import { buildLocalAgentSetupCommands, detectLocalAgentSetupPlatform, type LocalAgentSetupPlatform } from "@/lib/canvas/local-agent-setup";
import { listAddedSkills, type Skill } from "@/services/api/skills";
@@ -1318,7 +1319,7 @@ function toolName(name: string) {
}
function isReadTool(name: string) {
return name === "canvas_get_state" || name === "canvas_get_context" || name === "canvas_find_nodes" || name === "canvas_get_node" || name === "canvas_get_connection" || name === "canvas_get_generation_tasks" || name === "canvas_get_resources" || name === "canvas_validate_ops" || name === "canvas_get_selection" || name === "canvas_export_snapshot" || isProjectAgentReadTool(name);
return isCanvasReadTool(name) || isProjectAgentReadTool(name);
}
function isMcpToolItem(item?: AgentEventItem) {

View File

@@ -3,69 +3,12 @@ import { Input, Popover } from "antd";
import { LayoutTemplate, Search, WandSparkles } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { CANVAS_BUILTIN_PRESETS, type CanvasPromptPreset } from "@/lib/prompts";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasGenerationMode } from "@/types/canvas";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
export type CanvasPromptPreset = {
id: string;
name: string;
description: string;
prompt: string;
modes: CanvasGenerationMode[];
source: "builtin" | "skill";
};
const BUILTIN_PRESETS: CanvasPromptPreset[] = [
{
id: "character-sheet",
name: "角色设定图",
description: "正面、侧面、背面与表情参考,锁定角色一致性",
prompt: "生成角色设定图:保持同一角色身份、五官、发型、服装和体态一致,包含正面、侧面、背面和关键表情参考,背景简洁,便于后续镜头复用。",
modes: ["image"],
source: "builtin",
},
{
id: "multi-angle",
name: "多机位视角",
description: "围绕同一主体生成连续、可衔接的机位变化",
prompt: "围绕同一主体设计多机位画面,保持人物、服装、场景和光线一致,分别给出远景、全景、中景、近景、特写、侧面、背面和俯拍视角,镜头之间具有连续性。",
modes: ["image", "video"],
source: "builtin",
},
{
id: "next-shot",
name: "画面推演",
description: "推演当前画面的前后动作与镜头衔接",
prompt: "基于当前画面推演下一个连续镜头:保持角色和场景一致,明确主体接下来的动作、视线、环境变化、镜头运动和自然衔接方式,不要跳变构图或身份。",
modes: ["image", "video"],
source: "builtin",
},
{
id: "story-beats",
name: "连续镜头",
description: "将短剧情拆成可生成的连续镜头节拍",
prompt: "把这段内容拆成连续镜头节拍。每个镜头写清主体动作、景别、构图、机位、运镜、光线、情绪和与前后镜头的衔接,并保持角色、场景和道具一致。",
modes: ["text", "image", "video"],
source: "builtin",
},
{
id: "cinematic-light",
name: "电影光影优化",
description: "保留内容,优化真实光线、层次和融合感",
prompt: "保留主体身份、动作和原始构图,优化为真实电影摄影光线:明确主光方向、环境反射、阴影层次、肤色和背景融合,降低塑料感与过度锐化,不改变画面内容。",
modes: ["image", "video"],
source: "builtin",
},
{
id: "video-prompt",
name: "视频提示词优化",
description: "整理为模型更容易执行的时序化镜头指令",
prompt: "将当前要求改写为结构化视频提示词,按时间顺序描述开场画面、主体动作、镜头运动、环境变化、声音和结束画面;消除冲突指令,保留所有关键约束。",
modes: ["text", "video"],
source: "builtin",
},
];
export type { CanvasPromptPreset };
export function CanvasPresetPicker({
mode,
@@ -100,17 +43,17 @@ export function CanvasPresetPicker({
if (!reference.skill) return [];
return [
{
id: `skill:${reference.skill.skill_id}`,
name: reference.skill.skill_name,
id: `skill:${reference.skill.skillId}`,
name: reference.skill.skillName,
description: reference.skill.description || reference.skill.instruction || "已加入技能",
prompt: `@${reference.skill.skill_name} `,
prompt: `@${reference.skill.skillName} `,
modes: ["text", "image", "video", "audio"],
source: "skill",
},
];
});
const normalized = query.trim().toLowerCase();
return [...BUILTIN_PRESETS.filter((preset) => preset.modes.includes(mode)), ...skills].filter((preset) => !normalized || `${preset.name} ${preset.description}`.toLowerCase().includes(normalized));
return [...CANVAS_BUILTIN_PRESETS.filter((preset) => preset.modes.includes(mode)), ...skills].filter((preset) => !normalized || `${preset.name} ${preset.description}`.toLowerCase().includes(normalized));
}, [mode, query, skillReferences]);
const content = (

View File

@@ -724,7 +724,7 @@ function MentionReferenceList({ references, activeReferenceId, onSelect }: { ref
<span className="canvas-resource-mention-copy">
<span className="canvas-resource-mention-title-row"><strong title={reference.label}>{reference.label}</strong>{reference.kind === "skill" ? <em>技能</em> : null}</span>
{reference.kind === "skill" ? (
<span className="canvas-resource-mention-meta"><span>{reference.skill?.description || reference.text || "工作流技能"}</span><small>{reference.skill?.version ? `v${reference.skill.version}` : ""}{reference.skill?.file_count ? ` · ${reference.skill.file_count} 文件` : ""}</small></span>
<span className="canvas-resource-mention-meta"><span>{reference.skill?.description || reference.text || "工作流技能"}</span><small>{reference.skill?.version ? `v${reference.skill.version}` : ""}{reference.skill?.fileCount ? ` · ${reference.skill.fileCount} 文件` : ""}</small></span>
) : reference.text && reference.text !== reference.title ? <span className="canvas-resource-mention-meta"><span>{reference.text}</span></span> : null}
</span>
</button>

View File

@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Clock3, Copy, Eye, Palette, Pencil, Plus, Search, SlidersHorizontal, Star, Trash2, UserRound } from "lucide-react";
import { App, Button, Input, Modal } from "antd";
import { App, Button, Input } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { nanoid } from "nanoid";
import { StyleProfileEditorModal } from "@/components/canvas/style-profile-editor-modal";
@@ -536,7 +537,7 @@ export function CanvasStylePickerModal({ open, value, currentProfile, startInEdi
return (
<>
<Modal rootClassName="canvas-style-picker-modal" open={open} title={null} footer={null} centered width="min(1240px, calc(100vw - 24px))" onCancel={onClose} styles={{ container: { padding: 0 }, body: { padding: 0 } }}>
<AppModal rootClassName="canvas-style-picker-modal" open={open} title={null} footer={null} centered width="min(1240px, calc(100vw - 24px))" onCancel={onClose} flush>
<div className="canvas-style-center-shell flex min-h-0 flex-col overflow-hidden" style={{ color: theme.node.text, background: theme.node.panel }}>
<header className="flex min-h-16 flex-col gap-3 border-b px-4 py-3 pr-12 sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:pr-14" style={{ borderColor: theme.node.stroke }}>
<div className="min-w-0">
@@ -592,7 +593,7 @@ export function CanvasStylePickerModal({ open, value, currentProfile, startInEdi
</main>
</div>
</div>
</Modal>
</AppModal>
<CanvasStyleDetailModal open={Boolean(detailPreset)} preset={detailPreset} selected={detailPreset?.id === value} onClose={() => setDetailPreset(null)} onSelect={(preset) => { setDetailPreset(null); const item = [...systemItems, ...userItems].find((candidate) => candidate.preset.id === preset.id); if (item) selectItem(item); }} />
<StyleProfileEditorModal open={Boolean(editor)} initialProfile={editor?.profile || null} saving={saveMutation.isPending} onClose={() => setEditor(null)} onSave={(profile, apply) => saveMutation.mutate({ profile, apply })} />
</>
@@ -672,7 +673,7 @@ export function CanvasStyleDetailModal({ open, preset, selected = false, onClose
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const sections = preset ? parseStyleSections(preset.prompt) : [];
return (
<Modal rootClassName="canvas-style-detail-modal" open={open} title={null} footer={null} centered destroyOnHidden width="min(820px, calc(100vw - 24px))" onCancel={onClose} styles={{ container: { padding: 0 }, body: { padding: 0 } }}>
<AppModal rootClassName="canvas-style-detail-modal" open={open} title={null} footer={null} centered destroyOnHidden width="min(820px, calc(100vw - 24px))" onCancel={onClose} flush>
{preset ? <div className="canvas-style-detail-shell flex flex-col overflow-hidden" style={{ color: theme.node.text, background: theme.node.panel }}>
<div className="flex h-44 shrink-0 items-center justify-center overflow-hidden border-b sm:h-52" style={{ borderColor: theme.node.stroke, background: theme.canvas.background }}>
<img src={preset.imageUrl} width="960" height="540" alt={`${preset.title}画风示意`} className="h-full w-full object-contain" style={preset.id === "black-white-noir" ? { filter: "grayscale(1) contrast(1.08)" } : undefined} />
@@ -694,7 +695,7 @@ export function CanvasStyleDetailModal({ open, preset, selected = false, onClose
</div>
<footer className="flex shrink-0 flex-wrap justify-end gap-2 border-t px-4 py-3 sm:px-5" style={{ borderColor: theme.node.stroke }}><Button onClick={onClose}>关闭</Button>{onSelect ? <Button type="primary" disabled={selected} icon={selected ? <Check className="size-3.5" /> : <Palette className="size-3.5" />} onClick={() => onSelect(preset)}>{selected ? "当前画风" : "选择该画风"}</Button> : null}</footer>
</div> : null}
</Modal>
</AppModal>
);
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { App, Button, ColorPicker, Input, InputNumber, Modal, Progress, Segmented } from "antd";
import { App, Button, ColorPicker, Input, InputNumber, Progress, Segmented } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { Switch } from "@/components/ui/base/switch";
import { Captions, FileDown, FileUp, ListPlus, LoaderCircle, Plus, Scissors, Sparkles, Trash2 } from "lucide-react";
import { saveAs } from "file-saver";
@@ -501,7 +502,7 @@ export function CanvasSubtitleDialog({ node, open, projectId, config, onClose, o
);
return (
<Modal className="canvas-subtitle-dialog" title={title} open={open} centered footer={null} width={1120} destroyOnHidden onCancel={onClose} styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}>
<AppModal className="canvas-subtitle-dialog" title={title} open={open} centered footer={null} width={1120} destroyOnHidden onCancel={onClose} flush>
<div className="flex h-[min(72vh,680px)] min-h-[420px] flex-col text-sm" style={{ color: theme.node.text }}>
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-3" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
<input
@@ -612,6 +613,6 @@ export function CanvasSubtitleDialog({ node, open, projectId, config, onClose, o
</div>
</div>
</div>
</Modal>
</AppModal>
);
}

View File

@@ -1,4 +1,5 @@
import { App, Button, ColorPicker, Dropdown, Input, Modal, Popover } from "antd";
import { App, Button, ColorPicker, Dropdown, Input, Popover } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { Tooltip } from "@/components/ui/base/tooltip";
import { useEffect, useMemo, useState, type ReactNode } from "react";
@@ -109,7 +110,7 @@ export function CanvasTextEditorModal({ node, open, onClose, onSave }: CanvasTex
}, [dirty, editor, node?.id, open, saving, title]);
return (
<Modal
<AppModal
className="canvas-text-editor-modal"
open={open && Boolean(node)}
title={null}
@@ -119,7 +120,7 @@ export function CanvasTextEditorModal({ node, open, onClose, onSave }: CanvasTex
destroyOnHidden
width="min(1180px, calc(100vw - 24px))"
onCancel={close}
styles={{ container: { padding: 0, overflow: "hidden", borderRadius: 8 }, body: { padding: 0 } }}
flush styles={{ container: { borderRadius: 8 } }}
>
<section className="flex h-[min(88dvh,840px)] flex-col overflow-hidden" style={{ background: theme.node.panel, color: theme.node.text }}>
<header className="flex h-13 shrink-0 items-center gap-3 border-b px-3" style={{ borderColor: theme.node.stroke }}>
@@ -154,7 +155,7 @@ export function CanvasTextEditorModal({ node, open, onClose, onSave }: CanvasTex
<span className="ml-auto">Ctrl/⌘S 保存</span>
</footer>
</section>
</Modal>
</AppModal>
);
}

View File

@@ -1,4 +1,5 @@
import { App, Button, Dropdown, Input, InputNumber, Modal, Progress } from "antd";
import { App, Button, Dropdown, Input, InputNumber, Progress } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { Tooltip } from "@/components/ui/base/tooltip";
// 二期:多轨时间线编辑弹窗。
// 数据源是项目级 TimelineProject:视频/音频节点自动入轨,字幕条目转字幕片段。
@@ -634,7 +635,7 @@ export function CanvasTimelineDialog({
);
return (
<Modal
<AppModal
className="canvas-timeline-dialog"
title={title}
open={open}
@@ -646,7 +647,7 @@ export function CanvasTimelineDialog({
afterOpenChange={(visible) => {
if (visible) ensureToolbarObserved();
}}
styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}
flush
>
<div className="flex h-[min(76vh,760px)] min-h-[420px] flex-col text-sm" style={{ color: theme.node.text }}>
<div ref={toolbarRef} className="flex flex-nowrap items-center gap-2 overflow-hidden border-b px-4 py-3" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.panel }}>
@@ -919,6 +920,6 @@ export function CanvasTimelineDialog({
<span className="ml-auto truncate text-xs opacity-45">拖拽片段移动,左右边缘裁剪,字幕片段来自视频节点的字幕数据</span>
</div>
</div>
</Modal>
</AppModal>
);
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { App, Button, Modal, Upload, type UploadFile } from "antd";
import { App, Button, Upload, type UploadFile } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { FileImage, FileText, Film, Music2, UploadCloud, X } from "lucide-react";
import { isAudioFile } from "@/lib/canvas/canvas-project-generation";
@@ -40,7 +41,7 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
};
return (
<Modal
<AppModal
open={open}
title={null}
footer={null}
@@ -50,7 +51,7 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
keyboard={!uploading}
mask={{ closable: !uploading }}
onCancel={onClose}
styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}
flush
>
<div className="flex min-h-96 flex-col overflow-hidden">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border py-0 pl-5 pr-12">
@@ -128,7 +129,7 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
</div>
</footer>
</div>
</Modal>
</AppModal>
);
}

View File

@@ -13,7 +13,7 @@ import {
} from "@/lib/canvas/director/director-onboarding";
/**
* 导演台首次上手引导浮层(P2)。
* 导演台首次上手引导浮层。
*
* 硬约束:
* - 非阻塞。不是 dialog,没有遮罩,不抢焦点,不做焦点陷阱:引导旁边就是要动手的工作台,

View File

@@ -24,7 +24,7 @@ import { createDirectorActor, createDirectorBillboard, createDirectorCamera, cre
import { describeDirectorSaveStatus, resolveDirectorCloseOutcome, shouldBlockDirectorUnload, shouldOfferDirectorDraftRecovery } from "@/lib/canvas/director/director-save-wiring";
import { useDirectorSaveCoordinator } from "@/components/canvas/director/use-director-save-coordinator";
import { uploadMediaFile } from "@/services/file-storage";
import { saveRemoteUserDataNow } from "@/services/user-data-sync";
import { localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { useAssetStore, type ModelAsset } from "@/stores/use-asset-store";
import { useDirectorWorkbenchStore } from "@/stores/canvas/use-director-workbench-store";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -420,10 +420,14 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onboardingSco
if (!file || !/\.(glb|gltf)$/i.test(file.name)) return;
const uploaded = await uploadMediaFile(file, "model");
const assetId = addAsset({ kind: "model", title: file.name.replace(/\.(glb|gltf)$/i, ""), coverUrl: "", tags: ["3D模型"], source: "导演台", data: { url: uploaded.url, storageKey: uploaded.storageKey, bytes: uploaded.bytes, mimeType: uploaded.mimeType, fileName: file.name }, metadata: { source: "director" } });
await saveRemoteUserDataNow();
const asset = useAssetStore.getState().assets.find((item): item is ModelAsset => item.id === assetId && item.kind === "model");
if (asset) addModelAsset(asset);
message.success("3D 模型已加入场景和素材库");
try {
await saveRemoteUserDataNow();
message.success("3D 模型已加入场景和素材库");
} catch (error) {
message.warning(localSavedRemotePendingMessage("3D 模型已加入场景和本机素材库", error));
}
};
const addBillboard = (node: CanvasNodeData) => {

View File

@@ -1,4 +1,5 @@
import { Button, InputNumber, Modal, Popconfirm, Segmented, Select, Tag } from "antd";
import { Button, InputNumber, Popconfirm, Segmented, Select, Tag } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { Switch } from "@/components/ui/base/switch";
import { Callout } from "@/components/ui/product/callout";
import { StatusBadge } from "@/components/ui/base/badges";
@@ -11,7 +12,7 @@ import { portraitVisionModelError, portraitVisionModelLabel, portraitVisionModel
import { usePluginStore } from "@/stores/use-plugin-store";
import { useEffectiveConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { cancelPortraitTask, createPortraitClearanceTask, deletePortraitTask, downloadPortraitReport, imageNodeDataUrl, installPortraitClearanceModels, listPortraitTasks, portraitOwnerScopeHash, readPortraitRuntimeStatus, readPortraitTaskResult, retryPortraitTask, type PortraitRuntimeStatus, type PortraitRuntimeTask } from "@/services/portrait-clearance-runtime";
import { cancelPortraitTask, createPortraitClearanceTask, deletePortraitTask, downloadPortraitReport, imageNodeDataUrl, installPortraitClearanceModels, listPortraitTasks, portraitOwnerScopeHash, readPortraitRuntimeStatus, readPortraitTaskImage, readPortraitTaskResult, retryPortraitTask, type PortraitRuntimeStatus, type PortraitRuntimeTask } from "@/services/portrait-clearance-runtime";
import type { PortraitFeatureKey, PortraitPairResult, PortraitRiskLevel } from "@/lib/portrait-clearance/contracts";
type PortraitClearanceModalProps = {
@@ -249,7 +250,7 @@ export function PortraitClearanceModal({ projectId, node, upstreamNodes, open, o
};
return (
<Modal
<AppModal
open={open}
onCancel={onClose}
footer={null}
@@ -259,7 +260,7 @@ export function PortraitClearanceModal({ projectId, node, upstreamNodes, open, o
centered
destroyOnHidden={false}
className="portrait-clearance-modal"
styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}
flush
>
<div className="flex max-h-[calc(100dvh-32px)] min-h-[min(680px,calc(100dvh-32px))] flex-col overflow-hidden bg-background text-foreground">
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b px-5 py-4" style={{ borderColor: "var(--border)" }}>
@@ -334,7 +335,7 @@ export function PortraitClearanceModal({ projectId, node, upstreamNodes, open, o
<div className="flex flex-wrap items-center justify-end gap-2"><Select size="small" value={reportFormat} disabled={!state.lastResult || Boolean(busyAction)} options={[{ value: "html", label: "HTML 报告" }, { value: "md", label: "Markdown 报告" }, { value: "docx", label: "DOCX 报告" }, { value: "json", label: "JSON 结果" }]} onChange={(value) => setReportFormat(value as "json" | "md" | "html" | "docx")} /><Button icon={<Download className="size-3.5" />} loading={busyAction === "export"} disabled={!state.lastResult} onClick={() => void exportReport()}>导出</Button>{state.activeTaskId && state.task?.status !== "failed" && state.task?.status !== "cancelled" ? <Popconfirm title="停止当前排查?" description="已下载的候选和本机任务会保留,可稍后查看或重试。" onConfirm={() => void stopTask()}><Button danger icon={<Pause className="size-3.5" />} loading={busyAction === "stop"}>停止</Button></Popconfirm> : null}{state.task?.status === "failed" || state.task?.status === "cancelled" ? <Button icon={<Play className="size-3.5" />} loading={busyAction === "retry"} onClick={() => void retryTask()}>重试</Button> : null}<Button type="primary" icon={<Play className="size-3.5" />} loading={starting} disabled={!canStart} onClick={() => void startTask()}>开始排查</Button></div>
</footer>
</div>
</Modal>
</AppModal>
);
}
@@ -411,9 +412,15 @@ const PORTRAIT_FEATURE_SIMILARITY_LABELS = { high: "高度相似", medium: "中
function ResultPairCard({ taskId, pair, candidate, onAddCandidate }: { taskId: string; pair: PortraitPairResult; candidate?: PortraitResultView["candidates"][number]; onAddCandidate?: PortraitClearanceModalProps["onAddCandidate"] }) {
const [image, setImage] = useState("");
useEffect(() => {
let disposed = false;
void import("@/services/portrait-clearance-runtime").then(({ readPortraitTaskImage }) => readPortraitTaskImage(taskId, pair.comparisonImageId)).then((value) => { if (!disposed) setImage(value.dataUrl); }).catch(() => undefined);
return () => { disposed = true; };
const controller = new AbortController();
void readPortraitTaskImage(taskId, pair.comparisonImageId, controller.signal)
.then((value) => setImage(value.dataUrl))
.catch((error) => {
if (controller.signal.aborted) return;
// 结果正文仍可阅读,但图片读取失败必须留下任务与图片上下文,不能静默伪装成“尚未加载”。
console.warn("读取肖像对比图片失败", { taskId, imageId: pair.comparisonImageId, error });
});
return () => controller.abort();
}, [pair.comparisonImageId, taskId]);
const vision = pair.visionComparison;
return <article className="overflow-hidden rounded-lg border" style={{ borderColor: "var(--border)" }}>

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Button, Input, InputNumber, Modal, Select } from "antd";
import { Button, Input, InputNumber, Select } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { Switch } from "@/components/ui/base/switch";
import { AlertTriangle, Box, Check, FileImage, Link2, Plus, Save, SlidersHorizontal, Trash2 } from "lucide-react";
import { nanoid } from "nanoid";
@@ -77,7 +78,7 @@ export function StyleAssetBindingModal({ open, profile, onClose, onApply }: Styl
};
return (
<Modal
<AppModal
rootClassName="style-asset-binding-modal"
open={open}
title={null}
@@ -85,7 +86,7 @@ export function StyleAssetBindingModal({ open, profile, onClose, onApply }: Styl
centered
width="min(980px, calc(100vw - 24px))"
onCancel={onClose}
styles={{ container: { padding: 0 }, body: { padding: 0 } }}
flush
>
<div className="flex max-h-dvh min-h-0 flex-col overflow-hidden bg-background text-foreground">
<header className="flex items-start justify-between gap-3 border-b border-border px-4 py-3 pr-12 sm:items-center sm:px-5">
@@ -169,7 +170,7 @@ export function StyleAssetBindingModal({ open, profile, onClose, onApply }: Styl
</div>
</footer>
</div>
</Modal>
</AppModal>
);
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { App, Button, Input, Modal, Segmented, Select } from "antd";
import { App, Button, Input, Segmented, Select } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { Braces, Image, Layers3, Save, Sparkles } from "lucide-react";
import { StyleAssetBindingModal } from "@/components/canvas/style-asset-binding-modal";
@@ -52,7 +53,7 @@ export function StyleProfileEditorModal({ open, initialProfile, saving = false,
return (
<>
<Modal
<AppModal
rootClassName="style-profile-editor-modal"
open={open}
title={null}
@@ -60,7 +61,7 @@ export function StyleProfileEditorModal({ open, initialProfile, saving = false,
centered
width="min(1120px, calc(100vw - 24px))"
onCancel={requestClose}
styles={{ container: { padding: 0 }, body: { padding: 0 } }}
flush
>
<div className="flex max-h-dvh min-h-0 flex-col overflow-hidden bg-background text-foreground">
<header className="flex min-h-16 items-center border-b border-border px-4 pr-12 sm:px-5 sm:pr-14">
@@ -99,7 +100,7 @@ export function StyleProfileEditorModal({ open, initialProfile, saving = false,
</div>
</footer>
</div>
</Modal>
</AppModal>
<StyleAssetBindingModal
open={assetsOpen}
profile={draft}

View File

@@ -13,7 +13,7 @@ type EditorStoreHook = UseBoundStore<StoreApi<EditorStore>>;
export type EditorHostContextValue = {
projectId: string;
/** 项目资产列表(M3.4 asset-ingest 消费;由宿主在进入编辑器时获取)。 */
/** 当前用户、当前项目的资产快照;由编辑器宿主获取,素材导入插件只消费和触发刷新。 */
assets: ProjectAsset[];
/** 重新拉取项目资产(导入媒体后刷新列表)。 */
refreshAssets: () => Promise<ProjectAsset[] | null>;

View File

@@ -1,8 +1,8 @@
import { Modal } from "antd";
import { motion, useReducedMotion } from "motion/react";
import { ScrollText } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { AppModal } from "@/components/ui/product/app-modal/app-modal";
import { aceternityMotion } from "@/lib/aceternity-motion";
export function AppChangelogDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
@@ -10,7 +10,8 @@ export function AppChangelogDialog({ open, onClose }: { open: boolean; onClose:
const version = `v${__APP_VERSION__.replace(/^v/, "")}`;
return (
<Modal
<AppModal
flush
rootClassName="app-spatial-modal app-changelog-modal"
title={
<div className="app-changelog-heading">
@@ -29,7 +30,6 @@ export function AppChangelogDialog({ open, onClose }: { open: boolean; onClose:
footer={null}
centered
onCancel={onClose}
styles={{ body: { padding: 0 } }}
modalRender={(node) => (
<motion.div initial={reducedMotion ? false : { opacity: 0, y: 14, scale: 0.975 }} animate={{ opacity: 1, y: 0, scale: 1 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }}>
{node}
@@ -61,6 +61,6 @@ export function AppChangelogDialog({ open, onClose }: { open: boolean; onClose:
{__APP_CHANGELOG__}
</ReactMarkdown>
</div>
</Modal>
</AppModal>
);
}

View File

@@ -1,4 +1,5 @@
import { Modal, Tabs } from "antd";
import { Tabs } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import type { ReactNode } from "react";
import "./model-editor-modal.css";
@@ -31,7 +32,7 @@ export function ModelEditorModal({
const content = typeof children === "function" ? children(tabs) : children || tabs;
return (
<Modal
<AppModal
open={open}
centered
width={1120}
@@ -48,9 +49,9 @@ export function ModelEditorModal({
closable={!busy}
onCancel={onClose}
footer={footer}
styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0, minHeight: 0, flex: 1 }, header: { margin: 0 }, footer: { margin: 0 } }}
flush styles={{ body: { minHeight: 0, flex: 1 }, header: { margin: 0 }, footer: { margin: 0 } }}
>
{content}
</Modal>
</AppModal>
);
}

View File

@@ -12,7 +12,7 @@ export function useSkillRuntimeCatalog() {
let cancelled = false;
listAddedSkills()
.then((result) => {
if (!cancelled) setSkills(result.skills.filter((skill) => skill.is_added));
if (!cancelled) setSkills(result.skills.filter((skill) => skill.isAdded));
})
.catch(() => {
if (!cancelled) setSkills([]);
@@ -30,7 +30,7 @@ export function useSkillRuntimeCatalog() {
export function SkillRuntimePicker({ skills, loading, value, onChange, placeholder = "选择本次生成使用的技能", profile = "canvas" }: { skills: Skill[]; loading?: boolean; value: string[]; onChange: (skillIds: string[]) => void; placeholder?: string; profile?: SkillRuntimeProfile }) {
const options = useMemo(
() => skills.map((skill) => ({ value: skill.skill_id, label: skill.skill_name, title: skill.description })),
() => skills.map((skill) => ({ value: skill.skillId, label: skill.skillName, title: skill.description })),
[skills],
);
const maxSkills = SKILL_RUNTIME_PROFILES[profile].maxSkills;

View File

@@ -41,8 +41,7 @@
- `base/tooltip/` — Tooltip(RAC 浮层,AntD 8 向 placement 映射,title 空不渲染)
- `base/switch/` — Switch(button role=switch + data-state,checkedChildren 文本轨、loading、Form.Item 注入)
- `product/empty-state/` — EmptyState(空态占位)
- `product/callout/` — Callout(规划中,P0)
- `base/badges/` — StatusBadge(状态点徽章;NodeTypeBadge 规划中)
- `product/empty-state/` — EmptyState(空态占位)
- `product/callout/` — Callout(规划中,P0)
- `product/callout/` — Callout
- `product/app-modal/` — AppModal(产品弹窗壳,`flush` 去掉 AntD 默认内边距)
- `product/app-drawer/` — AppDrawer(产品侧栏壳)
- `base/buttons/` — IconButton / ToolButton / ButtonGroup(规划中,P0)

View File

@@ -0,0 +1,22 @@
import { Drawer, type DrawerProps } from "antd";
import type { CSSProperties } from "react";
// 用途:产品侧栏壳。消费 token 由内容自己负责。默认 destroyOnHidden,可选 flush 去内边距。
export type AppDrawerProps = DrawerProps & {
flush?: boolean;
};
export function AppDrawer({ flush = false, destroyOnHidden = true, styles, className, rootClassName, ...props }: AppDrawerProps) {
const mergeResolvedStyles = (resolved: unknown) => {
const base = resolved && typeof resolved === "object" ? (resolved as Record<string, unknown>) : {};
if (!flush) return base;
const body = base.body && typeof base.body === "object" ? (base.body as CSSProperties) : {};
return { ...base, body: { ...body, padding: 0 } };
};
const mergedStyles =
typeof styles === "function"
? (info: { props: DrawerProps }) => mergeResolvedStyles(styles(info))
: mergeResolvedStyles(styles);
return <Drawer destroyOnHidden={destroyOnHidden} className={className} rootClassName={rootClassName} {...props} styles={mergedStyles as DrawerProps["styles"]} />;
}

View File

@@ -0,0 +1 @@
export { AppDrawer, type AppDrawerProps } from "./app-drawer";

View File

@@ -0,0 +1,33 @@
import { Modal, type ModalProps } from "antd";
import type { CSSProperties } from "react";
// 用途:产品弹窗壳。消费 token 由内容自己负责。接管 AntD Modal 的重复 flush 内边距。
const FLUSH_STYLES = {
container: { padding: 0, overflow: "hidden" },
body: { padding: 0 },
} as const;
export type AppModalProps = ModalProps & {
/** 内容自带外壳时去掉 AntD 默认内边距,避免每个业务弹窗复制同一套 styles。 */
flush?: boolean;
};
export function AppModal({ flush = false, destroyOnHidden = true, styles, ...props }: AppModalProps) {
const mergeResolvedStyles = (resolved: unknown) => {
const base = resolved && typeof resolved === "object" ? (resolved as Record<string, unknown>) : {};
if (!flush) return base;
const container = base.container && typeof base.container === "object" ? (base.container as CSSProperties) : {};
const body = base.body && typeof base.body === "object" ? (base.body as CSSProperties) : {};
return {
...base,
container: { ...container, ...FLUSH_STYLES.container },
body: { ...body, ...FLUSH_STYLES.body },
};
};
const mergedStyles =
typeof styles === "function"
? (info: { props: ModalProps }) => mergeResolvedStyles(styles(info))
: mergeResolvedStyles(styles);
return <Modal destroyOnHidden={destroyOnHidden} {...props} styles={mergedStyles as ModalProps["styles"]} />;
}

View File

@@ -0,0 +1 @@
export { AppModal, type AppModalProps } from "./app-modal";