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;
|
||||
}
|
||||
@@ -236,7 +236,7 @@ export function attachNodeToStoryboardRow(nodes: CanvasNodeData[], connection: P
|
||||
if (!scriptNodeId || !linkedNode || !scriptNode) return nodes;
|
||||
const row = rowId ? scriptNode.metadata?.storyboard?.rows.find((item) => item.id === rowId) : undefined;
|
||||
const videoPrompt = row ? (row.videoMotionPrompt || row.plotDescription).trim() : "";
|
||||
const videoComposerContent = row ? storyboardComposerContent(videoPrompt, storyboardRowReferenceNodeIds(scriptNode, row, nodes, [], false)) : "";
|
||||
const videoComposerContent = row ? storyboardComposerContent(videoPrompt, storyboardRowReferenceNodeIds(scriptNode, row, nodes, [], false), nodes) : "";
|
||||
|
||||
return nodes.map((node) => {
|
||||
if (row && node.id === linkedNode.id && scriptNodeId === connection.fromNodeId && node.type === CanvasNodeType.Video) {
|
||||
@@ -272,7 +272,8 @@ export function expandStoryboardTextMentions(prompt: string, references: CanvasR
|
||||
let expanded = prompt;
|
||||
references.filter((reference) => reference.active && reference.kind === "text" && reference.text?.trim()).forEach((reference) => {
|
||||
const replacement = `【项目设定:${reference.title}】\n${reference.text!.trim()}`;
|
||||
for (const token of [canvasResourceMentionToken(reference), `@${reference.label}`]) {
|
||||
for (const token of [canvasResourceMentionToken(reference), `@${reference.label}`, reference.nodeId ? `@[node:${reference.nodeId}]` : ""]) {
|
||||
if (!token) continue;
|
||||
if (expanded.includes(token)) expanded = expanded.split(token).join(replacement);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -21,23 +21,21 @@ export type CanvasResourceReference = {
|
||||
skill?: Skill;
|
||||
assetId?: string;
|
||||
category?: AssetCategory;
|
||||
mentionToken?: string;
|
||||
};
|
||||
|
||||
export function canvasResourceMentionToken(reference: CanvasResourceReference) {
|
||||
if (reference.mentionToken) return reference.mentionToken;
|
||||
if (reference.kind === "skill" && reference.skill?.skill_id) return `@[skill:${reference.skill.skill_id}]`;
|
||||
if (reference.assetId) return `@[asset:${reference.assetId}]`;
|
||||
return `@[node:${reference.nodeId}]`;
|
||||
return `@${reference.label}`;
|
||||
}
|
||||
|
||||
export function removeCanvasResourceMention(prompt: string, reference: CanvasResourceReference) {
|
||||
const token = canvasResourceMentionToken(reference);
|
||||
if (!token || !prompt.includes(token)) return prompt;
|
||||
return prompt
|
||||
.split(token)
|
||||
.join("")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
export function normalizeCanvasNodeMentionTokens(prompt: string, references: CanvasResourceReference[]) {
|
||||
return references.reduce((value, reference) => {
|
||||
if (!reference.nodeId || reference.assetId || reference.kind === "skill") return value;
|
||||
return value.split(`@[node:${reference.nodeId}]`).join(`@${reference.label}`);
|
||||
}, prompt);
|
||||
}
|
||||
|
||||
export function buildAssetMentionReferences(assets: Asset[]): CanvasResourceReference[] {
|
||||
@@ -73,6 +71,10 @@ export function buildNodeMentionReferences(node: CanvasNodeData, nodes: CanvasNo
|
||||
return labelResourceNodes(getMentionResourceNodes(node.id, nodes, connections), true);
|
||||
}
|
||||
|
||||
export function buildOrderedCanvasResourceReferences(nodes: CanvasNodeData[], active = true) {
|
||||
return labelResourceNodes(nodes.filter(isResourceNode), active);
|
||||
}
|
||||
|
||||
export function getMentionResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||
const configInputs = getConnectedConfigResourceNodes(nodeId, nodes, connections);
|
||||
if (configInputs.length) return configInputs;
|
||||
@@ -138,7 +140,7 @@ function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) {
|
||||
kind,
|
||||
label,
|
||||
title: node.title || label,
|
||||
previewUrl: node.metadata?.workflowKind === "character" ? node.metadata.characterCoverUrl : node.type === CanvasNodeType.Drawing ? node.metadata?.drawingPreviewUrl : node.metadata?.content,
|
||||
previewUrl: node.metadata?.workflowKind === "character" ? node.metadata.characterCoverUrl : node.type === CanvasNodeType.Drawing ? node.metadata?.drawingPreviewUrl : node.metadata?.previewContent || node.metadata?.content,
|
||||
storageKey: node.metadata?.storageKey,
|
||||
text: node.metadata?.workflowKind === "character" ? node.metadata.characterPrompt : node.type === CanvasNodeType.Text ? node.metadata?.content || node.metadata?.prompt : node.type === CanvasNodeType.Skill ? skillResourceText(node) : undefined,
|
||||
active,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { storyboardAssetRoleForNode } from "@/lib/canvas/canvas-storyboard-assets";
|
||||
import { buildOrderedCanvasResourceReferences, canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type StoryboardAssetBinding, type StoryboardRow } from "@/types/canvas";
|
||||
|
||||
export function storyboardRowReferenceNodeIds(
|
||||
@@ -28,8 +29,13 @@ export function storyboardRowReferenceNodeIds(
|
||||
return Array.from(referenceIds).filter((nodeId) => nodes.some((node) => node.id === nodeId));
|
||||
}
|
||||
|
||||
export function storyboardComposerContent(prompt: string, referenceNodeIds: string[]) {
|
||||
const mentions = Array.from(new Set(referenceNodeIds)).map((nodeId) => `@[node:${nodeId}]`);
|
||||
export function storyboardComposerContent(prompt: string, referenceNodeIds: string[], nodes: CanvasNodeData[]) {
|
||||
const referenceIds = Array.from(new Set(referenceNodeIds));
|
||||
const referenceNodes = referenceIds.flatMap((nodeId) => {
|
||||
const node = nodes.find((candidate) => candidate.id === nodeId);
|
||||
return node ? [node] : [];
|
||||
});
|
||||
const mentions = buildOrderedCanvasResourceReferences(referenceNodes).map(canvasResourceMentionToken);
|
||||
return mentions.length ? [`参考资产:${mentions.join(" ")}`, prompt.trim()].filter(Boolean).join("\n") : prompt.trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-co
|
||||
import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-panel";
|
||||
import { useFocusMode } from "@/hooks/use-focus-mode";
|
||||
import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store";
|
||||
import { getContextResourceNodes, removeCanvasResourceMention, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { getContextResourceNodes, normalizeCanvasNodeMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays";
|
||||
import { CanvasOverlayLayerContainer, CanvasOverlayLayerProvider } from "@/components/canvas/canvas-overlay-layer";
|
||||
import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer";
|
||||
@@ -147,6 +147,31 @@ const CanvasDrawingEditorModal = lazy(() => import("@/components/canvas/canvas-d
|
||||
const NODE_STATUS_SUCCESS = "success" as const;
|
||||
const EMPTY_RESOURCE_REFERENCES: CanvasResourceReference[] = [];
|
||||
|
||||
async function copyImageToSystemClipboard(source: string) {
|
||||
if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) throw new Error("当前浏览器不支持复制图片");
|
||||
const response = await fetch(source);
|
||||
if (!response.ok) throw new Error(`图片读取失败(HTTP ${response.status})`);
|
||||
const sourceBlob = await response.blob();
|
||||
const blob = sourceBlob.type === "image/png" ? sourceBlob : await convertClipboardImageToPNG(sourceBlob);
|
||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
||||
}
|
||||
|
||||
async function convertClipboardImageToPNG(blob: Blob) {
|
||||
if (typeof createImageBitmap !== "function") throw new Error("当前浏览器无法转换这张图片的格式");
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("当前浏览器无法处理这张图片");
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
return await new Promise<Blob>((resolve, reject) => canvas.toBlob((value) => value ? resolve(value) : reject(new Error("图片格式转换失败")), "image/png"));
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
function visibleGenerationBatch(node: CanvasNodeData) {
|
||||
const batches = node.metadata?.generationBatches || [];
|
||||
for (let index = batches.length - 1; index >= 0; index -= 1) {
|
||||
@@ -1081,15 +1106,11 @@ function InfiniteCanvasPage() {
|
||||
.filter((connection) => connection.fromNodeId === referenceNodeId && (connection.toNodeId === targetNodeId || connection.toNodeId === configNodeId))
|
||||
.map((connection) => connection.id),
|
||||
);
|
||||
const targetNode = nodesRef.current.find((node) => node.id === targetNodeId);
|
||||
const currentPrompt = targetNode?.metadata?.composerContent ?? targetNode?.metadata?.prompt ?? "";
|
||||
const nextPrompt = removeCanvasResourceMention(currentPrompt, reference);
|
||||
if (nextPrompt !== currentPrompt) handleNodePromptChange(targetNodeId, nextPrompt);
|
||||
if (!removedConnectionIds.size) return;
|
||||
connectionsRef.current = connectionsRef.current.filter((connection) => !removedConnectionIds.has(connection.id));
|
||||
setConnections(connectionsRef.current);
|
||||
setSelectedConnectionId((current) => current && removedConnectionIds.has(current) ? null : current);
|
||||
}, [connectionsRef, handleNodePromptChange, nodesRef, setConnections, setSelectedConnectionId]);
|
||||
}, [connectionsRef, nodesRef, setConnections, setSelectedConnectionId]);
|
||||
|
||||
const handleProjectFolderInsert = useCallback((folderId: string) => {
|
||||
const folder = linkedProjectQuery.data?.assetFolders.find((item) => item.id === folderId);
|
||||
@@ -1211,6 +1232,26 @@ function InfiniteCanvasPage() {
|
||||
scriptEditorNodeId,
|
||||
dialogNodeId,
|
||||
});
|
||||
useEffect(() => {
|
||||
setNodes((current) => {
|
||||
let changed = false;
|
||||
const next = current.map((node) => {
|
||||
const references = mentionReferencesByNodeId.get(node.id);
|
||||
const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt;
|
||||
if (!references?.length || !savedPrompt?.includes("@[node:")) return node;
|
||||
const normalizedPrompt = normalizeCanvasNodeMentionTokens(savedPrompt, references);
|
||||
if (normalizedPrompt === savedPrompt) return node;
|
||||
changed = true;
|
||||
return {
|
||||
...node,
|
||||
metadata: node.metadata?.composerContent !== undefined
|
||||
? { ...node.metadata, composerContent: normalizedPrompt }
|
||||
: { ...node.metadata, prompt: normalizedPrompt },
|
||||
};
|
||||
});
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [mentionReferencesByNodeId, setNodes]);
|
||||
const dialogNode = dialogNodeId ? nodeById.get(dialogNodeId) || null : null;
|
||||
const subtitleNode = subtitleNodeId ? nodeById.get(subtitleNodeId) || null : null;
|
||||
const timelineNode = timelineNodeId ? nodeById.get(timelineNodeId) || null : null;
|
||||
@@ -1449,29 +1490,26 @@ function InfiniteCanvasPage() {
|
||||
const copyNodeContentToClipboard = useCallback(
|
||||
async (node: CanvasNodeData | null) => {
|
||||
releaseCopiedNodesPastePriority();
|
||||
const content = node?.metadata?.content;
|
||||
if (!node || !content) {
|
||||
const content = node?.metadata?.content?.trim();
|
||||
const resourceId = resourceIdFromStorageKey(node?.metadata?.storageKey);
|
||||
const copySource = content || (node?.type === CanvasNodeType.Image && resourceId ? resourceFileUrl(resourceId) : "");
|
||||
if (!node || !copySource) {
|
||||
message.warning("没有可复制的内容");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (node.type === CanvasNodeType.Image && typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) {
|
||||
const response = await fetch(content);
|
||||
const blob = await response.blob();
|
||||
await navigator.clipboard.write([new ClipboardItem({ [blob.type || "image/png"]: blob })]);
|
||||
if (node.type === CanvasNodeType.Image) {
|
||||
await copyImageToSystemClipboard(copySource);
|
||||
message.success("图片已复制");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
message.warning("当前浏览器不支持写入剪贴板");
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(content);
|
||||
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(copySource);
|
||||
else if (!copyToClipboard(copySource)) throw new Error("当前浏览器不支持写入剪贴板");
|
||||
message.success(node.type === CanvasNodeType.Text ? "文本已复制" : "内容链接已复制");
|
||||
} catch {
|
||||
message.error("复制失败,请检查浏览器剪贴板权限");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "复制失败,请检查浏览器剪贴板权限");
|
||||
}
|
||||
},
|
||||
[message, releaseCopiedNodesPastePriority],
|
||||
@@ -1670,8 +1708,8 @@ function InfiniteCanvasPage() {
|
||||
y: sourceNode.position.y + sourceNode.height / 2,
|
||||
},
|
||||
{
|
||||
prompt: `@[node:${sourceNode.id}]`,
|
||||
composerContent: `@[node:${sourceNode.id}]`,
|
||||
prompt: "@文本1",
|
||||
composerContent: "@文本1",
|
||||
model: effectiveConfig.imageModel || effectiveConfig.model,
|
||||
size: effectiveConfig.size,
|
||||
quality: effectiveConfig.quality,
|
||||
|
||||
@@ -145,7 +145,7 @@ export function useCanvasMediaTools({
|
||||
generationMode: "text",
|
||||
model: effectiveConfig.textModel || effectiveConfig.model || defaultConfig.textModel,
|
||||
count: 1,
|
||||
composerContent: `参考图片:@[node:${node.id}]\n任务说明:@[node:${textNode.id}]`,
|
||||
composerContent: "参考图片:@图片1\n任务说明:@文本1",
|
||||
}),
|
||||
title: "反推提示词结果",
|
||||
};
|
||||
@@ -163,7 +163,7 @@ export function useCanvasMediaTools({
|
||||
return;
|
||||
}
|
||||
const portraitTextureSettings = { ...DEFAULT_PORTRAIT_TEXTURE_SETTINGS, ...node.metadata?.portraitTexture };
|
||||
const composerContent = node.metadata?.composerContent?.trim() || node.metadata?.prompt?.trim() || `@[node:${node.id}]`;
|
||||
const composerContent = node.metadata?.composerContent?.trim() || node.metadata?.prompt?.trim() || "@图片1";
|
||||
setHoveredNodeId(null);
|
||||
setToolbarNodeId(null);
|
||||
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, prompt: composerContent, composerContent, portraitTexture: portraitTextureSettings } } : item));
|
||||
|
||||
@@ -200,7 +200,7 @@ export function useCanvasStoryboard({
|
||||
const existing = row.imageNodeId ? nextNodes.find((node) => node.id === row.imageNodeId && node.type === CanvasNodeType.Image) : undefined;
|
||||
const existingMetadata = existing?.metadata?.content ? existing.metadata : resetGenerationTaskMetadata(existing?.metadata);
|
||||
const referenceIds = storyboardRowReferenceNodeIds(scriptNode, row, nextNodes, nextConnections, false, existing?.id);
|
||||
const composerContent = storyboardComposerContent(prompt, referenceIds);
|
||||
const composerContent = storyboardComposerContent(prompt, referenceIds, nextNodes);
|
||||
const imageNode = existing
|
||||
? { ...existing, metadata: { ...existingMetadata, prompt, composerContent, ...storyboardPromptTemplateMetadata(row, "image"), workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 分镜图`, shotIndex: row.shotNumber } }
|
||||
: createCanvasNode(CanvasNodeType.Image, { x: startX + imageSpec.width / 2, y: scriptNode.position.y + index * (imageSpec.height + 36) + imageSpec.height / 2 }, { prompt, composerContent, ...storyboardPromptTemplateMetadata(row, "image"), workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 分镜图`, shotIndex: row.shotNumber, status: NODE_STATUS_IDLE });
|
||||
@@ -285,7 +285,7 @@ export function useCanvasStoryboard({
|
||||
const existingIndex = row.videoNodeId ? nextNodes.findIndex((node) => node.id === row.videoNodeId && node.type === CanvasNodeType.Video) : -1;
|
||||
const existing = existingIndex >= 0 ? nextNodes[existingIndex] : undefined;
|
||||
const referenceIds = storyboardRowReferenceNodeIds(scriptNode, row, nextNodes, nextConnections, false, existing?.id);
|
||||
const composerContent = storyboardComposerContent(prompt, referenceIds);
|
||||
const composerContent = storyboardComposerContent(prompt, referenceIds, nextNodes);
|
||||
if (existingIndex >= 0) {
|
||||
const existing = nextNodes[existingIndex];
|
||||
const existingMetadata = existing.metadata?.content ? existing.metadata : resetGenerationTaskMetadata(existing.metadata);
|
||||
@@ -447,7 +447,7 @@ export function useCanvasStoryboard({
|
||||
const prompt = (row.videoMotionPrompt || row.plotDescription).trim();
|
||||
const existing = row.videoNodeId ? nextNodes.find((node) => node.id === row.videoNodeId && node.type === CanvasNodeType.Video) : undefined;
|
||||
const referenceIds = storyboardRowReferenceNodeIds(currentScriptNode, row, nextNodes, nextConnections, true, existing?.id);
|
||||
const composerContent = storyboardComposerContent(prompt, referenceIds);
|
||||
const composerContent = storyboardComposerContent(prompt, referenceIds, nextNodes);
|
||||
const existingMetadata = existing?.metadata?.content ? existing.metadata : resetGenerationTaskMetadata(existing?.metadata);
|
||||
const videoNode = existing
|
||||
? { ...existing, metadata: { ...existingMetadata, prompt, composerContent, model: videoModel, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video" as const, videoEditOperation: "image_to_video" as const, videoStartFrameNodeId: row.imageNodeId, seconds: String(row.durationSeconds) } }
|
||||
|
||||
@@ -8,7 +8,11 @@ export type CreationReference = CanvasResourceReference & {
|
||||
};
|
||||
|
||||
export function buildCreationMentionReferences(skills: Skill[], attachments: CreationAttachment[] = [], snapshots: CreationReference[] = []) {
|
||||
const attachmentReferences = attachments.map(attachmentReference);
|
||||
const counts = { image: 0, video: 0, audio: 0, file: 0 };
|
||||
const attachmentReferences = attachments.map((attachment) => {
|
||||
const kind = creationAttachmentKind(attachment);
|
||||
return attachmentReference(attachment, counts[kind]++);
|
||||
});
|
||||
const skillReferences = buildSkillMentionReferences(skills) as CreationReference[];
|
||||
const current = [...attachmentReferences, ...skillReferences];
|
||||
const currentIDs = new Set(current.map((reference) => reference.id));
|
||||
@@ -17,7 +21,7 @@ export function buildCreationMentionReferences(skills: Skill[], attachments: Cre
|
||||
}
|
||||
|
||||
export function selectedCreationReferences(prompt: string, references: CreationReference[]) {
|
||||
return references.filter((reference) => prompt.includes(canvasResourceMentionToken(reference)));
|
||||
return references.filter((reference) => prompt.includes(canvasResourceMentionToken(reference)) || prompt.includes(`@${reference.label}`));
|
||||
}
|
||||
|
||||
export function reconcileCreationAttachmentLimit(attachments: CreationAttachment[], references: CreationReference[], maxReferences: number) {
|
||||
@@ -31,7 +35,35 @@ export function reconcileCreationAttachmentLimit(attachments: CreationAttachment
|
||||
}
|
||||
|
||||
export function removeCreationReferenceTokens(value: string, references: CreationReference[]) {
|
||||
return references.reduce((current, reference) => current.split(canvasResourceMentionToken(reference)).join(""), value);
|
||||
return references.reduce((current, reference) => current.split(canvasResourceMentionToken(reference)).join("").split(`@${reference.label}`).join(""), value);
|
||||
}
|
||||
|
||||
export function replaceCreationAttachmentReference(prompt: string, attachments: CreationAttachment[], targetAttachmentId: string, replacement: CreationAttachment) {
|
||||
const targetIndex = attachments.findIndex((attachment) => attachment.id === targetAttachmentId);
|
||||
if (targetIndex < 0) throw new Error("要替换的参考内容不存在");
|
||||
if (replacement.id === targetAttachmentId) return { prompt, attachments };
|
||||
|
||||
const currentReferences = buildCreationMentionReferences([], attachments);
|
||||
const targetReference = currentReferences.find((reference) => reference.attachmentId === targetAttachmentId);
|
||||
if (!targetReference) throw new Error("要替换的提示词引用不存在");
|
||||
|
||||
const remainingAttachments = attachments.filter((attachment) => attachment.id !== targetAttachmentId && attachment.id !== replacement.id);
|
||||
const insertionIndex = Math.min(targetIndex, remainingAttachments.length);
|
||||
const nextAttachments = [
|
||||
...remainingAttachments.slice(0, insertionIndex),
|
||||
replacement,
|
||||
...remainingAttachments.slice(insertionIndex),
|
||||
];
|
||||
const replacementReference = buildCreationMentionReferences([], nextAttachments).find((reference) => reference.attachmentId === replacement.id);
|
||||
if (!replacementReference) throw new Error("替换后的提示词引用无效");
|
||||
|
||||
const targetToken = canvasResourceMentionToken(targetReference);
|
||||
const replacementToken = canvasResourceMentionToken(replacementReference);
|
||||
const normalizedPrompt = replaceVisibleReferenceLabel(prompt, targetReference.label, replacementToken);
|
||||
return {
|
||||
prompt: normalizedPrompt.split(targetToken).join(replacementToken),
|
||||
attachments: nextAttachments,
|
||||
};
|
||||
}
|
||||
|
||||
export function displayCreationPrompt(prompt: string, references: CreationReference[]) {
|
||||
@@ -81,5 +113,11 @@ function attachmentReference(attachment: CreationAttachment, index: number): Cre
|
||||
storageKey: attachment.storageKey,
|
||||
active: true,
|
||||
attachmentId: attachment.id,
|
||||
mentionToken: `@[attachment:${attachment.id}]`,
|
||||
};
|
||||
}
|
||||
|
||||
function replaceVisibleReferenceLabel(value: string, label: string, replacementToken: string) {
|
||||
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return value.replace(new RegExp(`@${escapedLabel}(?=$|\\s|[,.!?;:,。!?;:、)\\]}】)])`, "gu"), replacementToken);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ import type { PromptOptimizerProvider } from "@/lib/plugins/plugin-types";
|
||||
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 { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, selectedCreationReferences, type CreationReference } from "./creation-references";
|
||||
import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences, type CreationReference } from "./creation-references";
|
||||
import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromExternalAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, splitCreationAttachments, type CreationAttachment } from "./creation-assets";
|
||||
|
||||
type CreationMode = "text" | "image" | "video";
|
||||
@@ -151,6 +151,8 @@ export default function CreatePage() {
|
||||
const [mode, setMode] = useState<CreationMode>("video");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [attachments, setAttachments] = useState<CreationAttachment[]>([]);
|
||||
const promptRef = useRef(prompt);
|
||||
const attachmentsRef = useRef(attachments);
|
||||
const [draftReferences, setDraftReferences] = useState<CreationReference[]>([]);
|
||||
const [addedSkills, setAddedSkills] = useState<Skill[]>([]);
|
||||
const [ratio, setRatio] = useState("16:9");
|
||||
@@ -159,6 +161,7 @@ export default function CreatePage() {
|
||||
const [videoQuality, setVideoQuality] = useState(config.vquality || "720");
|
||||
const [count, setCount] = useState(String(Math.max(1, Math.min(4, Number(config.count) || 1))));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [referenceReplacementBusy, setReferenceReplacementBusy] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<CreationViewMode>("chat");
|
||||
const [selectedShotIndex, setSelectedShotIndex] = useState(-1);
|
||||
const [composingNextShot, setComposingNextShot] = useState(false);
|
||||
@@ -174,6 +177,8 @@ export default function CreatePage() {
|
||||
const retryPreparingRef = useRef(new Set<string>());
|
||||
const pendingRetryRef = useRef<{ context: CreationRetryContext; lockKey: string } | null>(null);
|
||||
const [retrySequence, setRetrySequence] = useState(0);
|
||||
promptRef.current = prompt;
|
||||
attachmentsRef.current = attachments;
|
||||
|
||||
const activeConversation = useMemo(() => conversations.find((item) => item.id === activeId) || conversations[0], [activeId, conversations]);
|
||||
const historyConversations = useMemo(
|
||||
@@ -472,9 +477,53 @@ export default function CreatePage() {
|
||||
};
|
||||
|
||||
const reorderAttachments = useCallback((next: CreationAttachment[]) => {
|
||||
attachmentsRef.current = next;
|
||||
setAttachments(next);
|
||||
}, []);
|
||||
|
||||
const replaceAttachmentReference = useCallback((targetAttachmentId: string, replacement: CreationAttachment) => {
|
||||
const currentAttachments = attachmentsRef.current;
|
||||
const target = currentAttachments.find((attachment) => attachment.id === targetAttachmentId);
|
||||
if (!target) throw new Error("要替换的参考图不存在");
|
||||
if (creationAttachmentKind(target) !== "image" || creationAttachmentKind(replacement) !== "image") throw new Error("目前只支持替换提示词中的图片引用");
|
||||
if (target.id === replacement.id) return false;
|
||||
|
||||
const result = replaceCreationAttachmentReference(promptRef.current, currentAttachments, targetAttachmentId, replacement);
|
||||
promptRef.current = result.prompt;
|
||||
attachmentsRef.current = result.attachments;
|
||||
setPrompt(result.prompt);
|
||||
setAttachments(result.attachments);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const replaceReferenceFromTrack = useCallback((targetAttachmentId: string, replacement: CreationAttachment) => {
|
||||
try {
|
||||
if (replaceAttachmentReference(targetAttachmentId, replacement)) toast.success("参考图已替换,槽位不变,提示词无需修改");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "参考图替换失败");
|
||||
}
|
||||
}, [replaceAttachmentReference, toast]);
|
||||
|
||||
const replaceReferenceFromFiles = useCallback(async (targetAttachmentId: string, files: File[]) => {
|
||||
if (busy || referenceReplacementBusy) return;
|
||||
const file = files.find((item) => item.type.startsWith("image/"));
|
||||
if (!file) {
|
||||
toast.warning("请拖入图片文件进行替换");
|
||||
return;
|
||||
}
|
||||
setReferenceReplacementBusy(true);
|
||||
try {
|
||||
const { asset, attachment } = await uploadCreationAsset(file);
|
||||
if (creationAttachmentKind(attachment) !== "image") throw new Error("上传结果不是可用图片");
|
||||
if (asset) addAsset(asset);
|
||||
if (replaceAttachmentReference(targetAttachmentId, attachment)) toast.success("参考图已替换,槽位不变,提示词无需修改");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "参考图上传或替换失败");
|
||||
} finally {
|
||||
setReferenceReplacementBusy(false);
|
||||
}
|
||||
}, [addAsset, busy, referenceReplacementBusy, replaceAttachmentReference, toast]);
|
||||
|
||||
const submit = async (retryContext?: CreationRetryContext, retryLockKey?: string) => {
|
||||
const releaseRetryLock = () => {
|
||||
if (retryLockKey) retryPreparingRef.current.delete(retryLockKey);
|
||||
@@ -830,12 +879,15 @@ export default function CreatePage() {
|
||||
prompt,
|
||||
setPrompt,
|
||||
busy,
|
||||
referenceReplacementBusy,
|
||||
attachments,
|
||||
referenceImageSize,
|
||||
maxReferences,
|
||||
references: mentionReferences,
|
||||
onRemoveAttachment: removeAttachment,
|
||||
onReorderAttachments: reorderAttachments,
|
||||
onReplaceAttachment: replaceReferenceFromTrack,
|
||||
onReplaceReferenceFiles: replaceReferenceFromFiles,
|
||||
onOpenLibrary: () => setLibraryOpen(true),
|
||||
fileInputRef,
|
||||
onFileChange: handleFileChange,
|
||||
@@ -1084,7 +1136,7 @@ function CreationAttachmentThumbnail({ item, onPreview, onRemove }: {
|
||||
{kind === "video" ? <video src={item.url} poster={item.previewUrl !== item.url ? item.previewUrl : undefined} muted playsInline preload="metadata" aria-label={item.name} /> : kind === "image" ? <img src={item.previewUrl} alt={item.name} /> : <span className="creation-chat-file-icon">{kind === "audio" ? <Music2 /> : <FileText />}<em>{item.name}</em></span>}
|
||||
{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}
|
||||
</button>
|
||||
<button type="button" className="creation-reference-card-remove" onClick={(event) => { event.stopPropagation(); onRemove(item.id); }} aria-label={`移除 ${item.name}`}><X /></button>
|
||||
<button type="button" className="creation-reference-card-remove" onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onRemove(item.id); }} aria-label={`移除 ${item.name}`}><X /></button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1094,12 +1146,15 @@ type ComposerProps = {
|
||||
prompt: string;
|
||||
setPrompt: (value: string) => void;
|
||||
busy: boolean;
|
||||
referenceReplacementBusy: boolean;
|
||||
attachments: CreationAttachment[];
|
||||
referenceImageSize?: { width: number; height: number };
|
||||
maxReferences: number;
|
||||
references: CreationReference[];
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
onReorderAttachments: (attachments: CreationAttachment[]) => void;
|
||||
onReplaceAttachment: (targetAttachmentId: string, replacement: CreationAttachment) => void;
|
||||
onReplaceReferenceFiles: (targetAttachmentId: string, files: File[]) => void;
|
||||
onOpenLibrary: () => void;
|
||||
fileInputRef: RefObject<HTMLInputElement | null>;
|
||||
onFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
@@ -1130,11 +1185,13 @@ function CreationComposer(props: ComposerProps) {
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||||
const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false);
|
||||
const [dropTargetReferenceId, setDropTargetReferenceId] = useState<string | null>(null);
|
||||
const attachmentTrackRef = useRef<HTMLUListElement>(null);
|
||||
const cardDragRef = useRef<{ startX: number; startY: number; moved: boolean } | null>(null);
|
||||
const suppressAttachmentClickRef = useRef(false);
|
||||
const [trackState, setTrackState] = useState({ canScrollLeft: false, canScrollRight: false, isExpanded: false, isDragging: false });
|
||||
const canSubmit = Boolean(props.prompt.trim()) && !props.busy;
|
||||
const interactionBusy = props.busy || props.referenceReplacementBusy;
|
||||
const canSubmit = Boolean(props.prompt.trim()) && !interactionBusy;
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const priceChannel = resolveModelChannel(props.config, props.model);
|
||||
const canOptimizePrompt = Boolean(props.promptOptimizerProvider) && (props.mode === "image" || props.mode === "video");
|
||||
@@ -1148,7 +1205,7 @@ function CreationComposer(props: ComposerProps) {
|
||||
});
|
||||
const showCost = creditsEnabled && credits !== null;
|
||||
const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
|
||||
const actionLabel = props.busy ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送";
|
||||
const actionLabel = props.referenceReplacementBusy ? "正在替换参考图" : props.busy ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送";
|
||||
const placeholder = props.mode === "text"
|
||||
? "描述你的故事、角色或想继续讨论的创意"
|
||||
: props.mode === "image"
|
||||
@@ -1229,11 +1286,20 @@ function CreationComposer(props: ComposerProps) {
|
||||
track.scrollBy({ left: direction * Math.max(track.clientWidth * 0.72, 120), behavior: "smooth" });
|
||||
window.setTimeout(updateTrackScrollState, 180);
|
||||
};
|
||||
const imageReferenceAtPoint = (x: number, y: number) => {
|
||||
for (const element of document.elementsFromPoint(x, y)) {
|
||||
const chip = element.closest<HTMLElement>("[data-mention-reference-id]");
|
||||
const referenceId = chip?.dataset.mentionReferenceId;
|
||||
const reference = referenceId ? props.references.find((item) => item.id === referenceId) : undefined;
|
||||
if (reference?.kind === "image" && reference.attachmentId) return reference;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const composer = <section className={`creation-chat-composer is-${props.variant}`}>
|
||||
<div className="creation-chat-writing-surface">
|
||||
<input ref={props.fileInputRef} type="file" hidden accept={creationUploadAccept(props.mode)} multiple onChange={props.onFileChange} />
|
||||
<div className="creation-chat-editor">
|
||||
<CanvasResourceMentionTextarea ref={props.composerFocusRef} value={props.prompt} references={props.references} mentionMenuWidth={400} sendOnEnter={false} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.placeholderOverride || (props.variant === "empty" ? emptyPlaceholder : placeholder)} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能" spellCheck disabled={props.busy} />
|
||||
<CanvasResourceMentionTextarea ref={props.composerFocusRef} value={props.prompt} references={props.references} mentionMenuWidth={400} sendOnEnter={false} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.placeholderOverride || (props.variant === "empty" ? emptyPlaceholder : placeholder)} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能" spellCheck disabled={interactionBusy} activeDropReferenceId={dropTargetReferenceId} onReferenceFilesDrop={(reference, files) => { const target = props.references.find((item) => item.id === reference.id); if (target?.attachmentId) props.onReplaceReferenceFiles(target.attachmentId, files); }} />
|
||||
{props.attachments.length || canAddMoreReferences ? <div className="creation-reference-track-wrapper">
|
||||
<div className="creation-reference-stack-shell" onMouseEnter={handleTrackMouseEnter} onMouseLeave={handleTrackMouseLeave} onFocus={handleTrackFocus} onBlur={handleTrackBlur}>
|
||||
{trackState.canScrollLeft ? <button type="button" className="creation-reference-track-button is-left" onClick={() => scrollAttachmentTrack(-1)} aria-label="向左浏览参考内容" title="向左浏览参考内容"><ChevronLeft aria-hidden="true" /></button> : null}
|
||||
@@ -1253,18 +1319,28 @@ function CreationComposer(props: ComposerProps) {
|
||||
key={item.id}
|
||||
value={item}
|
||||
layout="position"
|
||||
drag={!props.busy}
|
||||
drag={!interactionBusy}
|
||||
className="creation-reference-stack-card"
|
||||
onPointerDown={beginCardDrag}
|
||||
onPointerMove={moveCardDrag}
|
||||
onPointerUp={endCardDrag}
|
||||
onPointerCancel={endCardDrag}
|
||||
onDragStart={() => setTrackState((current) => ({ ...current, isDragging: true, isExpanded: true }))}
|
||||
onDragEnd={() => setTrackState((current) => ({ ...current, isDragging: false, isExpanded: true }))}
|
||||
onDragStart={() => { setDropTargetReferenceId(null); setTrackState((current) => ({ ...current, isDragging: true, isExpanded: true })); }}
|
||||
onDrag={(_, info) => {
|
||||
if (creationAttachmentKind(item) !== "image") return;
|
||||
const target = imageReferenceAtPoint(info.point.x, info.point.y);
|
||||
setDropTargetReferenceId(target?.attachmentId !== item.id ? target?.id || null : null);
|
||||
}}
|
||||
onDragEnd={(_, info) => {
|
||||
const target = creationAttachmentKind(item) === "image" ? imageReferenceAtPoint(info.point.x, info.point.y) : undefined;
|
||||
setDropTargetReferenceId(null);
|
||||
setTrackState((current) => ({ ...current, isDragging: false, isExpanded: true }));
|
||||
if (target?.attachmentId && target.attachmentId !== item.id) props.onReplaceAttachment(target.attachmentId, item);
|
||||
}}
|
||||
>
|
||||
<CreationAttachmentThumbnail item={item} onPreview={previewAttachment} onRemove={props.onRemoveAttachment} />
|
||||
</Reorder.Item>)}
|
||||
{canAddMoreReferences ? <li className="creation-reference-add-slot"><Tooltip title="添加更多参考内容"><button type="button" className="creation-reference-add-button" onClick={props.onOpenLibrary} disabled={props.busy} aria-label="添加更多参考内容"><Plus aria-hidden="true" /></button></Tooltip></li> : null}
|
||||
{canAddMoreReferences ? <li className="creation-reference-add-slot"><Tooltip title="添加更多参考内容"><button type="button" className="creation-reference-add-button" onClick={props.onOpenLibrary} disabled={interactionBusy} aria-label="添加更多参考内容"><Plus aria-hidden="true" /></button></Tooltip></li> : null}
|
||||
</Reorder.Group>
|
||||
{trackState.canScrollRight ? <button type="button" className="creation-reference-track-button is-right" onClick={() => scrollAttachmentTrack(1)} aria-label="向右浏览参考内容" title="向右浏览参考内容"><ChevronRight aria-hidden="true" /></button> : null}
|
||||
</div>
|
||||
@@ -1274,7 +1350,7 @@ function CreationComposer(props: ComposerProps) {
|
||||
<footer className="creation-chat-dock">
|
||||
<div className="creation-chat-controls">
|
||||
<VoiceRecordingButton
|
||||
disabled={props.busy}
|
||||
disabled={interactionBusy}
|
||||
onTranscribed={(text) => props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)}
|
||||
/>
|
||||
<ModePicker mode={props.mode} onModeChange={props.onModeChange} />
|
||||
@@ -1291,8 +1367,8 @@ function CreationComposer(props: ComposerProps) {
|
||||
<span>优化</span>
|
||||
</button>
|
||||
</Tooltip> : null}
|
||||
<Tooltip title="从本机上传附件"><button type="button" className="creation-chat-control" onClick={() => props.fileInputRef.current?.click()} disabled={props.busy || !referencesSupported} aria-label="从本机上传附件"><Paperclip /><span>附件</span></button></Tooltip>
|
||||
<Tooltip title={!referencesSupported ? "当前模型不支持参考媒体" : "从素材库选择参考内容"}><button type="button" className="creation-chat-control" onClick={props.onOpenLibrary} disabled={props.busy || !referencesSupported} aria-label="打开素材库选择参考内容"><FolderOpen /><span>素材库</span></button></Tooltip>
|
||||
<Tooltip title="从本机上传附件"><button type="button" className="creation-chat-control" onClick={() => props.fileInputRef.current?.click()} disabled={interactionBusy || !referencesSupported} aria-label="从本机上传附件"><Paperclip /><span>附件</span></button></Tooltip>
|
||||
<Tooltip title={!referencesSupported ? "当前模型不支持参考媒体" : "从素材库选择参考内容"}><button type="button" className="creation-chat-control" onClick={props.onOpenLibrary} disabled={interactionBusy || !referencesSupported} aria-label="打开素材库选择参考内容"><FolderOpen /><span>素材库</span></button></Tooltip>
|
||||
<ModelPicker config={props.config} value={props.model} onChange={props.onModelChange} capability={props.mode} requirements={props.modelRequirements} className="creation-model-picker" placeholder={`选择${modeLabels[props.mode]}模型`} showSelectedPrice variant="creation" />
|
||||
{props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? <GenerationSettingsMenu {...props} /> : null}
|
||||
{props.mode === "video" ? <DurationMenu profile={props.videoProfile} seconds={props.seconds} onChange={props.setSeconds} /> : null}
|
||||
@@ -1300,18 +1376,18 @@ function CreationComposer(props: ComposerProps) {
|
||||
<Button
|
||||
type="text"
|
||||
className={`canvas-node-composer-submit ${showCost ? "has-cost" : ""}`}
|
||||
disabled={props.busy || !canSubmit}
|
||||
disabled={interactionBusy || !canSubmit}
|
||||
style={{
|
||||
color: !props.busy && !canSubmit ? "var(--creation-faint)" : "var(--creation-text)",
|
||||
"--canvas-composer-submit-action": !props.busy && !canSubmit ? "var(--creation-surface-hover)" : "var(--creation-text)",
|
||||
"--canvas-composer-submit-action-fg": !props.busy && !canSubmit ? "var(--creation-faint)" : "var(--creation-bg)",
|
||||
color: !interactionBusy && !canSubmit ? "var(--creation-faint)" : "var(--creation-text)",
|
||||
"--canvas-composer-submit-action": !interactionBusy && !canSubmit ? "var(--creation-surface-hover)" : "var(--creation-text)",
|
||||
"--canvas-composer-submit-action-fg": !interactionBusy && !canSubmit ? "var(--creation-faint)" : "var(--creation-bg)",
|
||||
} as CSSProperties}
|
||||
onClick={props.busy ? undefined : props.onSubmit}
|
||||
onClick={interactionBusy ? undefined : props.onSubmit}
|
||||
aria-label={actionLabel}
|
||||
title={actionLabel}
|
||||
>
|
||||
{showCost ? <span className="canvas-node-composer-submit-cost"><CreditSymbol /><span>{formattedCredits}</span></span> : null}
|
||||
<span className="canvas-node-composer-submit-action" aria-hidden>{props.busy ? <LoaderCircle className="size-3 animate-spin" /> : <ArrowUp className="size-3" />}</span>
|
||||
<span className="canvas-node-composer-submit-action" aria-hidden>{interactionBusy ? <LoaderCircle className="size-3 animate-spin" /> : <ArrowUp className="size-3" />}</span>
|
||||
</Button>
|
||||
</footer>
|
||||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||||
|
||||
@@ -945,7 +945,7 @@
|
||||
--workspace-border-strong: rgba(17, 17, 17, 0.2);
|
||||
--workspace-grid-line: rgba(17, 17, 17, 0.035);
|
||||
--workspace-nav: var(--workspace-navigation);
|
||||
--workspace-sidebar: color-mix(in srgb, var(--workspace-surface) 55%, var(--workspace-navigation));
|
||||
--workspace-sidebar: var(--workspace-navigation);
|
||||
--workspace-accent: #171717;
|
||||
--workspace-accent-soft: rgba(17, 17, 17, 0.065);
|
||||
background-color: var(--workspace-canvas);
|
||||
@@ -966,7 +966,7 @@
|
||||
--workspace-border-strong: rgba(255, 255, 255, 0.18);
|
||||
--workspace-grid-line: rgba(255, 255, 255, 0.026);
|
||||
--workspace-nav: var(--workspace-navigation);
|
||||
--workspace-sidebar: color-mix(in srgb, var(--workspace-surface) 55%, var(--workspace-navigation));
|
||||
--workspace-sidebar: var(--workspace-navigation);
|
||||
--workspace-accent: #f5f5f5;
|
||||
--workspace-accent-soft: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
@@ -8849,6 +8849,7 @@
|
||||
}
|
||||
|
||||
.canvas-resource-inline-mention {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
@@ -8869,6 +8870,26 @@
|
||||
transform: translateY(var(--canvas-mention-chip-offset-y));
|
||||
}
|
||||
|
||||
.canvas-resource-inline-mention.is-replace-target {
|
||||
outline: var(--stroke-2) solid var(--control-focus-ring);
|
||||
outline-offset: var(--space-half);
|
||||
background: color-mix(in srgb, var(--control-focus-ring) 18%, var(--canvas-composer-control-surface));
|
||||
}
|
||||
|
||||
.canvas-resource-inline-mention.is-replace-target::after {
|
||||
content: "替换";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--color-neutral-950) 76%, transparent);
|
||||
color: var(--color-neutral-0);
|
||||
font-size: var(--fs-micro);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.canvas-resource-inline-at,
|
||||
.canvas-resource-inline-label {
|
||||
flex: 0 0 auto;
|
||||
@@ -11457,8 +11478,8 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
|
||||
.admin-model-editor-section {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 176px) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
padding: 20px 22px;
|
||||
}
|
||||
|
||||
@@ -11472,6 +11493,11 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-model-editor-section-heading {
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
}
|
||||
|
||||
.admin-model-editor-section-heading h2 {
|
||||
color: var(--foreground);
|
||||
font-size: 15px;
|
||||
@@ -11714,7 +11740,7 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
|
||||
.admin-price-tier-card-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-price-tier-block {
|
||||
@@ -11722,7 +11748,7 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
}
|
||||
|
||||
.admin-price-tier-block + .admin-price-tier-block {
|
||||
border-left: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
border-top: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
}
|
||||
|
||||
.admin-price-tier-block-title {
|
||||
@@ -11991,19 +12017,6 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-model-editor-section {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-model-editor-section-heading {
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
}
|
||||
|
||||
.admin-price-tier-block + .admin-price-tier-block {
|
||||
border-top: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 管理后台可读性基线:收敛历史页面中过低的文字透明度,并统一表单层级。 */
|
||||
|
||||
Reference in New Issue
Block a user