feat(*): 短剧创作工作台 - 完善章节分镜、资产管理、技能运行与生成任务恢复
This commit is contained in:
@@ -34,7 +34,7 @@ export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: P
|
||||
category: asset.category || "other",
|
||||
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
|
||||
asset,
|
||||
searchText: asset.tags.join(" "),
|
||||
searchText: (asset.tags || []).join(" "),
|
||||
})),
|
||||
...externalAssetSources.items,
|
||||
], [externalAssetSources.items, insertableAssets]);
|
||||
|
||||
@@ -34,15 +34,15 @@ import { canvasAgentPostconditionMessage, previewCanvasAgentOps, summarizeCanvas
|
||||
import { buildCanvasAgentContext, findCanvasAgentNodes, getCanvasAgentConnection, getCanvasAgentGenerationTasks, getCanvasAgentNode, getCanvasAgentResources, validateCanvasAgentOps } from "@/lib/canvas/canvas-agent-context";
|
||||
import { canvasAgentPromptCacheKey } from "@/lib/openai-prompt-cache";
|
||||
import { resolveStoryboardGenerationContext } from "@/lib/canvas/canvas-storyboard-context";
|
||||
import { buildSkillMentionReferences } from "@/lib/canvas/canvas-skill-mentions";
|
||||
import { buildCanvasWorkflowOps, looksLikeWorkflowRequest, type CanvasWorkflowInput } from "@/lib/canvas/canvas-agent-workflow";
|
||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
import { buildSkillMentionReferences, SKILL_RUNTIME_AGENT_GUIDANCE, skillRuntime } from "@/services/skill-runtime";
|
||||
|
||||
export const CANVAS_AGENT_PANEL_MOTION_MS = 500;
|
||||
const PANEL_MOTION_SECONDS = CANVAS_AGENT_PANEL_MOTION_MS / 1000;
|
||||
const ONLINE_AGENT_MAX_STEPS = 4;
|
||||
const ONLINE_AGENT_MAX_STEPS = 8;
|
||||
const ONLINE_AGENT_PROMPT =
|
||||
"你是影策网页内置在线画布助手。首轮必须先调用 canvas_get_context;涉及已有节点时用 canvas_find_nodes 获取真实 id,涉及媒体参考时用 canvas_get_resources。流水线、工作流、管线、节点图或用户要求连线时,必须使用 canvas_create_workflow:把需求拆成有语义的节点类型、真实内容/提示词、边和布局,禁止把业务阶段退化成几个空文本卡片;工具会自动分配 id、布局并建立连线。复杂写操作先 canvas_validate_ops,再执行 canvas_apply_ops。任何写入后都必须检查工具返回的真实节点类型、connectionCount、overlapWarnings 和 verification;没有真实连线时绝不能说已连线,没有生成资源时绝不能说已完成。不要输出 JSON ops、不要猜 id、不要把未就绪资源当作可用素材、不要编造执行结果。需要用户选择时,给出可点击的短选项,不要只让用户输入 1、2、3。技能不是被拼进用户消息的提示词:用户提及技能时,先用 canvas_get_skill 按 id 或名称加载该技能,再按工具返回的技能契约执行。";
|
||||
`你是影策网页内置在线画布助手。首轮必须先调用 canvas_get_context;涉及已有节点时用 canvas_find_nodes 获取真实 id,涉及媒体参考时用 canvas_get_resources。流水线、工作流、管线、节点图或用户要求连线时,必须使用 canvas_create_workflow:把需求拆成有语义的节点类型、真实内容/提示词、边和布局,禁止把业务阶段退化成几个空文本卡片;工具会自动分配 id、布局并建立连线。复杂写操作先 canvas_validate_ops,再执行 canvas_apply_ops。任何写入后都必须检查工具返回的真实节点类型、connectionCount、overlapWarnings 和 verification;没有真实连线时绝不能说已连线,没有生成资源时绝不能说已完成。不要输出 JSON ops、不要猜 id、不要把未就绪资源当作可用素材、不要编造执行结果。需要用户选择时,给出可点击的短选项,不要只让用户输入 1、2、3。${SKILL_RUNTIME_AGENT_GUIDANCE}`;
|
||||
const JSON_RECORD_SCHEMA = { type: "object", additionalProperties: true };
|
||||
const POSITION_SCHEMA = { type: "object", properties: { x: { type: "number" }, y: { type: "number" } }, required: ["x", "y"], additionalProperties: false };
|
||||
const VIEWPORT_SCHEMA = { type: "object", properties: { x: { type: "number" }, y: { type: "number" }, k: { type: "number" } }, required: ["x", "y", "k"], additionalProperties: false };
|
||||
@@ -91,7 +91,7 @@ const CANVAS_OP_SCHEMA = {
|
||||
required: ["type"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
const ONLINE_READ_TOOLS = new Set(["canvas_list_skills", "canvas_get_skill", "canvas_get_state", "canvas_get_context", "canvas_find_nodes", "canvas_get_node", "canvas_get_connection", "canvas_get_generation_tasks", "canvas_get_resources", "canvas_validate_ops", "canvas_get_selection", "canvas_export_snapshot"]);
|
||||
const ONLINE_READ_TOOLS = new Set([...skillRuntime.agentToolNames("onlineAgent"), "canvas_get_state", "canvas_get_context", "canvas_find_nodes", "canvas_get_node", "canvas_get_connection", "canvas_get_generation_tasks", "canvas_get_resources", "canvas_validate_ops", "canvas_get_selection", "canvas_export_snapshot"]);
|
||||
|
||||
function toolDefinition(name: string, description: string, properties: Record<string, unknown>, required: string[] = [], strict = false): ResponseFunctionTool {
|
||||
return { type: "function", function: { name, description, parameters: { type: "object", properties, required, additionalProperties: false }, strict } };
|
||||
@@ -116,8 +116,7 @@ function generationToolDefinition(name: string, description: string, mode?: "tex
|
||||
}
|
||||
|
||||
const ONLINE_AGENT_TOOLS: ResponseFunctionTool[] = [
|
||||
toolDefinition("canvas_list_skills", "列出当前用户已加入、可按需加载的画布技能;只返回元数据,不返回完整指令。", {}),
|
||||
toolDefinition("canvas_get_skill", "按 skillId 或技能名称按需加载一个画布技能的完整契约。技能正文通过工具结果提供,不会自动注入每条用户消息。", { skillId: { type: "string" }, name: { type: "string" } }),
|
||||
...skillRuntime.agentTools("onlineAgent"),
|
||||
toolDefinition("canvas_get_state", "读取当前网页画布的节点、连线、选区和视口。", {}),
|
||||
toolDefinition("canvas_get_context", "读取语义化画布上下文、真实节点 id、连接关系、资源就绪状态和状态哈希。", {}),
|
||||
toolDefinition("canvas_find_nodes", "按标题、内容、提示词、类型、状态或资产检索真实节点。", { query: { type: "string" }, ids: { type: "array", items: { type: "string" } }, types: { type: "array", items: { type: "string" } }, statuses: { type: "array", items: { type: "string" } }, resourceOnly: { type: "boolean" }, limit: { type: "number" } }),
|
||||
@@ -779,21 +778,8 @@ export function CanvasAssistantPanel({
|
||||
if (expectedRevision !== undefined && expectedRevision !== (current.revision ?? 0)) return { ok: false, message: "画布 revision 已变化,请重新读取 canvas_get_context 后再执行写操作。" };
|
||||
const expectedStateHash = typeof args.expectedStateHash === "string" ? args.expectedStateHash : "";
|
||||
if (expectedStateHash && expectedStateHash !== buildCanvasAgentContext(current).stateHash) return { ok: false, message: "画布状态已变化,请重新读取 canvas_get_context 后再执行写操作。" };
|
||||
if (name === "canvas_list_skills") {
|
||||
const data = composerSkills.filter((skill) => skill.is_added).map((skill) => ({ skillId: skill.skill_id, name: skill.skill_name, description: skill.description, tag: skill.tag }));
|
||||
return { ok: true, message: data.length ? "已列出当前可用技能。" : "当前没有已加入技能。", data };
|
||||
}
|
||||
if (name === "canvas_get_skill") {
|
||||
const skillId = typeof args.skillId === "string" ? args.skillId : "";
|
||||
const nameQuery = typeof args.name === "string" ? args.name.trim().toLocaleLowerCase() : "";
|
||||
const skill = composerSkills.find((item) => item.is_added && (item.skill_id === skillId || item.skill_name.toLocaleLowerCase() === nameQuery));
|
||||
if (!skill) return { ok: false, message: "未找到已加入的技能,请先调用 canvas_list_skills。" };
|
||||
return {
|
||||
ok: true,
|
||||
message: `已按需加载技能「${skill.skill_name}」。`,
|
||||
data: { skillId: skill.skill_id, name: skill.skill_name, description: skill.description, instruction: skill.instruction || skill.description, version: skill.update_time },
|
||||
};
|
||||
}
|
||||
const skillToolResult = await skillRuntime.executeAgentTool("onlineAgent", name, args, composerSkills);
|
||||
if (skillToolResult) return skillToolResult;
|
||||
if (name === "canvas_get_state") return { ok: true, message: describeCanvasSnapshot(current), data: compactSnapshot(current) };
|
||||
if (name === "canvas_get_context") return { ok: true, message: "已读取语义化画布上下文。", data: buildCanvasAgentContext(current) };
|
||||
if (name === "canvas_find_nodes") return { ok: true, message: "已按条件检索真实节点。", data: findCanvasAgentNodes(current, args as Parameters<typeof findCanvasAgentNodes>[1]) };
|
||||
@@ -1701,7 +1687,10 @@ function previewOnlineToolCalls(calls: ResponseToolCall[], snapshot: CanvasAgent
|
||||
|
||||
function toolCallLabel(name: string) {
|
||||
if (name === "canvas_list_skills") return "列出技能";
|
||||
if (name === "canvas_get_skill") 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 "读取上下文";
|
||||
@@ -1913,7 +1902,7 @@ async function buildToolAgentMessages(snapshot: CanvasAgentSnapshot, history: Ca
|
||||
const skillCatalog = skills
|
||||
.filter((skill) => skill.is_added)
|
||||
.slice(0, 40)
|
||||
.map((skill) => `- ${skill.skill_name}(${skill.skill_id}):${skill.description}`)
|
||||
.map((skill) => `- ${skill.skill_name}(${skill.skill_id},v${skill.version || "1"},${skill.file_count || 1} 文件):${skill.description}`)
|
||||
.join("\n");
|
||||
const systemContent = [ONLINE_AGENT_PROMPT, skillCatalog ? `当前可按需加载的技能(仅元数据):\n${skillCatalog}` : ""].filter(Boolean).join("\n\n");
|
||||
return [
|
||||
|
||||
@@ -116,9 +116,9 @@ export function CanvasNodeContextMenu({
|
||||
}, [categoryOpen, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
setAddOpen(false);
|
||||
setAddOpen(menu.type === "canvas" && Boolean(menu.createOpen));
|
||||
setCategoryOpen(false);
|
||||
}, [menu.type, menu.x, menu.y]);
|
||||
}, [menu]);
|
||||
|
||||
const runAction = (action: () => void) => {
|
||||
action();
|
||||
|
||||
@@ -26,8 +26,8 @@ import { canvasAgentPostconditionMessage, hashCanvasAgentSnapshot, previewCanvas
|
||||
import { buildCanvasAgentContext, findCanvasAgentNodes, getCanvasAgentConnection, getCanvasAgentGenerationTasks, getCanvasAgentNode, getCanvasAgentResources, validateCanvasAgentOps } from "@/lib/canvas/canvas-agent-context";
|
||||
import { buildCanvasResourceReferences } from "@/lib/canvas/canvas-resource-references";
|
||||
import { buildLocalAgentSetupCommands, detectLocalAgentSetupPlatform, type LocalAgentSetupPlatform } from "@/lib/canvas/local-agent-setup";
|
||||
import { resolveSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
|
||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
import { skillRuntime } from "@/services/skill-runtime";
|
||||
import { isProjectAgentReadTool, isProjectAgentToolName, runProjectAgentTool } from "@/services/api/project-agent-tools";
|
||||
import { AgentChatComposer, AgentChatMessage, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
|
||||
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
|
||||
@@ -36,6 +36,7 @@ import { AgentChatEmptyState } from "./canvas-agent-panel-chrome";
|
||||
const PANEL_MOTION_SECONDS = 0.5;
|
||||
const MAX_ATTACHMENTS = 6;
|
||||
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
|
||||
const MAX_AGENT_TURN_PAYLOAD_BYTES = 56 * 1024 * 1024;
|
||||
type AgentEventPayload = {
|
||||
agent?: string;
|
||||
type?: string;
|
||||
@@ -57,7 +58,7 @@ type AgentTurnPayload = {
|
||||
canvasId: string;
|
||||
threadId?: string;
|
||||
attachments: Array<Pick<AgentAttachment, "name" | "type" | "dataUrl">>;
|
||||
skills: Array<{ skillId: string; name: string; description: string; instruction: string }>;
|
||||
skills: Array<{ skillId: string; name: string; description: string; version: string; files: Array<{ path: string; mimeType: string; contentBase64: string }> }>;
|
||||
};
|
||||
|
||||
export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
|
||||
@@ -386,20 +387,31 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
|
||||
const sendPrompt = async (overrideText?: string) => {
|
||||
const text = (overrideText ?? prompt).trim();
|
||||
const files = attachments;
|
||||
const mentionedSkills = resolveSkillMentions(text, composerSkills);
|
||||
const requestPrompt = promptWithAttachments(text, files);
|
||||
if (!connected || !requestPrompt || sending || waiting) return;
|
||||
if (!connected || !promptWithAttachments(text, files) || sending || waiting) return;
|
||||
if (attachmentPayloadBytes(files) > MAX_ATTACHMENT_PAYLOAD_BYTES) {
|
||||
addMessage({ role: "error", title: "图片过大", text: "图片附件超过 30MB,请删减后再发送。" });
|
||||
return;
|
||||
}
|
||||
let skillExecution: Awaited<ReturnType<typeof skillRuntime.prepare<"localAgent">>>;
|
||||
try {
|
||||
skillExecution = await skillRuntime.prepare({ profile: "localAgent", prompt: text, skills: composerSkills });
|
||||
} catch (error) {
|
||||
addMessage({ role: "error", title: "技能加载失败", text: error instanceof Error ? error.message : "无法读取技能包" });
|
||||
return;
|
||||
}
|
||||
const requestPrompt = promptWithAttachments(skillExecution.prompt, files);
|
||||
const skillBundles: AgentTurnPayload["skills"] = skillExecution.skills;
|
||||
if (attachmentPayloadBytes(files) + skillBundlePayloadBytes(skillBundles) > MAX_AGENT_TURN_PAYLOAD_BYTES) {
|
||||
addMessage({ role: "error", title: "本轮内容过大", text: "图片与技能包合计超过本地 Agent 单轮限制,请减少附件或只选择一个技能。" });
|
||||
return;
|
||||
}
|
||||
await submitTurn({
|
||||
text,
|
||||
prompt: requestPrompt,
|
||||
canvasId: snapshotRef.current.projectId,
|
||||
threadId: useCanvasAgentStore.getState().activeThreadId || undefined,
|
||||
attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })),
|
||||
skills: mentionedSkills.map((skill) => ({ skillId: skill.skill_id, name: skill.skill_name, description: skill.description, instruction: skill.instruction || skill.description })),
|
||||
skills: skillBundles,
|
||||
}, true);
|
||||
};
|
||||
|
||||
@@ -1385,6 +1397,10 @@ function attachmentPayloadBytes(attachments: AgentAttachment[]) {
|
||||
return attachments.reduce((total, item) => total + item.dataUrl.length, 0);
|
||||
}
|
||||
|
||||
function skillBundlePayloadBytes(skills: AgentTurnPayload["skills"]) {
|
||||
return skills.reduce((total, skill) => total + skill.files.reduce((fileTotal, file) => fileTotal + file.contentBase64.length, 0), 0);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)}MB` : `${Math.ceil(bytes / 1024)}KB`;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { resolveCanvasDrawingReference } from "@/lib/canvas/canvas-drawing-refer
|
||||
import { compileCharacterReferencePrompt } from "@/lib/canvas/canvas-character-reference";
|
||||
import { nodeReferenceImage } from "@/lib/canvas/canvas-project-generation";
|
||||
import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
|
||||
import { audioFileExtension } from "@/lib/character-voice-formats";
|
||||
import type { ModelReferenceLimits } from "@/lib/model-selection";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
|
||||
@@ -433,7 +434,7 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
|
||||
if (!context.characterReferences.length) return { ...context, referenceImages };
|
||||
if (!domainProjectId) throw new Error("角色引用未关联短剧项目,无法解析角色版本");
|
||||
const { getProjectCharacter } = await import("@/services/api/projects");
|
||||
const { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } = await import("@/services/api/resources");
|
||||
const { getResource, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } = await import("@/services/api/resources");
|
||||
const details = await Promise.all(context.characterReferences.map((reference) => getProjectCharacter(domainProjectId, reference.assetId)));
|
||||
const remainingBudget = Math.max(0, (referenceLimits?.maxImages ?? 9) - referenceImages.length);
|
||||
const selected = details.flatMap((detail) => {
|
||||
@@ -491,13 +492,17 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
|
||||
}
|
||||
const maxAudios = referenceLimits?.maxAudios ?? 3;
|
||||
if (context.referenceAudios.length + voiceSamples.length > maxAudios) throw new Error(`当前模型参考音频容量不足:已连接 ${context.referenceAudios.length} 个音频,角色声音样本还需要 ${voiceSamples.length} 个名额`);
|
||||
const characterVoiceAudios = voiceSamples.map((voice) => ({
|
||||
id: `character-voice-${voice.assetId}`,
|
||||
name: `${voice.characterName}-声音样本.mp3`,
|
||||
type: "audio/mpeg",
|
||||
url: resourceFileUrl(voice.sampleResourceId!),
|
||||
storageKey: resourceStorageKey(voice.sampleResourceId!),
|
||||
} satisfies ReferenceAudio));
|
||||
const characterVoiceAudios = await Promise.all(voiceSamples.map(async (voice) => {
|
||||
const resource = await getResource(voice.sampleResourceId!);
|
||||
const extension = audioFileExtension(resource.mimeType, resource.objectKey);
|
||||
return {
|
||||
id: `character-voice-${voice.assetId}`,
|
||||
name: `${voice.characterName}-声音样本.${extension}`,
|
||||
type: resource.mimeType || "audio/mpeg",
|
||||
url: resourceFileUrl(voice.sampleResourceId!),
|
||||
storageKey: resourceStorageKey(voice.sampleResourceId!),
|
||||
} satisfies ReferenceAudio;
|
||||
}));
|
||||
const referenceAudios = [...context.referenceAudios, ...characterVoiceAudios];
|
||||
const voiceBlocks = mode === "video" ? resolvedCharacterVoices.map(compileResolvedVoicePrompt) : [];
|
||||
return {
|
||||
|
||||
@@ -49,7 +49,7 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
|
||||
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
|
||||
imageFit: character ? "contain" : "cover",
|
||||
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText,
|
||||
searchText: [media?.tags.join(" ") || "", project?.previewText || ""].join(" "),
|
||||
searchText: [media?.tags?.join(" ") || "", project?.previewText || ""].join(" "),
|
||||
};
|
||||
}), [items]);
|
||||
const pickerItems = useMemo<AssetLibraryPickerItem[]>(() => [...localPickerItems, ...externalAssetSources.items], [externalAssetSources.items, localPickerItems]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent, PointerEvent, TextareaHTMLAttributes } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ArrowLeft, ChevronRight, FileText, Folder, Image as ImageIcon, Music2, Pencil, Search, Sparkles, UserRound, Video } from "lucide-react";
|
||||
import { ArrowLeft, ChevronRight, FileText, Folder, Image as ImageIcon, Music2, Pencil, Search, UserRound, Video, Workflow } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
@@ -234,8 +234,8 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
assetReferences={assetReferences}
|
||||
filteredReferences={candidates}
|
||||
query={mention.query}
|
||||
cursorOffset={mention.end}
|
||||
activeReferenceId={activeIndex >= 0 ? candidates[Math.min(activeIndex, candidates.length - 1)]?.id : undefined}
|
||||
theme={theme}
|
||||
preferredWidth={mentionMenuWidth}
|
||||
onQueryChange={(query) => setMention((current) => current ? { ...current, query } : current)}
|
||||
onClose={closeMention}
|
||||
@@ -507,14 +507,14 @@ const ASSET_CATEGORY_LABELS: Record<AssetCategory, string> = {
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
function MentionMenu({ anchor, connectedReferences, assetReferences, filteredReferences, query, activeReferenceId, theme, preferredWidth, onQueryChange, onClose, onSelect }: {
|
||||
function MentionMenu({ anchor, connectedReferences, assetReferences, filteredReferences, query, cursorOffset, activeReferenceId, preferredWidth, onQueryChange, onClose, onSelect }: {
|
||||
anchor: HTMLElement;
|
||||
connectedReferences: CanvasResourceReference[];
|
||||
assetReferences: CanvasResourceReference[];
|
||||
filteredReferences: CanvasResourceReference[];
|
||||
query: string;
|
||||
cursorOffset: number;
|
||||
activeReferenceId?: string;
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
preferredWidth: number;
|
||||
onQueryChange: (query: string) => void;
|
||||
onClose: () => void;
|
||||
@@ -523,14 +523,26 @@ function MentionMenu({ anchor, connectedReferences, assetReferences, filteredRef
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const selectedRef = useRef(false);
|
||||
const [category, setCategory] = useState<AssetCategory | null>(null);
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const boundary = anchor.closest(".ant-modal-container")?.getBoundingClientRect() || { left: 8, top: 8, right: window.innerWidth - 8, bottom: window.innerHeight - 8 };
|
||||
const menuWidth = Math.min(preferredWidth, Math.max(0, boundary.right - boundary.left - 16));
|
||||
const maxMenuHeight = Math.min(320, Math.max(0, boundary.bottom - boundary.top - 16));
|
||||
const gap = 6;
|
||||
const left = clamp(rect.left, boundary.left + 8, boundary.right - menuWidth - 8);
|
||||
const showAbove = rect.bottom + gap + maxMenuHeight > boundary.bottom && rect.top - gap - maxMenuHeight >= boundary.top;
|
||||
const top = clamp(showAbove ? rect.top - gap - maxMenuHeight : rect.bottom + gap, boundary.top + 8, boundary.bottom - maxMenuHeight - 8);
|
||||
const [position, setPosition] = useState(() => mentionMenuPosition(anchor, cursorOffset, preferredWidth));
|
||||
|
||||
useLayoutEffect(() => {
|
||||
let frame = 0;
|
||||
const updatePosition = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => setPosition(mentionMenuPosition(anchor, cursorOffset, preferredWidth)));
|
||||
};
|
||||
updatePosition();
|
||||
const observer = new ResizeObserver(updatePosition);
|
||||
observer.observe(anchor);
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [anchor, cursorOffset, preferredWidth]);
|
||||
|
||||
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
@@ -566,7 +578,8 @@ function MentionMenu({ anchor, connectedReferences, assetReferences, filteredRef
|
||||
ref={menuRef}
|
||||
data-canvas-resource-mention-menu="true"
|
||||
className="canvas-resource-mention-menu fixed z-[var(--z-tooltip)]"
|
||||
style={{ left, top, width: menuWidth, maxHeight: maxMenuHeight, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
data-placement={position.showAbove ? "top" : "bottom"}
|
||||
style={{ left: position.left, top: position.top, width: position.width, maxHeight: position.maxHeight, transform: position.showAbove ? "translateY(-100%)" : undefined }}
|
||||
onPointerDown={stopCanvasInteraction}
|
||||
onMouseDown={stopCanvasInteraction}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
@@ -575,7 +588,7 @@ function MentionMenu({ anchor, connectedReferences, assetReferences, filteredRef
|
||||
<Search aria-hidden />
|
||||
<input
|
||||
value={query}
|
||||
placeholder="搜索素材..."
|
||||
placeholder="搜索节点、素材或技能"
|
||||
aria-label="搜索引用素材"
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
@@ -595,7 +608,7 @@ function MentionMenu({ anchor, connectedReferences, assetReferences, filteredRef
|
||||
</div>
|
||||
<div className="canvas-resource-mention-scroll thin-scrollbar">
|
||||
{query ? (
|
||||
<MentionReferenceList references={visibleReferences} activeReferenceId={activeReferenceId} theme={theme} onSelect={selectReference} />
|
||||
<MentionReferenceList references={visibleReferences} activeReferenceId={activeReferenceId} onSelect={selectReference} />
|
||||
) : category ? (
|
||||
<>
|
||||
<button type="button" className="canvas-resource-mention-back" onClick={() => setCategory(null)}>
|
||||
@@ -603,25 +616,25 @@ function MentionMenu({ anchor, connectedReferences, assetReferences, filteredRef
|
||||
<span>{ASSET_CATEGORY_LABELS[category]}</span>
|
||||
<small>{visibleReferences.length}</small>
|
||||
</button>
|
||||
<MentionReferenceList references={visibleReferences} activeReferenceId={activeReferenceId} theme={theme} onSelect={selectReference} />
|
||||
<MentionReferenceList references={visibleReferences} activeReferenceId={activeReferenceId} onSelect={selectReference} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{connectedNodes.length ? (
|
||||
<section className="canvas-resource-mention-section">
|
||||
<h4>已连接节点</h4>
|
||||
<MentionReferenceList references={connectedNodes} activeReferenceId={activeReferenceId} theme={theme} onSelect={selectReference} />
|
||||
<h4><span>已连接节点</span><small>{connectedNodes.length}</small></h4>
|
||||
<MentionReferenceList references={connectedNodes} activeReferenceId={activeReferenceId} onSelect={selectReference} />
|
||||
</section>
|
||||
) : null}
|
||||
{skillReferences.length ? (
|
||||
<section className="canvas-resource-mention-section">
|
||||
<h4>技能库</h4>
|
||||
<MentionReferenceList references={skillReferences} activeReferenceId={activeReferenceId} theme={theme} onSelect={selectReference} />
|
||||
<h4><span>技能库</span><small>{skillReferences.length}</small></h4>
|
||||
<MentionReferenceList references={skillReferences} activeReferenceId={activeReferenceId} onSelect={selectReference} />
|
||||
</section>
|
||||
) : null}
|
||||
{categoryItems.length ? (
|
||||
<section className="canvas-resource-mention-section">
|
||||
<h4>素材库</h4>
|
||||
<h4><span>素材库</span><small>{assetReferences.length}</small></h4>
|
||||
{categoryItems.map((item) => (
|
||||
<button key={item.value} type="button" className="canvas-resource-mention-folder" onClick={() => setCategory(item.value)}>
|
||||
<Folder aria-hidden />
|
||||
@@ -640,14 +653,14 @@ function MentionMenu({ anchor, connectedReferences, assetReferences, filteredRef
|
||||
);
|
||||
}
|
||||
|
||||
function MentionReferenceList({ references, activeReferenceId, theme, onSelect }: { references: CanvasResourceReference[]; activeReferenceId?: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
|
||||
if (!references.length) return <div className="canvas-resource-mention-empty">没有匹配的素材</div>;
|
||||
function MentionReferenceList({ references, activeReferenceId, onSelect }: { references: CanvasResourceReference[]; activeReferenceId?: string; onSelect: (reference: CanvasResourceReference) => void }) {
|
||||
if (!references.length) return <div className="canvas-resource-mention-empty">没有匹配的引用</div>;
|
||||
return references.map((reference) => (
|
||||
<button
|
||||
key={reference.id}
|
||||
type="button"
|
||||
className="canvas-resource-mention-item"
|
||||
style={{ background: reference.id === activeReferenceId ? theme.toolbar.activeBg : "transparent", color: reference.id === activeReferenceId ? theme.toolbar.activeText : theme.node.text }}
|
||||
className={`canvas-resource-mention-item ${reference.kind === "skill" ? "is-skill" : ""} ${reference.id === activeReferenceId ? "is-active" : ""}`}
|
||||
aria-selected={reference.id === activeReferenceId}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -660,29 +673,31 @@ function MentionReferenceList({ references, activeReferenceId, theme, onSelect }
|
||||
}}
|
||||
>
|
||||
<ReferencePreview reference={reference} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium" title={reference.label}>{reference.label}</span>
|
||||
{reference.kind !== "skill" && reference.text && reference.text !== reference.title ? <span className="block truncate opacity-55">{reference.text}</span> : null}
|
||||
<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>
|
||||
) : reference.text && reference.text !== reference.title ? <span className="canvas-resource-mention-meta"><span>{reference.text}</span></span> : null}
|
||||
</span>
|
||||
</button>
|
||||
));
|
||||
}
|
||||
|
||||
function ReferencePreview({ reference }: { reference: CanvasResourceReference }) {
|
||||
if (reference.kind === "image" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-7 rounded-sm object-cover" />;
|
||||
if (reference.kind === "video" && reference.previewUrl) return <video src={reference.previewUrl} className="size-7 rounded-sm bg-black object-cover" muted preload="metadata" />;
|
||||
if (reference.kind === "character" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-7 rounded-sm bg-black/5 object-contain" />;
|
||||
if (reference.kind === "image" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="canvas-resource-mention-preview is-image" />;
|
||||
if (reference.kind === "video" && reference.previewUrl) return <video src={reference.previewUrl} className="canvas-resource-mention-preview is-video" muted preload="metadata" />;
|
||||
if (reference.kind === "character" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="canvas-resource-mention-preview is-character" />;
|
||||
if (reference.kind === "skill") {
|
||||
return (
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-sm bg-cyan-500/10 text-cyan-600 dark:text-cyan-200">
|
||||
<Sparkles className="size-3.5" />
|
||||
<span className="canvas-resource-mention-preview is-skill">
|
||||
<Workflow aria-hidden />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const Icon = reference.sourceType === CanvasNodeType.Drawing ? Pencil : reference.kind === "character" ? UserRound : reference.kind === "audio" ? Music2 : reference.kind === "video" ? Video : reference.kind === "image" ? ImageIcon : FileText;
|
||||
return (
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-sm bg-black/10">
|
||||
<Icon className="size-3.5" />
|
||||
<span className="canvas-resource-mention-preview is-fallback">
|
||||
<Icon aria-hidden />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -823,6 +838,105 @@ function isMentionElement(node: Node): node is HTMLElement {
|
||||
return node instanceof HTMLElement && Boolean(node.dataset.mentionToken);
|
||||
}
|
||||
|
||||
type MentionAnchorRect = Pick<DOMRect, "left" | "right" | "top" | "bottom" | "width" | "height">;
|
||||
|
||||
function mentionMenuPosition(anchor: HTMLElement, cursorOffset: number, preferredWidth: number) {
|
||||
const caret = mentionCaretRect(anchor, cursorOffset);
|
||||
const boundary = anchor.closest(".ant-modal-container")?.getBoundingClientRect() || { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
|
||||
const inset = 8;
|
||||
const gap = 8;
|
||||
const availableWidth = Math.max(0, boundary.right - boundary.left - inset * 2);
|
||||
const width = Math.min(preferredWidth, availableWidth);
|
||||
const left = clamp(caret.left, boundary.left + inset, boundary.right - width - inset);
|
||||
const spaceAbove = Math.max(0, caret.top - boundary.top - gap - inset);
|
||||
const spaceBelow = Math.max(0, boundary.bottom - caret.bottom - gap - inset);
|
||||
const showAbove = spaceBelow < 220 && spaceAbove > spaceBelow;
|
||||
const maxHeight = Math.min(320, showAbove ? spaceAbove : spaceBelow);
|
||||
const top = showAbove ? caret.top - gap : caret.bottom + gap;
|
||||
return { left, top, width, maxHeight, showAbove };
|
||||
}
|
||||
|
||||
function mentionCaretRect(anchor: HTMLElement, cursorOffset: number): MentionAnchorRect {
|
||||
if (anchor instanceof HTMLTextAreaElement) return textareaCaretRect(anchor, cursorOffset);
|
||||
const point = pointForOffset(anchor, cursorOffset);
|
||||
const range = document.createRange();
|
||||
range.setStart(point.node, point.offset);
|
||||
range.collapse(true);
|
||||
const rangeRect = range.getClientRects()[0] || range.getBoundingClientRect();
|
||||
if (rangeRect && (rangeRect.height || rangeRect.width)) return rangeRect;
|
||||
return fallbackCaretRect(anchor);
|
||||
}
|
||||
|
||||
function textareaCaretRect(textarea: HTMLTextAreaElement, cursorOffset: number): MentionAnchorRect {
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const computed = window.getComputedStyle(textarea);
|
||||
const mirror = document.createElement("div");
|
||||
const marker = document.createElement("span");
|
||||
const copiedProperties = [
|
||||
"boxSizing",
|
||||
"borderTopWidth",
|
||||
"borderRightWidth",
|
||||
"borderBottomWidth",
|
||||
"borderLeftWidth",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontStyle",
|
||||
"fontVariant",
|
||||
"fontWeight",
|
||||
"fontStretch",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"textAlign",
|
||||
"textIndent",
|
||||
"textTransform",
|
||||
"wordSpacing",
|
||||
"tabSize",
|
||||
"wordBreak",
|
||||
"overflowWrap",
|
||||
] as const;
|
||||
copiedProperties.forEach((property) => {
|
||||
mirror.style[property] = computed[property];
|
||||
});
|
||||
Object.assign(mirror.style, {
|
||||
position: "fixed",
|
||||
left: `${rect.left}px`,
|
||||
top: `${rect.top}px`,
|
||||
width: `${rect.width}px`,
|
||||
height: "auto",
|
||||
minHeight: "0",
|
||||
maxHeight: "none",
|
||||
overflow: "hidden",
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "pre-wrap",
|
||||
zIndex: "-1",
|
||||
});
|
||||
mirror.append(document.createTextNode(textarea.value.slice(0, Math.max(0, cursorOffset))));
|
||||
marker.textContent = "\u200b";
|
||||
mirror.append(marker);
|
||||
document.body.append(mirror);
|
||||
const markerRect = marker.getBoundingClientRect();
|
||||
mirror.remove();
|
||||
|
||||
const lineHeight = Number.parseFloat(computed.lineHeight) || Number.parseFloat(computed.fontSize) * 1.4 || 20;
|
||||
const top = clamp(markerRect.top - textarea.scrollTop, rect.top, rect.bottom - lineHeight);
|
||||
const left = clamp(markerRect.left - textarea.scrollLeft, rect.left, rect.right);
|
||||
return { left, right: left, top, bottom: top + lineHeight, width: 0, height: lineHeight };
|
||||
}
|
||||
|
||||
function fallbackCaretRect(anchor: HTMLElement): MentionAnchorRect {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const computed = window.getComputedStyle(anchor);
|
||||
const lineHeight = Number.parseFloat(computed.lineHeight) || Number.parseFloat(computed.fontSize) * 1.4 || 20;
|
||||
const left = rect.left + Number.parseFloat(computed.paddingLeft || "0");
|
||||
const top = rect.top + Number.parseFloat(computed.paddingTop || "0");
|
||||
return { left, right: left, top, bottom: top + lineHeight, width: 0, height: lineHeight };
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
if (max < min) return min;
|
||||
return Math.min(Math.max(value, min), max);
|
||||
|
||||
Reference in New Issue
Block a user