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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("")} />