fix(*): 创作工作台 - 修复 Agent、资源引用、附件交互与 Agnes 视频协议
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user