fix(*): 创作工作台 - 修复 Agent、资源引用、附件交互与 Agnes 视频协议

This commit is contained in:
ddCat
2026-08-27 22:12:04 +08:00
parent 0b0d4f0774
commit d42e392ed7
33 changed files with 1218 additions and 239 deletions

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
import { Button, Tooltip } from "antd";
import { motion, useReducedMotion } from "motion/react";
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, Sparkles, UserRound, Wrench, X, XCircle } from "lucide-react";
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, RotateCcw, Sparkles, UserRound, Wrench, X, XCircle } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import type { CanvasAgentOperationImpact } from "@/lib/canvas/canvas-agent-ops";
@@ -48,7 +48,7 @@ export function extractCanvasAgentQuickActions(text: string): CanvasAgentQuickAc
const WORKING_TEXT = "正在推演...";
export function AgentChatMessage({ item, theme, user, isStreaming = false, onRejectTool, onApproveTool, onQuickAction }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null; isStreaming?: boolean; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void; onQuickAction?: (prompt: string) => void }) {
export function AgentChatMessage({ item, theme, user, isStreaming = false, retrying = false, onRejectTool, onApproveTool, onQuickAction, onRetry }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null; isStreaming?: boolean; retrying?: boolean; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void; onQuickAction?: (prompt: string) => void; onRetry?: () => void }) {
const isUser = item.role === "user";
const isSystem = item.role === "system";
const color = item.role === "error" ? "#dc2626" : item.role === "tool" ? "#2563eb" : theme.node.text;
@@ -77,6 +77,11 @@ export function AgentChatMessage({ item, theme, user, isStreaming = false, onRej
{!isUser ? <AgentAvatar theme={theme} /> : null}
<div className={`min-w-0 max-w-[86%] text-sm leading-6 ${isUser ? "rounded-md px-3 py-2.5 text-right" : "text-left"}`} style={{ color, ...(isUser ? { background: theme.accent.primarySoft } : {}) }}>
{item.role === "assistant" ? <AIMessageMarkdown className="text-left" isStreaming={isStreaming}>{item.text}</AIMessageMarkdown> : <div className="whitespace-pre-wrap break-words text-left">{item.text}</div>}
{item.role === "error" && onRetry ? (
<Button size="small" className="mt-2 !h-7" icon={retrying ? <LoaderCircle className="size-3.5 animate-spin" /> : <RotateCcw className="size-3.5" />} disabled={retrying} onClick={onRetry}>
{retrying ? "重试中" : "重试本轮"}
</Button>
) : null}
{quickActions.length && onQuickAction ? (
<div className="mt-3 flex flex-wrap gap-1.5" aria-label="快捷选项">
{quickActions.map((action) => (
@@ -341,7 +346,7 @@ export function AgentChatComposer({
value={prompt}
references={references}
includeAssetLibrary={includeAssetLibrary}
sendOnEnter
sendOnEnter={false}
disabled={disabled}
onChange={handlePromptChange}
onSubmit={onSubmit}

View File

@@ -21,6 +21,7 @@ import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { navigateToSettings } from "@/lib/settings-navigation";
import { cinematicAgentSessionOpsJson, createCinematicAgentSession, isAgentSessionPollingAbort, resumeCinematicAgentSession } from "@/lib/canvas/canvas-agent-session";
import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary";
import { buildOrderedCanvasResourceReferences, canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references";
import { AgentChatComposer, AgentChatMessage, AgentWorkingMessage, type CanvasAgentChatMessage, type CanvasAgentMode } from "./canvas-agent-chat-ui";
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
import { ModelLogo } from "@/components/model-logo";
@@ -1560,7 +1561,20 @@ function generationFlowOps(input: Record<string, unknown>, snapshot: CanvasAgent
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 tokens = [`@[node:${textId}]`, ...referenceNodeIds.map((id) => `@[node:${id}]`)];
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),

View File

@@ -7,7 +7,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { NodeGenerationInput } from "./canvas-node-generation";
import { generationInputMentionLabel, normalizeGenerationNodeMentionTokens, type NodeGenerationInput } from "./canvas-node-generation";
import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools";
import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker";
import type { CanvasGenerationMode, CanvasNodeMetadata, CanvasWorkspaceMode } from "@/types/canvas";
@@ -42,7 +42,7 @@ type ComposerCandidate =
reference: CanvasResourceReference;
};
export const CONFIG_REFERENCE_PATTERN = /@\[node:([^\]]+)\]/g;
export const CONFIG_REFERENCE_PATTERN = /@\[node:([^\]]+)\]|@(图片|视频|音频|文本|角色|绘图)(\d+)/g;
export function CanvasConfigComposer({ value, inputs, skillReferences = [], generationMode, metadata, onChange, onMetadataChange, onClose, workspaceMode = "professional" }: CanvasConfigComposerProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
@@ -54,7 +54,8 @@ export function CanvasConfigComposer({ value, inputs, skillReferences = [], gene
const [presetOpen, setPresetOpen] = useState(false);
const simpleMode = workspaceMode === "simple";
const workflowVideoReferenceMode = generationMode === "video" && isCanvasWorkflowProvider(metadata);
const tokens = useMemo(() => parseComposerTokens(value), [value]);
const normalizedValue = useMemo(() => normalizeGenerationNodeMentionTokens(value, inputs), [inputs, value]);
const tokens = useMemo(() => parseComposerTokens(normalizedValue, inputs), [inputs, normalizedValue]);
const referenceById = useMemo(() => new Map(inputs.map((input) => [input.nodeId, input])), [inputs]);
const videoFrameOptions = useMemo(
() =>
@@ -72,6 +73,10 @@ export function CanvasConfigComposer({ value, inputs, skillReferences = [], gene
}, [inputs, mention, skillReferences]);
useEffect(() => {
if (normalizedValue !== value) {
onChange(normalizedValue);
return;
}
if (document.activeElement === editorRef.current) return;
const editor = editorRef.current;
if (!editor) return;
@@ -84,7 +89,7 @@ export function CanvasConfigComposer({ value, inputs, skillReferences = [], gene
const input = referenceById.get(token.nodeId);
if (input) editor.append(createReferenceChip(input, inputs, theme, setImagePreview));
});
}, [inputs, referenceById, theme, tokens]);
}, [inputs, normalizedValue, onChange, referenceById, theme, tokens, value]);
const syncFromEditor = () => {
const editor = editorRef.current;
@@ -344,6 +349,7 @@ function createReferenceChip(input: NodeGenerationInput, inputs: NodeGenerationI
const wrapper = document.createElement("span");
wrapper.contentEditable = "false";
wrapper.dataset.referenceNodeId = input.nodeId;
wrapper.dataset.referenceToken = `@${generationInputMentionLabel(input, inputs)}`;
wrapper.className = "mx-px inline-flex h-7 max-w-40 items-center justify-center overflow-hidden rounded-md border px-1 text-xs leading-none align-middle";
Object.assign(wrapper.style, chipStyle(theme));
if (input.type === "image" && input.image && input.sourceKind !== "drawing") {
@@ -377,8 +383,8 @@ function serializeNodes(nodes: NodeListOf<ChildNode>) {
nodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) result += node.textContent || "";
if (!(node instanceof HTMLElement)) return;
const nodeId = node.dataset.referenceNodeId;
if (nodeId) result += `@[node:${nodeId}]`;
const referenceToken = node.dataset.referenceToken;
if (referenceToken) result += referenceToken;
else if (node.tagName === "BR") result += "\n";
else result += serializeNodes(node.childNodes);
});
@@ -454,27 +460,31 @@ function placeCaretAtEnd(element: HTMLElement) {
selection?.addRange(range);
}
function parseComposerTokens(value: string): Token[] {
function parseComposerTokens(value: string, inputs: NodeGenerationInput[]): Token[] {
const tokens: Token[] = [];
const inputByLabel = new Map(inputs.map((input) => [generationInputMentionLabel(input, inputs), input]));
const inputByNodeId = new Map(inputs.map((input) => [input.nodeId, input]));
let lastIndex = 0;
for (const match of value.matchAll(CONFIG_REFERENCE_PATTERN)) {
if (match.index === undefined) continue;
const input = match[1] ? inputByNodeId.get(match[1]) : inputByLabel.get(`${match[2]}${match[3]}`);
const end = match.index + match[0].length;
if (!input || (!match[1] && !hasMentionBoundary(value, end))) continue;
if (match.index > lastIndex) tokens.push({ type: "text", value: value.slice(lastIndex, match.index) });
tokens.push({ type: "reference", nodeId: match[1] });
lastIndex = match.index + match[0].length;
tokens.push({ type: "reference", nodeId: input.nodeId });
lastIndex = end;
}
if (lastIndex < value.length) tokens.push({ type: "text", value: value.slice(lastIndex) });
return tokens;
}
function resourceLabel(input: NodeGenerationInput, inputs: NodeGenerationInput[]) {
const sameTypeInputs = inputs.filter((item) => item.type === input.type && item.sourceKind === input.sourceKind);
const index = Math.max(0, sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId));
if (input.sourceKind === "drawing") return `绘图${index + 1}`;
if (input.type === "image") return `图片${index + 1}`;
if (input.type === "video") return `视频${index + 1}`;
if (input.type === "audio") return `音频${index + 1}`;
return `文本${index + 1}`;
return generationInputMentionLabel(input, inputs);
}
function hasMentionBoundary(value: string, index: number) {
const char = value[index];
return !char || /\s|[,.!?;:,。!?;:、)\]}】)]/.test(char);
}
function chipStyle(theme: (typeof canvasThemes)[keyof typeof canvasThemes]): CSSProperties {

View File

@@ -207,6 +207,7 @@ export function CanvasNodeContextMenu({
<MenuDivider />
<MenuSection label="节点" />
<MenuButton icon={<Copy />} label="复制节点" shortcut="⌘C" onClick={() => runAction(onCopyNode)} />
{isImage ? <MenuButton icon={<Clipboard />} label="复制图片" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null}
<MenuButton icon={<Link2 />} label={isImage ? "复制图片地址" : "复制视频地址"} disabled={!canCopyMediaUrl} onClick={() => runAction(onCopyMediaUrl)} />
<MenuButton icon={<Layers3 />} label="创建参数变体" shortcut="⌘D" onClick={() => runAction(onDuplicate)} />
<MenuButton icon={<Trash2 />} label="删除节点" danger onClick={() => runAction(onDelete)} />

View File

@@ -56,6 +56,14 @@ type AgentLogContext = { connected: boolean; enabled: boolean; activity: string;
type AgentWorkspace = { canvasId: string; workspacePath: string; activeThreadId?: string };
type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] };
type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[] };
type AgentTurnPayload = {
text: string;
prompt: string;
canvasId: string;
threadId?: string;
attachments: Array<Pick<AgentAttachment, "name" | "type" | "dataUrl">>;
skills: Array<{ skillId: string; name: string; description: string; instruction: string }>;
};
export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
snapshot,
@@ -106,6 +114,8 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
clearEventLogs,
} = useCanvasAgentStore();
const [resizing, setResizing] = useState(false);
const [retryTurn, setRetryTurn] = useState<AgentTurnPayload | null>(null);
const [retryMessageId, setRetryMessageId] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
// 供 Agent 输入框「@」插入的画布节点引用候选(active 标记为可用,供「@」菜单列出),与「/」弹出的已加入技能候选
const composerReferences = useMemo(() => buildCanvasResourceReferences(snapshot.nodes, snapshot.connections).map((item) => ({ ...item, active: true })), [snapshot]);
@@ -139,6 +149,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
const connectionControllerRef = useRef<AbortController | null>(null);
const activeToolRequestIdsRef = useRef(new Set<string>());
const recoveredToolResultIdsRef = useRef(new Set<string>());
const activeTurnRef = useRef<AgentTurnPayload | null>(null);
const syncState = useCallback(
(clientId: string, nextSnapshot: CanvasAgentSnapshot) => {
const stateHash = hashCanvasAgentSnapshot(nextSnapshot);
@@ -202,6 +213,9 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
runtimeRevisionRef.current = 0;
runtimeStateHashRef.current = "";
runtimeCanonicalStateHashRef.current = "";
activeTurnRef.current = null;
setRetryTurn(null);
setRetryMessageId(null);
}, [snapshot.projectId]);
useEffect(() => {
if (!connected) return;
@@ -269,8 +283,12 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
}
if (event.type === "agent_error") {
const errorMessage = parseEventJson<{ message?: unknown }>(event.data)?.message;
setAgentState({ activity: "出错", waiting: false });
addMessage({ role: "error", title: "错误", text: normalizeText(errorMessage) });
setAgentState({ activity: "出错", waiting: false, sending: false });
const messageId = addMessage({ role: "error", title: "错误", text: normalizeText(errorMessage) || "本地 Agent 执行失败,请重试本轮。" });
if (activeTurnRef.current) {
setRetryTurn(activeTurnRef.current);
setRetryMessageId(messageId || null);
}
addEventLog("错误", errorMessage, errorMessage);
return;
}
@@ -322,6 +340,54 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
return () => clearTimeout(timer);
}, [connected, snapshot, syncState]);
const submitTurn = async (payload: AgentTurnPayload, appendUserMessage: boolean) => {
const state = useCanvasAgentStore.getState();
if (!state.connected || state.sending || state.waiting) return;
activeTurnRef.current = payload;
if (appendUserMessage) {
setRetryTurn(null);
setRetryMessageId(null);
}
setAgentState({ activity: appendUserMessage ? "发送中" : "重试中", sending: true, waiting: true });
if (appendUserMessage) {
const files = state.attachments;
addMessage({ role: "user", text: payload.text || "发送了图片", attachments: files });
addEventLog("用户发送", { text: payload.text, attachments: files.map(({ name, type, size }) => ({ name, type, size })) });
files.forEach((item) => {
URL.revokeObjectURL(item.url);
attachmentUrlsRef.current.delete(item.url);
});
setAgentState({ prompt: "", attachments: [] });
} else {
addEventLog("重试本轮", { threadId: payload.threadId, attachments: payload.attachments.map(({ name, type }) => ({ name, type })) });
}
try {
const data = await fetchAgentJson<{ threadId?: string }>("/agent/codex/turn", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
prompt: payload.prompt,
canvasId: payload.canvasId,
threadId: payload.threadId,
attachments: payload.attachments,
skills: payload.skills,
}),
});
const acceptedPayload = data.threadId ? { ...payload, threadId: data.threadId } : payload;
activeTurnRef.current = acceptedPayload;
if (data.threadId) setAgentState({ activeThreadId: data.threadId });
addEventLog(appendUserMessage ? "本地 Agent 已接收" : "本地 Agent 已接收重试", { accepted: true });
} catch (error) {
setRetryTurn(activeTurnRef.current);
setAgentState({ activity: "发送失败", waiting: false });
const messageId = addMessage({ role: "error", title: appendUserMessage ? "发送失败" : "重试失败", text: error instanceof Error ? error.message : "发送失败" });
setRetryMessageId(messageId || null);
addEventLog(appendUserMessage ? "发送失败" : "重试失败", error);
} finally {
setAgentState({ sending: false });
}
};
const sendPrompt = async (overrideText?: string) => {
const text = (overrideText ?? prompt).trim();
const files = attachments;
@@ -332,35 +398,14 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
addMessage({ role: "error", title: "图片过大", text: "图片附件超过 30MB,请删减后再发送。" });
return;
}
setAgentState({ activity: "发送中", sending: true, waiting: true });
addMessage({ role: "user", text: text || "发送了图片", attachments: files });
addEventLog("用户发送", { text, attachments: files.map(({ name, type, size }) => ({ name, type, size })) });
try {
const data = await fetchAgentJson<{ threadId?: string }>("/agent/codex/turn", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
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 })),
}),
});
if (data.threadId) setAgentState({ activeThreadId: data.threadId });
addEventLog("本地 Agent 已接收", { accepted: true });
files.forEach((item) => {
URL.revokeObjectURL(item.url);
attachmentUrlsRef.current.delete(item.url);
});
setAgentState({ prompt: "", attachments: [] });
} catch (error) {
setAgentState({ activity: "发送失败", waiting: false });
addMessage({ role: "error", title: "发送失败", text: error instanceof Error ? error.message : "发送失败" });
addEventLog("发送失败", error);
} finally {
setAgentState({ sending: false });
}
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 })),
}, true);
};
const addAttachments = async (files: FileList | File[] | null) => {
@@ -549,6 +594,9 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
setAgentState({ loadingThreads: true });
try {
const data = await fetchAgentJson<AgentThreadResponse>("/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
activeTurnRef.current = null;
setRetryTurn(null);
setRetryMessageId(null);
setAgentState({ activeThreadId: data.thread?.id || data.workspace?.activeThreadId || "", messages: [], activeTab: "chat", activity: "新对话" });
await loadThreads();
} catch (error) {
@@ -565,6 +613,9 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
setAgentState({ loadingThreads: true });
try {
const data = await fetchAgentJson<AgentThreadResponse>(`/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
activeTurnRef.current = null;
setRetryTurn(null);
setRetryMessageId(null);
setAgentState({ activeThreadId: data.thread?.id || threadId, messages: normalizeHistoryMessages(data.messages || []), activeTab: "chat", activity: "已恢复会话" });
await loadThreads();
} catch (error) {
@@ -582,6 +633,11 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
try {
await fetchAgentJson(`/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
const current = useCanvasAgentStore.getState();
if (current.activeThreadId === threadId) {
activeTurnRef.current = null;
setRetryTurn(null);
setRetryMessageId(null);
}
setAgentState({
threads: current.threads.filter((thread) => thread.id !== threadId),
activeThreadId: current.activeThreadId === threadId ? "" : current.activeThreadId,
@@ -630,24 +686,25 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
const addMessage = (item: Omit<AgentChatItem, "id">) => {
const text = normalizeText(item.text);
if (!text && !item.attachments?.length) return;
if (!text && !item.attachments?.length) return undefined;
const next = { ...item, id: `${Date.now()}-${Math.random()}`, text };
const currentMessages = useCanvasAgentStore.getState().messages;
if (next.streamId) {
const index = currentMessages.findIndex((message) => message.streamId === next.streamId);
if (index >= 0) {
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, ...next, id: message.id, text: next.text || message.text } : message)) });
return;
return currentMessages[index].id;
}
}
const last = currentMessages.at(-1);
if (last?.role === "assistant" && next.role === "assistant" && last.title === next.title) {
const merged = mergeAgentText(last.text, next.text);
if (merged === last.text) return;
if (merged === last.text) return last.id;
setAgentState({ messages: [...useCanvasAgentStore.getState().messages.slice(0, -1), { ...last, text: merged, meta: next.meta || last.meta }] });
return;
return last.id;
}
pushMessage(next);
return next.id;
};
const addEventLog = (title: string, text: unknown, raw?: unknown) => {
@@ -660,11 +717,20 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
const nextActivity = activityText(event);
if (nextActivity) setAgentState({ activity: nextActivity });
if (event.type === "turn.started") setAgentState({ waiting: true });
if (event.type === "turn.completed" || event.type === "turn.failed" || event.type === "error") setAgentState({ waiting: false, sending: false });
if (event.type === "turn.completed") {
activeTurnRef.current = null;
setRetryTurn(null);
setRetryMessageId(null);
setAgentState({ waiting: false, sending: false });
} else if (event.type === "turn.failed" || event.type === "error") {
if (activeTurnRef.current) setRetryTurn(activeTurnRef.current);
setAgentState({ waiting: false, sending: false });
}
const item = formatAgentEvent(event);
if (item) {
if (item.role === "error") setAgentState({ waiting: false, sending: false });
addMessage(item);
const messageId = addMessage(item);
if ((event.type === "turn.failed" || event.type === "error") && activeTurnRef.current && item.role === "error") setRetryMessageId(messageId || null);
}
};
@@ -716,7 +782,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
/>
) : null}
{messages.map((item) => (
<AgentChatMessage key={item.id} item={agentMessageToChatMessage(item)} theme={theme} user={user} isStreaming={(sending || waiting) && item.id === messages.at(-1)?.id && item.role === "assistant"} onQuickAction={(text) => void sendPrompt(text)} />
<AgentChatMessage key={item.id} item={agentMessageToChatMessage(item)} theme={theme} user={user} isStreaming={(sending || waiting) && item.id === messages.at(-1)?.id && item.role === "assistant"} retrying={sending && item.id === retryMessageId} onRetry={item.id === retryMessageId && retryTurn ? () => void submitTurn(retryTurn, false) : undefined} onQuickAction={(text) => void sendPrompt(text)} />
))}
{pendingTool ? (
<AgentPendingToolCard
@@ -1025,6 +1091,7 @@ function agentAttachmentToChatAttachment(item: AgentAttachment): CanvasAgentChat
function formatAgentEvent(event: AgentEventPayload): Omit<AgentChatItem, "id"> | null {
const item = event.item;
if (event.type === "turn.failed" || event.type === "error") return { role: "error", title: "模型调用失败", text: normalizeText(event.error?.message || event.message) || "模型调用失败,请重试本轮。", detail: event };
if (event.type === "item.completed" && item?.type === "error") return { role: "error", title: "错误", text: normalizeText(item.message), detail: item };
if ((event.type === "item.updated" || event.type === "item.completed") && item?.type === "agent_message") return { role: "assistant", title: "Codex", text: stringText(item.text), meta: usageText(event), streamId: item.id };
if (event.type === "item.completed" && isMcpToolItem(item) && isReadTool(String(item?.tool || ""))) return { role: "tool", title: `${toolName(String(item?.tool || ""))}完成`, text: item?.error?.message || toolSummary(item), detail: toolDetail(item) };

View File

@@ -317,13 +317,14 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere
/>
) : richTextHTML ? (
<div
className="thin-scrollbar block h-full w-full overflow-y-auto break-words bg-transparent px-4 pb-4 font-mono [&_a]:underline [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:opacity-70 [&_code]:rounded [&_code]:bg-black/6 [&_code]:px-1 dark:[&_code]:bg-white/8 [&_h1]:my-2 [&_h1]:text-[1.55em] [&_h1]:font-semibold [&_h2]:my-2 [&_h2]:text-[1.3em] [&_h2]:font-semibold [&_h3]:my-1.5 [&_h3]:text-[1.12em] [&_h3]:font-semibold [&_hr]:my-3 [&_li]:my-0.5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-1 [&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-black/90 [&_pre]:p-2 [&_pre]:text-white [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5"
className="thin-scrollbar block h-full w-full select-text overflow-y-auto break-words bg-transparent px-4 pb-4 font-mono [&_a]:underline [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:opacity-70 [&_code]:rounded [&_code]:bg-black/6 [&_code]:px-1 dark:[&_code]:bg-white/8 [&_h1]:my-2 [&_h1]:text-[1.55em] [&_h1]:font-semibold [&_h2]:my-2 [&_h2]:text-[1.3em] [&_h2]:font-semibold [&_h3]:my-1.5 [&_h3]:text-[1.12em] [&_h3]:font-semibold [&_hr]:my-3 [&_li]:my-0.5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-1 [&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-black/90 [&_pre]:p-2 [&_pre]:text-white [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5"
style={textStyle}
onMouseDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
dangerouslySetInnerHTML={{ __html: richTextHTML }}
/>
) : (
<div className="thin-scrollbar block h-full w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent px-4 pb-4 pt-0 font-mono" style={textStyle} onWheel={(event) => event.stopPropagation()}>
<div className="thin-scrollbar block h-full w-full select-text overflow-y-auto whitespace-pre-wrap break-words bg-transparent px-4 pb-4 pt-0 font-mono" style={textStyle} onMouseDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()}>
{node.metadata?.content || <span style={{ color: theme.node.placeholder }}>双击编辑文字</span>}
</div>
)}

View File

@@ -71,7 +71,7 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData
: [];
const inputs = [...connectedInputs, ...portraitTextureInput, ...buildAssetGenerationInputs(assets)];
const storyboardInputs = getConnectedStoryboardRows(nodeId, nodes, connections);
const hasExplicitResourceMention = /@\[(?:node|asset):[^\]]+\]/.test(normalizeLegacyNodeMentions(prompt, inputs));
const hasExplicitResourceMention = hasResolvableGenerationMention(prompt, inputs);
const isWorkflowSource = sourceNode?.type === CanvasNodeType.Config && isCanvasWorkflowProvider(sourceNode.metadata);
if ((Boolean(sourceNode?.metadata?.composerContent?.trim()) && (sourceNode?.type === CanvasNodeType.Config || isWorkflowSource)) || hasExplicitResourceMention) {
const autoIncludeWorkflowMedia = isWorkflowSource;
@@ -139,8 +139,9 @@ function buildComposerGenerationContext(
autoIncludeWorkflowMedia = false,
workflowMediaInputs: NodeGenerationInput[] = [],
): NodeGenerationContext {
const normalizedPrompt = normalizeLegacyNodeMentions(prompt, inputs);
const inputByToken = new Map(inputs.map((input) => [generationInputToken(input), input]));
const normalizedPrompt = normalizeGenerationNodeMentionTokens(prompt, inputs);
const slotInputByToken = new Map(generationSlotEntries(inputs).map(({ token, input }) => [token, input]));
const assetInputById = new Map(inputs.filter((input) => input.nodeId.startsWith("asset:")).map((input) => [input.nodeId.slice("asset:".length), input]));
const nodeInputById = new Map(inputs.filter((input) => !input.nodeId.startsWith("asset:")).map((input) => [input.nodeId, input]));
const selectedInputs: NodeGenerationInput[] = [];
const labelByNodeId = new Map<string, string>();
@@ -160,12 +161,12 @@ function buildComposerGenerationContext(
});
}
for (const match of normalizedPrompt.matchAll(/@\[(node|asset):([^\]]+)\]/g)) {
for (const match of normalizedPrompt.matchAll(GENERATION_MENTION_PATTERN)) {
if (match.index === undefined) continue;
hasToken = true;
nextPrompt += normalizedPrompt.slice(lastIndex, match.index);
const input = inputByToken.get(`${match[1]}:${match[2]}`);
const input = resolveGenerationMention(normalizedPrompt, match, slotInputByToken, nodeInputById, assetInputById);
if (input) {
hasToken = true;
let label = labelByNodeId.get(input.nodeId);
if (!label) {
const labelKind = input.sourceKind === "drawing" ? "drawing" : input.type;
@@ -243,32 +244,56 @@ function buildComposerGenerationContext(
};
}
// 旧画布保存的是 @角色1 等显示标签;生成时升级为稳定节点 Token,避免标题或排序变化后引用错位。
function normalizeLegacyNodeMentions(prompt: string, inputs: NodeGenerationInput[]) {
const counts = { image: 0, drawing: 0, video: 0, audio: 0, text: 0, character: 0 };
const labels = inputs.filter((input) => !input.nodeId.startsWith("asset:")).map((input) => {
const kind = input.sourceKind === "drawing" ? "drawing" : input.type;
return { label: generationLabel(kind, counts[kind]++), nodeId: input.nodeId };
}).sort((a, b) => b.label.length - a.label.length);
let next = prompt;
labels.forEach(({ label, nodeId }) => {
const token = `@${label}`;
let cursor = 0;
let result = "";
while (cursor < next.length) {
const found = next.indexOf(token, cursor);
if (found < 0) {
result += next.slice(cursor);
break;
}
const end = found + token.length;
result += next.slice(cursor, found);
result += hasMentionBoundary(next, end) ? `@[node:${nodeId}]` : token;
cursor = end;
}
next = result;
const GENERATION_MENTION_PATTERN = /@\[(node|asset):([^\]]+)\]|@(图片|视频|音频|文本|角色|绘图)(\d+)/g;
export function generationInputMentionLabel(input: NodeGenerationInput, inputs: NodeGenerationInput[]) {
const entry = generationSlotEntries(inputs).find((item) => item.input.nodeId === input.nodeId);
return entry?.label || generationLabel(input.sourceKind === "drawing" ? "drawing" : input.type, 0);
}
export function normalizeGenerationNodeMentionTokens(prompt: string, inputs: NodeGenerationInput[]) {
const labelByNodeId = new Map(generationSlotEntries(inputs).map(({ input, label }) => [input.nodeId, label]));
return prompt.replace(/@\[node:([^\]]+)\]/g, (token, nodeId: string) => {
const label = labelByNodeId.get(nodeId);
return label ? `@${label}` : token;
});
return next;
}
function hasResolvableGenerationMention(prompt: string, inputs: NodeGenerationInput[]) {
const normalizedPrompt = normalizeGenerationNodeMentionTokens(prompt, inputs);
const slotInputByToken = new Map(generationSlotEntries(inputs).map(({ token, input }) => [token, input]));
const nodeInputById = new Map(inputs.filter((input) => !input.nodeId.startsWith("asset:")).map((input) => [input.nodeId, input]));
const assetInputById = new Map(inputs.filter((input) => input.nodeId.startsWith("asset:")).map((input) => [input.nodeId.slice("asset:".length), input]));
for (const match of normalizedPrompt.matchAll(GENERATION_MENTION_PATTERN)) {
if (resolveGenerationMention(normalizedPrompt, match, slotInputByToken, nodeInputById, assetInputById)) return true;
}
return false;
}
function generationSlotEntries(inputs: NodeGenerationInput[]) {
const counts = { image: 0, drawing: 0, video: 0, audio: 0, text: 0, character: 0 };
return inputs.flatMap((input) => {
if (input.nodeId.startsWith("asset:")) return [];
const kind = input.sourceKind === "drawing" ? "drawing" : input.type;
const label = generationLabel(kind, counts[kind]++);
return [{ input, label, token: `@${label}` }];
});
}
function resolveGenerationMention(
prompt: string,
match: RegExpMatchArray,
slotInputByToken: Map<string, NodeGenerationInput>,
nodeInputById: Map<string, NodeGenerationInput>,
assetInputById: Map<string, NodeGenerationInput>,
) {
if (match[3]) {
const end = (match.index || 0) + match[0].length;
return hasMentionBoundary(prompt, end) ? slotInputByToken.get(match[0]) : undefined;
}
if (match[1] === "node") return nodeInputById.get(match[2]);
if (match[1] === "asset") return assetInputById.get(match[2]);
return undefined;
}
function hasMentionBoundary(value: string, index: number) {
@@ -307,10 +332,6 @@ function buildAssetGenerationInputs(assets: Asset[]): NodeGenerationInput[] {
});
}
function generationInputToken(input: NodeGenerationInput) {
return input.nodeId.startsWith("asset:") ? input.nodeId : `node:${input.nodeId}`;
}
function getConnectedStoryboardRows(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] {
const targetNodeIds = new Set([nodeId]);
connections.forEach((connection) => {

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
import { Button, Image as AntImage, InputNumber, Modal, Tooltip } from "antd";
import { ModelPicker } from "@/components/model-picker";
@@ -21,10 +21,11 @@ import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-pic
import { CanvasPortraitTexturePopover } from "./canvas-portrait-texture-popover";
import { CanvasPromptOptimizerDrawer } from "./canvas-prompt-optimizer-drawer";
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode } from "@/types/canvas";
import { canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { canvasResourceMentionToken, normalizeCanvasNodeMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
import { createPluginHostContext } from "@/services/plugin-host";
import { usePluginStore } from "@/stores/use-plugin-store";
import { useResolvedCanvasResourceReferences } from "./use-resolved-canvas-resource-references";
export type CanvasNodeGenerationMode = CanvasGenerationMode;
@@ -75,7 +76,9 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const [manualExpandedPromptHeight, setManualExpandedPromptHeight] = useState<number | null>(null);
const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴)
const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false);
const activeReferences = mentionReferences.filter((item) => item.active && item.kind !== "skill");
const resolvedMentionReferences = useResolvedCanvasResourceReferences(mentionReferences);
const normalizedSavedPrompt = useMemo(() => normalizeCanvasNodeMentionTokens(savedPrompt, mentionReferences), [mentionReferences, savedPrompt]);
const activeReferences = resolvedMentionReferences.filter((item) => item.active && item.kind !== "skill");
const requirements: ModelRequirements = {
capability: mode,
input: {
@@ -116,7 +119,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
seconds: mode === "video" ? config.videoSeconds : 1,
});
const activeReferenceCount = activeReferences.length;
const videoFrameOptions = mentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl }));
const videoFrameOptions = resolvedMentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl }));
const hasVideoPromptTools = mode === "video" && !simpleMode && videoFrameOptions.length > 0;
const monochromeAccent = theme.node.activeStroke;
const composerTokens = {
@@ -143,19 +146,20 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture);
useEffect(() => {
setPrompt(node.metadata?.composerContent ?? node.metadata?.prompt ?? "");
}, [node.id, node.metadata?.composerContent, node.metadata?.prompt]);
setPrompt(normalizedSavedPrompt);
if (normalizedSavedPrompt !== savedPrompt) onPromptChange(node.id, normalizedSavedPrompt);
}, [node.id, normalizedSavedPrompt, onPromptChange, savedPrompt]);
useEffect(() => {
setExpandedPromptOpen(false);
setExpandedPresetOpen(false);
setPromptContentHeight(estimatePromptContentHeight(savedPrompt, false));
setExpandedPromptContentHeight(estimatePromptContentHeight(savedPrompt, true));
setPromptContentHeight(estimatePromptContentHeight(normalizedSavedPrompt, false));
setExpandedPromptContentHeight(estimatePromptContentHeight(normalizedSavedPrompt, true));
setManualPromptHeight(null);
setManualExpandedPromptHeight(null);
}, [node.id]);
const skillReferences = useMemo(() => mentionReferences.filter((item) => item.kind === "skill"), [mentionReferences]);
const skillReferences = useMemo(() => resolvedMentionReferences.filter((item) => item.kind === "skill"), [resolvedMentionReferences]);
const updatePrompt = (value: string) => {
setPrompt(value);
@@ -276,7 +280,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
style={
{
color: isSubmitDisabled ? theme.node.faint : theme.node.text,
"--canvas-composer-submit-action": isSubmitDisabled ? theme.toolbar.itemHover : isRunning ? theme.accent.danger : monochromeAccent,
"--canvas-composer-submit-action": isSubmitDisabled ? theme.toolbar.itemHover : monochromeAccent,
"--canvas-composer-submit-action-fg": isSubmitDisabled ? theme.node.faint : theme.canvas.background,
} as CSSProperties
}
@@ -291,7 +295,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
</span>
) : null}
<span className="canvas-node-composer-submit-action" aria-hidden>
{isRunning ? <span className="size-2.5 animate-pulse" aria-hidden="true" /> : <ArrowUp className="size-3" />}
{isRunning ? <LoaderCircle className="size-3 animate-spin motion-reduce:animate-none" /> : <ArrowUp className="size-3" />}
</span>
</Button>
);
@@ -368,10 +372,10 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
return (
<>
<div className="canvas-node-composer-editor" style={{ height }}>
<ConnectedReferenceShelf references={mentionReferences} theme={theme} onInsert={insertPromptReference} onRemove={(reference) => onRemoveReference?.(node.id, reference)} />
<ConnectedReferenceShelf references={resolvedMentionReferences} theme={theme} onInsert={insertPromptReference} onRemove={(reference) => onRemoveReference?.(node.id, reference)} />
<CanvasResourceMentionTextarea
value={prompt}
references={mentionReferences}
references={resolvedMentionReferences}
includeAssetLibrary
onChange={updatePrompt}
onContentSizeChange={expanded ? setExpandedPromptContentHeight : setPromptContentHeight}

View File

@@ -1,5 +1,5 @@
import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, ClipboardEvent, KeyboardEvent, MouseEvent, PointerEvent, TextareaHTMLAttributes } 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";
@@ -8,6 +8,7 @@ import { useThemeStore } from "@/stores/use-theme-store";
import { buildAssetMentionReferences, canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { useAssetStore, type AssetCategory } from "@/stores/use-asset-store";
import { CanvasNodeType } from "@/types/canvas";
import { useResolvedCanvasResourceReferences } from "./use-resolved-canvas-resource-references";
type MentionState = {
start: number;
@@ -42,10 +43,12 @@ type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange" | "val
sendOnEnter?: boolean;
onContentSizeChange?: (height: number) => void;
includeAssetLibrary?: boolean;
activeDropReferenceId?: string | null;
onReferenceFilesDrop?: (reference: CanvasResourceReference, files: File[]) => void;
};
export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Props>(function CanvasResourceMentionTextarea(
{ value, references, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, mentionMenuWidth = 320, sendOnEnter = true, onContentSizeChange, includeAssetLibrary = false, ...props },
{ value, references, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, mentionMenuWidth = 320, sendOnEnter = true, onContentSizeChange, includeAssetLibrary = false, activeDropReferenceId, onReferenceFilesDrop, ...props },
forwardedRef,
) {
const rawTheme = useThemeStore((state) => state.theme);
@@ -59,8 +62,11 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
const lastRenderedValueRef = useRef("");
const [mention, setMention] = useState<MentionState | null>(null);
const [activeIndex, setActiveIndex] = useState(-1);
const assetReferences = useMemo(() => includeAssetLibrary ? buildAssetMentionReferences(assets) : [], [assets, includeAssetLibrary]);
const activeCanvasReferences = useMemo(() => references.filter((item) => item.active), [references]);
const [nativeDropReferenceId, setNativeDropReferenceId] = useState<string | null>(null);
const canvasReferences = useResolvedCanvasResourceReferences(references);
const rawAssetReferences = useMemo(() => includeAssetLibrary ? buildAssetMentionReferences(assets) : [], [assets, includeAssetLibrary]);
const assetReferences = useResolvedCanvasResourceReferences(rawAssetReferences);
const activeCanvasReferences = useMemo(() => canvasReferences.filter((item) => item.active), [canvasReferences]);
const availableReferences = useMemo(() => [...activeCanvasReferences, ...assetReferences], [activeCanvasReferences, assetReferences]);
const candidates = useMemo(() => {
if (!mention) return [];
@@ -106,6 +112,15 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
reportContentSize(editor);
}, [activeReferences, reportContentSize, useRichEditor, value]);
useLayoutEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const dropReferenceId = nativeDropReferenceId || activeDropReferenceId || "";
editor.querySelectorAll<HTMLElement>("[data-mention-reference-id]").forEach((chip) => {
chip.classList.toggle("is-replace-target", Boolean(dropReferenceId) && chip.dataset.mentionReferenceId === dropReferenceId);
});
}, [activeDropReferenceId, nativeDropReferenceId, useRichEditor, value]);
useLayoutEffect(() => {
const element = useRichEditor ? editorRef.current : textareaRef.current;
const container = containerRef.current;
@@ -199,6 +214,14 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
if (typeof cursor === "number") syncMention(serializeEditableValue(editor), cursor);
};
const referenceForDropTarget = (target: EventTarget | null) => {
const element = target instanceof Element ? target.closest<HTMLElement>("[data-mention-reference-id]") : null;
const referenceId = element?.dataset.mentionReferenceId;
return referenceId ? activeReferences.find((reference) => reference.id === referenceId) : undefined;
};
const imageFilesFromTransfer = (event: DragEvent<HTMLDivElement>) => Array.from(event.dataTransfer.files).filter((file) => file.type.startsWith("image/"));
const mergedStyle = {
...(style || {}),
caretColor: style?.color || theme.node.text,
@@ -254,6 +277,35 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
event.preventDefault();
replaceEditableSelection(event.clipboardData.getData("text/plain"));
}}
onDragOver={(event: DragEvent<HTMLDivElement>) => {
const reference = referenceForDropTarget(event.target);
const hasImageFile = Array.from(event.dataTransfer.items).some((item) => item.kind === "file" && (!item.type || item.type.startsWith("image/")))
|| Array.from(event.dataTransfer.files).some((file) => file.type.startsWith("image/"));
if (!onReferenceFilesDrop || reference?.kind !== "image" || !hasImageFile) {
setNativeDropReferenceId(null);
props.onDragOver?.(event as unknown as React.DragEvent<HTMLTextAreaElement>);
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setNativeDropReferenceId(reference.id);
props.onDragOver?.(event as unknown as React.DragEvent<HTMLTextAreaElement>);
}}
onDragLeave={(event: DragEvent<HTMLDivElement>) => {
const bounds = event.currentTarget.getBoundingClientRect();
if (event.clientX <= bounds.left || event.clientX >= bounds.right || event.clientY <= bounds.top || event.clientY >= bounds.bottom) setNativeDropReferenceId(null);
props.onDragLeave?.(event as unknown as React.DragEvent<HTMLTextAreaElement>);
}}
onDrop={(event: DragEvent<HTMLDivElement>) => {
const reference = referenceForDropTarget(event.target);
const files = imageFilesFromTransfer(event);
setNativeDropReferenceId(null);
if (onReferenceFilesDrop && reference?.kind === "image" && files.length) {
event.preventDefault();
onReferenceFilesDrop(reference, files);
}
props.onDrop?.(event as unknown as React.DragEvent<HTMLTextAreaElement>);
}}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current)) return;
if (mention && candidates.length) {
@@ -409,6 +461,7 @@ function createInlineMentionChip(reference: CanvasResourceReference, token: stri
const chip = document.createElement("span");
chip.contentEditable = "false";
chip.dataset.mentionToken = token;
chip.dataset.mentionReferenceId = reference.id;
chip.className = "canvas-resource-inline-mention";
const at = document.createElement("span");
@@ -641,6 +694,7 @@ function splitMentionText(value: string, references: CanvasResourceReference[])
const serializedToken = canvasResourceMentionToken(reference);
referenceByToken.set(serializedToken, { reference, serializedToken });
referenceByToken.set(`@${reference.label}`, { reference, serializedToken });
if (reference.nodeId && !reference.assetId) referenceByToken.set(`@[node:${reference.nodeId}]`, { reference, serializedToken });
});
const tokens = [...referenceByToken.keys()].sort((a, b) => b.length - a.length);
const parts: MentionTextPart[] = [];

View File

@@ -0,0 +1,79 @@
import { useEffect, useMemo, useState } from "react";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { resolveMediaUrl } from "@/services/file-storage";
import { resolveImageUrl } from "@/services/image-storage";
type ResolvedPreview = {
identity: string;
url: string;
};
const previewPromiseCache = new Map<string, Promise<string>>();
export function useResolvedCanvasResourceReferences(references: CanvasResourceReference[]) {
const requests = useMemo(
() => references.flatMap((reference) => {
const identity = previewIdentity(reference);
return identity ? [{ reference, identity }] : [];
}),
[references],
);
const [resolvedById, setResolvedById] = useState<Record<string, ResolvedPreview>>({});
useEffect(() => {
if (!requests.length) return;
let cancelled = false;
void Promise.all(
requests.map(async ({ reference, identity }) => ({
id: reference.id,
identity,
url: await resolveReferencePreview(reference, identity),
})),
).then((resolved) => {
if (cancelled) return;
setResolvedById((current) => {
let changed = false;
const next = { ...current };
resolved.forEach(({ id, identity, url }) => {
if (!url || (current[id]?.identity === identity && current[id]?.url === url)) return;
next[id] = { identity, url };
changed = true;
});
return changed ? next : current;
});
});
return () => {
cancelled = true;
};
}, [requests]);
return useMemo(
() => references.map((reference) => {
const identity = previewIdentity(reference);
const resolved = identity ? resolvedById[reference.id] : undefined;
return resolved?.identity === identity && resolved.url !== reference.previewUrl ? { ...reference, previewUrl: resolved.url } : reference;
}),
[references, resolvedById],
);
}
function previewIdentity(reference: CanvasResourceReference) {
if (!reference.storageKey || !["image", "video", "character"].includes(reference.kind)) return "";
return `${reference.kind}:${reference.storageKey}`;
}
function resolveReferencePreview(reference: CanvasResourceReference, identity: string) {
const cached = previewPromiseCache.get(identity);
if (cached) return cached;
const pending = (reference.kind === "video"
? resolveMediaUrl(reference.storageKey, reference.previewUrl || "")
: resolveImageUrl(reference.storageKey, reference.previewUrl || "", { cacheMiss: true }))
.catch(() => reference.previewUrl || "")
.then((url) => {
if (!url) previewPromiseCache.delete(identity);
return url;
});
previewPromiseCache.set(identity, pending);
return pending;
}