import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; // AI 创作对话框样式优化版 v12:白色极简高级风格,选项按钮、标题、头像与关键字统一使用 #8b5cf6。 import { Layout, Button, Input, Select, message, Space, Typography, Tooltip, Popconfirm, Modal, App, } from 'antd'; import bg1 from '../assets/bg1.png'; import bg2 from '../assets/bg2.png'; import bg3 from '../assets/bg3.png'; import text from '../assets/testb.png'; import UploadSelector from '../components/UploadSelector'; import GenerationTaskResourceGrid from '../components/generation/GenerationTaskResourceGrid'; import { getParameters, createGenerationTask, getgen_list, getEngine, uploadImage,uploadAudio, uploadVideo, getCreditRatios, deleteHistory, calculateCredits } from '../api'; import { useAppStore } from '../store/useAppStore'; import { PrivatePortraitAssetPicker } from '../components/privatePortrait'; import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset, UploadResourceHistoryItem } from '../types'; import { PlusOutlined, MenuUnfoldOutlined, MenuFoldOutlined, SendOutlined, DeleteOutlined, CloseOutlined, RobotOutlined, LoadingOutlined, PictureOutlined, VideoCameraOutlined, CaretDownOutlined, SwapOutlined, WarningOutlined, SettingOutlined, LayoutOutlined, ArrowUpOutlined, DownloadOutlined, ReloadOutlined, AudioOutlined, PauseOutlined, } from '@ant-design/icons'; import { div } from 'three/tsl'; const { Header, Sider, Content } = Layout; // 解构Input组件 const { TextArea } = Input; // 解构Select组件 const { Option } = Select; // 解构Typography组件 const { Text } = Typography; const GENERATION_RESOURCE_BASE = (import.meta.env.VITE_API_BASE || 'http://localhost:8000') .replace(/\/api\/?$/i, '') .replace(/\/$/, ''); const resolveGenerationResourceUrl = (url?: string | null): string => { if (!url) return ''; const value = String(url).trim(); if (!value) return ''; if (/^(https?:)?\/\//i.test(value) || /^(blob|data):/i.test(value)) return value; return `${GENERATION_RESOURCE_BASE}${value.startsWith('/') ? value : `/${value}`}`; }; interface MediaReference { name: string; type: 'image' | 'video' | 'audio'; url: string; duration?: number; role?: string; label?: string; source?: string; private_asset_id?: string; remote_asset_id?: string; upload_resource_id?: string; fileSizeBytes?: number; } interface Message { id: string; original_prompt: string; gen_type: string; idempotency_key: string; media_references?: MediaReference[]; image_size?: string; image_proportion?: string; image_px?: string; duration?: number; ratio?: string; resolution?: string; timestamp?: string; engine_id: string; generation_count: number; } interface Conversation { id: string; title: string; lastMessage: string; timestamp: string; messages: any; } const mockUpload = (file: File): Promise<{ url: string }> => { return new Promise((resolve) => { setTimeout(() => { // 使用URL.createObjectURL创建本地文件预览URL const url = URL.createObjectURL(file); resolve({ url }); }, 500); }); }; const AIChatPage: React.FC = () => { // ==================== 状态定义 ==================== const { message: antdMessage } = App.useApp(); const location = useLocation(); const [collapsed, setCollapsed] = useState(false); const [currentConversationId, setCurrentConversationId] = useState('1'); const [conversations, setConversations] = useState([ ]); // 从 store 读取生成配置状态(包括输入框内容) const { mediaType, countType, generationCount, selectedRatio, selectedResolution, width, height, videoDuration, videoAspectRatio, videoResolution, engineOptions, enginesele, inputValue, setMediaType, setCountType, setGenerationCount, setSelectedRatio, setSelectedResolution, setWidth, setHeight, setVideoDuration, setVideoAspectRatio, setVideoResolution, setEngineOptions, setEnginesele, setInputValue, currentMedia, setCurrentMedia, } = useAppStore(); // 获取当前选中引擎的媒体上传限制 const enginesData = Array.isArray(enginesele) ? {} : enginesele; const currentEngineList = mediaType === 'image' ? enginesData.image : enginesData.video; const currentEngine = currentEngineList?.find((e: any) => e.id === countType); const maxImageCount = currentEngine?.maxImageCount ?? 4; const maxVideoCount = currentEngine?.maxVideoCount ?? 1; const multiGenerationEnabled = Boolean(currentEngine?.multiGenerationEnabled); const configuredMaxGenerationCount = multiGenerationEnabled ? Math.max(1, Math.min(5, Number(currentEngine?.maxGenerationCount || 1))) : 1; const referenceImageCount = mediaType === 'image' ? currentMedia.filter((item) => item.type === 'image').length : 0; const imageProviderRemainingCount = mediaType === 'image' ? Math.max(1, Number(currentEngine?.multiImageMaxImages || 15) - referenceImageCount) : 5; const effectiveMaxGenerationCount = Math.max( 1, Math.min( 5, configuredMaxGenerationCount, mediaType === 'image' ? imageProviderRemainingCount : 5, ), ); const [uploading, setUploading] = useState(false); const [loading, setLoading] = useState(false); const [enginesLoaded, setEnginesLoaded] = useState(false); const [referenceMode, setReferenceMode] = useState<'universal' | 'first_last_frame'>('universal'); const [firstFrame, setFirstFrame] = useState(null); const [lastFrame, setLastFrame] = useState(null); const [uploadTarget, setUploadTarget] = useState<'first' | 'last' | null>(null); const [referenceModeDropdownVisible, setReferenceModeDropdownVisible] = useState(false); const [privateAssetPickerOpen, setPrivateAssetPickerOpen] = useState(false); const [privateAssetPickerLibraryType, setPrivateAssetPickerLibraryType] = useState('real_person'); const [mediaStackHovered, setMediaStackHovered] = useState(false); const mediaStackCloseTimerRef = useRef(null); const openMediaStackTray = useCallback(() => { if (mediaStackCloseTimerRef.current) { window.clearTimeout(mediaStackCloseTimerRef.current); mediaStackCloseTimerRef.current = null; } setMediaStackHovered(true); }, []); const closeMediaStackTray = useCallback(() => { if (mediaStackCloseTimerRef.current) { window.clearTimeout(mediaStackCloseTimerRef.current); } mediaStackCloseTimerRef.current = window.setTimeout(() => { setMediaStackHovered(false); mediaStackCloseTimerRef.current = null; }, 260); }, []); // @ 提及相关状态 const [mentionVisible, setMentionVisible] = useState(false); const mentionInputRef = useRef(null); const isReEditingRef = useRef(false); const [mentionPosition, setMentionPosition] = useState({ top: 0, left: 0 }); // 数字转中文数字(一、二、三、四) const numberToChinese = (num: number): string => { const map = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']; if (num <= 10) return map[num]; if (num < 20) return '十' + map[num % 10]; if (num < 100) { const tens = Math.floor(num / 10); const ones = num % 10; return map[tens] + '十' + (ones ? map[ones] : ''); } return String(num); }; // 根据媒体列表生成标签(图片一、图片二、视频一、视频二...) const generateMediaLabels = (media: { type: 'image' | 'video' | 'audio' }[]) => { let imgCount = 0; let vidCount = 0; let audCount = 0; return media.map((m) => { if (m.type === 'image') { imgCount++; return `图片${imgCount}`; } else if (m.type === 'video') { vidCount++; return `视频${vidCount}`; } else { audCount++; return `音频${audCount}`; } }); }; const [previewVisible, setPreviewVisible] = useState(false); const [previewUrl, setPreviewUrl] = useState(''); const [previewType, setPreviewType] = useState<'image' | 'video'>('image'); const videoRef = useRef(null); useEffect(() => { if (previewVisible && previewType === 'video') { const playVideo = () => { if (videoRef.current) { videoRef.current.play().catch(() => {}); } }; if (videoRef.current) { if (videoRef.current.readyState >= 2) { playVideo(); } else { videoRef.current.addEventListener('loadedmetadata', playVideo); } } const timer = setTimeout(playVideo, 300); return () => { clearTimeout(timer); if (videoRef.current) { videoRef.current.removeEventListener('loadedmetadata', playVideo); videoRef.current.pause(); } }; } else { if (videoRef.current) { videoRef.current.pause(); } } }, [previewVisible, previewType]); // 提示词展开状态 const [expandedPrompts, setExpandedPrompts] = useState>(new Set()); // 从URL中提取exp时间戳(支持相对路径和完整URL) const extractExpTimestamp = (url: string): number | null => { if (!url) return null; try { // 尝试作为完整URL解析 const urlObj = new URL(url); const expStr = urlObj.searchParams.get('exp'); if (expStr) { return parseInt(expStr, 10); } return null; } catch { // 如果完整URL解析失败,尝试解析相对路径中的查询参数 try { // 查找 ? 后面的部分 const queryStart = url.indexOf('?'); if (queryStart !== -1) { const queryString = url.substring(queryStart + 1); const params = new URLSearchParams(queryString); const expStr = params.get('exp'); if (expStr) { return parseInt(expStr, 10); } } return null; } catch { return null; } } }; // 检查媒体是否过期 const isMediaExpired = (url: string): boolean => { const expTimestamp = extractExpTimestamp(url); if (!expTimestamp) { return false; // 没有exp参数,视为不过期 } const currentTimestamp = Math.floor(Date.now() / 1000); return currentTimestamp > expTimestamp; }; // 附件预览弹窗状态(独立弹窗) const [attachmentPreviewVisible, setAttachmentPreviewVisible] = useState(false); const [attachmentPreviewUrl, setAttachmentPreviewUrl] = useState(''); const [attachmentPreviewType, setAttachmentPreviewType] = useState<'image' | 'video' | 'audio'>('image'); const [attachmentPreviewName, setAttachmentPreviewName] = useState(''); const attachmentPreviewVideoRef = useRef(null); // 弹窗打开时自动播放视频,关闭时停止 useEffect(() => { if (attachmentPreviewVisible && attachmentPreviewType === 'video' && attachmentPreviewVideoRef.current) { const v = attachmentPreviewVideoRef.current; v.currentTime = 0; v.muted = false; const playPromise = v.play(); if (playPromise && typeof playPromise.catch === 'function') { playPromise.catch(() => { // 自动播放被阻止时静音重试 v.muted = true; v.play().catch(() => {}); }); } } else if (!attachmentPreviewVisible && attachmentPreviewVideoRef.current) { const v = attachmentPreviewVideoRef.current; v.pause(); v.muted = true; v.currentTime = 0; } }, [attachmentPreviewVisible, attachmentPreviewType, attachmentPreviewUrl]); const [playingAudioUrl, setPlayingAudioUrl] = useState(null); const [audioProgress, setAudioProgress] = useState(0); // 附件详情悬浮窗状态 const [attachmentPopupVisible, setAttachmentPopupVisible] = useState(false); const [attachmentPopupMessageId, setAttachmentPopupMessageId] = useState(null); const [attachmentPopupPosition, setAttachmentPopupPosition] = useState({ x: 0, y: 0 }); const [showImageSettingsModal, setShowImageSettingsModal] = useState(false); const [showVideoSettingsModal, setShowVideoSettingsModal] = useState(false); const [ratioOptions, setRatioOptions] = useState([]); const [resolutionOptions, setResolutionOptions] = useState([]); const [currentEngineSupportedSizes, setCurrentEngineSupportedSizes] = useState>>({}); const [showEngineModal, setShowEngineModal] = useState(false); const [showMediaTypeModal, setShowMediaTypeModal] = useState(false); const [creditRatios, setCreditRatios] = useState([]); const [cimage, setCimage] = useState([]); const [creditCalculationData, setCreditCalculationData] = useState([]); // 获取预估积分 - 根据引擎ID、类型和分辨率计算 const getEstimatedCredits = (): number => { // 根据当前选择的引擎ID、类型和分辨率查找对应的积分配置 let config: any = {}; config = creditCalculationData.find((item: any) => item.modelConfigId === countType && item.genType === mediaType && item.resolution === (mediaType === 'video' ? videoResolution : selectedResolution) ); if (!config) { if (mediaType === 'video') { // 如果没有找到对应的配置,则使用默认配置 config = { perSecondCredits: 2, baseCredits: 60, ratio: 1.3, inputVideoRatio: 1.3, inputVideoBaseCredits: 0, inputVideoPerSecondCredits: 15, inputImageRatio: 1, inputImageBaseCredits: 0, inputImagePerImageCredits: 0.0, }; if (videoResolution === '1080p') { config.ratio = 1.3; } else if (videoResolution === '720p') { config.ratio = 1.3; } else if (videoResolution === '480p') { config.ratio = 1.3; } } else { // 如果没有找到对应的配置,则使用默认配置 config = { perSecondCredits: 0.1, baseCredits: 2, ratio: 3, inputImageRatio: 1, inputImageBaseCredits: 0, inputImagePerImageCredits: 0.0, }; if (selectedResolution === '2K') { config.ratio = 3; } else if (selectedResolution === '4K') { config.ratio = 3; } else if (selectedResolution === '1K') { config.ratio = 3; } } } // 根据配置计算积分(保留两位小数,不做取整) if (mediaType === 'video') { // 视频:(秒数 × perSecondCredits + baseCredits) × ratio let total = (videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio; // 传入视频积分 const inputVideoDuration = currentMedia .filter((m) => m.type === 'video') .reduce((sum, m) => sum + (m.duration || 0), 0); if (inputVideoDuration > 0) { const inputVideoCost = ((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1); total += inputVideoCost; } // 传入图片积分 const inputImageCount = currentMedia .filter((m) => m.type === 'image') .length; if (inputImageCount > 0) { const inputImageCost = ((config.inputImageBaseCredits || 0) + (config.inputImagePerImageCredits || 0) * inputImageCount) * (config.inputImageRatio || 1); total += inputImageCost; } return Number((total * generationCount).toFixed(2)); } else { // 图片:baseCredits × ratio let total = config.baseCredits * config.ratio; // 传入图片积分 const inputImageCount = currentMedia .filter((m) => m.type === 'image') .length; if (inputImageCount > 0) { const inputImageCost = ((config.inputImageBaseCredits || 0) + (config.inputImagePerImageCredits || 0) * inputImageCount) * (config.inputImageRatio || 1); total += inputImageCost; } return Number((total * generationCount).toFixed(2)); } }; const messagesEndRef = useRef(null); const scrollContainerRef = useRef(null); const pollingRef = useRef(null); const isFirstLoadRef = useRef(true); const isInitialLoadDone = useRef(false); const lastAutoScrollTime = useRef(0); const currentConversation = conversations.find((c) => c.id === currentConversationId); const [gen_list, setGen_list] = useState([]); const [Pagebreak, setPagebreak] = useState({ page: 1, pageSize: 20, }); const [Totalnumber, setTotalnumber] = useState(0); const [isLoadingMore, setIsLoadingMore] = useState(false); // ==================== 副作用 ==================== useEffect(() => { const state = location.state as any; if (state) { if (state.generationPrompt) { setInputValue(state.generationPrompt); } if (state.mediaReferences && Array.isArray(state.mediaReferences) && state.mediaReferences.length > 0) { const mediaItems: MediaReference[] = state.mediaReferences.map((ref: any) => { const url = ref.url || ref.resourceUrl || ref.previewUrl || ''; const fullUrl = url.startsWith('http') ? url : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${url}`; return { ...ref, name: ref.name || `参考${ref.type}`, type: ref.type === 'video' ? 'video' : ref.type === 'audio' ? 'audio' : 'image', url: fullUrl, duration: ref.duration || 0, label: ref.label || '', }; }); setCurrentMedia(mediaItems); } if (state.aspectRatio) { setVideoAspectRatio(state.aspectRatio); } window.history.replaceState({}, '', window.location.pathname); } }, [location.state]); // 滚动到底部的辅助函数 useEffect(() => { return () => { if (mediaStackCloseTimerRef.current) { window.clearTimeout(mediaStackCloseTimerRef.current); } }; }, []); const scrollToBottom = useCallback((behavior: ScrollBehavior = 'instant') => { const scrollContainer = scrollContainerRef.current; if (!scrollContainer) return; lastAutoScrollTime.current = Date.now(); const doScroll = () => { scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); }; requestAnimationFrame(doScroll); }, []); // 初次打开或页面刷新时自动滚动到底部(只在初始加载时执行一次) useEffect(() => { if (gen_list.length > 0 && !isInitialLoadDone.current) { const timer = setTimeout(() => { scrollToBottom('smooth'); isInitialLoadDone.current = true; }, 300); return () => clearTimeout(timer); } }, [gen_list.length, scrollToBottom]); useEffect(() => { if (isReEditingRef.current) { isReEditingRef.current = false; return; } if (!enginesLoaded) return; const enginesData = Array.isArray(enginesele) ? {} : enginesele; const newOptions = mediaType === 'image' ? enginesData.image : enginesData.video; if (newOptions && newOptions.length > 0) { const isValidOption = newOptions.some((item: any) => item.id === countType); if (!isValidOption) { setCountType(newOptions[0].id); } } }, [mediaType, enginesele, enginesLoaded]); // 切换媒体类型或引擎时,默认回到最安全的单份生成。 useEffect(() => { if (!enginesLoaded) return; setGenerationCount(1); }, [mediaType, countType, enginesLoaded, setGenerationCount]); // 图片参考图数量变化后动态收敛本次可选数量;后端仍会再次校验。 useEffect(() => { if (generationCount > effectiveMaxGenerationCount) { setGenerationCount(effectiveMaxGenerationCount); if (mediaType === 'image') { antdMessage.info(`受当前引擎或参考图数量限制,本次最多生成 ${effectiveMaxGenerationCount} 份`); } } }, [generationCount, effectiveMaxGenerationCount, mediaType, setGenerationCount, antdMessage]); // 点击外部关闭弹窗 useEffect(() => { const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement; // 关闭媒体类型选择弹窗 if (showMediaTypeModal && !target.closest('.image-settings-popover') && !target.closest('.image-settings-trigger')) { setShowMediaTypeModal(false); } // 关闭引擎选择弹窗 if (showEngineModal && !target.closest('.image-settings-popover') && !target.closest('.image-settings-trigger')) { setShowEngineModal(false); } // 关闭图片设置弹窗 if (showImageSettingsModal && !target.closest('.image-settings-popover') && !target.closest('.image-settings-trigger')) { setShowImageSettingsModal(false); } // 关闭视频设置弹窗 if (showVideoSettingsModal && !target.closest('.image-settings-popover') && !target.closest('.image-settings-trigger')) { setShowVideoSettingsModal(false); } // 关闭参考模式选择弹窗 if (referenceModeDropdownVisible && !target.closest('.image-settings-popover') && !target.closest('.image-settings-trigger')) { setReferenceModeDropdownVisible(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [showMediaTypeModal, showEngineModal, showImageSettingsModal, showVideoSettingsModal, referenceModeDropdownVisible]); useEffect(() => { if (isReEditingRef.current) { isReEditingRef.current = false; return; } if (mediaType !== 'video') return; const engine = enginesData.video?.find((e: any) => e.id === countType); if (!engine) return; const supportsFLF = engine.supportsFirstLastFrame ?? false; const supportsUR = engine.supportsUniversalReference ?? true; setReferenceMode((prevMode) => { if (supportsUR) return 'universal'; if (prevMode === 'universal' && !supportsUR) { if (supportsFLF) return 'first_last_frame'; } return prevMode; }); }, [countType, mediaType, enginesele]); // 初始化获取参数 - 只在组件挂载时执行一次 useEffect(() => { getEngine() .then((data: any) => { // console.log('引擎', data); setEnginesele(data.engine); setEnginesLoaded(true); // 如果是视频模式,初始化视频参数选项 if (data.engine.video && data.engine.video.length > 0) { const defaultEngine = data.engine.video[0]; // 查找用户之前选择的引擎(如果存在) const savedEngine = data.engine.video.find((e: any) => e.id === countType); const targetEngine = savedEngine || defaultEngine; // 更新引擎选项为当前引擎支持的参数 setEngineOptions({ ratios: targetEngine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'], resolutions: targetEngine.supportedResolutions || ['480p', '720p', '1080p'], durations: targetEngine.supportedDurations || [5, 8, 10, 12, 15], }); // 确保视频参数在引擎支持的范围内 if (!targetEngine.supportedRatios?.includes(videoAspectRatio)) { setVideoAspectRatio(targetEngine.supportedRatios?.[0] || '16:9'); } if (!targetEngine.supportedResolutions?.includes(videoResolution)) { setVideoResolution(targetEngine.supportedResolutions?.[0] || '720p'); } if (!targetEngine.supportedDurations?.includes(videoDuration)) { setVideoDuration(targetEngine.supportedDurations?.[0] || 5); } // 如果之前没有选择过引擎(还是默认值),才设置默认引擎 if (countType === '请选择') { setCountType(targetEngine.id); } } if (data.engine.image && data.engine.image.length > 0) { const savedImageEngine = data.engine.image.find((e: any) => e.id === countType); const targetImageEngine = savedImageEngine || data.engine.image[0]; const supportedSizes = targetImageEngine.supportedSizes || {}; const resolutionLevels = Object.keys(supportedSizes).sort((a, b) => { const levelOrder = { '1K': 0, '2K': 1, '4K': 2 }; return (levelOrder[a] || 0) - (levelOrder[b] || 0); }); const primaryResolution = resolutionLevels[0] || '2K'; const supportedResolutions = Object.keys(supportedSizes[primaryResolution] || {}); const newRatioOptions = supportedResolutions.map((res: any) => ({ value: res, label: res, })); setRatioOptions(newRatioOptions); setCurrentEngineSupportedSizes(supportedSizes); setResolutionOptions(resolutionLevels.map((level) => { const match = level.match(/(\d+)K/); const num = match ? parseInt(match[1]) : 1; const labels: Record = { 1: '标清', 2: '高清', 4: '超清' }; return { value: level, label: `${labels[num] || '高清'} ${level}`, }; })); if (supportedSizes[primaryResolution] && supportedSizes[primaryResolution][supportedResolutions[0]]) { const defaultSize = supportedSizes[primaryResolution][supportedResolutions[0]]; const [w, h] = defaultSize.split(/[×x]/); setWidth(Number(w)); setHeight(Number(h)); setSelectedRatio(supportedResolutions[0]); setSelectedResolution(primaryResolution); } if (countType === '请选择') { setCountType(targetImageEngine.id); } } }) .catch(() => { }); calculateCredits().then((data: any) => { // console.log('积分计算', data); // 保存积分计算数据 setCreditCalculationData(data); }) getgen_list(Pagebreak).then((data: any) => { // API 按创建时间倒序返回;对话区按时间正序展示,最新消息保持在底部。 const mess_list = data.items const total = data.total setGen_list(mess_list) setTotalnumber(total) }) // getParameters() // .then((data) => { // let supportedSizes = (data as any).items?.[0]?.supportedSizes || {}; // let supportedResolutions = []; // let twokwidth = []; // let fourkwidth = []; // for (let key in supportedSizes["2K"]) { // supportedResolutions.push(key); // twokwidth.push(supportedSizes["2K"][key]); // } // const newRatioOptions = supportedResolutions.map((res: any, index: number) => ({ // value: res, // label: String(index), // })); // setRatioOptions(newRatioOptions); // for (let key in supportedSizes["4K"]) { // fourkwidth.push(supportedSizes["4K"][key]); // } // const newWidthandHeight = [twokwidth, fourkwidth]; // setWidthandHeight(newWidthandHeight); // setResolutionOptions([ // { value: '2K', label: '高清 2K' }, // { value: '4K', label: '超清 4K' }, // ]); // }) // .catch(() => { }); }, []); // 轮询逻辑 - 当有任务在生成时,每5秒查询一次状态 useEffect(() => { // 先清理之前的定时器 if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } // 判断是否有正在生成的任务 const hasGeneratingTask = gen_list.some((item: any) => item.status === 'generating'); if (!hasGeneratingTask) return; // 设置5秒轮询 pollingRef.current = window.setInterval(() => { getgen_list(Pagebreak).then((data: any) => { // 只更新正在生成的任务,不影响其他已加载的数据 setGen_list((prevList: any[]) => { return prevList.map((prevItem: any) => { // 只更新正在生成的任务 if (prevItem.status === 'generating') { const newItem = data.items.find((item: any) => item.id === prevItem.id); // 如果找到了对应的新数据,使用新数据;否则保持旧数据 return newItem || prevItem; } // 非生成中的任务保持不变 return prevItem; }); }); setTotalnumber(data.total); }).catch((error) => { }); }, 10000); // 清理定时器 return () => { if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } }; }, [gen_list, Pagebreak]); // 点击空白处关闭图片设置浮层 useEffect(() => { if (!showImageSettingsModal) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement; if ( !target.closest(".image-settings-popover") && !target.closest(".image-settings-trigger") ) { setShowImageSettingsModal(false); } }; document.addEventListener("click", handleClickOutside); return () => document.removeEventListener("click", handleClickOutside); }, [showImageSettingsModal]); useEffect(() => { if (!showVideoSettingsModal) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement; if ( !target.closest(".image-settings-popover") && !target.closest(".image-settings-trigger") ) { setShowVideoSettingsModal(false); } }; document.addEventListener("click", handleClickOutside); return () => document.removeEventListener("click", handleClickOutside); }, [showVideoSettingsModal]); // 根据比例和分辨率计算尺寸 const calculateSizeFromRatione = (ratio: string) => { const resolutionLevels = Object.keys(currentEngineSupportedSizes).sort((a, b) => { const levelOrder: Record = { '1K': 0, '2K': 1, '4K': 2 }; return (levelOrder[a] || 0) - (levelOrder[b] || 0); }); const targetResolution = currentEngineSupportedSizes[selectedResolution] && currentEngineSupportedSizes[selectedResolution][ratio] ? selectedResolution : resolutionLevels.find(level => currentEngineSupportedSizes[level] && currentEngineSupportedSizes[level][ratio]) || resolutionLevels[0]; if (targetResolution && currentEngineSupportedSizes[targetResolution] && currentEngineSupportedSizes[targetResolution][ratio]) { const size = currentEngineSupportedSizes[targetResolution][ratio]; const [w, h] = size.split(/[×x]/); setWidth(Number(w)); setHeight(Number(h)); setSelectedRatio(ratio); setSelectedResolution(targetResolution); } }; const calculateSizeFromRatiotwo = (resolution: string) => { if (!currentEngineSupportedSizes[resolution]) { const resolutionLevels = Object.keys(currentEngineSupportedSizes).sort((a, b) => { const levelOrder: Record = { '1K': 0, '2K': 1, '4K': 2 }; return (levelOrder[a] || 0) - (levelOrder[b] || 0); }); resolution = resolutionLevels[0] || resolution; } const supportedRatios = currentEngineSupportedSizes[resolution] ? Object.keys(currentEngineSupportedSizes[resolution]) : []; const newRatioOptions = supportedRatios.map((res: any) => ({ value: res, label: res, })); setRatioOptions(newRatioOptions); const targetRatio = supportedRatios.includes(selectedRatio) ? selectedRatio : supportedRatios[0] || ''; if (targetRatio && currentEngineSupportedSizes[resolution] && currentEngineSupportedSizes[resolution][targetRatio]) { const size = currentEngineSupportedSizes[resolution][targetRatio]; const [w, h] = size.split(/[×x]/); setWidth(Number(w)); setHeight(Number(h)); setSelectedRatio(targetRatio); setSelectedResolution(resolution); } }; // 交换宽高 const handleSwap = () => { setWidth(height); setHeight(width); }; const currentVideoEngine = enginesele?.video?.find((e: any) => e.id === countType); const supportsFirstLastFrame = currentVideoEngine?.supportsFirstLastFrame ?? false; const supportsUniversalReference = currentVideoEngine?.supportsUniversalReference ?? true; const handleReferenceModeChange = (mode: 'universal' | 'first_last_frame') => { if (mode === 'first_last_frame' && !supportsFirstLastFrame) return; if (mode === 'universal' && !supportsUniversalReference) return; setReferenceMode(mode); setReferenceModeDropdownVisible(false); setCurrentMedia([]); setInputValue(''); }; const handleSwapFrames = () => { const temp = firstFrame; setFirstFrame(lastFrame); setLastFrame(temp); }; const handleRemoveFirstFrame = () => { setFirstFrame(null); setLastFrame(null); }; const handleRemoveLastFrame = () => { setLastFrame(null); }; // ==================== 事处理函数 ==================== const handleNewChat = () => { // 生成唯一ID(使用时间戳) const newId = Date.now().toString(); const newConversation: Conversation = { id: newId, title: '新对话', lastMessage: '', timestamp: new Date().toLocaleString('zh-CN'), messages: [], }; // 将新对话插入到列表顶部 setConversations((prev) => [newConversation, ...prev]); // 切换到新对话 setCurrentConversationId(newId); // 清空输入框和已上传媒体 setInputValue(''); setCurrentMedia([]); setFirstFrame(null); setLastFrame(null); // 显示提示消息 antdMessage.info('已开启新对话'); }; const handleDeleteChat = (conversationId: string) => { // 过滤掉要删除的对话 setConversations((prev) => prev.filter((c) => c.id !== conversationId)); // 如果删除的是当前选中的对话,切换到其他对话 if (currentConversationId === conversationId) { setCurrentConversationId( conversations[0]?.id === conversationId ? conversations[1]?.id || null : conversations[0]?.id || null ); } // 显示成功提示 antdMessage.success('对话已删除'); }; const handleSelectChat = (conversationId: string) => { setCurrentConversationId(conversationId); setCurrentMedia([]); setFirstFrame(null); setLastFrame(null); }; const handleSend = async () => { const isFirstLastFrameMode = mediaType === 'video' && referenceMode === 'first_last_frame'; if (isFirstLastFrameMode) { if (!inputValue.trim()) { antdMessage.warning('请输入内容'); return; } } else { if (!inputValue.trim() && currentMedia.length === 0) { antdMessage.warning('请输入内容或上传图片/视频'); return; } } let mediaReferences: MediaReference[] | undefined; if (isFirstLastFrameMode) { mediaReferences = []; if (firstFrame) { mediaReferences.push({ ...firstFrame, role: 'first_frame' }); } if (lastFrame) { mediaReferences.push({ ...lastFrame, role: 'last_frame' }); } if (mediaReferences.length === 0) { mediaReferences = undefined; } } else { if (currentMedia.length > 0) { mediaReferences = currentMedia.map((m) => { if (m.type === 'image') { const { duration, ...rest } = m; return rest; } return m; }); } else { mediaReferences = undefined; } } // console.log(mediaReferences); // 创建用户消息对象 const newMessage: Message = { id: '', gen_type: mediaType, original_prompt: inputValue.trim(), engine_id: countType, idempotency_key: new Date().toLocaleString('zh-CN'), generation_count: generationCount, media_references: mediaReferences, // 图片参数(仅图片模式时添加) ...(mediaType === 'image' && { image_size: selectedResolution, image_proportion: selectedRatio, image_px: width + "x" + height, }), // 视频参数(仅视频模式时添加) ...(mediaType === 'video' && { duration: videoDuration, aspect_ratio: videoAspectRatio, resolution: videoResolution, }), }; // 设置加载状态 setLoading(true); createGenerationTask(newMessage).then(() => { // 创建任务成功后,清空输入框和已上传媒体 setInputValue(''); setCurrentMedia([]); setFirstFrame(null); setLastFrame(null); setGenerationCount(1); // 创建任务成功后,重置页数为1,获取最新列表 const newPagebreak = { ...Pagebreak, page: 1 }; setPagebreak(newPagebreak); getgen_list(newPagebreak).then((data: any) => { // 将data.items的最后一个元素添加到gen_list末尾 const newestItem = Array.isArray(data.items) && data.items.length > 0 ? data.items[data.items.length - 1] : null; if (newestItem) { setGen_list((prev: any[]) => [...prev, newestItem]); } setTotalnumber(data.total); // 发送消息后滚动到底部 setTimeout(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, 100); }).catch((error) => { }); }).catch((error: any) => { // 失败时不清空输入框和媒体 // 尝试从错误对象中提取detail字段 let errorMessage = "创建任务失败"; if (error?.response?.data?.detail) { errorMessage = error.response.data.detail; } else if (error?.data?.detail) { errorMessage = error.data.detail; } else if (error?.detail) { errorMessage = error.detail; } else if (error?.message) { errorMessage = error.message; } antdMessage.error({ content: errorMessage, duration: 3, }); }).finally(() => { setLoading(false); }); setTimeout(() => { setLoading(false); }, 3000); }; // 加载更多 const handleLoadMore = async () => { // 检查是否还有更多数据 if (gen_list.length >= Totalnumber) { return; } // 保存当前滚动位置(相对于顶部的偏移) const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { return; } const scrollTopBefore = scrollContainer.scrollTop; const scrollHeightBefore = scrollContainer.scrollHeight; setIsLoadingMore(true); try { const nextPage = Pagebreak.page + 1; const result = await getgen_list({ ...Pagebreak, page: nextPage, }); // 处理返回数据,兼容数组或对象格式 const data = Array.isArray(result) ? { items: result, total: result.length } : result; // 更新总数 if (data.total) { setTotalnumber(data.total); } // 更新页码 setPagebreak((prev: any) => ({ ...prev, page: nextPage })); // 去重后合并数据(根据 id 去重,新数据添加到前面) setGen_list((prev: any[]) => { const existingIds = new Set(prev.map((item: any) => item.id)); // 只添加不存在的新数据,保持新数据的原有顺序 const newItems = (data.items || []).filter((item: any) => { if (!item.id) return false; if (existingIds.has(item.id)) return false; existingIds.add(item.id); return true; }).reverse(); // 加载的是更早一页,按时间正序放到现有消息前面。 return [...newItems, ...prev]; }); // 恢复滚动位置(等待DOM更新完成后执行,增加超时时间确保DOM完全更新) setTimeout(() => { const scrollHeightAfter = scrollContainer.scrollHeight; const addedHeight = scrollHeightAfter - scrollHeightBefore; // 新数据添加到前面,保持当前查看内容的位置不变 scrollContainer.scrollTop = scrollTopBefore + addedHeight; }, 200); } catch (error) { } finally { setIsLoadingMore(false); } }; const getVideoDuration = (file: File): Promise => { return new Promise((resolve, reject) => { const video = document.createElement('video'); video.preload = 'metadata'; video.onloadedmetadata = () => { window.URL.revokeObjectURL(video.src); resolve(video.duration); }; video.onerror = () => { window.URL.revokeObjectURL(video.src); reject(new Error('无法获取视频时长')); }; video.src = URL.createObjectURL(file); }); }; const getAudioDuration = (file: File): Promise => { return new Promise((resolve, reject) => { const audio = document.createElement('audio'); audio.preload = 'metadata'; audio.onloadedmetadata = () => { URL.revokeObjectURL(audio.src); resolve(audio.duration); }; audio.onerror = () => { URL.revokeObjectURL(audio.src); reject(new Error('无法获取音频时长')); }; audio.src = URL.createObjectURL(file); }); }; const handleAudioPlay = (url: string) => { const audioUrl = url.startsWith('http') ? url : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${url}`; if (playingAudioUrl === audioUrl) { const audio = document.getElementById('audio-player') as HTMLAudioElement; if (audio) { audio.pause(); } setPlayingAudioUrl(null); } else { setPlayingAudioUrl(audioUrl); } }; const getImageDimensions = (file: File): Promise<{ width: number; height: number }> => { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { window.URL.revokeObjectURL(img.src); resolve({ width: img.naturalWidth, height: img.naturalHeight }); }; img.onerror = () => { window.URL.revokeObjectURL(img.src); reject(new Error('无法读取图片尺寸')); }; img.src = URL.createObjectURL(file); }); }; const getVideoDimensions = (file: File): Promise<{ width: number; height: number; duration: number }> => { return new Promise((resolve, reject) => { const video = document.createElement('video'); video.preload = 'metadata'; video.onloadedmetadata = () => { window.URL.revokeObjectURL(video.src); resolve({ width: video.videoWidth, height: video.videoHeight, duration: video.duration }); }; video.onerror = () => { window.URL.revokeObjectURL(video.src); reject(new Error('无法读取视频尺寸')); }; video.src = URL.createObjectURL(file); }); }; // 图片尺寸校验:宽高比 (0.4, 2.5),宽高长度 (300, 6000) const validateImageDimensions = (width: number, height: number): string | null => { if (width < 300 || width > 6000) return `图片宽度需在 300~6000px 之间,当前为 ${width}px`; if (height < 300 || height > 6000) return `图片高度需在 300~6000px 之间,当前为 ${height}px`; const ratio = width / height; if (ratio < 0.4 || ratio > 2.5) return `图片宽高比需在 0.4~2.5 之间,当前为 ${ratio.toFixed(2)}`; return null; }; // 视频尺寸校验:宽高比 [0.4, 2.5],宽高 [300, 6000],总像素 [409600, 8295044] const validateVideoDimensions = (width: number, height: number): string | null => { if (width < 300 || width > 6000) return `视频宽度需在 300~6000px 之间,当前为 ${width}px`; if (height < 300 || height > 6000) return `视频高度需在 300~6000px 之间,当前为 ${height}px`; const ratio = width / height; if (ratio < 0.4 || ratio > 2.5) return `视频宽高比需在 0.4~2.5 之间,当前为 ${ratio.toFixed(2)}`; const totalPixels = width * height; if (totalPixels < 409600) return `视频总像素数过小(${width}×${height}=${totalPixels}),需 ≥ 640×640=409600`; if (totalPixels > 8295044) return `视频总像素数过大(${width}×${height}=${totalPixels}),需 ≤ 3326×2494=8295044`; return null; }; const handleUpload = async (file: File, frameTarget?: 'first' | 'last' | File[]) => { const isImage = file.type.startsWith('image/'); const isVideo = file.type.startsWith('video/'); if (mediaType === 'video' && referenceMode === 'first_last_frame') { if (!isImage) { antdMessage.error('首尾帧模式仅支持上传图片'); return false; } if (file.size / 1024 / 1024 > 10) { antdMessage.error('图片大小不能超过10MB'); return false; } try { const { width, height } = await getImageDimensions(file); const error = validateImageDimensions(width, height); if (error) { antdMessage.error(error); return false; } } catch { antdMessage.error('无法读取图片尺寸,请检查文件是否损坏'); return false; } const effectiveUploadTarget = frameTarget || uploadTarget; if (!effectiveUploadTarget) { antdMessage.error('请选择首帧或尾帧上传位置'); return false; } setUploading(true); try { const res = await uploadImage(file); const mediaRef: MediaReference = { name: file.name, type: 'image', url: res.url, role: effectiveUploadTarget === 'first' ? 'first_frame' : 'last_frame', }; if (effectiveUploadTarget === 'first') { setFirstFrame(mediaRef); } else { setLastFrame(mediaRef); } antdMessage.success('图片上传成功'); } catch (error: any) { const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败'; antdMessage.error(errorMsg); } finally { setUploading(false); setUploadTarget(null); } return false; } const isAudio = file.type.startsWith('audio/'); if (!isImage && !isVideo && !isAudio) { antdMessage.error('仅支持图片、视频或音频文件'); return false; } if (isAudio) { const audioExt = file.name.split('.').pop()?.toLowerCase(); if (!['wav', 'mp3'].includes(audioExt || '')) { antdMessage.error('音频仅支持wav和mp3格式'); return false; } } if (mediaType === 'image' && (isVideo || isAudio)) { antdMessage.error('图片模式仅支持上传图片'); return false; } if (isAudio && mediaType !== 'video') { antdMessage.error('仅视频模式支持上传音频'); return false; } const maxMB = isVideo ? 100 : (isAudio ? 50 : 10); const fileTypeText = isVideo ? '视频' : (isAudio ? '音频' : '图片'); if (file.size / 1024 / 1024 > maxMB) { antdMessage.error(`${fileTypeText}大小不能超过${maxMB}MB`); return false; } // 图片尺寸校验(所有图片都需要校验) if (isImage) { try { const { width, height } = await getImageDimensions(file); const error = validateImageDimensions(width, height); if (error) { antdMessage.error(error); return false; } } catch { antdMessage.error('无法读取图片尺寸,请检查文件是否损坏'); return false; } } const imageCount = currentMedia.filter((m) => m.type === 'image').length; if (isImage && imageCount >= maxImage) { antdMessage.error(`该引擎最多上传${maxImage}张图片`); return false; } const videoCount = currentMedia.filter((m) => m.type === 'video').length; if (isVideo && videoCount >= maxVideo) { antdMessage.error(`该引擎最多上传${maxVideo}个视频`); return false; } const audioCount = currentMedia.filter((m) => m.type === 'audio').length; if (isAudio && audioCount >= maxAudio) { antdMessage.error(`该引擎最多上传${maxAudio}个音频`); return false; } let videoDuration = 0; let audioDuration = 0; if (isVideo) { try { videoDuration = await getVideoDuration(file); if (videoDuration < 2) { antdMessage.error('视频素材最短不能少于 2 秒'); return false; } const existingVideoDuration = currentMedia .filter((m) => m.type === 'video') .reduce((sum, m) => sum + (m.duration || 0), 0); if (existingVideoDuration + videoDuration > 15) { antdMessage.error(`所有视频素材总时长不能超过 15 秒,当前 ${(existingVideoDuration + videoDuration).toFixed(1)} 秒`); return false; } // 视频尺寸校验 const { width, height } = await getVideoDimensions(file); const error = validateVideoDimensions(width, height); if (error) { antdMessage.error(error); return false; } } catch { antdMessage.error('无法获取视频信息,请检查文件是否损坏'); return false; } } if (isAudio) { try { audioDuration = await getAudioDuration(file); if (audioDuration < 2) { antdMessage.error('音频素材最短不能少于 2 秒'); return false; } const existingAudioDuration = currentMedia .filter((m) => m.type === 'audio') .reduce((sum, m) => sum + (m.duration || 0), 0); if (existingAudioDuration + audioDuration > 15) { antdMessage.error(`所有音频素材总时长不能超过 15 秒,当前 ${(existingAudioDuration + audioDuration).toFixed(1)} 秒`); return false; } } catch { antdMessage.error('无法获取音频信息,请检查文件是否损坏'); return false; } } setUploading(true); try { let res; if (isImage) { res = await uploadImage(file); } else if (isAudio) { res = await uploadAudio(file, audioDuration); } else { res = await uploadVideo(file, videoDuration); } const mediaType: 'image' | 'video' | 'audio' = isImage ? 'image' : (isAudio ? 'audio' : 'video'); const newList = [...currentMedia, { name: file.name, type: mediaType, url: res.url, label: '', ...(isVideo && { duration: videoDuration }), ...(isAudio && { duration: audioDuration }), }]; const labels = generateMediaLabels(newList); setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] }))); } catch (error) { const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败'; antdMessage.error(errorMsg); } finally { setUploading(false); } return false; }; const doUpload = async (file: File): Promise => { const isImage = file.type.startsWith('image/'); const isVideo = file.type.startsWith('video/'); const isAudio = file.type.startsWith('audio/'); if (!isImage && !isVideo && !isAudio) { antdMessage.error('仅支持图片、视频或音频文件'); return false; } const maxMB = isVideo ? 100 : (isAudio ? 50 : 30); if (file.size / 1024 / 1024 > maxMB) { antdMessage.error(`${isVideo ? '视频' : (isAudio ? '音频' : '图片')}大小不能超过${maxMB}MB`); return false; } if (isImage || isVideo) { const latestMedia = useAppStore.getState().currentMedia; const currentTotalSize = latestMedia .filter((m) => m.type === 'image' || m.type === 'video') .reduce((sum, m) => sum + (Number(m.fileSizeBytes) || 0), 0); const totalSizeMB = (currentTotalSize + file.size) / 1024 / 1024; if (totalSizeMB > 64) { antdMessage.error(`所有图片和视频总大小不能超过64MB,当前已${(currentTotalSize / 1024 / 1024).toFixed(1)}MB,加上此文件后${totalSizeMB.toFixed(1)}MB`); return false; } } if (isAudio) { const audioExt = file.name.split('.').pop()?.toLowerCase(); if (!['wav', 'mp3'].includes(audioExt || '')) { antdMessage.error('音频仅支持wav和mp3格式'); return false; } } // 图片尺寸校验 if (isImage) { try { const { width, height } = await getImageDimensions(file); const error = validateImageDimensions(width, height); if (error) { antdMessage.error(error); return false; } } catch { antdMessage.error('无法读取图片尺寸,请检查文件是否损坏'); return false; } } let videoDuration = 0; let audioDuration = 0; if (isVideo) { try { videoDuration = await getVideoDuration(file); if (videoDuration < 2) { antdMessage.error('视频素材最短不能少于 2 秒'); return false; } const latestMedia = useAppStore.getState().currentMedia; const existingVideoDuration = latestMedia .filter((m) => m.type === 'video') .reduce((sum, m) => sum + (m.duration || 0), 0); if (existingVideoDuration + videoDuration > 15) { antdMessage.error(`所有视频素材总时长不能超过 15 秒,当前 ${(existingVideoDuration + videoDuration).toFixed(1)} 秒`); return false; } } catch { antdMessage.error('无法获取视频信息,请检查文件是否损坏'); return false; } } if (isAudio) { try { audioDuration = await getAudioDuration(file); if (audioDuration < 2) { antdMessage.error('音频素材最短不能少于 2 秒'); return false; } const latestMedia = useAppStore.getState().currentMedia; const existingAudioDuration = latestMedia .filter((m) => m.type === 'audio') .reduce((sum, m) => sum + (m.duration || 0), 0); if (existingAudioDuration + audioDuration > 15) { antdMessage.error(`所有音频素材总时长不能超过 15 秒,当前 ${(existingAudioDuration + audioDuration).toFixed(1)} 秒`); return false; } } catch { antdMessage.error('无法获取音频信息,请检查文件是否损坏'); return false; } } const mediaType: 'image' | 'video' | 'audio' = isImage ? 'image' : (isAudio ? 'audio' : 'video'); const pendingMedia: MediaReference = { name: file.name, type: mediaType, url: '', label: '', ...(isVideo && { duration: videoDuration }), ...(isAudio && { duration: audioDuration }), }; const latestMedia = useAppStore.getState().currentMedia as MediaReference[]; if (!validateMediaReferencesBeforeAdd(latestMedia, [pendingMedia])) { return false; } try { let res; if (isImage) { res = await uploadImage(file); } else if (isAudio) { res = await uploadAudio(file, audioDuration); } else { res = await uploadVideo(file, videoDuration); } return { name: pendingMedia.name, type: pendingMedia.type, url: res.url, label: pendingMedia.label || '', fileSizeBytes: file.size, ...(pendingMedia.duration !== undefined && { duration: pendingMedia.duration }), }; } catch (error) { const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败'; antdMessage.error(errorMsg); return false; } }; const handleBatchUpload = async (files: File[]) => { let successCount = 0; let failCount = 0; setUploading(true); for (const file of files) { const result = await doUpload(file); if (result) { const latestMedia = useAppStore.getState().currentMedia; const newList = [...latestMedia, result]; const labels = generateMediaLabels(newList); setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] }))); successCount++; } else { failCount++; } } setUploading(false); if (successCount > 0) { antdMessage.success(`成功上传${successCount}个文件${failCount > 0 ? `,${failCount}个文件上传失败` : ''}`); } }; const getMediaDurationTotal = (items: MediaReference[], type: 'video' | 'audio') => { return items .filter((m) => m.type === type) .reduce((sum, m) => sum + (Number(m.duration) || 0), 0); }; const validateMediaReferencesBeforeAdd = (baseMedia: MediaReference[], incoming: MediaReference[]) => { if (!incoming.length) return false; if (mediaType === 'image' && incoming.some((item) => item.type !== 'image')) { antdMessage.error('图片模式仅支持添加图片素材'); return false; } if (incoming.some((item) => item.type === 'audio') && mediaType !== 'video') { antdMessage.error('仅视频模式支持添加音频素材'); return false; } const imageCount = baseMedia.filter((m) => m.type === 'image').length; const videoCount = baseMedia.filter((m) => m.type === 'video').length; const audioCount = baseMedia.filter((m) => m.type === 'audio').length; const incomingImageCount = incoming.filter((m) => m.type === 'image').length; const incomingVideoCount = incoming.filter((m) => m.type === 'video').length; const incomingAudioCount = incoming.filter((m) => m.type === 'audio').length; if (imageCount + incomingImageCount > maxImage) { antdMessage.error(`该引擎最多上传${maxImage}张图片,当前还能添加 ${Math.max(0, maxImage - imageCount)} 张`); return false; } if (videoCount + incomingVideoCount > maxVideo) { antdMessage.error(`该引擎最多上传${maxVideo}个视频,当前还能添加 ${Math.max(0, maxVideo - videoCount)} 个`); return false; } if (audioCount + incomingAudioCount > maxAudio) { antdMessage.error(`该引擎最多上传${maxAudio}个音频,当前还能添加 ${Math.max(0, maxAudio - audioCount)} 个`); return false; } for (const item of incoming) { if (item.type !== 'video') continue; const duration = Number(item.duration); if (!Number.isFinite(duration) || duration <= 0) { antdMessage.error(`${item.name || '视频素材'}缺少视频秒数,不能用于 AI 创作`); return false; } if (duration < 2) { antdMessage.error(`${item.name || '视频素材'}最短不能少于 2 秒`); return false; } if (duration > 15) { antdMessage.error(`${item.name || '视频素材'}最长不能超过 15 秒`); return false; } } const totalVideoDuration = getMediaDurationTotal(baseMedia, 'video') + getMediaDurationTotal(incoming, 'video'); if (totalVideoDuration > 15) { antdMessage.error(`所有视频素材总时长不能超过 15 秒,当前 ${totalVideoDuration.toFixed(1)} 秒`); return false; } const totalAudioDuration = getMediaDurationTotal(baseMedia, 'audio') + getMediaDurationTotal(incoming, 'audio'); if (totalAudioDuration > 15) { antdMessage.error(`所有音频素材总时长不能超过 15 秒,当前 ${totalAudioDuration.toFixed(1)} 秒`); return false; } return true; }; const normalizeUploadResourceHistoryItem = (item: UploadResourceHistoryItem): MediaReference | null => { const media = item.mediaReference; const refType = (media?.type || item.resourceType) as 'image' | 'video' | 'audio'; const url = media?.url || item.resourceUrl || item.displayUrl || item.previewUrl || ''; if (!url) { antdMessage.error(`${item.fileName || item.id} 缺少素材地址,不能用于 AI 创作`); return null; } if (refType === 'audio' && mediaType !== 'video') { antdMessage.error('仅视频模式支持添加音频素材'); return null; } const duration = Number(media?.duration ?? item.durationSeconds); return { name: media?.name || item.fileName || item.id, type: refType, url, source: 'upload_resource', upload_resource_id: item.id, label: '', ...((refType === 'video' || refType === 'audio') && Number.isFinite(duration) && duration > 0 ? { duration } : {}), }; }; const handleUploadResourceHistorySelected = (items: UploadResourceHistoryItem[]) => { const normalized = items .map(normalizeUploadResourceHistoryItem) .filter(Boolean) as MediaReference[]; if (!normalized.length) return; const latestMedia = useAppStore.getState().currentMedia as MediaReference[]; if (!validateMediaReferencesBeforeAdd(latestMedia, normalized)) { return; } const newList = [...latestMedia, ...normalized]; const labels = generateMediaLabels(newList); setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] }))); antdMessage.success(`已添加 ${normalized.length} 个历史上传素材参考`); }; const normalizePrivatePortraitAsset = (asset: PrivatePortraitSelectableAsset): MediaReference | null => { if (asset.assetType === 'Audio') { antdMessage.error('音频私域素材暂不支持用于 AI 创作'); return null; } const refType: 'image' | 'video' = asset.assetType === 'Video' ? 'video' : 'image'; const fallbackName = privateAssetPickerLibraryType === 'aigc_virtual' ? '虚拟素材' : '真人素材'; const previewUrl = refType === 'video' ? (asset.videoCoverUrl || asset.previewUrl || asset.displayUrl || asset.providerUrl || '') : (asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || ''); return { name: asset.name || fallbackName, type: refType, url: previewUrl, source: 'private_portrait_asset', private_asset_id: asset.id, label: '', ...(refType === 'video' && { duration: Number(asset.videoDuration) || undefined }), }; }; const openPrivatePortraitPicker = (libraryType: PrivatePortraitLibraryType) => { setPrivateAssetPickerLibraryType(libraryType); setPrivateAssetPickerOpen(true); }; const handlePrivatePortraitAssetsSelected = (assets: PrivatePortraitSelectableAsset[]) => { const normalized = assets .map(normalizePrivatePortraitAsset) .filter(Boolean) as MediaReference[]; const latestMedia = useAppStore.getState().currentMedia as MediaReference[]; if (!validateMediaReferencesBeforeAdd(latestMedia, normalized)) { return; } const newList = [...latestMedia, ...normalized]; const labels = generateMediaLabels(newList); setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] }))); antdMessage.success(`已添加 ${normalized.length} 个${privateAssetPickerLibraryType === 'aigc_virtual' ? '虚拟' : '真人'}素材参考`); }; const buildPreviewUrl = (url: string) => { if (!url) return ''; if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:') || url.startsWith('blob:')) { return url; } const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, ''); return `${base}${url.startsWith('/') ? '' : '/'}${url}`; }; const handleRemoveMedia = (index: number) => { const newList = currentMedia.filter((_, i) => i !== index); const labels = generateMediaLabels(newList); setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] }))); }; // const handleKeyPress = (e: React.KeyboardEvent) => { // if (e.key === 'Enter' && !e.shiftKey) { // e.preventDefault(); // handleSend(); // } // }; // 检测光标前的 @ 符号 const checkMention = (textarea: HTMLTextAreaElement, value: string) => { const cursorPos = textarea.selectionStart; const textBeforeCursor = value.slice(0, cursorPos); const atMatch = textBeforeCursor.match(/@([^@\s]*)$/); if (atMatch && currentMedia.length > 0) { setMentionVisible(true); return true; } setMentionVisible(false); return false; }; // 输入框变化处理 const handleInputChange = (e: React.ChangeEvent) => { const value = e.target.value; setInputValue(value); const textarea = e.target; checkMention(textarea, value); }; // 键盘事件处理(ESC 关闭提及、Tab/Enter 选择) const handleInputKeyDown = (e: React.KeyboardEvent) => { if (mentionVisible) { if (e.key === 'Escape') { setMentionVisible(false); } } }; // 插入 @ 提及 const insertMention = (label: string) => { const textarea = mentionInputRef.current?.resizableTextArea?.textArea; if (!textarea) return; const cursorPos = textarea.selectionStart; const textBefore = inputValue.slice(0, cursorPos); const textAfter = inputValue.slice(cursorPos); const atIndex = textBefore.lastIndexOf('@'); if (atIndex === -1) { setMentionVisible(false); return; } const newValue = textBefore.slice(0, atIndex) + `@${label} ` + textAfter; setInputValue(newValue); setMentionVisible(false); setTimeout(() => { const pos = atIndex + label.length + 2; textarea.focus(); textarea.setSelectionRange(pos, pos); }, 0); }; const handleClosePreview = () => { setPreviewVisible(false); setPreviewUrl(''); if (videoRef.current) { videoRef.current.pause(); } }; const handleDownload = (e: any) => { e.preventDefault(); e.stopPropagation(); if (!previewUrl) return; let downloadUrl = previewUrl.replace('/static', '').replace(/&w=\d+/i, '').replace(/&q=\d+/i, ''); if (!downloadUrl.includes('download=1')) { downloadUrl += '&download=1'; } const link = document.createElement('a'); link.href = downloadUrl; console.log(downloadUrl); link.download = previewType === 'image' ? 'image.png' : 'video.mp4'; document.body.appendChild(link); link.click(); document.body.removeChild(link); }; const getAttachmentMediaType = (ref: any): 'image' | 'video' | 'audio' => { const rawType = String(ref?.type || '').toLowerCase(); const rawUrl = String(ref?.url || ref?.name || '').toLowerCase(); if (rawType.includes('video') || /\.(mp4|mov|avi|webm|m4v)(\?|$)/.test(rawUrl)) return 'video'; if (rawType.includes('audio') || /\.(mp3|wav|ogg|aac|m4a)(\?|$)/.test(rawUrl)) return 'audio'; return 'image'; }; const buildAttachmentAssetUrl = (url: string): string => { if (!url) return ''; if (/^https?:\/\//i.test(url)) return url; return `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${url}`; }; const buildAttachmentDownloadUrl = (url: string): string => { const assetUrl = buildAttachmentAssetUrl(url); if (!assetUrl) return ''; return `${assetUrl}${assetUrl.includes('?') ? '&' : '?'}download=1`; }; const openAttachmentPreview = (ref: any) => { if (!ref?.url) return; setAttachmentPreviewUrl(ref.url); setAttachmentPreviewType(getAttachmentMediaType(ref)); setAttachmentPreviewName(ref.name || '附件'); setAttachmentPreviewVisible(true); }; const downloadAttachmentRef = (ref: any, e?: React.MouseEvent) => { e?.preventDefault(); e?.stopPropagation(); if (!ref?.url) return; const link = document.createElement('a'); link.href = buildAttachmentDownloadUrl(ref.url); const refType = getAttachmentMediaType(ref); link.download = ref.name || (refType === 'image' ? 'image.png' : refType === 'video' ? 'video.mp4' : 'audio.mp3'); document.body.appendChild(link); link.click(); document.body.removeChild(link); }; // ==================== 渲染 ==================== const isFirstLastFrameComposer = mediaType === 'video' && referenceMode === 'first_last_frame'; const composerCanSend = !uploading && (isFirstLastFrameComposer ? Boolean(inputValue.trim() || firstFrame) : Boolean(inputValue.trim() || currentMedia.length > 0)); const composerModeLabel = mediaType === 'image' ? '图片生成' : isFirstLastFrameComposer ? '首尾帧视频' : '视频生成'; const composerPlaceholder = mediaType === 'image' ? '输入画面描述,或上传图片参考风格、构图、主体与光影。例:@图片1 参考色调,生成一张简洁高级的产品海报。' : isFirstLastFrameComposer ? '首帧尾帧可以不传,如需传入建议描述首帧到尾帧的主体动作、镜头运动、节奏与转场。例:从首帧自然推进到尾帧,产品居中突出,动作平滑连贯。' : '上传最多12个参考素材,输入文字或 @ 引用参考内容,自由组合图、文、视频。例:@图片1 模仿 @视频1 的动作。'; const composerHelperText = mediaType === 'image' ? '图片参考 · 适合海报、产品图、场景图与风格图生成' : isFirstLastFrameComposer ? '首帧必传 · 尾帧可选 · 适合首尾画面连贯过渡' : '多素材参考 · 支持图片 / 视频 / 音频,输入 @ 可快速引用素材'; const maxImage = maxImageCount; const maxVideo = maxVideoCount; const maxAudio = currentEngine?.maxAudioCount ?? 1; return ( {/* 隐藏的音频播放器 */}