refactor(*): 稳定期治理 - 收敛核心架构、错误语义并修复跨项目数据竞态
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
573
web/src/components/canvas/canvas-assistant-online-tools.ts
Normal file
573
web/src/components/canvas/canvas-assistant-online-tools.ts
Normal 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 };
|
||||
}
|
||||
251
web/src/components/canvas/canvas-assistant-panel-views.tsx
Normal file
251
web/src/components/canvas/canvas-assistant-panel-views.tsx
Normal 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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
71
web/src/components/canvas/canvas-cinematic-continuation.ts
Normal file
71
web/src/components/canvas/canvas-cinematic-continuation.ts
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "@/lib/canvas/director/director-onboarding";
|
||||
|
||||
/**
|
||||
* 导演台首次上手引导浮层(P2)。
|
||||
* 导演台首次上手引导浮层。
|
||||
*
|
||||
* 硬约束:
|
||||
* - 非阻塞。不是 dialog,没有遮罩,不抢焦点,不做焦点陷阱:引导旁边就是要动手的工作台,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)" }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -13,7 +13,7 @@ type EditorStoreHook = UseBoundStore<StoreApi<EditorStore>>;
|
||||
|
||||
export type EditorHostContextValue = {
|
||||
projectId: string;
|
||||
/** 项目资产列表(M3.4 asset-ingest 消费;由宿主在进入编辑器时获取)。 */
|
||||
/** 当前用户、当前项目的资产快照;由编辑器宿主获取,素材导入插件只消费和触发刷新。 */
|
||||
assets: ProjectAsset[];
|
||||
/** 重新拉取项目资产(导入媒体后刷新列表)。 */
|
||||
refreshAssets: () => Promise<ProjectAsset[] | null>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
22
web/src/components/ui/product/app-drawer/app-drawer.tsx
Normal file
22
web/src/components/ui/product/app-drawer/app-drawer.tsx
Normal 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"]} />;
|
||||
}
|
||||
1
web/src/components/ui/product/app-drawer/index.ts
Normal file
1
web/src/components/ui/product/app-drawer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AppDrawer, type AppDrawerProps } from "./app-drawer";
|
||||
33
web/src/components/ui/product/app-modal/app-modal.tsx
Normal file
33
web/src/components/ui/product/app-modal/app-modal.tsx
Normal 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"]} />;
|
||||
}
|
||||
1
web/src/components/ui/product/app-modal/index.ts
Normal file
1
web/src/components/ui/product/app-modal/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AppModal, type AppModalProps } from "./app-modal";
|
||||
Reference in New Issue
Block a user