"use client"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"; import { Check, Download, ExternalLink, Film, ImageIcon, ImagePlus, ImageUp, Loader2, Music, Paintbrush, Pencil, Plus, RefreshCw, Save, Send, Upload, X } from "lucide-react"; import clsx from "clsx"; import { ImageEditor, type ImageEditMode } from "@/components/image-editor"; import { clampPage, pageItems, Pagination } from "@/components/pagination"; import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; import { VIDEO_DURATION_DEFAULT, VIDEO_DURATION_OPTIONS, VIDEO_RATIOS, VIDEO_RESOLUTIONS, clampVideoDuration } from "@/lib/video-settings"; import type { Asset, GenerationJob } from "@/lib/types"; import type { PromptMaterial } from "@/lib/prompt/assembler"; import { detectMaterialDraftStart, insertMaterialDraftToken, nextMaterialDraftToken, normalizeMaterialDraftToken, type MaterialDraftKind } from "@/lib/prompt/material-draft"; import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders"; type GenerateMode = "image" | "video"; type StudioMode = GenerateMode | ImageEditMode; type MaterialKind = PromptMaterial["type"]; type ImageGenerateEngine = "jimeng" | "evolink" | "bailian"; type VideoGenerateEngine = "seedance" | "bailian"; type MentionState = { start: number; query: string; }; type MaterialDraftTarget = "prompt" | "template"; type MaterialDraftState = { target: MaterialDraftTarget; index: number; query: string; activeIndex: number | null; }; type MaterialDraftOption = { token: string; label: string; caption: string; type: MaterialKind; }; type HealthCapability = { id: string; engine?: string; }; type ImageTemplate = { id: string; name: string; prompt: string; previewImageUrl?: string; settings: { engine?: ImageGenerateEngine; width?: number; height?: number; forceSingle?: boolean; scale?: number; quality?: string; }; sortOrder: number; updatedAt?: string; }; type TemplateForm = { name: string; prompt: string; previewImageUrl: string; engine: ImageGenerateEngine; jimengInfluence: string; evolinkQuality: string; size: string; sortOrder: string; }; const jimengInfluenceOptions = [ { id: "creative", label: "创意 35", scale: 35 }, { id: "balanced", label: "均衡 50", scale: 50 }, { id: "precise", label: "贴合 70", scale: 70 }, { id: "strict", label: "严格 85", scale: 85 } ]; const evolinkQualityOptions = [ { id: "low", label: "快速", quality: "low" }, { id: "medium", label: "标准", quality: "medium" }, { id: "high", label: "精细", quality: "high" } ]; const imageSizePresets = [ { label: "1:1", width: 2048, height: 2048 }, { label: "4:3", width: 2304, height: 1728 }, { label: "16:9", width: 2560, height: 1440 }, { label: "9:16", width: 1440, height: 2560 } ]; type ImageSizePreset = (typeof imageSizePresets)[number]; type TemplateConsoleSnapshot = { mode: GenerateMode; imagePrompt: string; imageSize: ImageSizePreset; imageEngine: ImageGenerateEngine; jimengInfluence: string; evolinkQuality: string; }; const materialUploadAccept = "image/*,video/*,audio/*"; const templatePreviewAccept = "image/*"; const MATERIAL_PAGE_SIZE = 6; const defaultTemplateForm: TemplateForm = { name: "", prompt: "", previewImageUrl: "", engine: "jimeng", jimengInfluence: jimengInfluenceOptions[1].id, evolinkQuality: evolinkQualityOptions[1].id, size: imageSizePresets[0].label, sortOrder: "0" }; export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMode }) { const [mode, setMode] = useState(initialMode); const [promptByMode, setPromptByMode] = useState>({ image: "", video: "" }); const [materials, setMaterials] = useState([]); const [imageTemplates, setImageTemplates] = useState([]); const [taskAssets, setTaskAssets] = useState([]); const [recentJobs, setRecentJobs] = useState([]); const [templatesLoading, setTemplatesLoading] = useState(false); const [tasksLoading, setTasksLoading] = useState(false); const [templateSaving, setTemplateSaving] = useState(false); const [templatePreviewUploading, setTemplatePreviewUploading] = useState(false); const [templateError, setTemplateError] = useState(null); const [tasksError, setTasksError] = useState(null); const [activeTemplateId, setActiveTemplateId] = useState(null); const [templateEditorOpen, setTemplateEditorOpen] = useState(false); const [editingTemplateId, setEditingTemplateId] = useState(null); const [previewTemplate, setPreviewTemplate] = useState(null); const [previewTaskAsset, setPreviewTaskAsset] = useState(null); const [durationNow, setDurationNow] = useState(() => Date.now()); const [templateForm, setTemplateForm] = useState(defaultTemplateForm); const [templateRestoreState, setTemplateRestoreState] = useState(null); const [busy, setBusy] = useState(false); const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [imageSize, setImageSize] = useState(imageSizePresets[0]); const [imageEngine, setImageEngine] = useState("jimeng"); const [jimengInfluence, setJimengInfluence] = useState(jimengInfluenceOptions[1].id); const [evolinkQuality, setEvolinkQuality] = useState(evolinkQualityOptions[1].id); const [videoEngine, setVideoEngine] = useState("seedance"); const [videoRatio, setVideoRatio] = useState("9:16"); const [videoDuration, setVideoDuration] = useState(VIDEO_DURATION_DEFAULT); const [videoResolution, setVideoResolution] = useState("720p"); const [mentionState, setMentionState] = useState(null); const [materialDraft, setMaterialDraft] = useState(null); const [activeMentionIndex, setActiveMentionIndex] = useState(null); const [promptScrollTop, setPromptScrollTop] = useState(0); const [templatePromptScrollTop, setTemplatePromptScrollTop] = useState(0); const [materialPage, setMaterialPage] = useState(1); const studioRef = useRef(null); const modePanelRef = useRef(null); const feedbackRef = useRef(null); const materialBoardRef = useRef(null); const promptRef = useRef(null); const templatePromptRef = useRef(null); const materialDraftInputRef = useRef(null); const isImageEditMode = mode === "inpaint" || mode === "upscale"; const generateMode: GenerateMode = mode === "video" ? "video" : "image"; const prompt = promptByMode[generateMode]; const selectedJimengInfluence = jimengInfluenceOptions.find((option) => option.id === jimengInfluence) || jimengInfluenceOptions[1]; const selectedEvolinkQuality = evolinkQualityOptions.find((option) => option.id === evolinkQuality) || evolinkQualityOptions[1]; const selectedTemplateJimengInfluence = jimengInfluenceOptions.find((option) => option.id === templateForm.jimengInfluence) || jimengInfluenceOptions[1]; const selectedTemplateEvolinkQuality = evolinkQualityOptions.find((option) => option.id === templateForm.evolinkQuality) || evolinkQualityOptions[1]; const templateFormReady = Boolean(templateForm.name.trim() && templateForm.prompt.trim() && templateForm.previewImageUrl.trim()); const templateFormPlaceholders = useMemo(() => extractMaterialPlaceholders(templateForm.prompt), [templateForm.prompt]); const materialPlaceholders = useMemo(() => extractMaterialPlaceholders(prompt), [prompt]); const referencedMaterialLabels = useMemo(() => new Set(materialPlaceholders.map((placeholder) => placeholder.token)), [materialPlaceholders]); const missingMaterialPlaceholders = useMemo(() => { const existing = new Set(materials.map((material) => normalizeMaterialLabel(material.label)).filter((label): label is string => Boolean(label))); return materialPlaceholders.filter((placeholder) => !existing.has(placeholder.token)); }, [materialPlaceholders, materials]); const submitDisabled = busy || !prompt.trim() || missingMaterialPlaceholders.length > 0; const submitTitle = missingMaterialPlaceholders.length ? `请先上传 ${missingMaterialPlaceholders.map((placeholder) => placeholder.token).join("、")}` : generateMode === "image" ? "生成图片" : "生成视频"; const visibleMaterials = pageItems(materials, materialPage, MATERIAL_PAGE_SIZE); const materialPageOffset = (clampPage(materialPage, materials.length, MATERIAL_PAGE_SIZE) - 1) * MATERIAL_PAGE_SIZE; const mentionSuggestions = useMemo(() => { if (!mentionState) return []; return materials.filter((material) => materialMatchesMention(material, mentionState.query)).slice(0, 8); }, [materials, mentionState]); const materialDraftOptions = useMemo(() => { if (!materialDraft) return []; return buildMaterialDraftOptions({ target: materialDraft.target, query: materialDraft.query, prompt: materialDraft.target === "template" ? templateForm.prompt : prompt, materials }); }, [materialDraft, materials, prompt, templateForm.prompt]); const taskAssetById = useMemo(() => { const map = new Map(); for (const asset of taskAssets) map.set(asset.id, asset); return map; }, [taskAssets]); const hasLiveTasks = useMemo(() => recentJobs.some((job) => !isTerminalStatus(job.status)), [recentJobs]); useEffect(() => { setActiveMentionIndex(null); }, [mentionState?.query, mentionSuggestions.length]); useEffect(() => { if (!materialDraft) return; window.requestAnimationFrame(() => materialDraftInputRef.current?.focus()); }, [materialDraft?.target, materialDraft?.index]); useEffect(() => { setMaterialPage((page) => clampPage(page, materials.length, MATERIAL_PAGE_SIZE)); }, [materials.length]); useEffect(() => { return runScopedMotion(studioRef, (scope) => revealChildren(scope)); }, []); useEffect(() => { crossfadeIn(modePanelRef.current); }, [mode]); useEffect(() => { pulseFeedback(feedbackRef.current); }, [error, notice]); useEffect(() => { if (materialBoardRef.current) revealChildren(materialBoardRef.current, ".material-card"); }, [materials.length, materialPage]); useEffect(() => { let active = true; void fetch("/api/health") .then((response) => response.ok ? response.json() : null) .then((payload: { capabilities?: HealthCapability[] } | null) => { const engine = payload?.capabilities?.find((capability) => capability.id === "image.generate")?.engine; if (active && (engine === "evolink" || engine === "jimeng" || engine === "bailian")) setImageEngine(engine); }) .catch(() => undefined); return () => { active = false; }; }, []); useEffect(() => { let active = true; void loadImageTemplates(() => active); return () => { active = false; }; }, []); useEffect(() => { let active = true; void loadTaskModuleData(() => active); const timer = window.setInterval(() => { if (document.visibilityState === "visible") { void loadTaskModuleData(() => active, { silent: true }); } }, 15000); return () => { active = false; window.clearInterval(timer); }; }, []); useEffect(() => { if (!hasLiveTasks) return undefined; setDurationNow(Date.now()); const timer = window.setInterval(() => { setDurationNow(Date.now()); }, 1000); return () => window.clearInterval(timer); }, [hasLiveTasks]); async function loadImageTemplates(isActive: () => boolean = () => true) { setTemplatesLoading(true); setTemplateError(null); try { const response = await fetch("/api/image-templates", { cache: "no-store" }); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || "读取模板失败"); const nextTemplates = ((payload.templates || []) as ImageTemplate[]).sort(sortTemplates); if (isActive()) { setImageTemplates(nextTemplates); } } catch (err) { if (isActive()) setTemplateError(err instanceof Error ? err.message : String(err)); } finally { if (isActive()) setTemplatesLoading(false); } } async function loadTaskModuleData(isActive: () => boolean = () => true, options: { silent?: boolean } = {}) { if (!options.silent) setTasksLoading(true); setTasksError(null); try { const [assetResponse, imageResponse, videoResponse] = await Promise.all([ fetch("/api/assets", { cache: "no-store" }), fetch("/api/generations/image", { cache: "no-store" }), fetch("/api/generations/video", { cache: "no-store" }) ]); const [assetPayload, imagePayload, videoPayload] = await Promise.all([ assetResponse.json(), imageResponse.json(), videoResponse.json() ]); if (!assetResponse.ok) throw new Error(assetPayload.error || "读取资产失败"); if (!imageResponse.ok) throw new Error(imagePayload.error || "读取图片任务失败"); if (!videoResponse.ok) throw new Error(videoPayload.error || "读取视频任务失败"); const dedupedJobs = new Map(); for (const job of [...(imagePayload.jobs || []), ...(videoPayload.jobs || [])] as GenerationJob[]) { dedupedJobs.set(job.id, job); } const nextJobs = [...dedupedJobs.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); if (isActive()) { setTaskAssets((assetPayload.assets || []) as Asset[]); setRecentJobs(nextJobs); } } catch (err) { if (isActive()) setTasksError(err instanceof Error ? err.message : String(err)); } finally { if (isActive() && !options.silent) setTasksLoading(false); } } function setPrompt(value: string) { setPromptByMode((items) => ({ ...items, [generateMode]: value })); } function handlePromptInput(value: string, cursor: number) { const draft = detectMaterialDraftStart(prompt, value, cursor); if (draft) { setPrompt(draft.text); startMaterialDraft("prompt", draft.index); return; } setPrompt(value); updateMention(value, cursor); } function updateMention(value: string, cursor: number) { setMentionState(findMentionAtCursor(value, cursor)); } async function uploadAssetFiles(files: File[]): Promise { const formData = new FormData(); for (const file of files) formData.append("files", file); const response = await fetch("/api/assets/upload", { method: "POST", body: formData }); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || "上传失败"); return payload.assets as Asset[]; } async function uploadFiles(files: FileList | null, options: { targetLabel?: string; expectedType?: MaterialKind } = {}) { const selectedFiles = Array.from(files || []); const expectedType = options.expectedType; if (!selectedFiles.length) return; if (expectedType && selectedFiles.some((file) => !fileMatchesMaterialKind(file, expectedType))) { setError(`请上传${shortTypeName(expectedType)}素材。`); return; } setUploading(true); setError(null); setNotice(null); try { const uploaded = await uploadAssetFiles(selectedFiles); if (expectedType && uploaded.some((asset) => materialTypeForAsset(asset) !== expectedType)) { throw new Error(`请上传${shortTypeName(expectedType)}素材。`); } setMaterials((items) => { const next = [...items]; uploaded.forEach((asset, index) => { const targetLabel = index === 0 ? normalizeMaterialLabel(options.targetLabel) : undefined; const material = materialFromAsset(asset, next, targetLabel); const existingIndex = targetLabel ? next.findIndex((item) => normalizeMaterialLabel(item.label) === targetLabel) : -1; if (existingIndex >= 0) { next[existingIndex] = material; return; } next.push(material); }); return next; }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setUploading(false); } } async function uploadTemplatePreview(files: FileList | null) { const file = files?.[0]; if (!file) return; if (!fileMatchesMaterialKind(file, "image")) { setTemplateError("模板预览图只能上传图片。"); return; } setTemplatePreviewUploading(true); setTemplateError(null); try { const [asset] = await uploadAssetFiles([file]); setTemplateForm((form) => ({ ...form, previewImageUrl: asset.url, name: form.name || asset.name.replace(/\.[^.]+$/, "") })); } catch (err) { setTemplateError(err instanceof Error ? err.message : String(err)); } finally { setTemplatePreviewUploading(false); } } function materialFromAsset(asset: Asset, existing: PromptMaterial[], preferredLabel?: string): PromptMaterial { const type = materialTypeForAsset(asset); const normalizedPreferredLabel = normalizeMaterialLabel(preferredLabel); return { id: asset.id, url: asset.url, type, role: "upload", label: normalizedPreferredLabel && materialTypeForLabel(normalizedPreferredLabel) === type ? normalizedPreferredLabel : nextMaterialLabel(type, existing), name: asset.name }; } function removeMaterial(index: number) { setMaterials((items) => items.filter((_, itemIndex) => itemIndex !== index)); } function insertToken(token?: string) { if (!token) return; insertAtCursor(token); } function insertAtCursor(text: string) { const textarea = promptRef.current; if (!textarea) { const joiner = prompt.endsWith("\n") || !prompt.trim() ? "" : "\n"; setPrompt(`${prompt}${joiner}${text}`); return; } const start = textarea.selectionStart ?? prompt.length; const end = textarea.selectionEnd ?? prompt.length; const before = prompt.slice(0, start); const after = prompt.slice(end); const needsLeadingSpace = before.length > 0 && !/[\s,。;:、((【\[]$/.test(before); const needsTrailingSpace = after.length > 0 && !/^[\s,。;:、))】\]]/.test(after); const insertion = `${needsLeadingSpace ? " " : ""}${text}${needsTrailingSpace ? " " : ""}`; const nextPrompt = `${before}${insertion}${after}`; const nextCursor = before.length + insertion.length; setPrompt(nextPrompt); window.requestAnimationFrame(() => { textarea.focus(); textarea.setSelectionRange(nextCursor, nextCursor); }); } function applyImageTemplate(template: ImageTemplate) { if (activeTemplateId === template.id) { clearImageTemplateSelection(); return; } if (!templateRestoreState) { setTemplateRestoreState({ mode: generateMode, imagePrompt: promptByMode.image, imageSize, imageEngine, jimengInfluence, evolinkQuality }); } applyTemplateToConsole(template, `已应用模板:${template.name}`); } function clearImageTemplateSelection() { const snapshot = templateRestoreState; setActiveTemplateId(null); setTemplateRestoreState(null); setMaterialDraft(null); setMentionState(null); setPromptScrollTop(0); setError(null); if (snapshot) { setMode(snapshot.mode); setPromptByMode((items) => ({ ...items, image: snapshot.imagePrompt })); setImageSize(snapshot.imageSize); setImageEngine(snapshot.imageEngine); setJimengInfluence(snapshot.jimengInfluence); setEvolinkQuality(snapshot.evolinkQuality); } else { setPromptByMode((items) => ({ ...items, image: "" })); setImageSize(imageSizePresets[0]); setImageEngine("jimeng"); setJimengInfluence(jimengInfluenceOptions[1].id); setEvolinkQuality(evolinkQualityOptions[1].id); } setNotice("已取消模板选择"); } function applyTemplateToConsole(template: ImageTemplate, message: string) { setMode("image"); setPromptByMode((items) => ({ ...items, image: template.prompt })); setActiveTemplateId(template.id); setMaterialDraft(null); const templateEngine = normalizeImageEngine(template.settings.engine); if (templateEngine) setImageEngine(templateEngine); const preset = imageSizePresets.find((item) => item.width === template.settings.width && item.height === template.settings.height); if (preset) setImageSize(preset); const templateInfluence = jimengInfluenceOptions.find((option) => option.scale === template.settings.scale); if (templateInfluence) setJimengInfluence(templateInfluence.id); const templateQuality = evolinkQualityOptions.find((option) => option.quality === template.settings.quality); if (templateQuality) setEvolinkQuality(templateQuality.id); setMentionState(null); setPromptScrollTop(0); setNotice(message); setError(null); } function resetTemplateForm() { setTemplateForm(defaultTemplateFormForEngine(imageEngine)); setEditingTemplateId(null); setTemplateError(null); setMaterialDraft(null); setTemplatePromptScrollTop(0); } function openTemplateCreator() { resetTemplateForm(); setTemplateEditorOpen(true); } function openTemplateEditor(template: ImageTemplate) { setTemplateForm(formFromTemplate(template)); setEditingTemplateId(template.id); setTemplateError(null); setMaterialDraft(null); setTemplatePromptScrollTop(0); setTemplateEditorOpen(true); } function closeTemplateEditor() { setTemplateEditorOpen(false); setEditingTemplateId(null); setTemplateError(null); setMaterialDraft(null); setTemplatePromptScrollTop(0); } async function saveTemplate() { const isEditing = Boolean(editingTemplateId); const endpoint = editingTemplateId ? `/api/image-templates/${encodeURIComponent(editingTemplateId)}` : "/api/image-templates"; setTemplateSaving(true); setTemplateError(null); try { const response = await fetch(endpoint, { method: isEditing ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(templatePayloadFromForm(templateForm)) }); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || "保存模板失败"); const saved = payload.template as ImageTemplate; setImageTemplates((items) => { const rest = items.filter((item) => item.id !== saved.id); return [...rest, saved].sort(sortTemplates); }); setTemplateForm(formFromTemplate(saved)); setEditingTemplateId(null); setTemplateEditorOpen(false); applyTemplateToConsole(saved, isEditing ? "模板已更新并应用。" : "模板已保存并应用。"); } catch (err) { setTemplateError(err instanceof Error ? err.message : String(err)); } finally { setTemplateSaving(false); } } function insertTemplatePlaceholder(type: MaterialKind) { const placeholders = extractMaterialPlaceholders(templateForm.prompt).filter((placeholder) => placeholder.type === type); const nextIndex = placeholders.reduce((max, placeholder) => Math.max(max, placeholder.index), 0) + 1; const token = labelFor(type, nextIndex); const textarea = templatePromptRef.current; const value = templateForm.prompt; if (!textarea) { const joiner = value.endsWith("\n") || !value.trim() ? "" : " "; setTemplateForm((form) => ({ ...form, prompt: `${value}${joiner}${token} ` })); return; } const start = textarea.selectionStart ?? value.length; const end = textarea.selectionEnd ?? value.length; const before = value.slice(0, start); const after = value.slice(end); const needsLeadingSpace = before.length > 0 && !/[\s,。;:、((【\[]$/.test(before); const insertion = `${needsLeadingSpace ? " " : ""}${token} `; const nextPrompt = `${before}${insertion}${after}`; const nextCursor = before.length + insertion.length; setTemplateForm((form) => ({ ...form, prompt: nextPrompt })); window.requestAnimationFrame(() => { textarea.focus(); textarea.setSelectionRange(nextCursor, nextCursor); }); } function startMaterialDraft(target: MaterialDraftTarget, index: number) { setMentionState(null); setMaterialDraft({ target, index, query: "", activeIndex: null }); } function updateMaterialDraftQuery(value: string) { setMaterialDraft((draft) => draft ? { ...draft, query: value.replace(/^@+/, ""), activeIndex: null } : draft); } function confirmMaterialDraft(option?: MaterialDraftOption) { if (!materialDraft) return; const sourcePrompt = materialDraft.target === "template" ? templateForm.prompt : prompt; const token = option?.token || normalizeMaterialDraftToken(materialDraft.query, sourcePrompt); if (!token) return; const next = insertMaterialDraftToken(sourcePrompt, materialDraft.index, token); const target = materialDraft.target; setMaterialDraft(null); setMentionState(null); if (target === "template") { setTemplateForm((form) => ({ ...form, prompt: next.text })); window.requestAnimationFrame(() => { templatePromptRef.current?.focus(); templatePromptRef.current?.setSelectionRange(next.cursor, next.cursor); }); return; } setPrompt(next.text); window.requestAnimationFrame(() => { promptRef.current?.focus(); promptRef.current?.setSelectionRange(next.cursor, next.cursor); }); } function handleMaterialDraftKeyDown(event: KeyboardEvent) { if (!materialDraft) return; if (event.key === "Escape") { event.preventDefault(); const target = materialDraft.target; setMaterialDraft(null); window.requestAnimationFrame(() => { (target === "template" ? templatePromptRef.current : promptRef.current)?.focus(); }); return; } if (event.key === "ArrowDown" && materialDraftOptions.length) { event.preventDefault(); setMaterialDraft((draft) => draft ? { ...draft, activeIndex: draft.activeIndex === null ? 0 : (draft.activeIndex + 1) % materialDraftOptions.length } : draft); return; } if (event.key === "ArrowUp" && materialDraftOptions.length) { event.preventDefault(); setMaterialDraft((draft) => draft ? { ...draft, activeIndex: draft.activeIndex === null ? materialDraftOptions.length - 1 : (draft.activeIndex - 1 + materialDraftOptions.length) % materialDraftOptions.length } : draft); return; } if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); confirmMaterialDraft(materialDraftOptions[materialDraft.activeIndex ?? 0]); } } function handleTemplatePromptInput(value: string, cursor: number) { const draft = detectMaterialDraftStart(templateForm.prompt, value, cursor); if (draft) { setTemplateForm((form) => ({ ...form, prompt: draft.text })); startMaterialDraft("template", draft.index); return; } setTemplateForm((form) => ({ ...form, prompt: value })); } function selectMention(material: PromptMaterial) { if (!material.label) return; const textarea = promptRef.current; const end = textarea?.selectionStart ?? prompt.length; const start = mentionState?.start ?? end; const before = prompt.slice(0, start); const after = prompt.slice(end); const needsTrailingSpace = after.length > 0 && !/^[\s,。;:、))】\]]/.test(after); const insertion = `${material.label}${needsTrailingSpace ? " " : ""}`; const nextPrompt = `${before}${insertion}${after}`; const nextCursor = before.length + insertion.length; setPrompt(nextPrompt); setMentionState(null); window.requestAnimationFrame(() => { textarea?.focus(); textarea?.setSelectionRange(nextCursor, nextCursor); }); } function handlePromptKeyDown(event: KeyboardEvent) { if (!mentionState) return; if (event.key === "Escape") { setMentionState(null); return; } if (!mentionSuggestions.length) return; if (event.key === "ArrowDown") { event.preventDefault(); setActiveMentionIndex((index) => index === null ? 0 : (index + 1) % mentionSuggestions.length); } if (event.key === "ArrowUp") { event.preventDefault(); setActiveMentionIndex((index) => index === null ? mentionSuggestions.length - 1 : (index - 1 + mentionSuggestions.length) % mentionSuggestions.length); } if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); selectMention(mentionSuggestions[activeMentionIndex ?? 0] || mentionSuggestions[0]); } } async function submit() { if (isImageEditMode) return; if (missingMaterialPlaceholders.length) { setError(`请先上传 ${missingMaterialPlaceholders.map((placeholder) => placeholder.token).join("、")}。`); return; } setBusy(true); setError(null); setNotice(null); try { const endpoint = generateMode === "image" ? "/api/generations/image" : "/api/generations/video"; if (generateMode === "video" && videoEngine === "bailian") { const imageCount = materials.filter((material) => material.type === "image").length; if (imageCount < 1 || imageCount > 2 || materials.some((material) => material.type !== "image")) { throw new Error("百炼图生视频请上传 1 张首帧图,或按顺序上传 2 张首尾帧图。"); } } const body = generateMode === "image" ? { capability: "image.generate", engine: imageEngine, prompt, materials: materials.filter((material) => material.type === "image"), width: imageSize.width, height: imageSize.height, ...(imageEngine === "evolink" ? { quality: selectedEvolinkQuality.quality } : { scale: selectedJimengInfluence.scale }), force_single: true } : { engine: videoEngine, prompt, materials, settings: { ...(videoEngine === "seedance" ? { ratio: videoRatio } : {}), duration: videoDuration, resolution: videoEngine === "bailian" ? videoResolution.toUpperCase() : videoResolution } }; const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || "提交生成失败"); const submittedJob = payload.job as GenerationJob | undefined; if (submittedJob?.id) { setRecentJobs((items) => [submittedJob, ...items.filter((item) => item.id !== submittedJob.id)].sort((a, b) => b.createdAt.localeCompare(a.createdAt))); } void loadTaskModuleData(() => true, { silent: true }); setNotice(`${generateMode === "image" ? "图片" : "视频"}生成已提交。任务已进入「结果」,生成完成后结果资产会自动保留。`); setPrompt(""); setActiveTemplateId(null); setMaterials([]); setMaterialPage(1); setMentionState(null); setPromptScrollTop(0); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } function renderCreateModeBar() { return (
{!isImageEditMode ? (
) : null}
); } return (
{isImageEditMode ? (
{ modePanelRef.current = node; }} data-animate>
{renderCreateModeBar()}
) : (
{ modePanelRef.current = node; }} data-animate>

制作中心

{renderCreateModeBar()}
{error || notice ? (
{error ?
{error}
: null} {notice ?
{notice}
: null}
) : null}