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