feat: 添加 VEO3 场景写作工具并优化文件处理逻辑
- 创建 VEO3SceneWriterTool 页面组件,集成聊天界面和文件选择功能 - 添加 veo3SceneWriterService 服务层,封装与 Rust 后端的通信逻辑 - 实现 Tauri 命令支持,调用 veo3-scene-writer crate - 更新工具数据配置,添加 VEO3 场景写作工具 - 优化文件处理逻辑:JSON/TXT 文件读取内容作为消息,图片文件作为附件 - 支持多种文本格式:.json, .txt, .md, .yaml, .yml, .toml - 提供专业的影视场景提示词生成功能
This commit is contained in:
@@ -41,6 +41,7 @@ pub fn run() {
|
||||
.manage(commands::image_editing_commands::ImageEditingState::new())
|
||||
.manage(commands::tvai_commands::TvaiState::new())
|
||||
.manage(commands::veo3_actor_define_commands::Veo3ActorDefineSessionManager::new(std::collections::HashMap::new()))
|
||||
.manage(commands::veo3_actor_define_commands::Veo3SceneWriterSessionManager::new(std::collections::HashMap::new()))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::project_commands::create_project,
|
||||
commands::project_commands::get_all_projects,
|
||||
@@ -790,7 +791,16 @@ pub fn run() {
|
||||
commands::veo3_actor_define_commands::veo3_actor_define_clear_conversation,
|
||||
commands::veo3_actor_define_commands::veo3_actor_define_remove_session,
|
||||
commands::veo3_actor_define_commands::veo3_actor_define_get_active_sessions,
|
||||
commands::veo3_actor_define_commands::veo3_actor_define_create_actor_file
|
||||
commands::veo3_actor_define_commands::veo3_actor_define_create_actor_file,
|
||||
|
||||
// VEO3 场景写作命令
|
||||
commands::veo3_actor_define_commands::veo3_scene_writer_check_availability,
|
||||
commands::veo3_actor_define_commands::veo3_scene_writer_send_message,
|
||||
commands::veo3_actor_define_commands::veo3_scene_writer_send_message_with_attachments,
|
||||
commands::veo3_actor_define_commands::veo3_scene_writer_get_conversation_history,
|
||||
commands::veo3_actor_define_commands::veo3_scene_writer_clear_conversation,
|
||||
commands::veo3_actor_define_commands::veo3_scene_writer_remove_session,
|
||||
commands::veo3_actor_define_commands::veo3_scene_writer_get_active_sessions
|
||||
])
|
||||
.setup(|app| {
|
||||
// 初始化日志系统
|
||||
|
||||
@@ -5,7 +5,7 @@ use tauri::State;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use chrono;
|
||||
use veo3_scene_writer::Veo3ActorDefine;
|
||||
use veo3_scene_writer::{Veo3ActorDefine, Veo3SceneWriter};
|
||||
use crate::infrastructure::tolerant_json_parser::TolerantJsonParser;
|
||||
|
||||
/// VEO3 角色定义响应结构
|
||||
@@ -20,6 +20,9 @@ pub struct Veo3ActorDefineResponse {
|
||||
/// VEO3 角色定义会话管理器
|
||||
pub type Veo3ActorDefineSessionManager = Mutex<HashMap<String, Veo3ActorDefine>>;
|
||||
|
||||
/// VEO3 场景写作会话管理器
|
||||
pub type Veo3SceneWriterSessionManager = Mutex<HashMap<String, Veo3SceneWriter>>;
|
||||
|
||||
/// 检查 VEO3 角色定义服务可用性
|
||||
#[tauri::command]
|
||||
pub async fn veo3_actor_define_check_availability() -> Result<(), String> {
|
||||
@@ -305,6 +308,168 @@ pub async fn veo3_actor_define_create_actor_file(
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== VEO3 场景写作命令 ====================
|
||||
|
||||
/// 检查 VEO3 场景写作服务可用性
|
||||
#[tauri::command]
|
||||
pub async fn veo3_scene_writer_check_availability() -> Result<(), String> {
|
||||
// 尝试创建一个实例来检查服务是否可用
|
||||
match Veo3SceneWriter::new().await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!("VEO3 场景写作服务不可用: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送消息到 VEO3 场景写作工具
|
||||
#[tauri::command]
|
||||
pub async fn veo3_scene_writer_send_message(
|
||||
session_id: String,
|
||||
message: String,
|
||||
session_manager: State<'_, Veo3SceneWriterSessionManager>,
|
||||
) -> Result<Veo3ActorDefineResponse, String> {
|
||||
// 检查会话是否存在,如果不存在则创建
|
||||
let session_exists = {
|
||||
let sessions = session_manager.lock().await;
|
||||
sessions.contains_key(&session_id)
|
||||
};
|
||||
|
||||
if !session_exists {
|
||||
let new_scene_writer = Veo3SceneWriter::new()
|
||||
.await
|
||||
.map_err(|e| format!("创建 VEO3 场景写作实例失败: {}", e))?;
|
||||
|
||||
let mut sessions = session_manager.lock().await;
|
||||
sessions.insert(session_id.clone(), new_scene_writer);
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
let (response_result, conversation_history) = {
|
||||
let mut sessions = session_manager.lock().await;
|
||||
let scene_writer = sessions.get_mut(&session_id).unwrap();
|
||||
|
||||
let response = scene_writer.send_message(&message).await;
|
||||
let history = scene_writer.get_conversation_history().clone();
|
||||
(response, history)
|
||||
};
|
||||
|
||||
match response_result {
|
||||
Ok(response) => Ok(Veo3ActorDefineResponse {
|
||||
success: true,
|
||||
content: response,
|
||||
error: None,
|
||||
conversation_history: Some(conversation_history),
|
||||
}),
|
||||
Err(e) => Ok(Veo3ActorDefineResponse {
|
||||
success: false,
|
||||
content: String::new(),
|
||||
error: Some(format!("发送消息失败: {}", e)),
|
||||
conversation_history: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送带附件的消息到 VEO3 场景写作工具
|
||||
#[tauri::command]
|
||||
pub async fn veo3_scene_writer_send_message_with_attachments(
|
||||
session_id: String,
|
||||
message: String,
|
||||
attachment_paths: Vec<String>,
|
||||
session_manager: State<'_, Veo3SceneWriterSessionManager>,
|
||||
) -> Result<Veo3ActorDefineResponse, String> {
|
||||
// 检查会话是否存在,如果不存在则创建
|
||||
let session_exists = {
|
||||
let sessions = session_manager.lock().await;
|
||||
sessions.contains_key(&session_id)
|
||||
};
|
||||
|
||||
if !session_exists {
|
||||
let new_scene_writer = Veo3SceneWriter::new()
|
||||
.await
|
||||
.map_err(|e| format!("创建 VEO3 场景写作实例失败: {}", e))?;
|
||||
|
||||
let mut sessions = session_manager.lock().await;
|
||||
sessions.insert(session_id.clone(), new_scene_writer);
|
||||
}
|
||||
|
||||
// 转换路径为字符串引用
|
||||
let attachment_refs: Vec<&str> = attachment_paths.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
// 发送带附件的消息
|
||||
let (response_result, conversation_history) = {
|
||||
let mut sessions = session_manager.lock().await;
|
||||
let scene_writer = sessions.get_mut(&session_id).unwrap();
|
||||
|
||||
let response = scene_writer.send_message_with_attachments(&message, attachment_refs).await;
|
||||
let history = scene_writer.get_conversation_history().clone();
|
||||
(response, history)
|
||||
};
|
||||
|
||||
match response_result {
|
||||
Ok(response) => Ok(Veo3ActorDefineResponse {
|
||||
success: true,
|
||||
content: response,
|
||||
error: None,
|
||||
conversation_history: Some(conversation_history),
|
||||
}),
|
||||
Err(e) => Ok(Veo3ActorDefineResponse {
|
||||
success: false,
|
||||
content: String::new(),
|
||||
error: Some(format!("发送带附件消息失败: {}", e)),
|
||||
conversation_history: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 VEO3 场景写作会话历史记录
|
||||
#[tauri::command]
|
||||
pub async fn veo3_scene_writer_get_conversation_history(
|
||||
session_id: String,
|
||||
session_manager: State<'_, Veo3SceneWriterSessionManager>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let sessions = session_manager.lock().await;
|
||||
|
||||
if let Some(scene_writer) = sessions.get(&session_id) {
|
||||
Ok(scene_writer.get_conversation_history().clone())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除 VEO3 场景写作会话历史记录
|
||||
#[tauri::command]
|
||||
pub async fn veo3_scene_writer_clear_conversation(
|
||||
session_id: String,
|
||||
session_manager: State<'_, Veo3SceneWriterSessionManager>,
|
||||
) -> Result<(), String> {
|
||||
let mut sessions = session_manager.lock().await;
|
||||
|
||||
if let Some(scene_writer) = sessions.get_mut(&session_id) {
|
||||
scene_writer.clear_conversation();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除 VEO3 场景写作会话
|
||||
#[tauri::command]
|
||||
pub async fn veo3_scene_writer_remove_session(
|
||||
session_id: String,
|
||||
session_manager: State<'_, Veo3SceneWriterSessionManager>,
|
||||
) -> Result<(), String> {
|
||||
let mut sessions = session_manager.lock().await;
|
||||
sessions.remove(&session_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取所有活跃的 VEO3 场景写作会话ID
|
||||
#[tauri::command]
|
||||
pub async fn veo3_scene_writer_get_active_sessions(
|
||||
session_manager: State<'_, Veo3SceneWriterSessionManager>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let sessions = session_manager.lock().await;
|
||||
Ok(sessions.keys().cloned().collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -319,6 +484,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_veo3_scene_writer_check_availability() {
|
||||
// 注意:这个测试需要有效的 API 配置才能通过
|
||||
if std::env::var("GEMINI_BEARER_TOKEN").is_ok() {
|
||||
let result = veo3_scene_writer_check_availability().await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_management() {
|
||||
let session_manager = Arc::new(Veo3ActorDefineSessionManager::new(HashMap::new()));
|
||||
|
||||
@@ -37,6 +37,7 @@ import { EnrichedAnalysisDemo } from './pages/tools/EnrichedAnalysisDemo';
|
||||
import AiModelFaceHairFixTool from './pages/tools/AiModelFaceHairFixTool';
|
||||
import TopazVideoAITool from './pages/tools/TopazVideoAITool';
|
||||
import Veo3ActorDefineTool from './pages/tools/Veo3ActorDefineTool';
|
||||
import Veo3SceneWriterTool from './pages/tools/Veo3SceneWriterTool';
|
||||
import CommandGenerator from './components/CommandGenerator';
|
||||
import MaterialCenter from './pages/MaterialCenter';
|
||||
import VideoGeneration from './pages/VideoGeneration';
|
||||
@@ -185,6 +186,7 @@ function App() {
|
||||
<Route path="/tools/ai-model-face-hair-fix" element={<AiModelFaceHairFixTool />} />
|
||||
<Route path="/tools/topaz-video-ai" element={<TopazVideoAITool />} />
|
||||
<Route path="/tools/veo3-actor-define" element={<Veo3ActorDefineTool />} />
|
||||
<Route path="/tools/veo3-scene-writer" element={<Veo3SceneWriterTool />} />
|
||||
<Route path="/tools/command-generator" element={<CommandGenerator />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Frame,
|
||||
Sparkles,
|
||||
User,
|
||||
Clapperboard,
|
||||
} from 'lucide-react';
|
||||
import { Tool, ToolCategory, ToolStatus } from '../types/tool';
|
||||
|
||||
@@ -35,6 +36,21 @@ export const TOOLS_DATA: Tool[] = [
|
||||
version: '1.0.0',
|
||||
lastUpdated: '2025-08-15'
|
||||
},
|
||||
{
|
||||
id: 'veo3-scene-writer',
|
||||
name: 'VEO3 场景写作工具',
|
||||
description: '专业的影视场景提示词生成助手,支持图片上传和智能对话,创建电影级别的场景描述',
|
||||
longDescription: '基于 VEO3 场景写作系统的专业场景提示词生成工具,集成 Gemini AI 提供智能对话功能。支持上传参考图片进行场景分析,或通过对话生成全新场景。能够创建包含视觉效果、镜头语言、环境氛围等完整场景描述,输出专业的提示词格式。适用于影视制作、广告创意、短视频制作等需要场景设计的场景。',
|
||||
icon: Clapperboard,
|
||||
route: '/tools/veo3-scene-writer',
|
||||
category: ToolCategory.AI_TOOLS,
|
||||
status: ToolStatus.STABLE,
|
||||
tags: ['场景写作', 'VEO3', '影视制作', 'AI对话', '图片分析', '提示词生成'],
|
||||
isNew: true,
|
||||
isPopular: true,
|
||||
version: '1.0.0',
|
||||
lastUpdated: '2025-08-15'
|
||||
},
|
||||
{
|
||||
id: 'topaz-video-ai',
|
||||
name: 'Topaz Video AI 参数配置器',
|
||||
|
||||
481
apps/desktop/src/pages/tools/Veo3SceneWriterTool.tsx
Normal file
481
apps/desktop/src/pages/tools/Veo3SceneWriterTool.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Send,
|
||||
AlertCircle,
|
||||
Sparkles,
|
||||
Upload,
|
||||
FileImage,
|
||||
X,
|
||||
Video,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
CheckCircle,
|
||||
Clapperboard,
|
||||
FolderOpen
|
||||
} from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import {
|
||||
veo3SceneWriterService,
|
||||
Veo3SceneWriterResponse
|
||||
} 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 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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 如果服务不可用,显示错误状态
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 输入区域 */}
|
||||
<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={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;
|
||||
193
apps/desktop/src/services/veo3SceneWriterService.ts
Normal file
193
apps/desktop/src/services/veo3SceneWriterService.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
/**
|
||||
* VEO3 场景写作消息接口
|
||||
*/
|
||||
export interface Veo3SceneWriterMessage {
|
||||
content: string;
|
||||
attachments?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* VEO3 场景写作响应接口
|
||||
*/
|
||||
export interface Veo3SceneWriterResponse {
|
||||
success: boolean;
|
||||
content: string;
|
||||
error?: string;
|
||||
conversation_history?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* VEO3 场景写作会话状态接口
|
||||
*/
|
||||
export interface Veo3SceneWriterSession {
|
||||
session_id: string;
|
||||
conversation_history: string[];
|
||||
created_at: string;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* VEO3 场景写作服务类
|
||||
* 封装与 Rust 后端 veo3-scene-writer crate 的通信逻辑
|
||||
*/
|
||||
export class Veo3SceneWriterService {
|
||||
private static instance: Veo3SceneWriterService;
|
||||
private sessionId: string;
|
||||
|
||||
private constructor() {
|
||||
this.sessionId = `veo3-scene-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务实例(单例模式)
|
||||
*/
|
||||
public static getInstance(): Veo3SceneWriterService {
|
||||
if (!Veo3SceneWriterService.instance) {
|
||||
Veo3SceneWriterService.instance = new Veo3SceneWriterService();
|
||||
}
|
||||
return Veo3SceneWriterService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文本消息到 VEO3 场景写作工具
|
||||
*/
|
||||
public async sendMessage(message: string): Promise<Veo3SceneWriterResponse> {
|
||||
try {
|
||||
const response = await invoke<Veo3SceneWriterResponse>('veo3_scene_writer_send_message', {
|
||||
sessionId: this.sessionId,
|
||||
message: message
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('VEO3 场景写作消息发送失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
content: '',
|
||||
error: error instanceof Error ? error.message : '未知错误'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送带附件的消息到 VEO3 场景写作工具
|
||||
*/
|
||||
public async sendMessageWithAttachments(
|
||||
message: string,
|
||||
attachmentPaths: string[]
|
||||
): Promise<Veo3SceneWriterResponse> {
|
||||
try {
|
||||
const response = await invoke<Veo3SceneWriterResponse>('veo3_scene_writer_send_message_with_attachments', {
|
||||
sessionId: this.sessionId,
|
||||
message: message,
|
||||
attachmentPaths: attachmentPaths
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('VEO3 场景写作带附件消息发送失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
content: '',
|
||||
error: error instanceof Error ? error.message : '未知错误'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话历史记录
|
||||
*/
|
||||
public async getConversationHistory(): Promise<string[]> {
|
||||
try {
|
||||
const history = await invoke<string[]>('veo3_scene_writer_get_conversation_history', {
|
||||
sessionId: this.sessionId
|
||||
});
|
||||
|
||||
return history;
|
||||
} catch (error) {
|
||||
console.error('获取 VEO3 场景写作会话历史失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除会话历史记录
|
||||
*/
|
||||
public async clearConversation(): Promise<boolean> {
|
||||
try {
|
||||
await invoke('veo3_scene_writer_clear_conversation', {
|
||||
sessionId: this.sessionId
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('清除 VEO3 场景写作会话历史失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前会话ID
|
||||
*/
|
||||
public getSessionId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置会话(生成新的会话ID)
|
||||
*/
|
||||
public resetSession(): void {
|
||||
this.sessionId = `veo3-scene-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查服务是否可用
|
||||
*/
|
||||
public async checkServiceAvailability(): Promise<boolean> {
|
||||
try {
|
||||
await invoke('veo3_scene_writer_check_availability');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('VEO3 场景写作服务不可用:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出服务实例
|
||||
*/
|
||||
export const veo3SceneWriterService = Veo3SceneWriterService.getInstance();
|
||||
|
||||
/**
|
||||
* 便捷函数:发送消息
|
||||
*/
|
||||
export const sendVeo3SceneWriterMessage = (message: string): Promise<Veo3SceneWriterResponse> => {
|
||||
return veo3SceneWriterService.sendMessage(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* 便捷函数:发送带附件的消息
|
||||
*/
|
||||
export const sendVeo3SceneWriterMessageWithAttachments = (
|
||||
message: string,
|
||||
attachmentPaths: string[]
|
||||
): Promise<Veo3SceneWriterResponse> => {
|
||||
return veo3SceneWriterService.sendMessageWithAttachments(message, attachmentPaths);
|
||||
};
|
||||
|
||||
/**
|
||||
* 便捷函数:获取会话历史
|
||||
*/
|
||||
export const getVeo3SceneWriterConversationHistory = (): Promise<string[]> => {
|
||||
return veo3SceneWriterService.getConversationHistory();
|
||||
};
|
||||
|
||||
/**
|
||||
* 便捷函数:清除会话
|
||||
*/
|
||||
export const clearVeo3SceneWriterConversation = (): Promise<boolean> => {
|
||||
return veo3SceneWriterService.clearConversation();
|
||||
};
|
||||
@@ -198,13 +198,24 @@ impl Veo3SceneWriter {
|
||||
message: &str,
|
||||
attachment_paths: Vec<&str>
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
// 处理文件内容,分离文本文件和图片文件
|
||||
let (text_content, image_attachments) = self.process_attachments(attachment_paths).await?;
|
||||
|
||||
// 构建完整消息,包含文本文件内容
|
||||
let mut full_message = if text_content.is_empty() {
|
||||
message.to_string()
|
||||
} else {
|
||||
format!("{}\n\n附件内容:\n{}", message, text_content)
|
||||
};
|
||||
|
||||
// 构建包含历史记录的完整消息
|
||||
let full_message = self.build_message_with_history(message);
|
||||
full_message = self.build_message_with_history(&full_message);
|
||||
|
||||
let mut request = MessageRequest::with_agent(&full_message, &self.agent_id);
|
||||
|
||||
for path in attachment_paths {
|
||||
request = request.attach(Attachment::new(path));
|
||||
// 只添加图片附件到请求中
|
||||
for path in image_attachments {
|
||||
request = request.attach(Attachment::new(&path));
|
||||
}
|
||||
|
||||
let response = self.sdk.send_message(request).await?;
|
||||
@@ -215,6 +226,45 @@ impl Veo3SceneWriter {
|
||||
Ok(response.content)
|
||||
}
|
||||
|
||||
/// 处理附件,分离文本文件和图片文件
|
||||
async fn process_attachments(
|
||||
&self,
|
||||
attachment_paths: Vec<&str>
|
||||
) -> Result<(String, Vec<String>), Box<dyn std::error::Error>> {
|
||||
let mut text_content = Vec::new();
|
||||
let mut image_attachments = Vec::new();
|
||||
|
||||
for path in attachment_paths {
|
||||
let path_lower = path.to_lowercase();
|
||||
|
||||
// 检查文件扩展名
|
||||
if path_lower.ends_with(".json") || path_lower.ends_with(".txt") ||
|
||||
path_lower.ends_with(".md") || path_lower.ends_with(".yaml") ||
|
||||
path_lower.ends_with(".yml") || path_lower.ends_with(".toml") {
|
||||
// 读取文本文件内容
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(content) => {
|
||||
let file_name = std::path::Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown");
|
||||
text_content.push(format!("=== {} ===\n{}", file_name, content));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("读取文件失败 {}: {}", path, e);
|
||||
text_content.push(format!("=== {} ===\n[文件读取失败: {}]", path, e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 图片文件或其他文件,作为附件处理
|
||||
image_attachments.push(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let combined_text = text_content.join("\n\n");
|
||||
Ok((combined_text, image_attachments))
|
||||
}
|
||||
|
||||
/// 发送带单个附件的消息
|
||||
pub async fn send_message_with_attachment(
|
||||
&mut self,
|
||||
@@ -357,13 +407,24 @@ impl Veo3ActorDefine {
|
||||
message: &str,
|
||||
attachment_paths: Vec<&str>
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
// 处理文件内容,分离文本文件和图片文件
|
||||
let (text_content, image_attachments) = self.process_attachments(attachment_paths).await?;
|
||||
|
||||
// 构建完整消息,包含文本文件内容
|
||||
let mut full_message = if text_content.is_empty() {
|
||||
message.to_string()
|
||||
} else {
|
||||
format!("{}\n\n附件内容:\n{}", message, text_content)
|
||||
};
|
||||
|
||||
// 构建包含历史记录的完整消息
|
||||
let full_message = self.build_message_with_history(message);
|
||||
full_message = self.build_message_with_history(&full_message);
|
||||
|
||||
let mut request = MessageRequest::with_agent(&full_message, &self.agent_id);
|
||||
|
||||
for path in attachment_paths {
|
||||
request = request.attach(Attachment::new(path));
|
||||
// 只添加图片附件到请求中
|
||||
for path in image_attachments {
|
||||
request = request.attach(Attachment::new(&path));
|
||||
}
|
||||
|
||||
let response = self.sdk.send_message(request).await?;
|
||||
@@ -374,6 +435,45 @@ impl Veo3ActorDefine {
|
||||
Ok(response.content)
|
||||
}
|
||||
|
||||
/// 处理附件,分离文本文件和图片文件
|
||||
async fn process_attachments(
|
||||
&self,
|
||||
attachment_paths: Vec<&str>
|
||||
) -> Result<(String, Vec<String>), Box<dyn std::error::Error>> {
|
||||
let mut text_content = Vec::new();
|
||||
let mut image_attachments = Vec::new();
|
||||
|
||||
for path in attachment_paths {
|
||||
let path_lower = path.to_lowercase();
|
||||
|
||||
// 检查文件扩展名
|
||||
if path_lower.ends_with(".json") || path_lower.ends_with(".txt") ||
|
||||
path_lower.ends_with(".md") || path_lower.ends_with(".yaml") ||
|
||||
path_lower.ends_with(".yml") || path_lower.ends_with(".toml") {
|
||||
// 读取文本文件内容
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(content) => {
|
||||
let file_name = std::path::Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown");
|
||||
text_content.push(format!("=== {} ===\n{}", file_name, content));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("读取文件失败 {}: {}", path, e);
|
||||
text_content.push(format!("=== {} ===\n[文件读取失败: {}]", path, e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 图片文件或其他文件,作为附件处理
|
||||
image_attachments.push(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let combined_text = text_content.join("\n\n");
|
||||
Ok((combined_text, image_attachments))
|
||||
}
|
||||
|
||||
/// 发送带单个附件的消息
|
||||
pub async fn send_message_with_attachment(
|
||||
&mut self,
|
||||
|
||||
Reference in New Issue
Block a user