返回 VideoClaw
Sandbox.tsx
1 'use client';
2
3 import { useState, useEffect, useRef, DragEvent } from 'react';
4 import { Sparkles, Image, Video, MessageSquare, Zap, Loader2, Copy, Check, Trash2, X, FolderOpen, Upload, Globe, Hexagon } from 'lucide-react';
5 import { useSearchParams } from 'next/navigation';
6 import { VIDEO_RATIOS, VIDEO_RESOLUTIONS, type ModelOption, type ProviderGroup } from '@/config/models';
7 import BrandHeader from '@/components/BrandHeader';
8 import { DIRECT_API_BASE, fetchSandboxTasks, uploadMedia } from '@/lib/workflowApi';
9 import { fetchModelGroupsByType } from '@/lib/modelRegistry';
10
11 // 辅助函数:将相对路径转换为完整 URL
12 const toMediaUrl = (path: string) => {
13 if (!path) return '';
14 // 如果已经是完整 URL,直接返回
15 if (path.startsWith('http://') || path.startsWith('https://')) return path;
16 // 相对路径添加 /code/ 前缀(result/xxx 格式)
17 if (path.startsWith('result/')) {
18 return `/code/${path}`;
19 } else if (!path.startsWith('/code/')) {
20 return `/code/result/${path}`;
21 }
22 return path;
23 };
24
25 async function readJsonResponse(resp: Response) {
26 const text = await resp.text();
27 if (!text.trim()) {
28 if (!resp.ok) throw new Error(`请求失败:${resp.status}`);
29 return {};
30 }
31 try {
32 return JSON.parse(text);
33 } catch {
34 const preview = text.replace(/\s+/g, ' ').slice(0, 160);
35 throw new Error(resp.ok ? `接口返回了非 JSON 内容:${preview}` : `请求失败:${resp.status} ${preview}`);
36 }
37 }
38
39 // 工具类型
40 type ToolType = 'llm' | 'vlm' | 't2i' | 'i2i' | 'video';
41
42 const EMPTY_MODEL_GROUPS: Record<ToolType, ProviderGroup[]> = {
43 llm: [],
44 vlm: [],
45 t2i: [],
46 i2i: [],
47 video: [],
48 };
49
50 const VIDEO_DURATIONS = [
51 { id: 5, label: '5s' },
52 { id: 10, label: '10s' },
53 { id: 15, label: '15s' },
54 ];
55
56 interface Tool {
57 id: ToolType;
58 name: string;
59 description: string;
60 icon: React.ReactNode;
61 }
62
63 const tools: Tool[] = [
64 { id: 'llm', name: 'LLM 对话', description: '文字生成', icon: <MessageSquare className="w-5 h-5" /> },
65 { id: 'vlm', name: '图片理解', description: '分析图片内容', icon: <Image className="w-5 h-5" /> },
66 { id: 't2i', name: '文生图', description: '文字生成图片', icon: <Sparkles className="w-5 h-5" /> },
67 { id: 'i2i', name: '图生图', description: '图片风格转换', icon: <Zap className="w-5 h-5" /> },
68 { id: 'video', name: '视频生成', description: '图生视频/文生视频', icon: <Video className="w-5 h-5" /> },
69 ];
70
71 // 历史记录类型
72 interface HistoryRecord {
73 id: string;
74 tool: string;
75 model: string;
76 input: {
77 prompt?: string;
78 images?: string[];
79 reference_image?: string;
80 ratio?: string;
81 resolution?: string;
82 duration?: number;
83 };
84 output?: {
85 response?: string;
86 images?: string[];
87 video?: string;
88 video_path?: string;
89 };
90 created_at: string;
91 }
92
93 function SandboxOutput({ output }: { output?: HistoryRecord['output'] | null }) {
94 if (!output) return null;
95 return (
96 <div className="space-y-4">
97 {output.response && (
98 <pre className="text-sm text-gray-700 whitespace-pre-wrap break-words max-h-96 overflow-y-auto">
99 {output.response}
100 </pre>
101 )}
102 {output.images && output.images.length > 0 && (
103 <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
104 {output.images.map((img, i) => (
105 <a key={i} href={toMediaUrl(img)} target="_blank" rel="noopener noreferrer" className="group block rounded-xl border border-gray-200 bg-white overflow-hidden">
106 <img src={toMediaUrl(img)} alt={`output-${i}`} className="w-full h-56 object-contain bg-gray-50" />
107 <div className="px-3 py-2 text-xs text-gray-500 group-hover:text-indigo-600 border-t border-gray-100">查看图片</div>
108 </a>
109 ))}
110 </div>
111 )}
112 {output.video_path && (
113 <div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
114 <video src={toMediaUrl(output.video_path)} controls className="w-full max-h-[28rem] bg-black object-contain" />
115 <div className="px-3 py-2 border-t border-gray-100">
116 <a href={toMediaUrl(output.video_path)} target="_blank" rel="noopener noreferrer" className="text-sm text-indigo-600 hover:underline">
117 查看视频
118 </a>
119 </div>
120 </div>
121 )}
122 </div>
123 );
124 }
125
126 // 图片上传组件
127 function ImageUploader({
128 value,
129 onChange,
130 required,
131 label,
132 }: {
133 value: string;
134 onChange: (url: string) => void;
135 required?: boolean;
136 label: string;
137 }) {
138 const [isDragging, setIsDragging] = useState(false);
139 const [uploading, setUploading] = useState(false);
140 const [inputMode, setInputMode] = useState<'url' | 'file'>('file');
141 const [previewUrl, setPreviewUrl] = useState('');
142 const fileInputRef = useRef<HTMLInputElement>(null);
143
144 useEffect(() => {
145 return () => {
146 if (previewUrl) URL.revokeObjectURL(previewUrl);
147 };
148 }, [previewUrl]);
149
150 const handleDragOver = (e: DragEvent) => {
151 e.preventDefault();
152 setIsDragging(true);
153 };
154
155 const handleDragLeave = (e: DragEvent) => {
156 e.preventDefault();
157 setIsDragging(false);
158 };
159
160 const handleDrop = async (e: DragEvent) => {
161 e.preventDefault();
162 setIsDragging(false);
163
164 const files = e.dataTransfer.files;
165 if (files.length > 0) {
166 await uploadFile(files[0]);
167 }
168 };
169
170 const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
171 const files = e.target.files;
172 if (files && files.length > 0) {
173 await uploadFile(files[0]);
174 }
175 };
176
177 const uploadFile = async (file: File) => {
178 if (!file.type.startsWith('image/')) {
179 alert('请选择图片文件');
180 return;
181 }
182
183 setUploading(true);
184 try {
185 const result = await uploadMedia(file);
186 setPreviewUrl(current => {
187 if (current) URL.revokeObjectURL(current);
188 return URL.createObjectURL(file);
189 });
190 onChange(result.file_path);
191 } catch (e) {
192 alert(e instanceof Error ? e.message : '上传失败');
193 } finally {
194 setUploading(false);
195 }
196 };
197
198 // 判断是否为 URL
199 const isUrl = value.startsWith('http://') || value.startsWith('https://');
200
201 return (
202 <div className="mb-4">
203 <label className="block text-sm font-medium text-gray-700 mb-2">
204 {label} {required && <span className="text-red-500">*</span>}
205 </label>
206
207 {/* 切换 URL / 文件上传 */}
208 <div className="flex gap-2 mb-2">
209 <button
210 type="button"
211 onClick={() => setInputMode('url')}
212 className={`text-xs px-3 py-1.5 rounded-lg transition-colors ${
213 inputMode === 'url' ? 'bg-indigo-100 text-indigo-700' : 'text-gray-500 hover:bg-gray-100'
214 }`}
215 >
216 URL 地址
217 </button>
218 <button
219 type="button"
220 onClick={() => setInputMode('file')}
221 className={`text-xs px-3 py-1.5 rounded-lg transition-colors ${
222 inputMode === 'file' ? 'bg-indigo-100 text-indigo-700' : 'text-gray-500 hover:bg-gray-100'
223 }`}
224 >
225 本地上传
226 </button>
227 </div>
228
229 {/* URL 输入模式 */}
230 {inputMode === 'url' && (
231 <div className="space-y-2">
232 <input
233 type="text"
234 value={isUrl ? value : ''}
235 onChange={e => {
236 setPreviewUrl(current => {
237 if (current) URL.revokeObjectURL(current);
238 return '';
239 });
240 onChange(e.target.value);
241 }}
242 placeholder="https://example.com/image.jpg"
243 className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none"
244 />
245 {value && isUrl && (
246 <div className="relative group">
247 <img src={value} alt="预览" className="max-h-48 rounded-lg border border-gray-200" />
248 <button
249 onClick={() => onChange('')}
250 className="absolute top-2 right-2 p-1.5 bg-red-500 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity"
251 >
252 <X className="w-4 h-4" />
253 </button>
254 </div>
255 )}
256 </div>
257 )}
258
259 {/* 文件上传模式 */}
260 {inputMode === 'file' && (
261 <>
262 {value && !isUrl ? (
263 <div className="relative group">
264 {previewUrl ? (
265 <img src={previewUrl} alt="上传的图片" className="max-h-48 rounded-lg border border-gray-200" />
266 ) : (
267 <div className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500 break-all">
268 已上传:{value}
269 </div>
270 )}
271 <button
272 onClick={() => {
273 setPreviewUrl(current => {
274 if (current) URL.revokeObjectURL(current);
275 return '';
276 });
277 onChange('');
278 }}
279 className="absolute top-2 right-2 p-1.5 bg-red-500 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity"
280 >
281 <X className="w-4 h-4" />
282 </button>
283 </div>
284 ) : (
285 <div
286 onDragOver={handleDragOver}
287 onDragLeave={handleDragLeave}
288 onDrop={handleDrop}
289 onClick={() => fileInputRef.current?.click()}
290 className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
291 isDragging ? 'border-indigo-500 bg-indigo-50' : 'border-gray-300 hover:border-gray-400'
292 }`}
293 >
294 <input
295 ref={fileInputRef}
296 type="file"
297 accept="image/*"
298 onChange={handleFileSelect}
299 className="hidden"
300 />
301 {uploading ? (
302 <Loader2 className="w-8 h-8 mx-auto mb-2 text-indigo-500 animate-spin" />
303 ) : (
304 <Upload className="w-8 h-8 mx-auto mb-2 text-gray-400" />
305 )}
306 <p className="text-sm text-gray-500">
307 拖拽图片到此处,或 <span className="text-indigo-600">点击选择文件</span>
308 </p>
309 <p className="text-xs text-gray-400 mt-1">支持 PNG、JPG、WebP 等格式</p>
310 </div>
311 )}
312 </>
313 )}
314 </div>
315 );
316 }
317
318 export default function SandboxPage() {
319 const [activeTool, setActiveTool] = useState<ToolType>('llm');
320 const [modelGroups, setModelGroups] = useState<Record<ToolType, ProviderGroup[]>>(EMPTY_MODEL_GROUPS);
321 const [prompt, setPrompt] = useState('');
322 const [imageUrl, setImageUrl] = useState('');
323 const [loading, setLoading] = useState(false);
324 const [result, setResult] = useState<string | null>(null);
325 const [currentOutput, setCurrentOutput] = useState<HistoryRecord['output'] | null>(null);
326 const [error, setError] = useState<string | null>(null);
327 const [copied, setCopied] = useState(false);
328
329 // 历史记录状态
330 const [history, setHistory] = useState<HistoryRecord[]>([]);
331 const [manageMode, setManageMode] = useState(false);
332 const [deleting, setDeleting] = useState<string | null>(null);
333 const [selectedRecord, setSelectedRecord] = useState<HistoryRecord | null>(null);
334 const searchParams = useSearchParams();
335
336 const flattenModels = (groups: ProviderGroup[]): ModelOption[] => groups.flatMap(group => group.models);
337
338 const getModels = () => flattenModels(modelGroups[activeTool] || []);
339
340 const firstModelId = (groups: ProviderGroup[]) => {
341 const models = flattenModels(groups);
342 return models.find(model => model.default)?.id || models[0]?.id || '';
343 };
344
345 const [selectedModel, setSelectedModel] = useState('');
346 const [webSearch, setWebSearch] = useState(false);
347 const [videoRatio, setVideoRatio] = useState('16:9');
348 const [videoResolution, setVideoResolution] = useState('720P');
349 const [videoDuration, setVideoDuration] = useState(5);
350
351 // 获取历史记录
352 const fetchHistory = async () => {
353 try {
354 const resp = await fetch('/api/sandbox/history');
355 const data = await readJsonResponse(resp);
356 if (data.success) {
357 setHistory(data.records);
358 }
359 } catch (e) {
360 console.error('Failed to fetch history:', e);
361 }
362 };
363
364 useEffect(() => {
365 fetchHistory();
366 }, []);
367
368 useEffect(() => {
369 let cancelled = false;
370 Promise.all([
371 fetchModelGroupsByType('llm'),
372 fetchModelGroupsByType('vlm'),
373 fetchModelGroupsByType('t2i'),
374 fetchModelGroupsByType('i2i'),
375 fetchModelGroupsByType('video'),
376 ])
377 .then(([llm, vlm, t2i, i2i, video]) => {
378 if (cancelled) return;
379 const groups = { llm, vlm, t2i, i2i, video };
380 setModelGroups(groups);
381 setSelectedModel(current => current || firstModelId(groups[activeTool]));
382 })
383 .catch(() => {});
384 return () => { cancelled = true; };
385 }, []);
386
387 const applyRecord = (record: HistoryRecord) => {
388 setSelectedRecord(record);
389 setActiveTool(record.tool as ToolType);
390 setSelectedModel(record.model);
391 setPrompt(record.input.prompt || '');
392 setImageUrl(record.input.reference_image || record.input.images?.[0] || '');
393 setVideoRatio(record.input.ratio || '16:9');
394 setVideoResolution(record.input.resolution || '720P');
395 setVideoDuration(Number(record.input.duration) || 5);
396 if (record.output?.response) {
397 setResult(record.output.response);
398 } else {
399 setResult(null);
400 }
401 setCurrentOutput(record.output || null);
402 setLoading(false);
403 setError(null);
404 };
405
406 // 检查 URL 参数,自动加载历史记录
407 useEffect(() => {
408 const recordId = searchParams.get('record');
409 if (recordId && history.length > 0) {
410 const record = history.find(r => r.id === recordId);
411 if (record) {
412 applyRecord(record);
413 }
414 }
415 }, [searchParams, history]);
416
417 useEffect(() => {
418 const taskId = searchParams.get('task');
419 if (!taskId) return;
420 let cancelled = false;
421
422 const loadTask = async () => {
423 const historyRecord = history.find(r => r.id === taskId);
424 if (historyRecord) {
425 applyRecord(historyRecord);
426 return;
427 }
428
429 const activeTasks = await fetchSandboxTasks();
430 const activeTask = activeTasks.find(item => item.id === taskId);
431 if (!activeTask || cancelled) return;
432 setActiveTool(activeTask.tool as ToolType);
433 setSelectedModel(activeTask.model);
434 setPrompt(activeTask.input?.prompt || '');
435 setImageUrl(activeTask.input?.reference_image || activeTask.input?.images?.[0] || '');
436 setVideoRatio(String(activeTask.input?.ratio || '16:9'));
437 setVideoResolution(String(activeTask.input?.resolution || '720P'));
438 setVideoDuration(Number(activeTask.input?.duration) || 5);
439 setCurrentOutput(null);
440 setResult(null);
441 setError(null);
442 setLoading(true);
443 };
444
445 loadTask().catch(() => {});
446 const timer = window.setInterval(() => {
447 fetchHistory().then(() => loadTask()).catch(() => {});
448 }, 3000);
449 return () => {
450 cancelled = true;
451 window.clearInterval(timer);
452 };
453 }, [searchParams, history]);
454
455 // 删除历史记录
456 const deleteRecord = async (id: string) => {
457 setDeleting(id);
458 try {
459 const resp = await fetch(`/api/sandbox/history/${id}`, { method: 'DELETE' });
460 const data = await readJsonResponse(resp);
461 if (data.success) {
462 setHistory(history.filter(r => r.id !== id));
463 if (selectedRecord?.id === id) {
464 setSelectedRecord(null);
465 setResult(null);
466 setCurrentOutput(null);
467 }
468 }
469 } catch (e) {
470 console.error('Failed to delete:', e);
471 } finally {
472 setDeleting(null);
473 }
474 };
475
476 // 工具切换时重置模型选择
477 const handleToolChange = (tool: ToolType) => {
478 setActiveTool(tool);
479 setSelectedModel(firstModelId(modelGroups[tool]));
480 setResult(null);
481 setCurrentOutput(null);
482 setError(null);
483 setImageUrl('');
484 };
485
486 // 监听工具变化,确保模型选择同步
487 useEffect(() => {
488 const models = getModels();
489 // 只有当前模型不在新工具的模型列表中时才更新
490 const currentInList = models.some(m => m.id === selectedModel);
491 if (!currentInList) {
492 setSelectedModel(models.find(m => m.default)?.id || models[0]?.id || '');
493 }
494 }, [activeTool, modelGroups, selectedModel]);
495
496 // 检查是否可以提交
497 const canSubmit = () => {
498 if (!selectedModel) return false;
499 if (!prompt.trim() && activeTool !== 't2i') return false;
500 if ((activeTool === 'i2i') && !imageUrl) return false;
501 return true;
502 };
503
504 const handleSubmit = async () => {
505 if (!canSubmit()) return;
506
507 setLoading(true);
508 setResult(null);
509 setCurrentOutput(null);
510 setError(null);
511
512 try {
513 let apiUrl = '';
514 let body: Record<string, unknown> = {
515 model: selectedModel,
516 prompt: prompt,
517 };
518
519 switch (activeTool) {
520 case 'llm':
521 apiUrl = '/api/sandbox/llm';
522 // web_search 只对 LLM 有效
523 if (webSearch) {
524 body.web_search = true;
525 }
526 break;
527 case 'vlm':
528 apiUrl = '/api/sandbox/vlm';
529 body.images = [imageUrl];
530 break;
531 case 't2i':
532 apiUrl = '/api/sandbox/t2i';
533 break;
534 case 'i2i':
535 apiUrl = '/api/sandbox/i2i';
536 body.image = imageUrl;
537 break;
538 case 'video':
539 // Video generation can run long enough for the Next.js rewrite proxy to abort
540 // while the FastAPI job still finishes. Call the API server directly.
541 apiUrl = `${DIRECT_API_BASE}/api/sandbox/video`;
542 body.image = imageUrl;
543 body.ratio = videoRatio;
544 body.resolution = videoResolution;
545 body.duration = videoDuration;
546 break;
547 }
548
549 const response = await fetch(apiUrl, {
550 method: 'POST',
551 headers: { 'Content-Type': 'application/json' },
552 body: JSON.stringify(body),
553 });
554
555 const data = await readJsonResponse(response);
556
557 if (data.success) {
558 if (activeTool === 't2i' || activeTool === 'i2i' || activeTool === 'video') {
559 const output = activeTool === 'video'
560 ? { video_path: data.video_path }
561 : { images: Array.isArray(data.result) ? data.result : [] };
562 setCurrentOutput(output);
563 setResult(null);
564 } else {
565 const output = { response: data.result };
566 setCurrentOutput(output);
567 setResult(data.result);
568 }
569 fetchHistory();
570 } else {
571 setError(data.error || '未知错误');
572 }
573 } catch (e: unknown) {
574 setError(e instanceof Error ? e.message : '请求失败');
575 } finally {
576 setLoading(false);
577 }
578 };
579
580 const copyResult = () => {
581 const copyText = result || JSON.stringify(currentOutput, null, 2);
582 if (copyText) {
583 navigator.clipboard.writeText(copyText);
584 setCopied(true);
585 setTimeout(() => setCopied(false), 2000);
586 }
587 };
588
589 // 获取工具名称
590 const getToolName = (tool: string) => {
591 const t = tools.find(x => x.id === tool);
592 return t?.name || tool;
593 };
594
595 // 格式化日期
596 const formatDate = (dateStr: string) => {
597 const date = new Date(dateStr);
598 return date.toLocaleString('zh-CN', {
599 month: '2-digit',
600 day: '2-digit',
601 hour: '2-digit',
602 minute: '2-digit',
603 });
604 };
605
606 // 获取图片输入的标签
607 const getImageLabel = () => {
608 switch (activeTool) {
609 case 'vlm': return '上传图片';
610 case 'i2i': return '参考图片';
611 case 'video': return '首帧图片';
612 default: return '图片';
613 }
614 };
615
616 return (
617 <div className="min-h-screen bg-gray-50/50">
618 <BrandHeader />
619
620 <main className="max-w-5xl mx-auto px-6 py-8">
621 <>
622 <div className="mb-8 text-center">
623 <div className="inline-flex items-center gap-2 mb-3">
624 <Hexagon className="w-7 h-7 text-blue-500" />
625 <h1 className="text-2xl font-bold text-gray-800">临时工作台</h1>
626 </div>
627 <p className="text-sm text-gray-500">独立调用各种 AI 工具</p>
628 </div>
629
630 {/* 工具选择 */}
631 <div className="grid grid-cols-5 gap-3 mb-8">
632 {tools.map(tool => (
633 <button
634 key={tool.id}
635 onClick={() => handleToolChange(tool.id)}
636 className={`p-4 rounded-xl border-2 transition-all text-center ${
637 activeTool === tool.id
638 ? 'border-indigo-500 bg-indigo-50 text-indigo-700 shadow-md'
639 : 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:shadow-sm'
640 }`}
641 >
642 <div className="flex justify-center mb-2">{tool.icon}</div>
643 <div className="font-medium text-sm">{tool.name}</div>
644 <div className="text-xs text-gray-400">{tool.description}</div>
645 </button>
646 ))}
647 </div>
648
649 {/* 输入区域 */}
650 <div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 mb-6">
651 {/* 模型选择 */}
652 <div className="mb-4">
653 <div className="flex items-center justify-between">
654 <div className="flex-1">
655 <label className="block text-sm font-medium text-gray-700 mb-2">选择模型</label>
656 <select
657 value={selectedModel}
658 onChange={e => setSelectedModel(e.target.value)}
659 className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none"
660 >
661 {getModels().map(m => (
662 <option key={m.id} value={m.id}>{m.label}</option>
663 ))}
664 </select>
665 </div>
666 {/* 联网搜索开关 */}
667 {activeTool === 'llm' && (
668 <button
669 onClick={() => setWebSearch(!webSearch)}
670 className={`ml-4 px-4 py-2.5 rounded-lg border-2 flex items-center gap-2 transition-colors ${
671 webSearch
672 ? 'border-indigo-500 bg-indigo-50 text-indigo-700'
673 : 'border-gray-200 text-gray-500 hover:border-gray-300'
674 }`}
675 >
676 <Globe className="w-4 h-4" />
677 <span className="text-sm font-medium">联网搜索</span>
678 </button>
679 )}
680 </div>
681 </div>
682
683 {activeTool === 'video' && (
684 <div className="mb-4 grid grid-cols-1 gap-3 md:grid-cols-3">
685 <label className="block">
686 <span className="mb-1.5 block text-xs font-medium text-gray-500">分辨率</span>
687 <select
688 value={videoResolution}
689 onChange={e => setVideoResolution(e.target.value)}
690 className="h-10 w-full rounded-lg border border-gray-200 bg-white px-3 text-sm text-gray-700 outline-none focus:border-transparent focus:ring-2 focus:ring-indigo-500"
691 >
692 {VIDEO_RESOLUTIONS.map(item => (
693 <option key={item.id} value={item.id}>{item.label}</option>
694 ))}
695 </select>
696 </label>
697 <label className="block">
698 <span className="mb-1.5 block text-xs font-medium text-gray-500">长宽比</span>
699 <select
700 value={videoRatio}
701 onChange={e => setVideoRatio(e.target.value)}
702 className="h-10 w-full rounded-lg border border-gray-200 bg-white px-3 text-sm text-gray-700 outline-none focus:border-transparent focus:ring-2 focus:ring-indigo-500"
703 >
704 {VIDEO_RATIOS.map(item => (
705 <option key={item.id} value={item.id}>{item.label}</option>
706 ))}
707 </select>
708 </label>
709 <label className="block">
710 <span className="mb-1.5 block text-xs font-medium text-gray-500">时长</span>
711 <select
712 value={videoDuration}
713 onChange={e => setVideoDuration(Number(e.target.value))}
714 className="h-10 w-full rounded-lg border border-gray-200 bg-white px-3 text-sm text-gray-700 outline-none focus:border-transparent focus:ring-2 focus:ring-indigo-500"
715 >
716 {VIDEO_DURATIONS.map(item => (
717 <option key={item.id} value={item.id}>{item.label}</option>
718 ))}
719 </select>
720 </label>
721 </div>
722 )}
723
724 {/* 图片上传(部分工具需要) */}
725 {(activeTool === 'vlm' || activeTool === 'i2i' || activeTool === 'video') && (
726 <ImageUploader
727 value={imageUrl}
728 onChange={setImageUrl}
729 required={activeTool === 'i2i'}
730 label={getImageLabel()}
731 />
732 )}
733
734 {/* 提示词输入 */}
735 <div className="mb-4">
736 <label className="block text-sm font-medium text-gray-700 mb-2">
737 {activeTool === 'llm' ? '对话内容' :
738 activeTool === 'vlm' ? '想了解图片的什么问题?' :
739 activeTool === 't2i' ? '图片描述(英文效果更好)' :
740 activeTool === 'i2i' ? '希望生成什么样的图片?' :
741 '视频描述(希望生成什么样的视频?)'}
742 </label>
743 <textarea
744 value={prompt}
745 onChange={e => setPrompt(e.target.value)}
746 placeholder={
747 activeTool === 'llm' ? '输入你想问的问题...' :
748 activeTool === 'vlm' ? '描述这张图片的内容...' :
749 'A cute cat sitting on a couch, realistic style'
750 }
751 rows={4}
752 className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none resize-none"
753 />
754 </div>
755
756 {/* 提交按钮 */}
757 <button
758 onClick={handleSubmit}
759 disabled={loading || !canSubmit()}
760 className="w-full py-3 px-6 bg-indigo-600 text-white font-medium rounded-xl hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
761 >
762 {loading ? (
763 <>
764 <Loader2 className="w-5 h-5 animate-spin" />
765 <span>处理中...</span>
766 </>
767 ) : (
768 <>
769 <Sparkles className="w-5 h-5" />
770 <span>生成</span>
771 </>
772 )}
773 </button>
774 </div>
775
776 {/* 结果展示 */}
777 {(currentOutput || error) && (
778 <div className={`rounded-2xl border p-6 ${error ? 'bg-red-50 border-red-200' : 'bg-green-50 border-green-200'}`}>
779 <div className="flex items-center justify-between mb-3">
780 <h3 className={`font-medium ${error ? 'text-red-700' : 'text-green-700'}`}>
781 {error ? '错误' : '结果'}
782 </h3>
783 {!error && currentOutput && (
784 <button onClick={copyResult} className="p-2 rounded-lg hover:bg-white/50 transition-colors" title="复制结果">
785 {copied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4 text-gray-500" />}
786 </button>
787 )}
788 </div>
789 {error ? (
790 <p className="text-red-600 text-sm">{error}</p>
791 ) : (
792 <SandboxOutput output={currentOutput} />
793 )}
794 </div>
795 )}
796 <section className="mt-10">
797 <div className="flex items-center gap-2 mb-4">
798 <FolderOpen className="w-4 h-4 text-gray-400" />
799 <h2 className="text-sm font-medium text-gray-600">{getToolName(activeTool)}历史记录</h2>
800 <button
801 onClick={() => setManageMode(value => !value)}
802 className={`ml-auto text-xs px-2.5 h-8 rounded-lg transition-colors ${
803 manageMode ? 'bg-red-50 text-red-600 hover:bg-red-100' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'
804 }`}
805 >
806 {manageMode ? '完成' : '管理'}
807 </button>
808 </div>
809 {history.filter(record => record.tool === activeTool).length === 0 ? (
810 <div className="h-32 rounded-xl border border-dashed border-gray-200 bg-white/70 flex items-center justify-center text-sm text-gray-400">
811 暂无历史记录
812 </div>
813 ) : (
814 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
815 {history.filter(record => record.tool === activeTool).map(record => (
816 <div
817 key={record.id}
818 onClick={() => {
819 if (manageMode) return;
820 setSelectedRecord(record);
821 setPrompt(record.input.prompt || '');
822 setImageUrl(record.input.reference_image || record.input.images?.[0] || '');
823 setCurrentOutput(record.output || null);
824 setResult(record.output?.response || null);
825 setError(null);
826 }}
827 className={`bg-white rounded-xl border border-gray-200 p-4 hover:border-indigo-300 hover:shadow-sm transition-all ${manageMode ? '' : 'cursor-pointer'}`}
828 >
829 <div className="flex items-start justify-between gap-3">
830 <div className="min-w-0">
831 <div className="text-sm font-medium text-gray-700 truncate">
832 {record.input.prompt || record.input.reference_image || '(无提示词)'}
833 </div>
834 <div className="mt-1.5 flex flex-wrap items-center gap-2">
835 <span className="text-[10px] bg-indigo-50 text-indigo-600 px-1.5 py-0.5 rounded">{record.model}</span>
836 <span className="text-[10px] text-gray-400">{formatDate(record.created_at)}</span>
837 </div>
838 </div>
839 {manageMode && (
840 <button
841 onClick={event => {
842 event.stopPropagation();
843 deleteRecord(record.id);
844 }}
845 disabled={deleting === record.id}
846 className="w-8 h-8 rounded-lg text-red-500 bg-red-50 hover:bg-red-100 flex items-center justify-center flex-shrink-0"
847 >
848 {deleting === record.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
849 </button>
850 )}
851 </div>
852 </div>
853 ))}
854 </div>
855 )}
856 </section>
857 </>
858 </main>
859 </div>
860 );
861 }
862
862 lines Plain Text