mixvideo-v2/apps/desktop/src/pages/tools/Veo3SceneWriterTool.tsx

552 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
Send,
AlertCircle,
Sparkles,
Upload,
FileImage,
X,
Video,
MessageSquare,
RefreshCw,
Copy,
CheckCircle,
Clapperboard,
FolderOpen,
Save
} from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import {
veo3SceneWriterService,
Veo3SceneWriterResponse,
CreateSceneFileResponse
} from '../../services/veo3SceneWriterService';
/**
* VEO3 场景写作消息接口
*/
interface SceneWriterMessage {
id: string;
type: 'user' | 'assistant';
content: string;
timestamp: Date;
status?: 'sending' | 'sent' | 'error';
attachments?: string[];
}
/**
* VEO3 场景写作工具页面
* 集成 ag-ui 实现对话和本地附件选择功能
*/
export const Veo3SceneWriterTool: React.FC = () => {
const [messages, setMessages] = useState<SceneWriterMessage[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [serviceAvailable, setServiceAvailable] = useState<boolean | null>(null);
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
const [isCreatingScene, setIsCreatingScene] = useState(false);
const [createSceneSuccess, setCreateSceneSuccess] = useState<string | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
// 检查服务可用性
useEffect(() => {
const checkService = async () => {
const available = await veo3SceneWriterService.checkServiceAvailability();
setServiceAvailable(available);
};
checkService();
}, []);
// 自动滚动到底部
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
// 生成消息ID
const generateMessageId = useCallback(() => {
return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}, []);
// 选择文件
const handleSelectFiles = useCallback(async () => {
try {
const selected = await open({
multiple: true,
filters: [
{
name: 'Images',
extensions: ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']
},
{
name: 'Videos',
extensions: ['mp4', 'avi', 'mov', 'mkv', 'webm']
},
{
name: 'All Files',
extensions: ['*']
}
]
});
if (selected && Array.isArray(selected)) {
setSelectedFiles(prev => [...prev, ...selected]);
} else if (selected && typeof selected === 'string') {
setSelectedFiles(prev => [...prev, selected]);
}
} catch (error) {
console.error('文件选择失败:', error);
setError('文件选择失败');
}
}, []);
// 移除选中的文件
const removeSelectedFile = useCallback((index: number) => {
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
}, []);
// 清空选中的文件
const clearSelectedFiles = useCallback(() => {
setSelectedFiles([]);
}, []);
// 发送消息
const handleSendMessage = useCallback(async () => {
if ((!input.trim() && selectedFiles.length === 0) || isLoading) return;
const userMessage: SceneWriterMessage = {
id: generateMessageId(),
type: 'user',
content: input.trim() || '(附件)',
timestamp: new Date(),
status: 'sent',
attachments: selectedFiles.length > 0 ? [...selectedFiles] : undefined
};
const assistantMessage: SceneWriterMessage = {
id: generateMessageId(),
type: 'assistant',
content: '',
timestamp: new Date(),
status: 'sending'
};
setMessages(prev => [...prev, userMessage, assistantMessage]);
setInput('');
setSelectedFiles([]);
setIsLoading(true);
setError(null);
try {
let response: Veo3SceneWriterResponse;
if (selectedFiles.length > 0) {
response = await veo3SceneWriterService.sendMessageWithAttachments(
userMessage.content,
selectedFiles
);
} else {
response = await veo3SceneWriterService.sendMessage(userMessage.content);
}
if (response.success) {
setMessages(prev => prev.map(msg =>
msg.id === assistantMessage.id
? {
...msg,
content: response.content,
status: 'sent'
}
: msg
));
} else {
throw new Error(response.error || '发送失败');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '未知错误';
setError(errorMessage);
setMessages(prev => prev.map(msg =>
msg.id === assistantMessage.id
? {
...msg,
content: `抱歉,处理时出现错误:${errorMessage}`,
status: 'error'
}
: msg
));
} finally {
setIsLoading(false);
}
}, [input, selectedFiles, isLoading, generateMessageId]);
// 处理键盘事件
const handleKeyPress = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}, [handleSendMessage]);
// 清除会话
const handleClearConversation = useCallback(async () => {
const success = await veo3SceneWriterService.clearConversation();
if (success) {
setMessages([]);
setError(null);
}
}, []);
// 重置会话
const handleResetSession = useCallback(() => {
veo3SceneWriterService.resetSession();
setMessages([]);
setError(null);
}, []);
// 复制消息内容
const handleCopyMessage = useCallback(async (content: string, messageId: string) => {
try {
await navigator.clipboard.writeText(content);
setCopiedMessageId(messageId);
setTimeout(() => setCopiedMessageId(null), 2000);
} catch (error) {
console.error('复制失败:', error);
}
}, []);
// 选择保存目录并创建场景文件
const handleCreateSceneFile = useCallback(async () => {
if (isCreatingScene) return;
try {
// 选择保存目录
const selectedDir = await open({
directory: true,
title: '选择场景文件保存目录'
});
if (!selectedDir || typeof selectedDir !== 'string') {
return;
}
setIsCreatingScene(true);
setError(null);
// 调用创建场景文件服务
const result = await veo3SceneWriterService.createSceneFile(selectedDir);
if (result.success && result.file_path) {
setCreateSceneSuccess(result.file_path);
setTimeout(() => setCreateSceneSuccess(null), 5000);
} else {
setError(result.error || '创建场景文件失败');
}
} catch (error) {
console.error('创建场景文件失败:', error);
setError(error instanceof Error ? error.message : '创建场景文件失败');
} finally {
setIsCreatingScene(false);
}
}, [isCreatingScene]);
// 如果服务不可用,显示错误状态
if (serviceAvailable === false) {
return (
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<AlertCircle className="w-16 h-16 text-red-500 mb-4" />
<h2 className="text-xl font-semibold text-gray-800 mb-2">VEO3 </h2>
<p className="text-gray-600 mb-4">
Gemini API
</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
</button>
</div>
);
}
return (
<div className="flex flex-col h-full bg-gradient-to-br from-blue-50 to-indigo-50">
{/* 头部 */}
<div className="flex-shrink-0 bg-white border-b border-gray-200 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-lg flex items-center justify-center">
<Clapperboard className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-xl font-semibold text-gray-800">VEO3 </h1>
<p className="text-sm text-gray-600"></p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleClearConversation}
className="px-3 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors text-sm"
>
<MessageSquare className="w-4 h-4 mr-1 inline" />
</button>
<button
onClick={handleResetSession}
className="px-3 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors text-sm"
>
<RefreshCw className="w-4 h-4 mr-1 inline" />
</button>
</div>
</div>
</div>
{/* 聊天消息区域 */}
<div className="flex-1 p-4 space-y-6 overflow-y-auto">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="w-20 h-20 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center mb-6">
<Sparkles className="w-10 h-10 text-blue-500" />
</div>
<p className="text-gray-600 max-w-lg mb-8 text-lg leading-relaxed">
VEO3
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl">
{[
'我想创建一个场景提示词',
'上传场景参考图片',
'生成电影级别的场景',
'创建特定风格的场景'
].map((suggestion, index) => (
<button
key={index}
onClick={() => setInput(suggestion)}
className="px-3 py-2 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-all duration-200 hover:shadow-md hover:scale-105"
>
{suggestion}
</button>
))}
</div>
</div>
) : (
<>
{messages.map((message) => (
<div
key={message.id}
className={`flex ${message.type === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-lg p-4 ${
message.type === 'user'
? 'bg-blue-500 text-white'
: 'bg-white border border-gray-200 text-gray-800'
}`}
>
{/* 消息内容 */}
<div className="whitespace-pre-wrap">{message.content}</div>
{/* 附件显示 */}
{message.attachments && message.attachments.length > 0 && (
<div className="mt-2 space-y-1">
{message.attachments.map((file, index) => (
<div key={index} className="flex items-center gap-2 text-sm opacity-80">
<FileImage className="w-4 h-4" />
<span>{file.split('/').pop() || file}</span>
</div>
))}
</div>
)}
{/* 消息操作 */}
{message.type === 'assistant' && message.status === 'sent' && (
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-gray-100">
<button
onClick={() => handleCopyMessage(message.content, message.id)}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
{copiedMessageId === message.id ? (
<>
<CheckCircle className="w-3 h-3" />
</>
) : (
<>
<Copy className="w-3 h-3" />
</>
)}
</button>
</div>
)}
{/* 状态指示 */}
{message.status === 'sending' && (
<div className="flex items-center gap-2 mt-2 text-sm opacity-70">
<div className="animate-spin w-4 h-4 border-2 border-current border-t-transparent rounded-full"></div>
<span>...</span>
</div>
)}
{message.status === 'error' && (
<div className="flex items-center gap-2 mt-2 text-sm text-red-500">
<AlertCircle className="w-4 h-4" />
<span></span>
</div>
)}
</div>
</div>
))}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* 错误提示 */}
{error && (
<div className="mx-4 mb-2 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
<span className="text-red-700 text-sm">{error}</span>
<button
onClick={() => setError(null)}
className="ml-auto text-red-600 hover:text-red-800 text-sm font-medium"
>
</button>
</div>
)}
{/* 成功提示 */}
{createSceneSuccess && (
<div className="mx-4 mb-2 p-3 bg-green-50 border border-green-200 rounded-lg flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0" />
<div className="flex-1">
<span className="text-green-700 text-sm"></span>
<div className="text-xs text-green-600 mt-1 break-all">{createSceneSuccess}</div>
</div>
<button
onClick={() => setCreateSceneSuccess(null)}
className="ml-auto text-green-600 hover:text-green-800 text-sm font-medium"
>
</button>
</div>
)}
{/* 输入区域 */}
<div className="flex-shrink-0 bg-white border-t border-gray-200">
{/* 选中的文件显示 */}
{selectedFiles.length > 0 && (
<div className="border-b border-gray-100 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">
{selectedFiles.length}
</span>
<button
onClick={clearSelectedFiles}
className="text-xs text-gray-500 hover:text-gray-700"
>
</button>
</div>
<div className="flex flex-wrap gap-2">
{selectedFiles.map((file, index) => (
<div
key={index}
className="flex items-center gap-2 bg-gray-100 rounded-lg px-3 py-1 text-sm"
>
<FileImage className="w-4 h-4 text-gray-500" />
<span className="max-w-32 truncate">{file.split('/').pop() || file}</span>
<button
onClick={() => removeSelectedFile(index)}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
{/* 输入框 */}
<div className="p-4">
<div className="flex gap-3 items-end">
<button
onClick={handleSelectFiles}
className="px-3 py-2 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-colors flex items-center gap-2 flex-shrink-0"
>
<Upload className="w-4 h-4" />
</button>
<div className="flex-1 relative">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="描述您想要创建的场景,或上传参考图片..."
disabled={isLoading}
className="w-full px-4 py-3 border border-gray-300 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
rows={1}
style={{
minHeight: '48px',
maxHeight: '120px',
height: 'auto'
}}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = 'auto';
target.style.height = `${Math.min(target.scrollHeight, 120)}px`;
}}
/>
</div>
<button
onClick={handleCreateSceneFile}
disabled={isCreatingScene || messages.length === 0}
className="px-4 bg-green-500 text-white rounded-lg hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:bg-gray-300 disabled:cursor-not-allowed transition-all duration-200 flex items-center justify-center flex-shrink-0"
style={{ height: '48px', minHeight: '48px' }}
title="从最近一次对话中提取JSON并保存为场景文件"
>
{isCreatingScene ? (
<div className="animate-spin w-5 h-5 border-2 border-white border-t-transparent rounded-full"></div>
) : (
<Save className="w-5 h-5" />
)}
</button>
<button
onClick={handleSendMessage}
disabled={(!input.trim() && selectedFiles.length === 0) || isLoading}
className="px-4 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:bg-gray-300 disabled:cursor-not-allowed transition-all duration-200 flex items-center justify-center flex-shrink-0"
style={{ height: '48px', minHeight: '48px' }}
>
{isLoading ? (
<div className="animate-spin w-5 h-5 border-2 border-white border-t-transparent rounded-full"></div>
) : (
<Send className="w-5 h-5" />
)}
</button>
</div>
<div className="mt-2 text-center text-xs text-gray-500">
<span> Enter Shift + Enter </span>
</div>
</div>
</div>
</div>
);
};
export default Veo3SceneWriterTool;