| 1 | 'use client'; |
| 2 | |
| 3 | import React, { useState, useRef, useEffect } from 'react'; |
| 4 | import { Sparkles, Play, Settings2, Clock, ArrowRight, Zap, CheckCircle, Trash2, X, Lock, Globe, ListOrdered, Upload, Loader2 } from 'lucide-react'; |
| 5 | import clsx from 'clsx'; |
| 6 | import { PROMPT_EXAMPLES } from '@/config/examples'; |
| 7 | import { |
| 8 | STYLES, |
| 9 | VIDEO_RATIOS, |
| 10 | VIDEO_RESOLUTIONS, |
| 11 | VIDEO_GENERATION_MODES, |
| 12 | type ProviderGroup, |
| 13 | type VideoGenerationMode, |
| 14 | } from '@/config/models'; |
| 15 | import { STAGES } from './TopBar'; |
| 16 | import { fetchModelGroupsByType, fetchVideoModelGroupsByAbility } from '@/lib/modelRegistry'; |
| 17 | |
| 18 | export interface ProjectParams { |
| 19 | idea: string; |
| 20 | file_path?: string; // 上传的文件路径 (由后端返回的文件名) |
| 21 | style: string; |
| 22 | video_ratio: string; |
| 23 | video_resolution: string; |
| 24 | llm_model: string; |
| 25 | vlm_model: string; |
| 26 | image_t2i_model: string; |
| 27 | image_it2i_model: string; |
| 28 | video_model: string; |
| 29 | video_first_frame_model: string; |
| 30 | video_start_end_model: string; |
| 31 | video_reference_model: string; |
| 32 | video_generation_mode: VideoGenerationMode; |
| 33 | expand_idea?: boolean; |
| 34 | enable_concurrency?: boolean; |
| 35 | web_search?: boolean; |
| 36 | episodes?: number; |
| 37 | } |
| 38 | |
| 39 | interface HistoryItem { |
| 40 | id: string; |
| 41 | idea: string; |
| 42 | style?: string; |
| 43 | date: string; |
| 44 | status: string; |
| 45 | stages?: Record<string, string>; |
| 46 | } |
| 47 | |
| 48 | interface HomePageProps { |
| 49 | onStartProject: (params: ProjectParams, autoMode?: boolean) => void; |
| 50 | onResumeProject: (sessionId: string) => void; |
| 51 | onDeleteSession: (sessionId: string) => Promise<void>; |
| 52 | history: HistoryItem[]; |
| 53 | } |
| 54 | |
| 55 | /* 根据 status 映射生成进度文本 */ |
| 56 | function stageProgressLabel(statusMap?: Record<string, string>): { text: string; color: string } { |
| 57 | const map = statusMap || {}; |
| 58 | const completed = Object.keys(map).filter(k => ["completed", "session_completed"].includes(map[k])); |
| 59 | if (completed.length === 0) return { text: '未开始', color: 'text-gray-400' }; |
| 60 | if (completed.length >= STAGES.length) return { text: '已完成', color: 'text-green-600' }; |
| 61 | |
| 62 | // 对比 STAGES 获取最后一个已完成的 |
| 63 | const lastStageId = STAGES.filter(s => completed.includes(s.id)).pop()?.id || completed[completed.length - 1]; |
| 64 | const stageDef = STAGES.find(s => s.id === lastStageId); |
| 65 | const name = stageDef?.shortName || lastStageId; |
| 66 | return { text: `已完成: ${name} (${completed.length}/${STAGES.length})`, color: 'text-blue-600' }; |
| 67 | } |
| 68 | |
| 69 | export default function HomePage({ onStartProject, onResumeProject, onDeleteSession, history }: HomePageProps) { |
| 70 | const [idea, setIdea] = useState(''); |
| 71 | const [showSettings, setShowSettings] = useState(false); |
| 72 | const [selectedStyle, setSelectedStyle] = useState('realistic'); |
| 73 | const [selectedLLM, setSelectedLLM] = useState(''); |
| 74 | const [selectedVLM, setSelectedVLM] = useState(''); |
| 75 | const [selectedT2I, setSelectedT2I] = useState(''); |
| 76 | const [selectedI2I, setSelectedI2I] = useState(''); |
| 77 | const [selectedFirstFrameVideo, setSelectedFirstFrameVideo] = useState(''); |
| 78 | const [selectedStartEndVideo, setSelectedStartEndVideo] = useState(''); |
| 79 | const [selectedReferenceVideo, setSelectedReferenceVideo] = useState(''); |
| 80 | const [selectedVideoMode, setSelectedVideoMode] = useState<VideoGenerationMode>('first_frame'); |
| 81 | const [selectedRatio, setSelectedRatio] = useState(''); |
| 82 | const [selectedResolution, setSelectedResolution] = useState('720P'); |
| 83 | const [configLoading, setConfigLoading] = useState(true); |
| 84 | const [configError, setConfigError] = useState(''); |
| 85 | const [enableConcurrency, setEnableConcurrency] = useState(true); |
| 86 | const [webSearch, setWebSearch] = useState(false); |
| 87 | const [episodes, setEpisodes] = useState(4); |
| 88 | const [showEpisodesPanel, setShowEpisodesPanel] = useState(false); |
| 89 | |
| 90 | // 上传相关状态 |
| 91 | const fileInputRef = useRef<HTMLInputElement>(null); |
| 92 | const [uploading, setUploading] = useState(false); |
| 93 | const [uploadedFile, setUploadedFile] = useState<{name: string, path: string} | null>(null); |
| 94 | |
| 95 | // 管理模式状态 |
| 96 | const [manageMode, setManageMode] = useState(false); |
| 97 | const [deleteTarget, setDeleteTarget] = useState<string | null>(null); |
| 98 | const [deleteError, setDeleteError] = useState(''); |
| 99 | const [deleting, setDeleting] = useState(false); |
| 100 | const [llmProviders, setLlmProviders] = useState<ProviderGroup[]>([]); |
| 101 | const [vlmProviders, setVlmProviders] = useState<ProviderGroup[]>([]); |
| 102 | const [t2iProviders, setT2iProviders] = useState<ProviderGroup[]>([]); |
| 103 | const [i2iProviders, setI2iProviders] = useState<ProviderGroup[]>([]); |
| 104 | const [firstFrameVideoProviders, setFirstFrameVideoProviders] = useState<ProviderGroup[]>([]); |
| 105 | const [startEndVideoProviders, setStartEndVideoProviders] = useState<ProviderGroup[]>([]); |
| 106 | const [referenceVideoProviders, setReferenceVideoProviders] = useState<ProviderGroup[]>([]); |
| 107 | const activeVideoModel = |
| 108 | selectedVideoMode === 'start_end_frame' |
| 109 | ? selectedStartEndVideo |
| 110 | : selectedVideoMode === 'reference' |
| 111 | ? selectedReferenceVideo |
| 112 | : selectedFirstFrameVideo; |
| 113 | const modelConfigReady = Boolean(selectedLLM && selectedVLM && selectedT2I && selectedI2I && activeVideoModel && selectedRatio && selectedResolution); |
| 114 | const canStart = Boolean((idea.trim() || uploadedFile) && modelConfigReady && !configLoading); |
| 115 | |
| 116 | useEffect(() => { |
| 117 | let cancelled = false; |
| 118 | fetchModelGroupsByType('llm') |
| 119 | .then(groups => { if (!cancelled) setLlmProviders(groups); }) |
| 120 | .catch(() => {}); |
| 121 | fetchModelGroupsByType('vlm') |
| 122 | .then(groups => { if (!cancelled) setVlmProviders(groups); }) |
| 123 | .catch(() => {}); |
| 124 | fetchModelGroupsByType('t2i') |
| 125 | .then(groups => { if (!cancelled) setT2iProviders(groups); }) |
| 126 | .catch(() => {}); |
| 127 | fetchModelGroupsByType('i2i') |
| 128 | .then(groups => { if (!cancelled) setI2iProviders(groups); }) |
| 129 | .catch(() => {}); |
| 130 | fetchVideoModelGroupsByAbility('first_frame_i2v') |
| 131 | .then(groups => { if (!cancelled) setFirstFrameVideoProviders(groups); }) |
| 132 | .catch(() => {}); |
| 133 | fetchVideoModelGroupsByAbility('start_end_frame_i2v') |
| 134 | .then(groups => { if (!cancelled) setStartEndVideoProviders(groups); }) |
| 135 | .catch(() => {}); |
| 136 | fetchVideoModelGroupsByAbility('reference_to_video') |
| 137 | .then(groups => { if (!cancelled) setReferenceVideoProviders(groups); }) |
| 138 | .catch(() => {}); |
| 139 | return () => { cancelled = true; }; |
| 140 | }, []); |
| 141 | |
| 142 | useEffect(() => { |
| 143 | let cancelled = false; |
| 144 | const loadDefaultConfig = async () => { |
| 145 | setConfigLoading(true); |
| 146 | setConfigError(''); |
| 147 | try { |
| 148 | const resp = await fetch('/api/config'); |
| 149 | if (!resp.ok) throw new Error('读取默认模型配置失败'); |
| 150 | const data = await resp.json(); |
| 151 | const models = data.config?.models || {}; |
| 152 | const generation = data.config?.generation || {}; |
| 153 | // Legacy config compatibility: older config.yaml only has models.video, so treat it as first-frame video. |
| 154 | const firstFrameModel = models.video_first_frame || models.video; |
| 155 | const startEndModel = models.video_start_end || 'wan2.7-i2v'; |
| 156 | const referenceModel = models.video_reference || 'wan2.7-r2v'; |
| 157 | const videoMode = (generation.video_generation_mode || 'first_frame') as VideoGenerationMode; |
| 158 | const selectedModel = videoMode === 'start_end_frame' ? startEndModel : videoMode === 'reference' ? referenceModel : firstFrameModel; |
| 159 | if (!models.llm || !models.vlm || !models.image_t2i || !models.image_it2i || !selectedModel) { |
| 160 | throw new Error('backend/config.yaml 缺少主流程默认模型'); |
| 161 | } |
| 162 | if (cancelled) return; |
| 163 | setSelectedStyle(generation.style || 'realistic'); |
| 164 | setSelectedLLM(models.llm); |
| 165 | setSelectedVLM(models.vlm); |
| 166 | setSelectedT2I(models.image_t2i); |
| 167 | setSelectedI2I(models.image_it2i); |
| 168 | setSelectedVideoMode(videoMode); |
| 169 | setSelectedFirstFrameVideo(firstFrameModel); |
| 170 | setSelectedStartEndVideo(startEndModel); |
| 171 | setSelectedReferenceVideo(referenceModel); |
| 172 | setSelectedRatio(generation.video_ratio || '16:9'); |
| 173 | setSelectedResolution(generation.video_resolution || '720P'); |
| 174 | } catch (e: any) { |
| 175 | if (!cancelled) setConfigError(e.message || '读取默认模型配置失败'); |
| 176 | } finally { |
| 177 | if (!cancelled) setConfigLoading(false); |
| 178 | } |
| 179 | }; |
| 180 | loadDefaultConfig(); |
| 181 | return () => { cancelled = true; }; |
| 182 | }, []); |
| 183 | |
| 184 | const handleDelete = async () => { |
| 185 | if (!deleteTarget) return; |
| 186 | setDeleting(true); |
| 187 | setDeleteError(''); |
| 188 | try { |
| 189 | await onDeleteSession(deleteTarget); |
| 190 | setDeleteTarget(null); |
| 191 | } catch (e: any) { |
| 192 | setDeleteError(e.message || '删除失败'); |
| 193 | } finally { |
| 194 | setDeleting(false); |
| 195 | } |
| 196 | }; |
| 197 | |
| 198 | const activeVideoProviders = |
| 199 | selectedVideoMode === 'start_end_frame' |
| 200 | ? startEndVideoProviders |
| 201 | : selectedVideoMode === 'reference' |
| 202 | ? referenceVideoProviders |
| 203 | : firstFrameVideoProviders; |
| 204 | |
| 205 | const setActiveVideoModel = (value: string) => { |
| 206 | if (selectedVideoMode === 'start_end_frame') { |
| 207 | setSelectedStartEndVideo(value); |
| 208 | } else if (selectedVideoMode === 'reference') { |
| 209 | setSelectedReferenceVideo(value); |
| 210 | } else { |
| 211 | setSelectedFirstFrameVideo(value); |
| 212 | } |
| 213 | }; |
| 214 | |
| 215 | const selectedVideoModeLabel = VIDEO_GENERATION_MODES.find(item => item.id === selectedVideoMode)?.label || '首帧生视频'; |
| 216 | |
| 217 | const handleStart = (auto?: boolean) => { |
| 218 | if (!canStart) return; |
| 219 | onStartProject({ |
| 220 | idea, |
| 221 | file_path: uploadedFile?.path, // 如果上传了文件,传给后端 |
| 222 | style: selectedStyle, |
| 223 | video_ratio: selectedRatio, |
| 224 | video_resolution: selectedResolution, |
| 225 | llm_model: selectedLLM, |
| 226 | vlm_model: selectedVLM, |
| 227 | image_t2i_model: selectedT2I, |
| 228 | image_it2i_model: selectedI2I, |
| 229 | video_generation_mode: selectedVideoMode, |
| 230 | video_first_frame_model: selectedFirstFrameVideo, |
| 231 | video_start_end_model: selectedStartEndVideo, |
| 232 | video_reference_model: selectedReferenceVideo, |
| 233 | video_model: activeVideoModel, |
| 234 | enable_concurrency: enableConcurrency, |
| 235 | web_search: webSearch, |
| 236 | episodes, |
| 237 | }, auto); |
| 238 | }; |
| 239 | |
| 240 | const handleExampleClick = (text: string) => { |
| 241 | setIdea(text); |
| 242 | }; |
| 243 | |
| 244 | const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { |
| 245 | const file = e.target.files?.[0]; |
| 246 | if (!file) return; |
| 247 | |
| 248 | const allowedExtensions = ['.doc', '.docx', '.txt', '.md', '.pdf']; |
| 249 | const extension = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); |
| 250 | |
| 251 | if (!allowedExtensions.includes(extension)) { |
| 252 | alert(`仅支持 ${allowedExtensions.join(', ')} 格式的文件`); |
| 253 | return; |
| 254 | } |
| 255 | |
| 256 | setUploading(true); |
| 257 | const formData = new FormData(); |
| 258 | formData.append('file', file); |
| 259 | |
| 260 | try { |
| 261 | const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'}/api/upload_file`, { |
| 262 | method: 'POST', |
| 263 | body: formData, |
| 264 | }); |
| 265 | |
| 266 | if (!response.ok) { |
| 267 | throw new Error('文件上传失败'); |
| 268 | } |
| 269 | |
| 270 | const data = await response.json(); |
| 271 | if (data.file_path) { |
| 272 | // 记录已上传的文件信息,不修改输入框 |
| 273 | setUploadedFile({ |
| 274 | name: file.name, |
| 275 | path: data.file_path |
| 276 | }); |
| 277 | } |
| 278 | } catch (error) { |
| 279 | console.error('上传错误:', error); |
| 280 | alert('上传提取内容失败,请重试'); |
| 281 | } finally { |
| 282 | setUploading(false); |
| 283 | // 清空 input 方便下次选择同一文件 |
| 284 | if (fileInputRef.current) fileInputRef.current.value = ''; |
| 285 | } |
| 286 | }; |
| 287 | |
| 288 | return ( |
| 289 | <div className="h-full flex flex-col items-center overflow-y-auto bg-gray-50/50"> |
| 290 | {/* 主区域 - 居中 */} |
| 291 | <div className="w-full max-w-6xl px-6 pt-16 pb-8 flex-shrink-0"> |
| 292 | {/* 标题 */} |
| 293 | <div className="text-center mb-10"> |
| 294 | <div className="inline-flex items-center gap-2 mb-3"> |
| 295 | <Sparkles className="w-7 h-7 text-blue-500" /> |
| 296 | <h1 className="text-2xl font-bold text-gray-800">Video-Claw</h1> |
| 297 | </div> |
| 298 | <p className="text-sm text-gray-500"> |
| 299 | 输入你的创意,AI 将为你分步生成完整短片 |
| 300 | </p> |
| 301 | </div> |
| 302 | |
| 303 | {/* 输入区域 */} |
| 304 | <div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-5 mb-6"> |
| 305 | <textarea |
| 306 | value={idea} |
| 307 | onChange={e => setIdea(e.target.value)} |
| 308 | placeholder="描述你的视频创意... 例如:一只叫Luna的猫意外进入太空站,遇到一个孤独的宇航员" |
| 309 | className="w-full bg-transparent text-sm text-gray-800 placeholder-gray-400 resize-none outline-none min-h-[100px]" |
| 310 | onKeyDown={e => { |
| 311 | if (e.key === 'Enter' && !e.shiftKey && (idea.trim() || uploadedFile)) { |
| 312 | e.preventDefault(); |
| 313 | handleStart(false); |
| 314 | } |
| 315 | }} |
| 316 | /> |
| 317 | |
| 318 | <div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-100"> |
| 319 | <div className="flex items-center gap-3"> |
| 320 | <div className="relative"> |
| 321 | <button |
| 322 | onClick={() => setShowEpisodesPanel(!showEpisodesPanel)} |
| 323 | className={clsx( |
| 324 | 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors', |
| 325 | showEpisodesPanel |
| 326 | ? 'bg-blue-50 text-blue-600' |
| 327 | : 'text-gray-400 hover:text-gray-600 hover:bg-gray-50' |
| 328 | )} |
| 329 | > |
| 330 | <ListOrdered className="w-3.5 h-3.5" /> |
| 331 | 剧集: {episodes}集 |
| 332 | </button> |
| 333 | |
| 334 | {showEpisodesPanel && ( |
| 335 | <> |
| 336 | <div |
| 337 | className="fixed inset-0 z-10" |
| 338 | onClick={() => setShowEpisodesPanel(false)} |
| 339 | /> |
| 340 | <div className="absolute bottom-full left-0 mb-2 w-48 bg-white rounded-xl shadow-xl border border-gray-100 p-4 z-20 animate-in fade-in slide-in-from-bottom-2"> |
| 341 | <div className="flex flex-col gap-3"> |
| 342 | <div className="flex items-center justify-between"> |
| 343 | <span className="text-xs font-semibold text-gray-700">设置总集数</span> |
| 344 | <span className="text-[10px] text-blue-500 font-bold bg-blue-50 px-1.5 py-0.5 rounded-full"> |
| 345 | {episodes} 集 |
| 346 | </span> |
| 347 | </div> |
| 348 | |
| 349 | <div className="flex items-center gap-2"> |
| 350 | <button |
| 351 | onClick={(e) => { e.stopPropagation(); setEpisodes(Math.max(1, episodes - 1)); }} |
| 352 | className="w-7 h-7 flex items-center justify-center rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 active:scale-95 transition-all text-sm font-bold" |
| 353 | > |
| 354 | - |
| 355 | </button> |
| 356 | <input |
| 357 | type="range" |
| 358 | min={1} |
| 359 | max={10} |
| 360 | value={episodes} |
| 361 | onChange={(e) => setEpisodes(parseInt(e.target.value))} |
| 362 | className="flex-1 h-1.5 bg-gray-100 rounded-lg appearance-none cursor-pointer accent-blue-500" |
| 363 | /> |
| 364 | <button |
| 365 | onClick={(e) => { e.stopPropagation(); setEpisodes(Math.min(10, episodes + 1)); }} |
| 366 | className="w-7 h-7 flex items-center justify-center rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 active:scale-95 transition-all text-sm font-bold" |
| 367 | > |
| 368 | + |
| 369 | </button> |
| 370 | </div> |
| 371 | |
| 372 | <div className="space-y-1 border-t border-gray-50 pt-2"> |
| 373 | <p className="text-[10px] text-gray-400 leading-tight"> |
| 374 | • 每集预估时长约 1-2 分钟 |
| 375 | </p> |
| 376 | <p className="text-[10px] text-blue-400/80 leading-tight"> |
| 377 | • 推荐设置 4-6 集 |
| 378 | </p> |
| 379 | </div> |
| 380 | </div> |
| 381 | </div> |
| 382 | </> |
| 383 | )} |
| 384 | </div> |
| 385 | |
| 386 | <button |
| 387 | onClick={() => setShowSettings(!showSettings)} |
| 388 | className={clsx( |
| 389 | 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors', |
| 390 | showSettings |
| 391 | ? 'bg-blue-50 text-blue-600' |
| 392 | : 'text-gray-400 hover:text-gray-600 hover:bg-gray-50' |
| 393 | )} |
| 394 | > |
| 395 | <Settings2 className="w-3.5 h-3.5" /> |
| 396 | 生成配置 |
| 397 | </button> |
| 398 | <button |
| 399 | onClick={() => setWebSearch(!webSearch)} |
| 400 | className={clsx( |
| 401 | 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors', |
| 402 | webSearch |
| 403 | ? 'bg-blue-50 text-blue-600' |
| 404 | : 'text-gray-400 hover:text-gray-600 hover:bg-gray-50' |
| 405 | )} |
| 406 | > |
| 407 | <Globe className="w-3.5 h-3.5" /> |
| 408 | 联网搜索 |
| 409 | </button> |
| 410 | </div> |
| 411 | <div className="flex items-center gap-2"> |
| 412 | {/* 隐藏的文件输入框 */} |
| 413 | <input |
| 414 | type="file" |
| 415 | ref={fileInputRef} |
| 416 | onChange={handleFileUpload} |
| 417 | accept=".doc,.docx,.txt,.md,.pdf" |
| 418 | className="hidden" |
| 419 | /> |
| 420 | <button |
| 421 | onClick={() => { |
| 422 | if (uploadedFile) { |
| 423 | setUploadedFile(null); // 已有文件则点击取消 |
| 424 | } else { |
| 425 | fileInputRef.current?.click(); |
| 426 | } |
| 427 | }} |
| 428 | disabled={uploading} |
| 429 | className={clsx( |
| 430 | 'flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-medium transition-colors border shadow-sm relative', |
| 431 | uploading |
| 432 | ? 'bg-gray-50 text-gray-400 cursor-not-allowed border-gray-100' |
| 433 | : uploadedFile |
| 434 | ? 'bg-blue-50 text-blue-600 border-blue-100 hover:bg-blue-100' |
| 435 | : 'bg-white text-gray-600 hover:bg-gray-50 border-gray-200' |
| 436 | )} |
| 437 | title={uploadedFile ? `已选择: ${uploadedFile.name} (点击取消)` : "上传文档 (Word/TXT/MD)"} |
| 438 | > |
| 439 | {uploading ? ( |
| 440 | <Loader2 className="w-4 h-4 animate-spin" /> |
| 441 | ) : uploadedFile ? ( |
| 442 | <CheckCircle className="w-4 h-4" /> |
| 443 | ) : ( |
| 444 | <Upload className="w-4 h-4" /> |
| 445 | )} |
| 446 | {uploading ? '上传中...' : uploadedFile ? `已选: ${uploadedFile.name.length > 8 ? uploadedFile.name.substring(0, 8) + '...' : uploadedFile.name}` : '上传文件'} |
| 447 | {uploadedFile && ( |
| 448 | <div className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full p-0.5"> |
| 449 | <X className="w-2.5 h-2.5" /> |
| 450 | </div> |
| 451 | )} |
| 452 | </button> |
| 453 | <button |
| 454 | onClick={() => handleStart(false)} |
| 455 | disabled={!canStart} |
| 456 | className={clsx( |
| 457 | 'flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-medium transition-colors', |
| 458 | canStart |
| 459 | ? 'bg-blue-500 text-white hover:bg-blue-600 shadow-sm' |
| 460 | : 'bg-gray-100 text-gray-400 cursor-not-allowed' |
| 461 | )} |
| 462 | > |
| 463 | <Play className="w-4 h-4" /> |
| 464 | 逐步创作 |
| 465 | </button> |
| 466 | <button |
| 467 | onClick={() => handleStart(true)} |
| 468 | disabled={!canStart} |
| 469 | className={clsx( |
| 470 | 'flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-colors', |
| 471 | canStart |
| 472 | ? 'bg-amber-500 text-white hover:bg-amber-600 shadow-sm' |
| 473 | : 'bg-gray-100 text-gray-400 cursor-not-allowed' |
| 474 | )} |
| 475 | title="自动执行全部六个阶段,无需手动确认" |
| 476 | > |
| 477 | <Zap className="w-4 h-4" /> |
| 478 | 一键生成 |
| 479 | </button> |
| 480 | </div> |
| 481 | </div> |
| 482 | {(configLoading || configError) && ( |
| 483 | <div className={clsx('mt-3 text-xs', configError ? 'text-red-500' : 'text-gray-400')}> |
| 484 | {configError || '正在读取 backend/config.yaml 中的默认模型...'} |
| 485 | </div> |
| 486 | )} |
| 487 | |
| 488 | {/* 模型设置折叠面板 */} |
| 489 | {showSettings && ( |
| 490 | <div className="mt-4 p-4 bg-gray-50 rounded-xl space-y-4 text-xs"> |
| 491 | <div className="grid grid-cols-1 gap-3"> |
| 492 | <label className="flex flex-col gap-1"> |
| 493 | <span className="text-gray-500 font-medium">风格</span> |
| 494 | <select |
| 495 | value={selectedStyle} |
| 496 | onChange={e => setSelectedStyle(e.target.value)} |
| 497 | className="bg-white border border-gray-200 rounded-lg px-2.5 py-2 text-gray-700 outline-none" |
| 498 | > |
| 499 | {STYLES.map(s => ( |
| 500 | <option key={s.id} value={s.id}>{s.label}</option> |
| 501 | ))} |
| 502 | </select> |
| 503 | </label> |
| 504 | </div> |
| 505 | |
| 506 | <div className="grid grid-cols-2 gap-3"> |
| 507 | <label className="flex flex-col gap-1"> |
| 508 | <span className="text-gray-500 font-medium">视频分辨率</span> |
| 509 | <select |
| 510 | value={selectedResolution} |
| 511 | onChange={e => setSelectedResolution(e.target.value)} |
| 512 | className="bg-white border border-gray-200 rounded-lg px-2.5 py-2 text-gray-700 outline-none min-h-[40px]" |
| 513 | > |
| 514 | {VIDEO_RESOLUTIONS.map(item => ( |
| 515 | <option key={item.id} value={item.id}>{item.label}</option> |
| 516 | ))} |
| 517 | </select> |
| 518 | </label> |
| 519 | <label className="flex flex-col gap-1"> |
| 520 | <span className="text-gray-500 font-medium">视频长宽比</span> |
| 521 | <div className="flex gap-1"> |
| 522 | {VIDEO_RATIOS.map(r => ( |
| 523 | <button |
| 524 | key={r.id} |
| 525 | onClick={() => setSelectedRatio(r.id)} |
| 526 | className={`flex flex-col items-center gap-1 p-2 rounded-lg border transition-all ${ |
| 527 | selectedRatio === r.id |
| 528 | ? 'border-indigo-500 bg-indigo-50' |
| 529 | : 'border-gray-200 hover:border-gray-300' |
| 530 | }`} |
| 531 | title={r.label} |
| 532 | > |
| 533 | <div |
| 534 | className="bg-gray-700 rounded-sm" |
| 535 | style={{ |
| 536 | width: r.ratio === '16:9' ? '32px' : |
| 537 | r.ratio === '9:16' ? '18px' : |
| 538 | r.ratio === '1:1' ? '24px' : |
| 539 | r.ratio === '4:3' ? '28px' : |
| 540 | r.ratio === '3:4' ? '20px' : |
| 541 | '36px', |
| 542 | height: r.ratio === '16:9' ? '18px' : |
| 543 | r.ratio === '9:16' ? '32px' : |
| 544 | r.ratio === '1:1' ? '24px' : |
| 545 | r.ratio === '4:3' ? '21px' : |
| 546 | r.ratio === '3:4' ? '28px' : |
| 547 | '15px', |
| 548 | }} |
| 549 | /> |
| 550 | <span className="text-[10px] text-gray-500">{r.label}</span> |
| 551 | </button> |
| 552 | ))} |
| 553 | </div> |
| 554 | </label> |
| 555 | </div> |
| 556 | |
| 557 | <div className="space-y-3 border-t border-gray-200/70 pt-4"> |
| 558 | <div className="flex items-center justify-between"> |
| 559 | <span className="text-gray-500 font-semibold">模型配置</span> |
| 560 | <span className="text-[10px] text-gray-400">用于主流程各阶段调用</span> |
| 561 | </div> |
| 562 | <div className="grid grid-cols-2 gap-3"> |
| 563 | <label className="flex flex-col gap-1"> |
| 564 | <span className="text-gray-500 font-medium">LLM 模型</span> |
| 565 | <select |
| 566 | value={selectedLLM} |
| 567 | onChange={e => setSelectedLLM(e.target.value)} |
| 568 | className="bg-white border border-gray-200 rounded-lg px-2.5 py-2 text-gray-700 outline-none" |
| 569 | > |
| 570 | {llmProviders.map(pg => ( |
| 571 | <optgroup key={pg.provider} label={pg.label}> |
| 572 | {pg.models.map(m => ( |
| 573 | <option key={m.id} value={m.id}>{m.label}</option> |
| 574 | ))} |
| 575 | </optgroup> |
| 576 | ))} |
| 577 | </select> |
| 578 | </label> |
| 579 | <label className="flex flex-col gap-1"> |
| 580 | <span className="text-gray-500 font-medium">VLM 评估模型</span> |
| 581 | <select |
| 582 | value={selectedVLM} |
| 583 | onChange={e => setSelectedVLM(e.target.value)} |
| 584 | className="bg-white border border-gray-200 rounded-lg px-2.5 py-2 text-gray-700 outline-none" |
| 585 | > |
| 586 | {vlmProviders.map(pg => ( |
| 587 | <optgroup key={pg.provider} label={pg.label}> |
| 588 | {pg.models.map(m => ( |
| 589 | <option key={m.id} value={m.id}>{m.label}</option> |
| 590 | ))} |
| 591 | </optgroup> |
| 592 | ))} |
| 593 | </select> |
| 594 | </label> |
| 595 | <label className="flex flex-col gap-1"> |
| 596 | <span className="text-gray-500 font-medium">文生图</span> |
| 597 | <select |
| 598 | value={selectedT2I} |
| 599 | onChange={e => setSelectedT2I(e.target.value)} |
| 600 | className="bg-white border border-gray-200 rounded-lg px-2.5 py-2 text-gray-700 outline-none" |
| 601 | > |
| 602 | {t2iProviders.map(pg => ( |
| 603 | <optgroup key={pg.provider} label={pg.label}> |
| 604 | {pg.models.map(m => ( |
| 605 | <option key={m.id} value={m.id}>{m.label}</option> |
| 606 | ))} |
| 607 | </optgroup> |
| 608 | ))} |
| 609 | </select> |
| 610 | </label> |
| 611 | <label className="flex flex-col gap-1"> |
| 612 | <span className="text-gray-500 font-medium">图生图</span> |
| 613 | <select |
| 614 | value={selectedI2I} |
| 615 | onChange={e => setSelectedI2I(e.target.value)} |
| 616 | className="bg-white border border-gray-200 rounded-lg px-2.5 py-2 text-gray-700 outline-none" |
| 617 | > |
| 618 | {i2iProviders.map(pg => ( |
| 619 | <optgroup key={pg.provider} label={pg.label}> |
| 620 | {pg.models.map(m => ( |
| 621 | <option key={m.id} value={m.id}>{m.label}</option> |
| 622 | ))} |
| 623 | </optgroup> |
| 624 | ))} |
| 625 | </select> |
| 626 | </label> |
| 627 | <label className="flex flex-col gap-1 col-span-2"> |
| 628 | <span className="text-gray-500 font-medium">视频生成方式</span> |
| 629 | <select |
| 630 | value={selectedVideoMode} |
| 631 | onChange={e => setSelectedVideoMode(e.target.value as VideoGenerationMode)} |
| 632 | className="bg-white border border-gray-200 rounded-lg px-2.5 py-2 text-gray-700 outline-none" |
| 633 | > |
| 634 | {VIDEO_GENERATION_MODES.map(item => ( |
| 635 | <option key={item.id} value={item.id}>{item.label}</option> |
| 636 | ))} |
| 637 | </select> |
| 638 | </label> |
| 639 | <label className="flex flex-col gap-1 col-span-2"> |
| 640 | <span className="text-gray-500 font-medium">{selectedVideoModeLabel}模型</span> |
| 641 | <select |
| 642 | value={activeVideoModel} |
| 643 | onChange={e => setActiveVideoModel(e.target.value)} |
| 644 | className="bg-white border border-gray-200 rounded-lg px-2.5 py-2 text-gray-700 outline-none" |
| 645 | > |
| 646 | {activeVideoProviders.map(pg => ( |
| 647 | <optgroup key={pg.provider} label={pg.label}> |
| 648 | {pg.models.map(m => ( |
| 649 | <option key={m.id} value={m.id}>{m.label}</option> |
| 650 | ))} |
| 651 | </optgroup> |
| 652 | ))} |
| 653 | </select> |
| 654 | </label> |
| 655 | <label className="flex items-center gap-2 text-sm cursor-pointer select-none"> |
| 656 | <input |
| 657 | type="checkbox" |
| 658 | checked={enableConcurrency} |
| 659 | onChange={e => setEnableConcurrency(e.target.checked)} |
| 660 | className="w-4 h-4 rounded border-gray-300 text-blue-500 focus:ring-blue-500/30" |
| 661 | /> |
| 662 | <span className="text-gray-600">并发生成</span> |
| 663 | </label> |
| 664 | </div> |
| 665 | </div> |
| 666 | </div> |
| 667 | )} |
| 668 | </div> |
| 669 | |
| 670 | {/* 示例卡片 */} |
| 671 | <div className="mb-10"> |
| 672 | <h3 className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-3"> |
| 673 | 灵感示例 |
| 674 | </h3> |
| 675 | <div className="grid grid-cols-2 md:grid-cols-3 gap-2.5"> |
| 676 | {PROMPT_EXAMPLES.map((ex, idx) => ( |
| 677 | <button |
| 678 | key={idx} |
| 679 | onClick={() => handleExampleClick(ex.text)} |
| 680 | className="text-left p-3.5 bg-white rounded-xl border border-gray-200 hover:border-blue-300 hover:shadow-sm transition-all group" |
| 681 | > |
| 682 | <div className="text-sm font-medium text-gray-700 group-hover:text-blue-600 transition-colors mb-1"> |
| 683 | {ex.title} |
| 684 | </div> |
| 685 | <div className="text-xs text-gray-400 line-clamp-2"> |
| 686 | {ex.description} |
| 687 | </div> |
| 688 | </button> |
| 689 | ))} |
| 690 | </div> |
| 691 | </div> |
| 692 | </div> |
| 693 | |
| 694 | {/* 历史记录区域 */} |
| 695 | {history.length > 0 && ( |
| 696 | <div className="w-full max-w-6xl px-6 pb-12 flex-shrink-0"> |
| 697 | <div className="flex items-center gap-2 mb-4"> |
| 698 | <Clock className="w-4 h-4 text-gray-400" /> |
| 699 | <h3 className="text-sm font-medium text-gray-600">历史记录</h3> |
| 700 | <button |
| 701 | onClick={() => setManageMode(m => !m)} |
| 702 | className={`ml-auto text-xs px-2 py-0.5 rounded transition-colors ${ |
| 703 | manageMode ? 'bg-red-100 text-red-600' : 'bg-gray-100 text-gray-500 hover:bg-gray-200' |
| 704 | }`} |
| 705 | > |
| 706 | {manageMode ? '完成' : '管理'} |
| 707 | </button> |
| 708 | </div> |
| 709 | <div className="max-h-[60vh] overflow-y-auto pr-1"> |
| 710 | <div className="grid grid-cols-2 gap-3"> |
| 711 | {history.map(item => { |
| 712 | const progress = stageProgressLabel(item.stages); |
| 713 | return ( |
| 714 | <div key={item.id} className="relative group"> |
| 715 | <div |
| 716 | onClick={() => !manageMode && onResumeProject(item.id)} |
| 717 | className={`w-full text-left p-4 bg-white rounded-xl border border-gray-200 hover:border-blue-300 hover:shadow-sm transition-all ${!manageMode ? 'cursor-pointer' : ''}`} |
| 718 | > |
| 719 | <div className="flex items-start justify-between gap-2"> |
| 720 | <div className="flex-1 min-w-0"> |
| 721 | <div className="text-sm font-medium text-gray-700 group-hover:text-blue-600 transition-colors truncate"> |
| 722 | {item.idea} |
| 723 | </div> |
| 724 | <div className="flex items-center gap-2 mt-1.5 flex-wrap"> |
| 725 | {item.style && ( |
| 726 | <span className="text-[10px] bg-gray-100 text-gray-500 px-1.5 py-0.5 rounded"> |
| 727 | {item.style} |
| 728 | </span> |
| 729 | )} |
| 730 | <span className="text-[10px] text-gray-400">{item.date}</span> |
| 731 | </div> |
| 732 | <div className={`flex items-center gap-1 mt-1.5 text-[10px] font-medium ${progress.color}`}> |
| 733 | {item.stages && Object.keys(item.stages).filter(k => ["completed", "session_completed"].includes(item.stages![k])).length >= STAGES.length ? ( |
| 734 | <CheckCircle className="w-3 h-3" /> |
| 735 | ) : ( |
| 736 | <span className="w-1.5 h-1.5 rounded-full bg-current flex-shrink-0" /> |
| 737 | )} |
| 738 | <span>{progress.text}</span> |
| 739 | </div> |
| 740 | </div> |
| 741 | {manageMode ? ( |
| 742 | <button |
| 743 | onClick={(e) => { e.stopPropagation(); setDeleteTarget(item.id); setDeleteError(''); }} |
| 744 | className="w-6 h-6 rounded-full bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transition-colors flex-shrink-0 mt-0.5" |
| 745 | title="删除" |
| 746 | > |
| 747 | <Trash2 className="w-3 h-3" /> |
| 748 | </button> |
| 749 | ) : ( |
| 750 | <ArrowRight className="w-4 h-4 text-gray-300 group-hover:text-blue-400 transition-colors flex-shrink-0 mt-0.5" /> |
| 751 | )} |
| 752 | </div> |
| 753 | </div> |
| 754 | </div> |
| 755 | ); |
| 756 | })} |
| 757 | </div> |
| 758 | </div> |
| 759 | </div> |
| 760 | )} |
| 761 | |
| 762 | {/* 删除确认弹窗 */} |
| 763 | {deleteTarget && ( |
| 764 | <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> |
| 765 | <div className="bg-white rounded-2xl shadow-xl w-80 p-6 relative"> |
| 766 | <button |
| 767 | onClick={() => { setDeleteTarget(null); setDeleteError(''); }} |
| 768 | className="absolute top-3 right-3 text-gray-400 hover:text-gray-600" |
| 769 | > |
| 770 | <X className="w-4 h-4" /> |
| 771 | </button> |
| 772 | <div className="flex items-center gap-2 mb-4"> |
| 773 | <Trash2 className="w-4 h-4 text-red-500" /> |
| 774 | <h4 className="text-sm font-semibold text-gray-700">确认删除</h4> |
| 775 | </div> |
| 776 | <p className="text-xs text-gray-500 mb-6">删除后不可恢复,确定要删除此项目吗?</p> |
| 777 | {deleteError && <p className="text-xs text-red-500 mb-2">{deleteError}</p>} |
| 778 | <div className="flex gap-2"> |
| 779 | <button |
| 780 | onClick={() => { setDeleteTarget(null); setDeleteError(''); }} |
| 781 | className="flex-1 text-sm py-1.5 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50" |
| 782 | > |
| 783 | 取消 |
| 784 | </button> |
| 785 | <button |
| 786 | onClick={handleDelete} |
| 787 | disabled={deleting} |
| 788 | className="flex-1 text-sm py-1.5 rounded-lg bg-red-500 text-white hover:bg-red-600 disabled:opacity-50" |
| 789 | > |
| 790 | {deleting ? '删除中…' : '确认删除'} |
| 791 | </button> |
| 792 | </div> |
| 793 | </div> |
| 794 | </div> |
| 795 | )} |
| 796 | </div> |
| 797 | ); |
| 798 | } |
| 799 |