552 lines
20 KiB
TypeScript
552 lines
20 KiB
TypeScript
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;
|