| 1 | 'use client'; |
| 2 | |
| 3 | import React, { useState, useRef, useEffect } from 'react'; |
| 4 | import { CheckCircle, Circle, Loader, Edit3, AlertCircle, Square, Zap, Settings2, ChevronDown } from 'lucide-react'; |
| 5 | import clsx from 'clsx'; |
| 6 | import { |
| 7 | VIDEO_RATIOS, |
| 8 | VIDEO_RESOLUTIONS, |
| 9 | VIDEO_GENERATION_MODES, |
| 10 | type ProviderGroup, |
| 11 | type VideoGenerationMode, |
| 12 | } from '@/config/models'; |
| 13 | import { fetchModelGroupsByType, fetchVideoModelGroupsByAbility } from '@/lib/modelRegistry'; |
| 14 | |
| 15 | export type StageStatus = 'pending' | 'running' | 'waiting' | 'completed' | 'error' | 'stopped'; |
| 16 | |
| 17 | export const STAGES = [ |
| 18 | { id: 'script_generation', name: '剧本生成', shortName: '剧本' }, |
| 19 | { id: 'character_design', name: '角色设计', shortName: '角色' }, |
| 20 | { id: 'storyboard', name: '分镜设计', shortName: '分镜' }, |
| 21 | { id: 'reference_generation', name: '参考图', shortName: '参考图' }, |
| 22 | { id: 'video_generation', name: '视频生成', shortName: '视频' }, |
| 23 | { id: 'post_production', name: '后期剪辑', shortName: '后期' }, |
| 24 | ] as const; |
| 25 | |
| 26 | export type StageId = typeof STAGES[number]['id']; |
| 27 | |
| 28 | export interface ModelConfig { |
| 29 | llm_model: string; |
| 30 | vlm_model: string; |
| 31 | image_t2i_model: string; |
| 32 | image_it2i_model: string; |
| 33 | video_model: string; |
| 34 | video_first_frame_model: string; |
| 35 | video_start_end_model: string; |
| 36 | video_reference_model: string; |
| 37 | video_generation_mode: VideoGenerationMode; |
| 38 | video_ratio: string; |
| 39 | video_resolution: string; |
| 40 | enable_concurrency: boolean; |
| 41 | } |
| 42 | |
| 43 | interface TopBarProps { |
| 44 | /** null = 首页 */ |
| 45 | activeStage: string | null; |
| 46 | stageStatuses: Record<string, StageStatus>; |
| 47 | onStageClick: (stageId: string) => void; |
| 48 | onHomeClick: () => void; |
| 49 | /** 是否处于工作流中(有 sessionId) */ |
| 50 | hasSession: boolean; |
| 51 | /** 是否正在执行 */ |
| 52 | isRunning: boolean; |
| 53 | /** 停止执行 */ |
| 54 | onStop: () => void; |
| 55 | /** 代理模式(自动执行全流程) */ |
| 56 | autoMode: boolean; |
| 57 | onAutoModeChange: (auto: boolean) => void; |
| 58 | /** 当前模型配置 */ |
| 59 | modelConfig?: ModelConfig; |
| 60 | /** 模型配置变更 */ |
| 61 | onModelConfigChange?: (config: ModelConfig) => void; |
| 62 | /** 项目状态(如 running, waiting, completed, stopped, idle, error 等) */ |
| 63 | projectStatus?: string; |
| 64 | } |
| 65 | |
| 66 | /* ─── 带 Provider 分组的 <select> ─── */ |
| 67 | function ProviderSelect({ |
| 68 | value, |
| 69 | providers, |
| 70 | onChange, |
| 71 | }: { |
| 72 | value: string; |
| 73 | providers: ProviderGroup[]; |
| 74 | onChange: (val: string) => void; |
| 75 | }) { |
| 76 | return ( |
| 77 | <select |
| 78 | value={value} |
| 79 | onChange={e => onChange(e.target.value)} |
| 80 | className="bg-gray-50 border border-gray-200 rounded-lg px-2 py-1.5 text-xs text-gray-700 outline-none w-full" |
| 81 | > |
| 82 | {providers.map(pg => ( |
| 83 | <optgroup key={pg.provider} label={pg.label}> |
| 84 | {pg.models.map(m => ( |
| 85 | <option key={m.id} value={m.id}>{m.label}</option> |
| 86 | ))} |
| 87 | </optgroup> |
| 88 | ))} |
| 89 | </select> |
| 90 | ); |
| 91 | } |
| 92 | |
| 93 | /* ─── 模型选择下拉面板 ─── */ |
| 94 | function ModelSelector({ |
| 95 | config, |
| 96 | onChange, |
| 97 | }: { |
| 98 | config: ModelConfig; |
| 99 | onChange: (config: ModelConfig) => void; |
| 100 | }) { |
| 101 | const [open, setOpen] = useState(false); |
| 102 | const ref = useRef<HTMLDivElement>(null); |
| 103 | const [llmProviders, setLlmProviders] = useState<ProviderGroup[]>([]); |
| 104 | const [vlmProviders, setVlmProviders] = useState<ProviderGroup[]>([]); |
| 105 | const [t2iProviders, setT2iProviders] = useState<ProviderGroup[]>([]); |
| 106 | const [i2iProviders, setI2iProviders] = useState<ProviderGroup[]>([]); |
| 107 | const [firstFrameVideoProviders, setFirstFrameVideoProviders] = useState<ProviderGroup[]>([]); |
| 108 | const [startEndVideoProviders, setStartEndVideoProviders] = useState<ProviderGroup[]>([]); |
| 109 | const [referenceVideoProviders, setReferenceVideoProviders] = useState<ProviderGroup[]>([]); |
| 110 | |
| 111 | useEffect(() => { |
| 112 | const handler = (e: MouseEvent) => { |
| 113 | if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); |
| 114 | }; |
| 115 | if (open) document.addEventListener('mousedown', handler); |
| 116 | return () => document.removeEventListener('mousedown', handler); |
| 117 | }, [open]); |
| 118 | |
| 119 | useEffect(() => { |
| 120 | let cancelled = false; |
| 121 | fetchModelGroupsByType('llm') |
| 122 | .then(groups => { if (!cancelled) setLlmProviders(groups); }) |
| 123 | .catch(() => {}); |
| 124 | fetchModelGroupsByType('vlm') |
| 125 | .then(groups => { if (!cancelled) setVlmProviders(groups); }) |
| 126 | .catch(() => {}); |
| 127 | fetchModelGroupsByType('t2i') |
| 128 | .then(groups => { if (!cancelled) setT2iProviders(groups); }) |
| 129 | .catch(() => {}); |
| 130 | fetchModelGroupsByType('i2i') |
| 131 | .then(groups => { if (!cancelled) setI2iProviders(groups); }) |
| 132 | .catch(() => {}); |
| 133 | fetchVideoModelGroupsByAbility('first_frame_i2v') |
| 134 | .then(groups => { if (!cancelled) setFirstFrameVideoProviders(groups); }) |
| 135 | .catch(() => {}); |
| 136 | fetchVideoModelGroupsByAbility('start_end_frame_i2v') |
| 137 | .then(groups => { if (!cancelled) setStartEndVideoProviders(groups); }) |
| 138 | .catch(() => {}); |
| 139 | fetchVideoModelGroupsByAbility('reference_to_video') |
| 140 | .then(groups => { if (!cancelled) setReferenceVideoProviders(groups); }) |
| 141 | .catch(() => {}); |
| 142 | return () => { cancelled = true; }; |
| 143 | }, []); |
| 144 | |
| 145 | const update = (key: keyof ModelConfig, val: string | boolean) => { |
| 146 | onChange({ ...config, [key]: val }); |
| 147 | }; |
| 148 | |
| 149 | const activeVideoModel = |
| 150 | config.video_generation_mode === 'start_end_frame' |
| 151 | ? config.video_start_end_model |
| 152 | : config.video_generation_mode === 'reference' |
| 153 | ? config.video_reference_model |
| 154 | : config.video_first_frame_model; |
| 155 | const activeVideoProviders = |
| 156 | config.video_generation_mode === 'start_end_frame' |
| 157 | ? startEndVideoProviders |
| 158 | : config.video_generation_mode === 'reference' |
| 159 | ? referenceVideoProviders |
| 160 | : firstFrameVideoProviders; |
| 161 | const activeVideoLabel = VIDEO_GENERATION_MODES.find(item => item.id === config.video_generation_mode)?.label || '首帧生视频'; |
| 162 | |
| 163 | const updateVideoMode = (mode: VideoGenerationMode) => { |
| 164 | const nextModel = |
| 165 | mode === 'start_end_frame' |
| 166 | ? config.video_start_end_model |
| 167 | : mode === 'reference' |
| 168 | ? config.video_reference_model |
| 169 | : config.video_first_frame_model; |
| 170 | onChange({ ...config, video_generation_mode: mode, video_model: nextModel }); |
| 171 | }; |
| 172 | |
| 173 | const updateActiveVideoModel = (model: string) => { |
| 174 | if (config.video_generation_mode === 'start_end_frame') { |
| 175 | onChange({ ...config, video_start_end_model: model, video_model: model }); |
| 176 | } else if (config.video_generation_mode === 'reference') { |
| 177 | onChange({ ...config, video_reference_model: model, video_model: model }); |
| 178 | } else { |
| 179 | onChange({ ...config, video_first_frame_model: model, video_model: model }); |
| 180 | } |
| 181 | }; |
| 182 | |
| 183 | return ( |
| 184 | <div ref={ref} className="relative"> |
| 185 | <button |
| 186 | onClick={() => setOpen(!open)} |
| 187 | className={clsx( |
| 188 | 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all', |
| 189 | open |
| 190 | ? 'bg-blue-50 text-blue-700 ring-1 ring-blue-200' |
| 191 | : 'text-gray-500 hover:bg-gray-50' |
| 192 | )} |
| 193 | title="生成配置" |
| 194 | > |
| 195 | <Settings2 className="w-3.5 h-3.5" /> |
| 196 | <span>生成配置</span> |
| 197 | <ChevronDown className={clsx('w-3 h-3 transition-transform', open && 'rotate-180')} /> |
| 198 | </button> |
| 199 | |
| 200 | {open && ( |
| 201 | <div className="absolute right-0 top-full mt-1 w-72 bg-white rounded-xl shadow-lg border border-gray-200 p-3 z-50 space-y-2.5"> |
| 202 | <label className="flex flex-col gap-1"> |
| 203 | <span className="text-[10px] text-gray-400 font-medium">LLM 模型</span> |
| 204 | <ProviderSelect value={config.llm_model} providers={llmProviders} onChange={v => update('llm_model', v)} /> |
| 205 | </label> |
| 206 | <label className="flex flex-col gap-1"> |
| 207 | <span className="text-[10px] text-gray-400 font-medium">VLM 评估模型</span> |
| 208 | <ProviderSelect value={config.vlm_model} providers={vlmProviders} onChange={v => update('vlm_model', v)} /> |
| 209 | </label> |
| 210 | <label className="flex flex-col gap-1"> |
| 211 | <span className="text-[10px] text-gray-400 font-medium">文生图</span> |
| 212 | <ProviderSelect value={config.image_t2i_model} providers={t2iProviders} onChange={v => update('image_t2i_model', v)} /> |
| 213 | </label> |
| 214 | <label className="flex flex-col gap-1"> |
| 215 | <span className="text-[10px] text-gray-400 font-medium">图生图</span> |
| 216 | <ProviderSelect value={config.image_it2i_model} providers={i2iProviders} onChange={v => update('image_it2i_model', v)} /> |
| 217 | </label> |
| 218 | <label className="flex flex-col gap-1"> |
| 219 | <span className="text-[10px] text-gray-400 font-medium">视频生成方式</span> |
| 220 | <select |
| 221 | value={config.video_generation_mode} |
| 222 | onChange={e => updateVideoMode(e.target.value as VideoGenerationMode)} |
| 223 | className="bg-gray-50 border border-gray-200 rounded-lg px-2 py-1.5 text-xs text-gray-700 outline-none w-full" |
| 224 | > |
| 225 | {VIDEO_GENERATION_MODES.map(item => ( |
| 226 | <option key={item.id} value={item.id}>{item.label}</option> |
| 227 | ))} |
| 228 | </select> |
| 229 | </label> |
| 230 | <label className="flex flex-col gap-1"> |
| 231 | <span className="text-[10px] text-gray-400 font-medium">{activeVideoLabel}模型</span> |
| 232 | <ProviderSelect value={activeVideoModel} providers={activeVideoProviders} onChange={updateActiveVideoModel} /> |
| 233 | </label> |
| 234 | <label className="flex flex-col gap-1"> |
| 235 | <span className="text-[10px] text-gray-400 font-medium">视频比例</span> |
| 236 | <div className="flex gap-0.5"> |
| 237 | {VIDEO_RATIOS.map(r => ( |
| 238 | <button |
| 239 | key={r.id} |
| 240 | onClick={() => update('video_ratio', r.id)} |
| 241 | className={`flex flex-col items-center gap-0.5 p-1 rounded border transition-all ${ |
| 242 | config.video_ratio === r.id |
| 243 | ? 'border-indigo-500 bg-indigo-50' |
| 244 | : 'border-gray-200 hover:border-gray-300' |
| 245 | }`} |
| 246 | title={r.label} |
| 247 | > |
| 248 | <div |
| 249 | className="bg-gray-600 rounded-sm" |
| 250 | style={{ |
| 251 | width: r.ratio === '16:9' ? '16px' : |
| 252 | r.ratio === '9:16' ? '9px' : |
| 253 | r.ratio === '1:1' ? '12px' : |
| 254 | r.ratio === '4:3' ? '14px' : |
| 255 | r.ratio === '3:4' ? '10px' : |
| 256 | '18px', |
| 257 | height: r.ratio === '16:9' ? '9px' : |
| 258 | r.ratio === '9:16' ? '16px' : |
| 259 | r.ratio === '1:1' ? '12px' : |
| 260 | r.ratio === '4:3' ? '10px' : |
| 261 | r.ratio === '3:4' ? '14px' : |
| 262 | '7px', |
| 263 | }} |
| 264 | /> |
| 265 | <span className="text-[8px] text-gray-500">{r.label}</span> |
| 266 | </button> |
| 267 | ))} |
| 268 | </div> |
| 269 | </label> |
| 270 | <label className="flex flex-col gap-1"> |
| 271 | <span className="text-[10px] text-gray-400 font-medium">视频分辨率</span> |
| 272 | <select |
| 273 | value={config.video_resolution} |
| 274 | onChange={e => update('video_resolution', e.target.value)} |
| 275 | className="bg-gray-50 border border-gray-200 rounded-lg px-2 py-1.5 text-xs text-gray-700 outline-none w-full" |
| 276 | > |
| 277 | {VIDEO_RESOLUTIONS.map(item => ( |
| 278 | <option key={item.id} value={item.id}>{item.label}</option> |
| 279 | ))} |
| 280 | </select> |
| 281 | </label> |
| 282 | <label className="flex items-center gap-2 text-xs cursor-pointer select-none"> |
| 283 | <input |
| 284 | type="checkbox" |
| 285 | checked={!!config.enable_concurrency} |
| 286 | onChange={e => update('enable_concurrency', e.target.checked)} |
| 287 | className="w-3.5 h-3.5 rounded border-gray-300 text-blue-500 focus:ring-blue-500/30" |
| 288 | /> |
| 289 | <span className="text-gray-500">并发生成</span> |
| 290 | </label> |
| 291 | </div> |
| 292 | )} |
| 293 | </div> |
| 294 | ); |
| 295 | } |
| 296 | |
| 297 | export default function TopBar({ |
| 298 | activeStage, |
| 299 | stageStatuses, |
| 300 | onStageClick, |
| 301 | onHomeClick, |
| 302 | hasSession, |
| 303 | isRunning, |
| 304 | onStop, |
| 305 | autoMode, |
| 306 | onAutoModeChange, |
| 307 | modelConfig, |
| 308 | onModelConfigChange, |
| 309 | projectStatus, |
| 310 | }: TopBarProps) { |
| 311 | const getStageIcon = (status: StageStatus, isActive: boolean) => { |
| 312 | switch (status) { |
| 313 | case 'completed': |
| 314 | return <CheckCircle className="w-4 h-4 text-green-500" />; |
| 315 | case 'running': |
| 316 | return <Loader className="w-4 h-4 text-blue-500 animate-spin" />; |
| 317 | case 'waiting': |
| 318 | return <Edit3 className="w-4 h-4 text-amber-500" />; |
| 319 | case 'error': |
| 320 | return <AlertCircle className="w-4 h-4 text-red-500" />; |
| 321 | default: |
| 322 | return ( |
| 323 | <Circle |
| 324 | className={clsx('w-4 h-4', isActive ? 'text-blue-400' : 'text-gray-300')} |
| 325 | /> |
| 326 | ); |
| 327 | } |
| 328 | }; |
| 329 | |
| 330 | return ( |
| 331 | <> |
| 332 | <header className="fixed top-0 right-0 left-[var(--app-sidebar-width)] z-30 h-14 bg-white border-b border-gray-200 flex items-center px-4 min-w-0 transition-[left] duration-300"> |
| 333 | {/* Logo & 名称 */} |
| 334 | <button |
| 335 | onClick={onHomeClick} |
| 336 | className="flex items-center gap-2 mr-6 hover:opacity-80 transition-opacity flex-shrink-0" |
| 337 | > |
| 338 | <img |
| 339 | src="/logo.jpg" |
| 340 | alt="Logo" |
| 341 | className="w-8 h-8 rounded-lg object-contain" |
| 342 | /> |
| 343 | <div className="flex flex-col leading-tight"> |
| 344 | <span className="font-bold text-sm text-gray-800 tracking-tight"> |
| 345 | Video-Claw |
| 346 | </span> |
| 347 | </div> |
| 348 | </button> |
| 349 | |
| 350 | {/* 分隔线 */} |
| 351 | {hasSession && <div className="w-px h-6 bg-gray-200 mr-4 flex-shrink-0" />} |
| 352 | |
| 353 | {/* 阶段进度条 */} |
| 354 | {hasSession && ( |
| 355 | <nav className="flex items-center gap-1 overflow-x-auto flex-1 min-w-0"> |
| 356 | {STAGES.map((stage, idx) => { |
| 357 | const status = stageStatuses[stage.id] || 'pending'; |
| 358 | const isActive = activeStage === stage.id; |
| 359 | |
| 360 | return ( |
| 361 | <React.Fragment key={stage.id}> |
| 362 | {idx > 0 && ( |
| 363 | <div |
| 364 | className={clsx( |
| 365 | 'w-6 h-px flex-shrink-0', |
| 366 | stageStatuses[STAGES[idx - 1].id] === 'completed' |
| 367 | ? 'bg-green-300' |
| 368 | : 'bg-gray-200' |
| 369 | )} |
| 370 | /> |
| 371 | )} |
| 372 | <button |
| 373 | onClick={() => onStageClick(stage.id)} |
| 374 | className={clsx( |
| 375 | 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all whitespace-nowrap flex-shrink-0', |
| 376 | isActive |
| 377 | ? 'bg-blue-50 text-blue-700 ring-1 ring-blue-200' |
| 378 | : status === 'completed' |
| 379 | ? 'text-green-700 hover:bg-green-50' |
| 380 | : status === 'error' |
| 381 | ? 'text-red-600 hover:bg-red-50' |
| 382 | : 'text-gray-500 hover:bg-gray-50' |
| 383 | )} |
| 384 | > |
| 385 | {getStageIcon(status, isActive)} |
| 386 | <span>{stage.shortName}</span> |
| 387 | </button> |
| 388 | </React.Fragment> |
| 389 | ); |
| 390 | })} |
| 391 | </nav> |
| 392 | )} |
| 393 | |
| 394 | {/* 右侧控制区 */} |
| 395 | <div className="ml-auto flex min-w-0 items-center justify-end gap-2 flex-shrink-0"> |
| 396 | {/* 模型选择 */} |
| 397 | {hasSession && modelConfig && onModelConfigChange && ( |
| 398 | <ModelSelector config={modelConfig} onChange={onModelConfigChange} /> |
| 399 | )} |
| 400 | |
| 401 | {/* 代理模式切换 */} |
| 402 | {hasSession && ( |
| 403 | <button |
| 404 | onClick={() => onAutoModeChange(!autoMode)} |
| 405 | className={clsx( |
| 406 | 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all', |
| 407 | autoMode |
| 408 | ? 'bg-amber-50 text-amber-700 ring-1 ring-amber-200' |
| 409 | : 'text-gray-500 hover:bg-gray-50' |
| 410 | )} |
| 411 | title={autoMode ? '代理模式:自动执行全流程' : '手动模式:每阶段需确认'} |
| 412 | > |
| 413 | <Zap className="w-3.5 h-3.5" /> |
| 414 | <span>{autoMode ? '自动' : '手动'}</span> |
| 415 | </button> |
| 416 | )} |
| 417 | |
| 418 | {/* 停止按钮 */} |
| 419 | {isRunning && ( |
| 420 | <button |
| 421 | onClick={onStop} |
| 422 | className="flex items-center gap-1.5 px-3 py-1.5 bg-red-50 text-red-600 hover:bg-red-100 rounded-lg text-xs font-medium transition-colors ring-1 ring-red-200" |
| 423 | title="停止执行" |
| 424 | > |
| 425 | <Square className="w-3.5 h-3.5 fill-current" /> |
| 426 | <span>停止</span> |
| 427 | </button> |
| 428 | )} |
| 429 | |
| 430 | {/* 项目状态 */} |
| 431 | {hasSession && projectStatus && ( |
| 432 | <div |
| 433 | className={clsx( |
| 434 | 'px-2 py-1 rounded-lg text-xs font-medium flex items-center gap-1', |
| 435 | projectStatus === 'running' && 'bg-blue-50 text-blue-700', |
| 436 | projectStatus === 'waiting' && 'bg-amber-50 text-amber-700', |
| 437 | projectStatus === 'completed' && 'bg-green-50 text-green-700', |
| 438 | projectStatus === 'pending' && 'bg-gray-50 text-gray-600', |
| 439 | projectStatus === 'error' && 'bg-red-50 text-red-700', |
| 440 | projectStatus === 'stopped' && 'bg-orange-50 text-orange-700' |
| 441 | )} |
| 442 | title="项目状态" |
| 443 | > |
| 444 | {projectStatus === 'running' && <Loader className="w-3 h-3 animate-spin" />} |
| 445 | {projectStatus === 'waiting' && <Edit3 className="w-3 h-3" />} |
| 446 | {projectStatus === 'error' && <AlertCircle className="w-3 h-3" />} |
| 447 | <span> |
| 448 | {projectStatus === 'running' ? '执行中' : |
| 449 | projectStatus === 'waiting' ? '等待确认' : |
| 450 | projectStatus === 'completed' ? '已完成' : |
| 451 | projectStatus === 'pending' ? '空闲' : |
| 452 | projectStatus === 'stopped' ? '已停止' : |
| 453 | projectStatus === 'error' ? '出错' : projectStatus} |
| 454 | </span> |
| 455 | </div> |
| 456 | )} |
| 457 | |
| 458 | </div> |
| 459 | </header> |
| 460 | <div className="h-14 flex-shrink-0" /> |
| 461 | </> |
| 462 | ); |
| 463 | } |
| 464 |