import { AppstoreOutlined, ArrowDownOutlined, ArrowUpOutlined, CaretRightOutlined, ClockCircleOutlined, CloseOutlined, CopyOutlined, DeleteOutlined, DownloadOutlined, FullscreenOutlined, LoadingOutlined, MessageOutlined, MutedOutlined, PictureOutlined, PauseOutlined, PlusOutlined, ReloadOutlined, SendOutlined, SettingOutlined, StopOutlined, ThunderboltOutlined, VideoCameraOutlined, } from "@ant-design/icons"; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type KeyboardEvent, type ReactNode, type SyntheticEvent, } from "react"; import type { WebGenerationPreviewTask, WebUserSession } from "../../types"; import { aiGenerationClient } from "../../api/aiGenerationClient"; import { claimGenerationSlot, getActiveGenerationTaskCount, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency"; import { preUploadReference, resolvePreUploadedUrl } from "../../api/referenceUploadService"; import { assetClient } from "../../api/assetClient"; import { communityClient } from "../../api/communityClient"; import { RechargeModal } from "../../components/RechargeModal/RechargeModal"; import { conversationClient, type ConversationSummary } from "../../api/conversationClient"; import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient"; import { buildApiUrl, buildAuthHeaders } from "../../api/serverConnection"; import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway"; import type { WebProjectSummary } from "../../types"; import { communityCaseToPromptCase, getPromptCaseCardClassName, type PromptCaseViewModel, } from "../community/communityCaseUtils"; import ProjectSidebar from "./ProjectSidebar"; import { ChatAttachmentPreview, GenerationPendingCard, ImmersiveVideoPlayer, MarkdownMessage, ResultCard, } from "./components/WorkbenchChatCards"; import { renderMarkdownBlocks } from "./markdownRenderer"; import { downloadResultAsset } from "./workbenchDownload"; import { translateTaskError } from "../../utils/translateTaskError"; import { detectMentionTrigger } from "../../utils/mentionTrigger"; import { isHappyHorseModel, toHappyHorseDisplayModel, } from "../../utils/happyHorseRouting"; import { isViduModel } from "../../utils/viduRouting"; import { isPixverseModel } from "../../utils/pixverseRouting"; import { resolveVideoRequestModel } from "../../utils/resolveVideoModel"; import { ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy"; import { getImageQualityOptions, getDefaultImageQuality, getVideoQualityOptions, getDefaultVideoQuality, getVideoQualityLabel, } from "../../utils/modelOptions"; import { filterImageModelOptionsForSession } from "../../utils/imageModelVisibility"; import { persistWorkbenchResultAsset, type PersistedWorkbenchResultAsset } from "./workbenchResultPersistence"; import { SmoothedProgressBar } from "./SmoothedProgressBar"; import { useSmoothedProgress } from "../../hooks/useSmoothedProgress"; import { type WorkbenchMode, type ToolbarMenuId, type ReferenceKind, type WorkbenchOption, type WorkbenchFieldGroup, type ReferenceItem, type PromptMentionItem, type PromptMentionTokenRange, type ChatAttachment, type ChatMessage, type DeleteDialogState, type WorkbenchKeepaliveTask, MODE_META, MODE_OPTIONS, IMAGE_MODEL_OPTIONS, VIDEO_MODEL_OPTIONS, RATIO_OPTIONS, GRID_MODE_OPTIONS, VIDEO_FRAME_OPTIONS, VIDEO_DURATION_OPTIONS, MESSAGE_STORAGE_KEY, ACTIVE_CONVERSATION_STORAGE_KEY, PROMPT_HISTORY_STORAGE_KEY, TASK_KEEPALIVE_STORAGE_KEY, WORKBENCH_TASK_STALE_MS, WORKBENCH_TASK_MAX_POLL_FAILURES, REFERENCE_IMAGE_COMPRESS_THRESHOLD, REFERENCE_IMAGE_MAX_DIMENSION, REFERENCE_IMAGE_INITIAL_QUALITY, REFERENCE_IMAGE_MIN_QUALITY, CHAT_MODEL, CHAT_NATURAL_SYSTEM_PROMPT, CHAT_TURN_STYLE_REMINDER, NON_CONVERSATIONAL_ASSISTANT_TEXT, getCachedRole, getSessionUserId, userKey, createId, formatWorkbenchTimestamp, parseWorkbenchTimestampValue, buildChatAttachments, buildNaturalChatHistoryMessages, getErrorText, isAuthFailure, isInsufficientBalance, isInsufficientBalanceMessage, isTransientMessage, getPersistableMessages, shouldPersistPatch, buildAssistantResult, } from "./workbenchConstants"; import { readStoredMessages, readStoredPromptHistory, readStoredActiveConversationId, persistActiveConversationId, persistMessages, clearWorkbenchLocalState, persistPromptHistory, buildRecoverableTaskFromMessage, readStoredKeepaliveTasks, persistKeepaliveTasks, } from "./workbenchStorage"; import { getRatioOptionClassName, getSettingsGridColumnsClassName, getReferenceAccept, getReferenceUploadLabel, getReferenceLimit, getReferenceKindLabel, getReferenceEmptyCopy, hexToRgbTriplet, inferReferenceKind, disposeReferencePreview, fileToDataUrl, bytesToHex, buildReferenceFingerprint, canCompressReferenceImage, compressReferenceImageIfNeeded, buildReferenceToken, resolveReferenceUrls, } from "./workbenchReferenceUtils"; import { renderPromptPreviewNodes, getPromptMentionTokenRanges, removePromptMentionTokenFromText, removePromptTextRange, } from "./workbenchMentionUtils"; import { findPromptMentionRangeInside, findPromptMentionRangeOverlap, ReferenceInlinePreview, ReferencePreview, PromptPreviewLayer, } from "./WorkbenchPromptPreview"; import { SelectChip, CompoundSelectChip, InlineOptionChip } from "./WorkbenchSelectChips"; export type { WorkbenchResultActionPayload } from "./workbenchConstants"; interface WorkbenchPageProps { isAuthenticated: boolean; session: WebUserSession | null; onRequireLogin: (input: CreatePreviewTaskInput) => void; onOpenResultInCanvas?: (payload: import("./workbenchConstants").WorkbenchResultActionPayload) => void; onRefreshUsage?: () => void; } // ─── Component ─────────────────────────────────────────────────────────── // (All types, constants, helpers, and sub-components extracted to sibling modules) // ─── REDUNDANT LOCAL DEFINITIONS REMOVED ───────────────────────────── // The following block (originally ~1200 lines of types, constants, utility // functions, and sub-components) has been extracted to sibling modules: // workbenchConstants.ts, workbenchStorage.ts, workbenchReferenceUtils.ts, // workbenchMentionUtils.tsx, WorkbenchPromptPreview.tsx, WorkbenchSelectChips.tsx // and is now imported at the top of this file. const MODE_ICONS: Record = { chat: , image: , video: , }; function WorkbenchPage({ isAuthenticated, session, onRequireLogin, onOpenResultInCanvas, onRefreshUsage, }: WorkbenchPageProps) { const textareaRef = useRef(null); const referenceInputRef = useRef(null); const referenceRefsRef = useRef(null); const toolbarRef = useRef(null); const messagesEndRef = useRef(null); const messagesSurfaceRef = useRef(null); const referenceItemsRef = useRef([]); const referenceTokenSequenceRef = useRef>({ image: 0, video: 0, audio: 0, file: 0, }); const generationAbortRef = useRef(null); const activeConversationIdRef = useRef(null); const messagesRef = useRef([]); const conversationMessagesCacheRef = useRef>(new Map()); const skipConversationAutoSelectRef = useRef(false); const keepaliveTasksRef = useRef>(readStoredKeepaliveTasks()); const taskAbortControllersRef = useRef>(new Map()); const lastScrollTopRef = useRef(0); const shouldFollowNewMessagesRef = useRef(true); const pendingScrollToLatestRef = useRef(true); const renderedMessageIdsRef = useRef([]); const hasHandledInitialMessagesRef = useRef(false); const [activeMode, setActiveMode] = useState("video"); const [inputValue, setInputValue] = useState(""); const [messages, setMessages] = useState(() => readStoredMessages()); const [promptHistory, setPromptHistory] = useState(() => readStoredPromptHistory()); const [toolbarMenuId, setToolbarMenuId] = useState(null); const [referenceItems, setReferenceItems] = useState([]); const [referencePreviewOpen, setReferencePreviewOpen] = useState(false); const [messagePreviewAttachment, setMessagePreviewAttachment] = useState(null); const [selectedPromptCase, setSelectedPromptCase] = useState(null); const [serverPromptCases, setServerPromptCases] = useState([]); const [promptCaseMeasuredRatios, setPromptCaseMeasuredRatios] = useState>({}); const [mentionPanelPlacement, setMentionPanelPlacement] = useState<"above" | "below">("above"); const [isGenerating, setIsGenerating] = useState(false); const [generationStatus, setGenerationStatus] = useState("准备就绪"); const [showRechargeModal, setShowRechargeModal] = useState(false); const [savedAssetMentionItems, setSavedAssetMentionItems] = useState< Pick[] >([]); const [projectError, setProjectError] = useState(null); const [conversations, setConversations] = useState([]); const [activeConversationId, setActiveConversationId] = useState(() => readStoredActiveConversationId(readStoredMessages()), ); const [sidebarCollapsed, setSidebarCollapsed] = useState(true); const [deleteDialog, setDeleteDialog] = useState(null); const [deleteSubmitting, setDeleteSubmitting] = useState(false); const [, setGenerationProgress] = useState(0); const [cursorIndex, setCursorIndex] = useState(0); const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 }); const [mentionActiveIndex, setMentionActiveIndex] = useState(0); const [composerHidden, setComposerHidden] = useState(false); const [workspaceStarted, setWorkspaceStarted] = useState(false); useEffect(() => { activeConversationIdRef.current = activeConversationId; }, []); useEffect(() => { let cancelled = false; assetClient .list() .then((assets) => { if (cancelled) return; const items = assets .filter((a) => a.url && (a.type === "scene" || a.type === "video" || a.type === "image" || a.type === "character" || a.type === "prop")) .slice(0, 20) .map((a, i) => { const kind: ReferenceKind = a.type === "video" ? "video" : "image"; return { id: `asset-${a.id}`, kind, name: a.name || `素材 ${i + 1}`, previewUrl: a.url || undefined, remoteUrl: a.url || undefined, token: `@素材${i + 1}`, }; }); setSavedAssetMentionItems(items); }) .catch(() => { // Silently ignore — saved assets are optional enhancement }); return () => { cancelled = true; }; }, []); const fallbackImageModelOptions = useMemo( () => filterImageModelOptionsForSession(IMAGE_MODEL_OPTIONS, session), [session], ); const fallbackImageModelValue = fallbackImageModelOptions[0]?.value || IMAGE_MODEL_OPTIONS[0].value; const [imageModelOptions, setImageModelOptions] = useState(() => fallbackImageModelOptions); const [videoModelOptions, setVideoModelOptions] = useState(VIDEO_MODEL_OPTIONS); const [imageModel, setImageModel] = useState(fallbackImageModelValue); const [imageRatio, setImageRatio] = useState("16:9"); const [imageQuality, setImageQuality] = useState(() => getDefaultImageQuality(fallbackImageModelValue)); const [imageGridMode, setImageGridMode] = useState("single"); const [videoModel, setVideoModel] = useState(VIDEO_MODEL_OPTIONS[0].value); const [videoFrameMode, setVideoFrameMode] = useState("omni"); const [videoRatio, setVideoRatio] = useState("16:9"); const [videoDuration, setVideoDuration] = useState("4"); const [videoQuality, setVideoQuality] = useState(() => getDefaultVideoQuality(VIDEO_MODEL_OPTIONS[0].value)); useEffect(() => { let cancelled = false; const applyImageModels = (models: WorkbenchOption[]) => { const filteredImageModels = filterImageModelOptionsForSession( models.length ? models : IMAGE_MODEL_OPTIONS, session, ); const nextImageModels = filteredImageModels.length ? filteredImageModels : fallbackImageModelOptions; setImageModelOptions(nextImageModels); setImageModel((current) => { if (nextImageModels.some((item) => item.value === current)) return current; const nextValue = nextImageModels[0]?.value || fallbackImageModelValue; setImageQuality(getDefaultImageQuality(nextValue)); return nextValue; }); }; if (!isAuthenticated) { applyImageModels(IMAGE_MODEL_OPTIONS); return () => { cancelled = true; }; } modelCapabilitiesClient .get() .then((capabilities) => { if (cancelled) return; const nextVideoModels = VIDEO_MODEL_OPTIONS; applyImageModels(capabilities.imageModels); setVideoModelOptions(nextVideoModels); setVideoModel((current) => { const normalizedCurrent = toHappyHorseDisplayModel(current); if (nextVideoModels.some((item) => item.value === normalizedCurrent)) return normalizedCurrent; const nextValue = nextVideoModels[0]?.value || VIDEO_MODEL_OPTIONS[0].value; setVideoQuality(getDefaultVideoQuality(nextValue)); return nextValue; }); }) .catch(() => {}); return () => { cancelled = true; }; }, [fallbackImageModelValue, isAuthenticated, session]); const toolTheme = MODE_META[activeMode]; const workbenchAccent = "#00ff88"; const hasConversationRecords = activeConversationId !== null || messages.length > 0; const hasActivatedWorkspace = workspaceStarted || isGenerating || hasConversationRecords; const referenceCount = referenceItems.length; const activeVideoModelValue = toHappyHorseDisplayModel(videoModel); const activeModelValue = activeMode === "image" ? imageModel : activeMode === "video" ? activeVideoModelValue : CHAT_MODEL; const activeModel = activeMode === "image" ? imageModelOptions.find((item) => item.value === imageModel)?.label || imageModel : activeMode === "video" ? videoModelOptions.find((item) => item.value === activeVideoModelValue)?.label || activeVideoModelValue : "OmniChat"; const conversationRecords = useMemo( () => conversations.map((conversation) => ({ id: String(conversation.id), name: conversation.title, description: null, thumbnailUrl: null, updatedAt: conversation.updatedAt, storyboardCount: 0, imageCount: 0, videoCount: 0, source: "server", mode: conversation.mode, })), [conversations], ); const hasSidebarRecords = conversationRecords.length > 0; const activeConversationTitle = useMemo(() => { if (!activeConversationId) return ""; return conversations.find((c) => c.id === activeConversationId)?.title || ""; }, [conversations, activeConversationId]); const downloadFilenameBase = useMemo(() => { const today = new Date(); const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; return activeConversationTitle ? `${activeConversationTitle}_${dateStr}` : dateStr; }, [activeConversationTitle]); useEffect(() => { setSidebarCollapsed(!hasSidebarRecords); }, [hasSidebarRecords]); const imageQualityOptions = useMemo(() => getImageQualityOptions(imageModel), [imageModel]); const videoQualityOptions = getVideoQualityOptions(videoModel); const videoQualityLabel = getVideoQualityLabel(videoModel, videoQuality); const imageSettingsSummary = `${imageRatio} / ${imageQuality}`; const composerPlaceholder = referenceItems.length > 0 ? `${toolTheme.placeholder},可输入 @ 引用参考内容` : toolTheme.placeholder; const dropdownDirection = hasActivatedWorkspace ? "up" : "down"; const accentRgb = hexToRgbTriplet(workbenchAccent); const themeVars = { "--accent": workbenchAccent, "--accent-rgb": accentRgb, "--accent-muted": `rgba(${accentRgb}, 0.12)`, "--border-accent": `rgba(${accentRgb}, 0.24)`, "--accent-glow": `0 0 24px rgba(${accentRgb}, 0.22)`, } as CSSProperties; const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior = "smooth") => { const scroll = () => { const surface = messagesSurfaceRef.current; if (!surface) { messagesEndRef.current?.scrollIntoView({ block: "end", behavior }); return; } setComposerHidden(false); shouldFollowNewMessagesRef.current = true; surface.scrollTo({ top: surface.scrollHeight, behavior }); lastScrollTopRef.current = surface.scrollTop; }; window.requestAnimationFrame(() => { scroll(); window.setTimeout(scroll, 80); }); }, []); const imageSettingGroups = useMemo( () => [ { label: "比例", value: imageRatio, options: RATIO_OPTIONS, onChange: setImageRatio, kind: "ratio", columns: 3, icon: , }, { label: "清晰度", value: imageQuality, options: imageQualityOptions, onChange: setImageQuality, kind: "pill", columns: imageQualityOptions.length >= 3 ? 3 : 2, icon: , }, ], [imageQuality, imageQualityOptions, imageRatio], ); const videoRatioGroups = useMemo( () => [ { label: "比例", value: videoRatio, options: RATIO_OPTIONS.filter((item) => item.value !== "3:4"), onChange: setVideoRatio, kind: "ratio", columns: 3, icon: , }, ], [videoRatio], ); const promptMentionOptions = useMemo(() => { const refOptions = referenceItems.map((item) => ({ token: item.token, id: item.id, name: item.name, kind: item.kind, previewUrl: item.previewUrl, remoteUrl: item.remoteUrl, })); const existingTokens = new Set(refOptions.map((o) => o.token)); const assetOptions = savedAssetMentionItems.filter((item) => !existingTokens.has(item.token)); return [...refOptions, ...assetOptions]; }, [referenceItems, savedAssetMentionItems]); const promptCaseDisplayItems = useMemo(() => { return serverPromptCases; }, [serverPromptCases]); const handlePromptCaseImageLoad = useCallback((itemId: string, event: SyntheticEvent) => { const { naturalWidth, naturalHeight } = event.currentTarget; if (!naturalWidth || !naturalHeight) return; const measuredRatio = naturalWidth / naturalHeight; setPromptCaseMeasuredRatios((current) => { const previousRatio = current[itemId]; if (previousRatio && Math.abs(previousRatio - measuredRatio) < 0.01) return current; return { ...current, [itemId]: measuredRatio, }; }); }, []); const referenceUploadLabel = getReferenceUploadLabel(activeMode); const referenceButtonLabel = referenceItems.length > 0 ? (referencePreviewOpen ? "点击折叠" : "点击显示") : referenceUploadLabel; const referenceLimit = getReferenceLimit(activeMode, videoFrameMode); const promptBeforeCursor = inputValue.slice(0, cursorIndex); const promptMentionMatch = detectMentionTrigger(promptBeforeCursor); const promptMentionOpen = promptMentionMatch !== null; const promptPreviewNodes = useMemo( () => renderPromptPreviewNodes(inputValue, promptMentionOptions), [inputValue, promptMentionOptions], ); const promptMentionTokenRanges = useMemo( () => getPromptMentionTokenRanges(inputValue, promptMentionOptions), [inputValue, promptMentionOptions], ); const hasPromptSelection = promptSelectionRange.start !== promptSelectionRange.end; const showPromptPreview = !hasPromptSelection && inputValue.length > 0 && promptPreviewNodes.length > 0 && /@(图片|视频|音频|附件|素材)\d+/.test(inputValue); useEffect(() => { let cancelled = false; communityClient .listApprovedCases({ limit: 100, tag: "生成页面社区", sort: "latest" }) .then((items) => { if (cancelled) return; setServerPromptCases( items .map(communityCaseToPromptCase) .filter((item): item is PromptCaseViewModel => Boolean(item)), ); }) .catch(() => { if (!cancelled) { setServerPromptCases([]); } }); return () => { cancelled = true; }; }, []); useEffect(() => { if (!videoQualityOptions.some((item) => item.value === videoQuality)) { setVideoQuality(getDefaultVideoQuality(videoModel)); } }, [videoModel, videoQuality, videoQualityOptions]); useEffect(() => { if (!messagePreviewAttachment) return undefined; const handleKeyDown = (event: globalThis.KeyboardEvent) => { if (event.key === "Escape") { setMessagePreviewAttachment(null); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [messagePreviewAttachment]); useEffect(() => { if (!promptMentionOpen) return undefined; const updatePlacement = () => { const anchor = textareaRef.current; const rect = anchor?.getBoundingClientRect(); if (!rect) return; const estimatedHeight = promptMentionOptions.length > 0 ? Math.min(240, 116 + promptMentionOptions.length * 44) : 152; const topSpace = rect.top; const bottomSpace = window.innerHeight - rect.bottom; setMentionPanelPlacement(topSpace < estimatedHeight + 24 || bottomSpace > topSpace ? "below" : "above"); }; updatePlacement(); window.addEventListener("resize", updatePlacement); window.addEventListener("scroll", updatePlacement, true); return () => { window.removeEventListener("resize", updatePlacement); window.removeEventListener("scroll", updatePlacement, true); }; }, [promptMentionOpen, promptMentionOptions.length]); useEffect(() => { if (!isAuthenticated) { Object.values(keepaliveTasksRef.current).forEach((task) => { taskAbortControllersRef.current.get(task.taskId)?.abort(); taskAbortControllersRef.current.delete(task.taskId); }); generationAbortRef.current?.abort(); generationAbortRef.current = null; keepaliveTasksRef.current = {}; skipConversationAutoSelectRef.current = false; clearWorkbenchLocalState(); referenceItemsRef.current.forEach((item) => disposeReferencePreview(item)); referenceItemsRef.current = []; setConversations([]); setActiveConversationId(null); activeConversationIdRef.current = null; conversationMessagesCacheRef.current.clear(); messagesRef.current = []; setMessages([]); setInputValue(""); setReferenceItems([]); setReferencePreviewOpen(false); setMessagePreviewAttachment(null); setWorkspaceStarted(false); setComposerHidden(false); setIsGenerating(false); setGenerationStatus("准备就绪"); setGenerationProgress(0); setProjectError(null); return; } }, [isAuthenticated]); const loadConversations = useCallback(async () => { if (!isAuthenticated) { setConversations([]); return; } try { const list = await conversationClient.list(); setConversations(list); const restoredId = activeConversationIdRef.current || readStoredActiveConversationId(messagesRef.current); if (restoredId && list.some((conversation) => conversation.id === restoredId)) { setActiveConversationId(restoredId); activeConversationIdRef.current = restoredId; persistActiveConversationId(restoredId); setWorkspaceStarted(true); } else if (restoredId && !list.some((conversation) => conversation.id === restoredId)) { setActiveConversationId(null); activeConversationIdRef.current = null; persistActiveConversationId(null); } else if ( list.length > 0 && !activeConversationIdRef.current && messagesRef.current.length === 0 && !skipConversationAutoSelectRef.current ) { const nextConversationId = list[0].id; setActiveConversationId(nextConversationId); activeConversationIdRef.current = nextConversationId; persistActiveConversationId(nextConversationId); setWorkspaceStarted(true); } setProjectError(null); } catch (error) { if (isAuthFailure(error)) { setProjectError(null); return; } setProjectError(error instanceof Error ? error.message : String(error)); } }, [isAuthenticated]); const loadConversation = useCallback(async (conversationId: number) => { try { skipConversationAutoSelectRef.current = false; const conversation = await conversationClient.get(conversationId); const nextMessages = conversation.messages.filter((item): item is ChatMessage => { return ( item && typeof item.id === "string" && (item.role === "user" || item.role === "assistant") && typeof item.body === "string" ); }) as ChatMessage[]; setActiveConversationId(conversation.id); activeConversationIdRef.current = conversation.id; pendingScrollToLatestRef.current = true; setMessages(nextMessages); rememberConversationMessages(conversation.id, nextMessages); setWorkspaceStarted(nextMessages.length > 0); setComposerHidden(false); setProjectError(null); } catch (error) { if (isAuthFailure(error)) { setProjectError(null); return; } setProjectError(error instanceof Error ? error.message : String(error)); } }, []); // Migrate orphaned localStorage messages (no conversationId) to server const migrateOrphanedMessages = useCallback(async () => { const stored = readStoredMessages(); if (stored.length === 0) return; const hasConversationId = stored.some((m) => m.conversationId); if (hasConversationId) return; // messages already linked to a server conversation try { const conv = await conversationClient.create( stored[0]?.body?.slice(0, 20) || "历史对话", stored[0]?.mode || "chat", stored.map((m) => ({ ...m, conversationId: undefined })), ); const migrated = stored.map((m) => ({ ...m, conversationId: conv.id })); setMessages(migrated); messagesRef.current = migrated; rememberConversationMessages(conv.id, migrated); setActiveConversationId(conv.id); activeConversationIdRef.current = conv.id; persistActiveConversationId(conv.id); persistMessages(migrated); void loadConversations(); } catch { // Migration is best-effort; if it fails, keep using localStorage } }, [loadConversations]); useEffect(() => { if (!isAuthenticated) return; void migrateOrphanedMessages(); }, [isAuthenticated, migrateOrphanedMessages]); useEffect(() => { void loadConversations(); }, [loadConversations]); useEffect(() => { if (!isAuthenticated || !activeConversationId || messages.length > 0) return; void loadConversation(activeConversationId); }, [activeConversationId, isAuthenticated, loadConversation, messages.length]); const saveProjectMessages = useCallback( async (nextMessages: ChatMessage[], saveReason = "web-chat-autosave") => { const conversationId = activeConversationIdRef.current; if (!conversationId) return; const persistableMessages = getPersistableMessages(nextMessages); if (persistableMessages.length === 0) { return; } await conversationClient.update(conversationId, { messages: persistableMessages as never[] }); setProjectError(null); void loadConversations(); return { saveReason }; }, [loadConversations], ); const handleProjectSaveError = useCallback((error: unknown) => { if (isAuthFailure(error)) { setProjectError(null); return; } setProjectError(getErrorText(error)); }, []); const rememberConversationMessages = (conversationId: number, nextMessages: ChatMessage[]) => { conversationMessagesCacheRef.current.set(conversationId, nextMessages); }; const getActiveKeepaliveTask = (conversationId: number | null) => { if (!conversationId) return undefined; return Object.values(keepaliveTasksRef.current).find((task) => task.conversationId === conversationId); }; const syncActiveGenerationUi = useCallback((conversationId: number | null = activeConversationIdRef.current) => { const activeTask = getActiveKeepaliveTask(conversationId); setIsGenerating(Boolean(activeTask)); if (activeTask) { setGenerationStatus(activeTask.statusLabel); setGenerationProgress(activeTask.progress); } else { setGenerationProgress(0); } }, []); const patchConversationMessage = useCallback( async (conversationId: number, messageId: string, patch: Partial) => { let sourceMessages = activeConversationIdRef.current === conversationId ? messagesRef.current : conversationMessagesCacheRef.current.get(conversationId); if (!sourceMessages) return; let changed = false; const nextMessages = sourceMessages.map((message) => { if (message.id !== messageId) return message; changed = true; const nextPatch = { ...patch }; if ( typeof nextPatch.taskProgress === "number" && typeof message.taskProgress === "number" && nextPatch.taskProgress < message.taskProgress && nextPatch.status !== "failed" ) { nextPatch.taskProgress = message.taskProgress; } return { ...message, ...nextPatch }; }); if (!changed) return; conversationMessagesCacheRef.current.set(conversationId, nextMessages); if (activeConversationIdRef.current === conversationId) { setMessages(nextMessages); } if (shouldPersistPatch(patch)) { await saveProjectMessages(nextMessages).catch(handleProjectSaveError); } }, [handleProjectSaveError, saveProjectMessages], ); const upsertKeepaliveTask = (task: WorkbenchKeepaliveTask) => { keepaliveTasksRef.current = { ...keepaliveTasksRef.current, [task.taskId]: task }; persistKeepaliveTasks(keepaliveTasksRef.current); }; const removeKeepaliveTask = (taskId: string) => { const removed = keepaliveTasksRef.current[taskId]; releaseGenerationSlot(removed?.concurrencySlotId); const { [taskId]: _removed, ...rest } = keepaliveTasksRef.current; keepaliveTasksRef.current = rest; persistKeepaliveTasks(rest); }; const runKeepalivePoll = useCallback( (task: WorkbenchKeepaliveTask) => { if (taskAbortControllersRef.current.has(task.taskId)) return; let lastKnownProgress = Math.max(0, Number(task.progress || 0)); let taskPollFailures = 0; const abortController = new AbortController(); taskAbortControllersRef.current.set(task.taskId, abortController); if (activeConversationIdRef.current === task.conversationId) { generationAbortRef.current = abortController; setIsGenerating(true); setGenerationStatus(task.statusLabel); setGenerationProgress(lastKnownProgress); } const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)); void (async () => { try { for (let attempt = 0; attempt < 220; attempt += 1) { if (abortController.signal.aborted) return; if (attempt > 0) await sleep(3000); if (abortController.signal.aborted) return; let status; try { status = await aiGenerationClient.getTaskStatus(task.taskId); } catch { taskPollFailures += 1; if (taskPollFailures >= WORKBENCH_TASK_MAX_POLL_FAILURES) { await patchConversationMessage(task.conversationId, task.assistantMessageId, { body: "任务状态连续查询失败,已停止等待。请重新生成或稍后在资产页查看。", status: "failed", taskProgress: 100, taskStatusLabel: "任务异常", }); removeKeepaliveTask(task.taskId); return; } continue; } taskPollFailures = 0; const currentMessageProgress = activeConversationIdRef.current === task.conversationId ? messagesRef.current.find((message) => message.id === task.assistantMessageId)?.taskProgress || 0 : 0; const serverProgress = Number(status.progress || 0); const baseProgress = Number.isFinite(serverProgress) ? serverProgress : 0; const progress = status.status === "completed" ? 100 : Math.min(99, Math.max(10, lastKnownProgress, currentMessageProgress, Math.round(baseProgress))); lastKnownProgress = Math.max(lastKnownProgress, progress); const isSuperResolveTask = task.operation === "video-super-resolution"; const statusLabel = status.status === "pending" ? (isSuperResolveTask ? "超分排队中" : "任务排队中") : status.status === "running" ? (isSuperResolveTask ? "超分处理中..." : "正在生成...") : status.status === "completed" ? (isSuperResolveTask ? "超分完成" : "生成完成") : (isSuperResolveTask ? "超分失败" : "生成失败"); const latestTask = { ...task, progress, statusLabel, }; upsertKeepaliveTask(latestTask); if (activeConversationIdRef.current === task.conversationId) { setIsGenerating(status.status !== "completed" && status.status !== "failed"); setGenerationStatus(statusLabel); setGenerationProgress(progress); } if (status.status === "completed" && status.resultUrl) { const completedPatch: Partial = { body: isSuperResolveTask ? "视频已完成超分,并已替换为高清版本" : task.mode === "image" ? "图像已经生成完成。" : "视频已经生成完成。", status: "completed", taskProgress: 100, taskStatusLabel: statusLabel, resultUrl: status.resultUrl, resultType: task.mode, resultOriginalUrl: status.resultUrl, }; if (!isSuperResolveTask) { completedPatch.result = buildAssistantResult(task.mode, task.modelLabel, task.specs, task.referenceCount); } await patchConversationMessage(task.conversationId, task.assistantMessageId, completedPatch); removeKeepaliveTask(task.taskId); onRefreshUsage?.(); try { if (status.resultUrl) { const persistedResult = await persistWorkbenchResultAsset({ title: task.mode === "image" ? "生成图片" : isSuperResolveTask ? "超分视频" : "生成视频", sourceUrl: status.resultUrl, resultType: task.mode, taskId: task.taskId, }); await patchConversationMessage(task.conversationId, task.assistantMessageId, { resultUrl: persistedResult.url, resultOriginalUrl: persistedResult.originalUrl, resultOssKey: persistedResult.ossKey, resultMimeType: persistedResult.mimeType, }); } } catch (error) { console.warn("[workbench] result persistence skipped after visible completion:", error); } return; } if (status.status === "completed" && !status.resultUrl) { await patchConversationMessage(task.conversationId, task.assistantMessageId, { body: "任务已结束,但没有返回可用资源。请重新生成。", status: "failed", taskProgress: 100, taskStatusLabel: "结果缺失", }); removeKeepaliveTask(task.taskId); return; } if (status.status === "failed" || status.status === "cancelled") { if (getCachedRole() === "admin") console.error("[轮询] 任务失败", { taskId: task.taskId, status, error: status.error }); if (isInsufficientBalanceMessage(status.error)) setShowRechargeModal(true); await patchConversationMessage(task.conversationId, task.assistantMessageId, { body: translateTaskError(status.error), status: "failed", taskProgress: progress, taskStatusLabel: statusLabel, }); removeKeepaliveTask(task.taskId); onRefreshUsage?.(); return; } await patchConversationMessage(task.conversationId, task.assistantMessageId, { body: isSuperResolveTask ? "视频超分任务已提交,正在增强画质..." : "任务已提交,正在生成中...", status: "thinking", taskProgress: progress, taskStatusLabel: statusLabel, }); } await patchConversationMessage(task.conversationId, task.assistantMessageId, { body: task.operation === "video-super-resolution" ? "视频超分超时,请稍后重试" : "生成超时,请稍后在资产页查看结果。", status: "failed", taskStatusLabel: task.operation === "video-super-resolution" ? "超分超时" : "生成超时", }); removeKeepaliveTask(task.taskId); } finally { taskAbortControllersRef.current.delete(task.taskId); if (generationAbortRef.current === abortController) { generationAbortRef.current = null; } syncActiveGenerationUi(); } })(); }, [patchConversationMessage, syncActiveGenerationUi], ); useEffect(() => { persistMessages(messages); if (activeConversationIdRef.current) { rememberConversationMessages(activeConversationIdRef.current, messages); } const previousMessageIds = renderedMessageIdsRef.current; const currentMessageIds = messages.map((message) => message.id); const messageListChanged = currentMessageIds.length !== previousMessageIds.length || currentMessageIds.some((messageId, index) => messageId !== previousMessageIds[index]); renderedMessageIdsRef.current = currentMessageIds; messagesRef.current = messages; const shouldForceScrollToLatest = pendingScrollToLatestRef.current && messages.length > 0; if (shouldForceScrollToLatest) { pendingScrollToLatestRef.current = false; } if ( shouldForceScrollToLatest || (hasHandledInitialMessagesRef.current && messageListChanged && shouldFollowNewMessagesRef.current) ) { scrollMessagesToLatest(shouldForceScrollToLatest ? "auto" : "smooth"); } hasHandledInitialMessagesRef.current = true; }, [messages, scrollMessagesToLatest]); useEffect(() => { activeConversationIdRef.current = activeConversationId; persistActiveConversationId(activeConversationId); syncActiveGenerationUi(activeConversationId); }, [activeConversationId, syncActiveGenerationUi]); useEffect(() => { if (!imageQualityOptions.some((option) => option.value === imageQuality)) { setImageQuality(getDefaultImageQuality(imageModel)); } }, [imageModel, imageQuality, imageQualityOptions]); useEffect(() => { if (activeMode !== "video" || videoFrameMode !== "start-end" || referenceItems.length <= 2) return; setReferenceItems((current) => { if (current.length <= 2) return current; const removed = current.slice(2); removed.forEach((item) => disposeReferencePreview(item)); return current.slice(0, 2); }); }, [activeMode, referenceItems.length, videoFrameMode]); useEffect(() => { Object.values(keepaliveTasksRef.current).forEach((task) => runKeepalivePoll(task)); return () => { taskAbortControllersRef.current.forEach((controller) => controller.abort()); taskAbortControllersRef.current.clear(); }; }, [runKeepalivePoll]); useEffect(() => { persistPromptHistory(promptHistory); }, [promptHistory]); const handleRefreshProject = useCallback(() => { void loadConversations(); }, [loadConversations]); const handleRenameProject = useCallback( async (projectId: string, title: string) => { const conversationId = Number(projectId); if (!Number.isFinite(conversationId)) return; try { await conversationClient.update(conversationId, { title }); setConversations((prev) => prev.map((c) => c.id === conversationId ? { ...c, title } : c)); setProjectError(null); } catch (error) { if (isAuthFailure(error)) { setProjectError(null); return; } setProjectError(error instanceof Error ? error.message : String(error)); } }, [], ); const requestDeleteProject = useCallback( (projectId: string) => { const record = conversationRecords.find((item) => item.id === projectId); setDeleteDialog({ projectId, title: record?.name || projectId }); }, [conversationRecords], ); const confirmDeleteProject = useCallback( async () => { if (!deleteDialog || deleteSubmitting) return; const conversationId = Number(deleteDialog.projectId); if (!Number.isFinite(conversationId)) { setDeleteDialog(null); return; } setDeleteSubmitting(true); try { await conversationClient.delete(conversationId, { cleanupUserData: true }); const remainingConversations = conversations.filter((conversation) => conversation.id !== conversationId); setConversations(remainingConversations); if (conversationId === activeConversationId) { Object.values(keepaliveTasksRef.current) .filter((task) => task.conversationId === activeConversationIdRef.current) .forEach((task) => { taskAbortControllersRef.current.get(task.taskId)?.abort(); taskAbortControllersRef.current.delete(task.taskId); removeKeepaliveTask(task.taskId); aiGenerationClient.cancelTask(task.taskId).catch(() => {}); }); const currentIndex = conversations.findIndex((conversation) => conversation.id === conversationId); const nextConversation = remainingConversations[Math.max(0, currentIndex)] || remainingConversations[Math.max(0, currentIndex - 1)] || remainingConversations[0] || null; if (nextConversation) { skipConversationAutoSelectRef.current = false; setActiveConversationId(nextConversation.id); activeConversationIdRef.current = nextConversation.id; persistActiveConversationId(nextConversation.id); setWorkspaceStarted(true); setComposerHidden(false); void loadConversation(nextConversation.id); } else { skipConversationAutoSelectRef.current = true; setActiveConversationId(null); activeConversationIdRef.current = null; persistActiveConversationId(null); messagesRef.current = []; setMessages([]); setInputValue(""); setWorkspaceStarted(false); setComposerHidden(false); syncActiveGenerationUi(null); } } setProjectError(null); setDeleteDialog(null); } catch (error) { if (isAuthFailure(error)) { setProjectError(null); setDeleteDialog(null); return; } setProjectError(error instanceof Error ? error.message : String(error)); } finally { setDeleteSubmitting(false); } }, [activeConversationId, conversations, deleteDialog, deleteSubmitting, loadConversation, syncActiveGenerationUi], ); const handleNewConversation = useCallback(() => { skipConversationAutoSelectRef.current = true; setActiveConversationId(null); persistActiveConversationId(null); messagesRef.current = []; pendingScrollToLatestRef.current = true; setMessages([]); setInputValue(""); setWorkspaceStarted(false); setComposerHidden(false); syncActiveGenerationUi(null); activeConversationIdRef.current = null; }, [syncActiveGenerationUi]); const handleSelectProject = useCallback((id: string) => { if (!id) { handleNewConversation(); return; } const conversationId = Number(id); if (Number.isFinite(conversationId)) { skipConversationAutoSelectRef.current = false; void loadConversation(conversationId); } }, [handleNewConversation, loadConversation]); const handleDeleteConversation = useCallback(async (id: number) => { try { await conversationClient.delete(id, { cleanupUserData: true }); setConversations((prev) => prev.filter((c) => c.id !== id)); if (activeConversationId === id) { setActiveConversationId(null); activeConversationIdRef.current = null; persistActiveConversationId(null); setMessages([]); setWorkspaceStarted(false); setComposerHidden(false); syncActiveGenerationUi(null); } } catch {} }, [activeConversationId, syncActiveGenerationUi]); const handleRenameConversation = useCallback(async (id: number, title: string) => { try { await conversationClient.update(id, { title }); setConversations((prev) => prev.map((c) => c.id === id ? { ...c, title } : c)); } catch {} }, []); useEffect(() => { referenceItemsRef.current = referenceItems; }, [referenceItems]); const focusPromptAt = useCallback((index: number) => { const nextIndex = Math.max(0, Math.min(inputValue.length, index)); setCursorIndex(nextIndex); setPromptSelectionRange({ start: nextIndex, end: nextIndex }); window.requestAnimationFrame(() => { textareaRef.current?.focus(); textareaRef.current?.setSelectionRange(nextIndex, nextIndex); }); }, [inputValue.length]); const normalizePromptSelection = useCallback( (selectionStart: number, selectionEnd: number) => { if (promptMentionTokenRanges.length === 0) { return { start: selectionStart, end: selectionEnd }; } if (selectionStart === selectionEnd) { const range = findPromptMentionRangeInside(selectionStart, promptMentionTokenRanges); if (!range) return { start: selectionStart, end: selectionEnd }; return { start: range.end, end: range.end }; } const selectionMin = Math.min(selectionStart, selectionEnd); const selectionMax = Math.max(selectionStart, selectionEnd); const overlappedRange = findPromptMentionRangeOverlap(selectionMin, selectionMax, promptMentionTokenRanges); if (overlappedRange) { return { start: overlappedRange.end, end: overlappedRange.end }; } const startRange = findPromptMentionRangeInside(selectionStart, promptMentionTokenRanges); const endRange = findPromptMentionRangeInside(selectionEnd, promptMentionTokenRanges); const nextStart = startRange ? startRange.start : selectionStart; const nextEnd = endRange ? endRange.end : selectionEnd; return { start: Math.min(nextStart, nextEnd), end: Math.max(nextStart, nextEnd) }; }, [promptMentionTokenRanges], ); useEffect( () => () => { referenceItemsRef.current.forEach((item) => { disposeReferencePreview(item); }); }, [], ); useEffect(() => { if (!toolbarMenuId) return undefined; const handlePointerDown = (event: PointerEvent) => { if (!toolbarRef.current?.contains(event.target as Node)) { setToolbarMenuId(null); } }; window.addEventListener("pointerdown", handlePointerDown); return () => window.removeEventListener("pointerdown", handlePointerDown); }, [toolbarMenuId]); useEffect(() => { if (!referencePreviewOpen) return undefined; const handlePointerDown = (event: PointerEvent) => { if (!referenceRefsRef.current?.contains(event.target as Node)) { setReferencePreviewOpen(false); } }; window.addEventListener("pointerdown", handlePointerDown); return () => window.removeEventListener("pointerdown", handlePointerDown); }, [referencePreviewOpen]); useEffect(() => { if (referenceItems.length === 0 && referencePreviewOpen) { setReferencePreviewOpen(false); } }, [referenceItems.length, referencePreviewOpen]); useEffect(() => { if (referenceItems.length === 0) { referenceTokenSequenceRef.current = { image: 0, video: 0, audio: 0, file: 0, }; } }, [referenceItems.length]); useEffect(() => { if (referenceItems.length === 0) return; if (referenceItems.length < referenceLimit) return; setReferencePreviewOpen(true); }, [referenceItems.length, referenceLimit]); useEffect(() => { const el = textareaRef.current; if (!el) return; el.style.height = "auto"; el.style.height = `${Math.min(Math.max(el.scrollHeight, 42), 120)}px`; }, [inputValue]); useEffect(() => { const surface = messagesSurfaceRef.current; if (!surface) return; const scrollDeltaThreshold = 8; const edgeThreshold = 3; const syncComposerVisibility = () => { const top = surface.scrollTop; const atTop = top <= edgeThreshold; const atBottom = top + surface.clientHeight >= surface.scrollHeight - edgeThreshold; shouldFollowNewMessagesRef.current = atBottom; setComposerHidden(!(atTop || atBottom)); lastScrollTopRef.current = top; }; lastScrollTopRef.current = surface.scrollTop; syncComposerVisibility(); const handleScroll = () => { const top = surface.scrollTop; const delta = top - lastScrollTopRef.current; const atTop = top <= edgeThreshold; const atBottom = top + surface.clientHeight >= surface.scrollHeight - edgeThreshold; shouldFollowNewMessagesRef.current = atBottom; if (atTop || atBottom) { setComposerHidden(false); } else if (Math.abs(delta) > scrollDeltaThreshold) { setComposerHidden(true); } lastScrollTopRef.current = top; }; surface.addEventListener("scroll", handleScroll, { passive: true }); return () => surface.removeEventListener("scroll", handleScroll); }, [hasActivatedWorkspace]); const scrollMessagesSurface = useCallback((direction: "top" | "bottom") => { const surface = messagesSurfaceRef.current; if (!surface) return; const top = direction === "top" ? 0 : surface.scrollHeight; setComposerHidden(false); surface.scrollTo({ top, behavior: "smooth" }); }, []); const closeToolbarMenus = () => setToolbarMenuId(null); const toggleToolbarMenu = (menuId: Exclude) => { setToolbarMenuId((current) => (current === menuId ? null : menuId)); }; const handleModeChange = (mode: WorkbenchMode) => { setActiveMode(mode); setToolbarMenuId(null); setReferencePreviewOpen(false); window.requestAnimationFrame(() => textareaRef.current?.focus()); }; const handlePromptChange = (event: ChangeEvent) => { const selection = normalizePromptSelection(event.target.selectionStart, event.target.selectionEnd); setInputValue(event.target.value); setCursorIndex(selection.start); setPromptSelectionRange(selection); if (selection.start !== event.target.selectionStart || selection.end !== event.target.selectionEnd) { window.requestAnimationFrame(() => event.target.setSelectionRange(selection.start, selection.end)); } }; const handlePromptSelectionChange = (event: SyntheticEvent) => { const { selectionStart, selectionEnd } = event.currentTarget; const selection = normalizePromptSelection(selectionStart, selectionEnd); setCursorIndex(selection.start); setPromptSelectionRange(selection); if (selection.start !== selectionStart || selection.end !== selectionEnd) { const target = event.currentTarget; window.requestAnimationFrame(() => target.setSelectionRange(selection.start, selection.end)); } }; const handlePromptScroll = (event: SyntheticEvent) => { const ta = event.currentTarget; const highlight = ta.parentElement?.querySelector(".wb-composer__highlight"); if (highlight) highlight.scrollTop = ta.scrollTop; }; const removeReferenceItem = (id: string) => { const target = referenceItems.find((item) => item.id === id); setReferenceItems((current) => { if (target) disposeReferencePreview(target); return current.filter((item) => item.id !== id); }); if (target?.token) { const nextValue = removePromptMentionTokenFromText(inputValue, target.token); if (nextValue !== inputValue) { const nextCursor = Math.min(cursorIndex, nextValue.length); setInputValue(nextValue); setCursorIndex(nextCursor); setPromptSelectionRange({ start: nextCursor, end: nextCursor }); window.requestAnimationFrame(() => { textareaRef.current?.setSelectionRange(nextCursor, nextCursor); }); } } }; const applyReferenceRemoteUrl = (id: string, remoteUrl: string) => { const nextItems = referenceItemsRef.current.map((item) => item.id === id ? { ...item, remoteUrl } : item, ); referenceItemsRef.current = nextItems; setReferenceItems(nextItems); }; const handleReferenceUpload = async (event: ChangeEvent) => { const files = Array.from(event.target.files || []); event.target.value = ""; if (files.length === 0) return; const existingFingerprints = new Set( referenceItemsRef.current .map((item) => item.fingerprint) .filter((fingerprint): fingerprint is string => Boolean(fingerprint)), ); const selectedFingerprints = new Set(); const preparedItems: Array> = []; for (const file of files) { try { const kind = inferReferenceKind(file, activeMode); const fingerprint = await buildReferenceFingerprint(file, kind); if (kind === "image") { if (existingFingerprints.has(fingerprint) || selectedFingerprints.has(fingerprint)) { continue; } selectedFingerprints.add(fingerprint); } const result = kind === "image" ? await compressReferenceImageIfNeeded(file) : { file, compressed: false }; const canPreview = kind === "image" || kind === "video"; preparedItems.push({ kind, name: file.name, file: result.file, previewUrl: canPreview ? URL.createObjectURL(result.file) : undefined, fingerprint, originalSize: file.size, compressed: result.compressed, }); } catch { continue; } } if (preparedItems.length > 0) { const currentItems = referenceItemsRef.current; const currentFingerprints = new Set( currentItems .map((item) => item.fingerprint) .filter((fingerprint): fingerprint is string => Boolean(fingerprint)), ); const acceptedItems: ReferenceItem[] = []; const nextTokenSequence = { ...referenceTokenSequenceRef.current }; preparedItems.forEach((item) => { if (currentItems.length + acceptedItems.length >= referenceLimit) { disposeReferencePreview(item); return; } if (item.kind === "image" && item.fingerprint && currentFingerprints.has(item.fingerprint)) { disposeReferencePreview(item); return; } if (item.fingerprint) currentFingerprints.add(item.fingerprint); const nextTokenIndex = nextTokenSequence[item.kind] + 1; nextTokenSequence[item.kind] = nextTokenIndex; acceptedItems.push({ ...item, id: createId("ref"), token: buildReferenceToken(item.kind, nextTokenIndex), }); }); if (acceptedItems.length > 0) { referenceTokenSequenceRef.current = nextTokenSequence; const nextReferenceItems = [...currentItems, ...acceptedItems]; referenceItemsRef.current = nextReferenceItems; setReferenceItems(nextReferenceItems); acceptedItems.forEach((item) => { if (item.file && (item.kind === "image" || item.kind === "video")) { void preUploadReference(item.file, item.name, item.fingerprint).then((url) => { if (url) applyReferenceRemoteUrl(item.id, url); }); } }); } } setToolbarMenuId(null); setReferencePreviewOpen(preparedItems.length > 0 || referenceItemsRef.current.length > 0); window.requestAnimationFrame(() => textareaRef.current?.focus()); }; const handleReferenceUploadClick = () => { if (referenceItems.length > 0) { setToolbarMenuId(null); setReferencePreviewOpen((current) => !current); return; } referenceInputRef.current?.click(); }; const handleReferenceAddMore = () => { setToolbarMenuId(null); setReferencePreviewOpen(true); referenceInputRef.current?.click(); }; const insertPromptMention = (token: string) => { const rawBefore = inputValue.slice(0, cursorIndex); const after = inputValue.slice(cursorIndex); const match = detectMentionTrigger(rawBefore); const before = match ? rawBefore.slice(0, match.atIndex) + token : `${rawBefore}${rawBefore && !/\s$/.test(rawBefore) ? " " : ""}${token}`; const spacer = after.length === 0 || !/^\s/.test(after) ? " " : ""; const nextValue = `${before}${spacer}${after}`; const nextCursor = before.length + spacer.length; setInputValue(nextValue); setCursorIndex(nextCursor); setPromptSelectionRange({ start: nextCursor, end: nextCursor }); setMentionActiveIndex(0); setToolbarMenuId(null); setReferencePreviewOpen(false); const selectedOption = promptMentionOptions.find((o) => o.token === token); if (selectedOption && selectedOption.id.startsWith("asset-")) { const alreadyAdded = referenceItemsRef.current.some((r) => r.id === selectedOption.id); if (!alreadyAdded) { const newItem: ReferenceItem = { id: selectedOption.id, kind: selectedOption.kind as ReferenceKind, name: selectedOption.name, previewUrl: selectedOption.previewUrl, remoteUrl: selectedOption.remoteUrl, token: selectedOption.token, }; const nextItems = [...referenceItemsRef.current, newItem]; referenceItemsRef.current = nextItems; setReferenceItems(nextItems); } } window.requestAnimationFrame(() => { textareaRef.current?.focus(); textareaRef.current?.setSelectionRange(nextCursor, nextCursor); }); }; const buildTaskPrompt = (trimmedPrompt: string) => { const specLines = activeMode === "image" ? [`模式:${MODE_META.image.label}`, `模型:${activeModel}`, `比例:${imageRatio}`, `清晰度:${imageQuality}`, `多宫格:${GRID_MODE_OPTIONS.find((item) => item.value === imageGridMode)?.label || imageGridMode}`] : activeMode === "video" ? [`模式:${MODE_META.video.label}`, `模型:${activeModel}`, `生成方式:${VIDEO_FRAME_OPTIONS.find((item) => item.value === videoFrameMode)?.label || videoFrameMode}`, `比例:${videoRatio}`, `时长:${videoDuration} 秒`, `清晰度:${videoQualityLabel}`] : [`模式:${MODE_META.chat.label}`]; const referenceLines = referenceItems.map((item) => { const token = item.token; return `${token}:${item.name}`; }); return [trimmedPrompt, "", ...specLines, ...(referenceLines.length ? ["", "参考素材:", ...referenceLines] : [])].join("\n"); }; const getCurrentSpecs = () => { if (activeMode === "image") { return [ activeModel, imageSettingsSummary, GRID_MODE_OPTIONS.find((item) => item.value === imageGridMode)?.label || imageGridMode, ]; } if (activeMode === "video") { return [ activeModel, VIDEO_FRAME_OPTIONS.find((item) => item.value === videoFrameMode)?.label || videoFrameMode, `${videoRatio} / ${videoDuration}s / ${videoQualityLabel}`, ]; } return ["工作流拆解", "脚本与分镜", "可复制节点"]; }; const updateAssistantMessage = (id: string, patch: Partial) => { const nextMessages = messagesRef.current.map((message) => { if (message.id !== id) return message; const nextPatch = { ...patch }; if ( typeof nextPatch.taskProgress === "number" && typeof message.taskProgress === "number" && nextPatch.taskProgress < message.taskProgress && nextPatch.status !== "failed" ) { nextPatch.taskProgress = message.taskProgress; } return { ...message, ...nextPatch }; }); messagesRef.current = nextMessages; if (activeConversationIdRef.current) { rememberConversationMessages(activeConversationIdRef.current, nextMessages); } setMessages(nextMessages); return nextMessages; }; const persistConversationAfterResult = (conversationId: number, nextMessages: ChatMessage[]) => { rememberConversationMessages(conversationId, nextMessages); void saveProjectMessages(nextMessages, "web-chat-result").catch(handleProjectSaveError); }; const handleSendAction = async (promptOverride?: string) => { const trimmedPrompt = (promptOverride ?? inputValue).trim(); if (!trimmedPrompt) return; const userKey = getGenerationUserKey(session?.user.id); if (activeMode !== "chat" && getActiveGenerationTaskCount(userKey) >= 3) return; setReferencePreviewOpen(false); let conversationId = activeConversationIdRef.current ?? activeConversationId; let baseMessages = conversationId && workspaceStarted ? messages : conversationId ? conversationMessagesCacheRef.current.get(conversationId) || [] : []; const taskInput: CreatePreviewTaskInput = { title: `${MODE_META[activeMode].label} 创作任务`, type: MODE_META[activeMode].taskType, prompt: buildTaskPrompt(trimmedPrompt), params: activeMode === "image" ? { model: activeModelValue, ratio: imageRatio, quality: imageQuality, gridMode: imageGridMode } : activeMode === "video" ? { model: toHappyHorseDisplayModel(activeModelValue), ratio: videoRatio, quality: videoQuality, resolution: videoQuality, duration: Number(videoDuration), frameMode: videoFrameMode, muted: false, hasReferenceVideo: referenceItems.some((item) => item.kind === "video"), } : { model: activeModelValue }, }; if (activeMode === "image" && getCachedRole() === "admin") { console.log("[ai/workbench-image-submit]", { model: taskInput.params?.model, ratio: taskInput.params?.ratio, quality: taskInput.params?.quality, gridMode: taskInput.params?.gridMode, referenceCount: referenceItems.length, promptLength: taskInput.prompt.length, requestWillStart: isAuthenticated, authRequired: !isAuthenticated, }); } if (!isAuthenticated) { onRequireLogin(taskInput); return; } if (conversationId && !conversationMessagesCacheRef.current.has(conversationId) && messages.length === 0) { try { const conversation = await conversationClient.get(conversationId); const restoredMessages = conversation.messages.filter((item): item is ChatMessage => { return ( item && typeof item.id === "string" && (item.role === "user" || item.role === "assistant") && typeof item.body === "string" ); }) as ChatMessage[]; baseMessages = restoredMessages; rememberConversationMessages(conversation.id, restoredMessages); setActiveConversationId(conversation.id); activeConversationIdRef.current = conversation.id; } catch { conversationId = null; activeConversationIdRef.current = null; setActiveConversationId(null); persistActiveConversationId(null); baseMessages = []; } } if (conversationId) { const conv = conversations.find((c) => c.id === conversationId); if (conv?.title === "New conversation" || conv?.title.startsWith("新对话")) { const newTitle = trimmedPrompt.slice(0, 20); conversationClient.update(conversationId, { title: newTitle }).catch((err) => { console.warn("[chat] conversation rename failed:", err?.message || err); }); setConversations((prev) => prev.map((c) => c.id === conversationId ? { ...c, title: newTitle } : c)); } } const attachments = buildChatAttachments(referenceItems); const messageCreatedAt = formatWorkbenchTimestamp(); const userMessage: ChatMessage = { id: createId("user"), role: "user", author: "你", mode: activeMode, body: trimmedPrompt, prompt: trimmedPrompt, createdAt: messageCreatedAt, conversationId: conversationId || undefined, attachments, }; const assistantMessageId = createId("assistant"); const assistantMessage: ChatMessage = { id: assistantMessageId, role: "assistant", author: MODE_META[activeMode].label, mode: activeMode, prompt: trimmedPrompt, body: activeMode === "chat" ? "我先看一下上下文,马上接上。" : "正在读取当前模式、模型、规格和参考素材,准备创建生成任务。", createdAt: messageCreatedAt, status: "thinking", conversationId: conversationId || undefined, taskProgress: 28, taskStatusLabel: activeMode === "chat" ? "正在整理思路" : "Creating generation task", }; setInputValue(""); setPromptHistory((current) => [trimmedPrompt, ...current.filter((item) => item !== trimmedPrompt)].slice(0, 20)); setWorkspaceStarted(true); setComposerHidden(false); const nextMessages = [...baseMessages, userMessage, assistantMessage]; messagesRef.current = nextMessages; setMessages(nextMessages); if (conversationId) { rememberConversationMessages(conversationId, nextMessages); } setIsGenerating(true); setGenerationStatus("正在创建生成任务"); setGenerationProgress(28); const abortController = new AbortController(); generationAbortRef.current = abortController; let keepaliveStarted = false; let claimedGenerationSlotId: string | undefined; let releaseClaimedGenerationSlot: (() => void) | null = null; try { if (activeMode === "image" || activeMode === "video") { let taskId: string; const isVideoFilteredModel = activeMode === "video" && (isHappyHorseModel(taskInput.params?.model) || isViduModel(taskInput.params?.model) || isPixverseModel(taskInput.params?.model)); const requestReferenceItems = isVideoFilteredModel ? referenceItems.filter((item) => item.kind === "image") : referenceItems; if (isVideoFilteredModel) { const droppedVideoCount = referenceItems.length - requestReferenceItems.length; if (droppedVideoCount > 0 && conversationId) { await patchConversationMessage(conversationId, userMessage.id, { body: `${userMessage.body}\n\n⚠ ${droppedVideoCount} 个视频参考素材已被忽略(当前模型仅支持图片参考)。`, }); } } const refUrls = await resolveReferenceUrls(requestReferenceItems); const latestReferenceItems = [...referenceItemsRef.current]; setReferenceItems(latestReferenceItems); const uploadedAttachments = buildChatAttachments(latestReferenceItems); if (uploadedAttachments.length) { const refreshedMessages = messagesRef.current.map((message) => message.id === userMessage.id ? { ...message, attachments: uploadedAttachments } : message, ); messagesRef.current = refreshedMessages; setMessages(refreshedMessages); if (conversationId) rememberConversationMessages(conversationId, refreshedMessages); } claimedGenerationSlotId = createId("workbench-generation"); releaseClaimedGenerationSlot = claimGenerationSlot({ userKey: getGenerationUserKey(session?.user.id), kind: activeMode, id: claimedGenerationSlotId, }); if (activeMode === "image") { const result = await aiGenerationClient.createImageTask({ conversationId: conversationId || undefined, model: taskInput.params?.model || fallbackImageModelValue, prompt: trimmedPrompt, ratio: taskInput.params?.ratio || imageRatio || "16:9", quality: taskInput.params?.quality || "1K", gridMode: taskInput.params?.gridMode || "single", referenceUrls: refUrls.length ? refUrls : undefined, }); taskId = result.taskId; } else { let requestModel = resolveVideoRequestModel({ model: taskInput.params?.model || ENTERPRISE_DEFAULT_VIDEO_MODEL, referenceUrls: refUrls, }); const result = await aiGenerationClient.createVideoTask({ conversationId: conversationId || undefined, model: requestModel, prompt: trimmedPrompt, ratio: taskInput.params?.ratio || "16:9", duration: taskInput.params?.duration || 5, quality: taskInput.params?.quality || taskInput.params?.resolution || "1080P", resolution: taskInput.params?.resolution || taskInput.params?.quality || "1080P", frameMode: taskInput.params?.frameMode || "start-end", referenceUrls: refUrls.length ? refUrls : undefined, muted: taskInput.params?.muted ?? false, hasReferenceVideo: requestReferenceItems.some((item) => item.kind === "video"), }); taskId = result.taskId; } onRefreshUsage?.(); if (!conversationId) { const taskSpecs = buildAssistantResult(activeMode, activeModel, getCurrentSpecs(), referenceItems.length); const submissionSourceMessages = messagesRef.current.length ? messagesRef.current : nextMessages; const submittedMessages = submissionSourceMessages.map((message) => { const base = { ...message, conversationId: 0 }; if (message.id !== assistantMessageId) return base; return { ...base, body: "Task submitted, generating...", status: "thinking" as const, taskId, taskProgress: 30, taskStatusLabel: "Task submitted, generating...", resultType: activeMode, result: taskSpecs, }; }); const conv = await conversationClient.create( trimmedPrompt.slice(0, 20) || "新对话", activeMode, submittedMessages.map((message) => ({ ...message, conversationId: undefined })), ); conversationId = conv.id; skipConversationAutoSelectRef.current = false; setActiveConversationId(conv.id); activeConversationIdRef.current = conv.id; setConversations((prev) => [conv, ...prev.filter((item) => item.id !== conv.id)]); const boundMessages = submittedMessages.map((message) => ({ ...message, conversationId: conv.id })); messagesRef.current = boundMessages; setMessages(boundMessages); rememberConversationMessages(conv.id, boundMessages); await aiGenerationClient.bindTaskToConversation(taskId, conv.id).catch((error) => { setProjectError(getErrorText(error)); }); } if (!conversationId) return; const keepaliveTask: WorkbenchKeepaliveTask = { taskId, conversationId, assistantMessageId, concurrencySlotId: claimedGenerationSlotId, mode: activeMode, modelLabel: activeModel, specs: getCurrentSpecs(), referenceCount: referenceItems.length, progress: 30, statusLabel: "Task submitted, generating...", startedAt: Date.now(), }; const taskSpecs = buildAssistantResult(activeMode, activeModel, getCurrentSpecs(), referenceItems.length); keepaliveStarted = true; upsertKeepaliveTask(keepaliveTask); releaseClaimedGenerationSlot = null; await patchConversationMessage(conversationId, assistantMessageId, { body: "Task submitted, generating...", status: "thinking", taskId, conversationId, taskProgress: 30, taskStatusLabel: "Task submitted, generating...", resultType: activeMode, result: taskSpecs, }); runKeepalivePoll(keepaliveTask); } else { let streamedText = ""; setGenerationProgress(36); setGenerationStatus("正在回复"); updateAssistantMessage(assistantMessageId, { body: "我在整理,马上说清楚。", status: "thinking", taskStatusLabel: "正在整理思路", }); const historyMessages = buildNaturalChatHistoryMessages(baseMessages); await aiGenerationClient.streamChat( { model: activeModelValue, messages: [ { role: "system", content: CHAT_NATURAL_SYSTEM_PROMPT }, ...historyMessages, { role: "system", content: CHAT_TURN_STYLE_REMINDER }, { role: "user", content: trimmedPrompt }, ], temperature: 0.82, }, (chunk) => { if (abortController.signal.aborted) return; streamedText += chunk; setGenerationProgress((current) => Math.min(95, current + 1)); updateAssistantMessage(assistantMessageId, { body: streamedText || "我在整理,马上说清楚。", status: "thinking", taskStatusLabel: "正在回复", }); }, abortController.signal, ); if (abortController.signal.aborted) return; setGenerationProgress(100); setGenerationStatus("回复完成"); const completedMessages = updateAssistantMessage(assistantMessageId, { body: streamedText.trim() || "收到。你可以继续补充目标,我会顺着当前上下文往下拆。", status: "completed", }); if (!conversationId) { const conv = await conversationClient.create( trimmedPrompt.slice(0, 20) || "新对话", activeMode, completedMessages.map((message) => ({ ...message, conversationId: undefined })), ); conversationId = conv.id; skipConversationAutoSelectRef.current = false; setActiveConversationId(conv.id); activeConversationIdRef.current = conv.id; setConversations((prev) => [conv, ...prev.filter((item) => item.id !== conv.id)]); const boundMessages = completedMessages.map((message) => ({ ...message, conversationId: conv.id })); messagesRef.current = boundMessages; setMessages(boundMessages); rememberConversationMessages(conv.id, boundMessages); await conversationClient.update(conv.id, { messages: getPersistableMessages(boundMessages) as never[] }); void loadConversations(); } else { persistConversationAfterResult(conversationId, messagesRef.current); } } } catch (error) { if (abortController.signal.aborted) { setGenerationStatus("已停止"); updateAssistantMessage(assistantMessageId, { body: "已停止当前生成。", status: "failed", }); return; } setGenerationStatus("创建失败"); if (isInsufficientBalance(error)) setShowRechargeModal(true); updateAssistantMessage(assistantMessageId, { body: isAuthFailure(error) ? "登录状态已失效,请重新登录后再继续。" : error instanceof Error ? error.message : "任务创建失败,请稍后重试。", status: "failed", }); } finally { if (generationAbortRef.current === abortController) { generationAbortRef.current = null; } releaseClaimedGenerationSlot?.(); if (keepaliveStarted) syncActiveGenerationUi(conversationId); else setIsGenerating(false); } }; const handleStopAction = () => { const conversationId = activeConversationIdRef.current; const tasksToStop = Object.values(keepaliveTasksRef.current).filter( (task) => task.conversationId === conversationId, ); tasksToStop.forEach((task) => { taskAbortControllersRef.current.get(task.taskId)?.abort(); taskAbortControllersRef.current.delete(task.taskId); removeKeepaliveTask(task.taskId); aiGenerationClient.cancelTask(task.taskId).catch(() => {}); void patchConversationMessage(task.conversationId, task.assistantMessageId, { body: "Stopped on this page.", status: "failed", taskStatusLabel: "Stopped", }); }); if (tasksToStop.length === 0) { generationAbortRef.current?.abort(); } setIsGenerating(false); setGenerationStatus("Stopped"); setGenerationProgress(0); setMessages((current) => current.map((message) => message.status === "thinking" && (!conversationId || message.conversationId === conversationId) ? { ...message, body: "Stopped on this page.", status: "failed", taskStatusLabel: "Stopped", } : message, ), ); }; const handleStopSingleTask = (messageId: string) => { const task = Object.values(keepaliveTasksRef.current).find( (t) => t.assistantMessageId === messageId, ); if (task) { taskAbortControllersRef.current.get(task.taskId)?.abort(); taskAbortControllersRef.current.delete(task.taskId); removeKeepaliveTask(task.taskId); aiGenerationClient.cancelTask(task.taskId).catch(() => {}); void patchConversationMessage(task.conversationId, task.assistantMessageId, { body: "已终止", status: "failed", taskStatusLabel: "已终止", }); } else { generationAbortRef.current?.abort(); } setMessages((current) => current.map((msg) => msg.id === messageId ? { ...msg, body: "已终止", status: "failed" as const, taskStatusLabel: "已终止" } : msg, ), ); const remainingTasks = Object.values(keepaliveTasksRef.current).filter( (t) => t.assistantMessageId !== messageId, ); if (remainingTasks.length === 0) { setIsGenerating(false); setGenerationStatus("准备就绪"); setGenerationProgress(0); } }; const handleRegenerate = (message: ChatMessage) => { const userMsg = messages.find( (m, i) => m.role === "user" && messages[i + 1]?.id === message.id, ); if (userMsg) { setInputValue(userMsg.body); void handleSendAction(userMsg.body); } }; const handleSuperResolveVideo = async (message: ChatMessage) => { if (!message.resultUrl || message.resultType !== "video") { setProjectError("仅支持对视频结果进行超分"); return; } if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) { setProjectError("当前任务数已达上限(3个),请等待任务完成后再试"); return; } if (!isAuthenticated) { setProjectError("请先登录后再进行视频超分"); return; } const conversationId = activeConversationIdRef.current || message.conversationId || activeConversationId; if (!conversationId) { setProjectError("请先在当前对话中保存记录后再进行视频超分"); return; } const assistantMessageId = message.id; const previousResultUrl = message.resultUrl; const previousStatus = message.status || "completed"; const previousTaskProgress = message.taskProgress; const previousTaskId = message.taskId; const previousResultOriginalUrl = message.resultOriginalUrl; const previousResultOssKey = message.resultOssKey; const previousResultMimeType = message.resultMimeType; const isAdmin = getCachedRole() === "admin"; setIsGenerating(true); setGenerationStatus("正在提交超分任务"); setGenerationProgress(12); if (isAdmin) console.log("[超分] 开始处理", { resultUrl: previousResultUrl, resultType: message.resultType, ossKey: message.resultOssKey, taskId: previousTaskId }); let sourceResult: PersistedWorkbenchResultAsset; try { sourceResult = await persistWorkbenchResultAsset({ title: message.result?.title || "生成视频", sourceUrl: previousResultUrl, resultType: "video", taskId: previousResultOssKey ? undefined : previousTaskId, originalUrl: previousResultOriginalUrl, existingOssKey: message.resultOssKey, mimeType: previousResultMimeType, }); if (isAdmin) console.log("[超分] 资源转存成功", sourceResult); } catch (error) { if (isAdmin) console.error("[超分] 资源转存失败", error); setProjectError(error instanceof Error ? error.message : "视频资源转存失败,请重新生成后再试"); setIsGenerating(false); setGenerationProgress(0); setGenerationStatus("超分失败"); return; } await patchConversationMessage(conversationId, assistantMessageId, { body: "视频超分任务已提交,正在增强画质...", status: "thinking", conversationId, taskProgress: 12, taskStatusLabel: "正在提交超分任务", resultType: "video", resultUrl: sourceResult.url, resultOriginalUrl: sourceResult.originalUrl, resultOssKey: sourceResult.ossKey, resultMimeType: sourceResult.mimeType, }); try { const superResolveVideoUrl = sourceResult.url; const result = await aiGenerationClient.createVideoSuperResolveTask({ conversationId, videoUrl: superResolveVideoUrl, }); if (isAdmin) console.log("[超分] API 提交成功", { taskId: result.taskId, videoUrl: superResolveVideoUrl }); const keepaliveTask: WorkbenchKeepaliveTask = { taskId: result.taskId, conversationId, assistantMessageId, operation: "video-super-resolution", mode: "video", modelLabel: "视频超分", specs: ["2x", "MP4"], referenceCount: 0, progress: 18, statusLabel: "超分处理中...", startedAt: Date.now(), }; upsertKeepaliveTask(keepaliveTask); await patchConversationMessage(conversationId, assistantMessageId, { taskId: result.taskId, status: "thinking", taskProgress: 18, taskStatusLabel: "超分处理中...", resultUrl: sourceResult.url, resultOriginalUrl: sourceResult.originalUrl, resultOssKey: sourceResult.ossKey, resultMimeType: sourceResult.mimeType, }); runKeepalivePoll(keepaliveTask); } catch (error) { if (isAdmin) console.error("[超分] API 提交失败", error, { videoUrl: sourceResult.url, conversationId }); if (isInsufficientBalance(error)) setShowRechargeModal(true); await patchConversationMessage(conversationId, assistantMessageId, { body: error instanceof Error ? error.message : "视频超分任务提交失败", status: previousStatus, taskId: previousTaskId, taskProgress: previousTaskProgress, taskStatusLabel: "超分失败", resultUrl: sourceResult.url, resultType: "video", resultOriginalUrl: sourceResult.originalUrl, resultOssKey: sourceResult.ossKey, resultMimeType: sourceResult.mimeType, }); setIsGenerating(false); setGenerationProgress(0); setGenerationStatus("超分失败"); } }; const handleSuperResolveImage = async (message: ChatMessage) => { if (!message.resultUrl || message.resultType === "video") { setProjectError("仅支持对图片结果进行超分"); return; } if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) { setProjectError("当前任务数已达上限(3个),请等待任务完成后再试"); return; } if (!isAuthenticated) { setProjectError("请先登录后再进行图片超分"); return; } const conversationId = activeConversationIdRef.current || message.conversationId || activeConversationId; if (!conversationId) { setProjectError("请先在当前对话中保存记录后再进行图片超分"); return; } const assistantMessageId = message.id; const previousResultUrl = message.resultUrl; const previousStatus = message.status || "completed"; const previousTaskProgress = message.taskProgress; const previousTaskId = message.taskId; const isAdmin = getCachedRole() === "admin"; setIsGenerating(true); setGenerationStatus("正在提交图片超分任务"); setGenerationProgress(12); const imageUrl = message.resultOriginalUrl || message.resultUrl; if (isAdmin) console.log("[图片超分] 开始处理", { imageUrl }); await patchConversationMessage(conversationId, assistantMessageId, { body: "图片超分任务已提交,正在增强画质...", status: "thinking", conversationId, taskProgress: 12, taskStatusLabel: "正在提交超分任务", resultType: "image", resultUrl: previousResultUrl, }); try { const result = await aiGenerationClient.createImageSuperResolveTask({ conversationId, imageUrl, scale: 2, }); if (isAdmin) console.log("[图片超分] API 提交成功", { taskId: result.taskId }); const keepaliveTask: WorkbenchKeepaliveTask = { taskId: result.taskId, conversationId, assistantMessageId, operation: "video-super-resolution", mode: "image", modelLabel: "图片超分", specs: ["2x"], referenceCount: 0, progress: 18, statusLabel: "超分处理中...", startedAt: Date.now(), }; upsertKeepaliveTask(keepaliveTask); await patchConversationMessage(conversationId, assistantMessageId, { taskId: result.taskId, status: "thinking", taskProgress: 18, taskStatusLabel: "超分处理中...", resultUrl: previousResultUrl, resultType: "image", }); runKeepalivePoll(keepaliveTask); } catch (error) { if (isAdmin) console.error("[图片超分] API 提交失败", error); if (isInsufficientBalance(error)) setShowRechargeModal(true); setProjectError(error instanceof Error ? error.message : "图片超分提交失败"); await patchConversationMessage(conversationId, assistantMessageId, { body: "图片超分失败", status: previousStatus, taskId: previousTaskId, taskProgress: previousTaskProgress, taskStatusLabel: "超分失败", resultUrl: previousResultUrl, resultType: "image", }); setIsGenerating(false); setGenerationProgress(0); setGenerationStatus("超分失败"); } }; const handlePromptKeyDown = (event: KeyboardEvent) => { if (promptMentionOpen && promptMentionOptions.length > 0) { if (event.key === "ArrowDown") { event.preventDefault(); setMentionActiveIndex((current) => (current + 1) % promptMentionOptions.length); return; } if (event.key === "ArrowUp") { event.preventDefault(); setMentionActiveIndex((current) => (current - 1 + promptMentionOptions.length) % promptMentionOptions.length); return; } if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); insertPromptMention(promptMentionOptions[mentionActiveIndex]?.token || promptMentionOptions[0].token); return; } } const { selectionStart, selectionEnd } = event.currentTarget; const selectionMin = Math.min(selectionStart, selectionEnd); const selectionMax = Math.max(selectionStart, selectionEnd); const selectedMentionRanges = promptMentionTokenRanges.filter( (range) => selectionMin < range.end && selectionMax > range.start, ); const getDeletionRange = () => { if (selectionStart !== selectionEnd) { if (selectedMentionRanges.length === 0) return null; return { start: selectedMentionRanges.reduce((min, range) => Math.min(min, range.start), selectionMin), end: selectedMentionRanges.reduce((max, range) => Math.max(max, range.end), selectionMax), }; } if (event.key === "Backspace") { return ( promptMentionTokenRanges.find( (range) => selectionStart === range.end || (selectionStart > range.start && selectionStart < range.end), ) || null ); } if (event.key === "Delete") { return ( promptMentionTokenRanges.find( (range) => selectionStart === range.start || (selectionStart > range.start && selectionStart < range.end), ) || null ); } return null; }; const deletionRange = event.key === "Backspace" || event.key === "Delete" ? getDeletionRange() : null; if (deletionRange) { event.preventDefault(); let start = deletionRange.start; let end = deletionRange.end; if (start > 0 && inputValue[start - 1] === " ") { start -= 1; } else if (end < inputValue.length && inputValue[end] === " ") { end += 1; } const nextValue = removePromptTextRange(inputValue, start, end); const nextCursor = Math.min(start, nextValue.length); setInputValue(nextValue); setCursorIndex(nextCursor); setPromptSelectionRange({ start: nextCursor, end: nextCursor }); window.requestAnimationFrame(() => { textareaRef.current?.setSelectionRange(nextCursor, nextCursor); }); return; } if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { event.preventDefault(); void handleSendAction(); } }; const sendDisabled = !inputValue.trim() || (activeMode !== "chat" && getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3); const suggestedPrompts = [ { text: "画一个赛博朋克风格的城市夜景", mode: "image" as WorkbenchMode }, { text: "生成一段雨中漫步的电影镜头", mode: "video" as WorkbenchMode }, { text: "帮我设计一个科幻短片的分镜脚本", mode: "chat" as WorkbenchMode }, { text: "画一组动漫角色定妆照", mode: "image" as WorkbenchMode }, ]; const handleSuggestedPrompt = (prompt: string, mode: WorkbenchMode) => { setActiveMode(mode); setInputValue(prompt); window.requestAnimationFrame(() => textareaRef.current?.focus()); }; const handleUsePromptCase = (item: PromptCaseViewModel) => { setActiveMode("image"); setInputValue(item.prompt); setSelectedPromptCase(null); window.requestAnimationFrame(() => textareaRef.current?.focus()); }; const handleCopyPromptCase = async (item: PromptCaseViewModel) => { await navigator.clipboard?.writeText(item.prompt); }; const renderConversationSidebar = () => ( setSidebarCollapsed((v) => (hasSidebarRecords ? !v : true))} onSelect={handleSelectProject} onRefresh={handleRefreshProject} onRename={handleRenameProject} onDelete={requestDeleteProject} /> ); const renderDeleteDialog = () => deleteDialog ? (
) : null; const renderMentionPanel = () => { if (!promptMentionOpen) return null; const refCount = referenceItems.length; const assetCount = promptMentionOptions.length - refCount; const hasBoth = refCount > 0 && assetCount > 0; return (
引用参考内容
{promptMentionOptions.length > 0 ? (
{promptMentionOptions.flatMap((item, index) => { const nodes: React.ReactNode[] = []; if (hasBoth && index === 0) nodes.push(
本次上传
); if (hasBoth && index === refCount) nodes.push(
资产库
); nodes.push( , ); return nodes; })}
) : (
上传参考内容或保存结果到资产库后,用 @ 引用。
)}
); }; const renderComposerReferences = (disabled = false) => (
0 ? " has-items" : ""}${referencePreviewOpen ? " is-open" : ""}`} > {referenceItems.length > 0 && referencePreviewOpen ? (
{referenceItems.map((item) => (
))} {!disabled && referenceItems.length < referenceLimit ? ( ) : null}
) : null}
); const renderComposerToolbar = (disabled = false, showStop = false) => (
toggleToolbarMenu("studio-mode")} onClose={closeToolbarMenus} onChange={(value) => handleModeChange(value as WorkbenchMode)} ariaLabel="工作台模式" direction={dropdownDirection} /> {activeMode === "image" && ( <> toggleToolbarMenu("image-model")} onClose={closeToolbarMenus} onChange={setImageModel} direction={dropdownDirection} /> toggleToolbarMenu("image-settings")} direction={dropdownDirection} /> toggleToolbarMenu("image-grid-mode")} onClose={closeToolbarMenus} onChange={setImageGridMode} direction={dropdownDirection} /> )} {activeMode === "video" && ( <> toggleToolbarMenu("video-model")} onClose={closeToolbarMenus} onChange={setVideoModel} direction={dropdownDirection} /> toggleToolbarMenu("video-mode")} onClose={closeToolbarMenus} onChange={setVideoFrameMode} direction={dropdownDirection} /> toggleToolbarMenu("video-ratio")} direction={dropdownDirection} /> } disabled={disabled} isOpen={toolbarMenuId === "video-duration"} onToggle={() => toggleToolbarMenu("video-duration")} onClose={closeToolbarMenus} onChange={setVideoDuration} direction={dropdownDirection} /> } disabled={disabled} isOpen={toolbarMenuId === "video-quality"} onToggle={() => toggleToolbarMenu("video-quality")} onClose={closeToolbarMenus} onChange={setVideoQuality} direction={dropdownDirection} /> )}
); // ─── LAUNCH STATE (homepage) ─── const renderMessagePreviewOverlay = () => messagePreviewAttachment?.previewUrl ? (
setMessagePreviewAttachment(null)}>
event.stopPropagation()} >
{messagePreviewAttachment.name}
{messagePreviewAttachment.kind === "video" ? ( ) : ( {messagePreviewAttachment.name} )}
) : null; const renderPromptCaseOverlay = () => selectedPromptCase ? (
{selectedPromptCase.author.slice(0, 1).toUpperCase()}
{selectedPromptCase.author} {selectedPromptCase.category}

{selectedPromptCase.title}

{selectedPromptCase.summary}

{selectedPromptCase.ratio} · 图片案例
图片提示词

{selectedPromptCase.prompt}

) : null; if (!hasActivatedWorkspace) { return (

今天想生成什么?

{renderComposerReferences(false)}