feat(*): 短剧创作工作台 - 完善章节分镜、资产管理、技能运行与生成任务恢复

This commit is contained in:
ddCat
2026-08-29 23:34:46 +08:00
parent 4f07daae9e
commit 2f6832f99c
113 changed files with 7016 additions and 723 deletions

View File

@@ -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]);

View File

@@ -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 [

View File

@@ -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();

View File

@@ -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`;
}

View File

@@ -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 {

View File

@@ -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]);

View File

@@ -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);