diff --git a/.gitignore b/.gitignore index f5cb4476a..8b75b94e8 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,6 @@ src-tauri/gen .task Taskfile.yml nul -.proptest-regressions \ No newline at end of file +.proptest-regressions +.codex +openspec \ No newline at end of file diff --git a/package.json b/package.json index cc79d9825..cd10e0ef7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.50.0", + "version": "0.51.0", "type": "module", "repository": { "type": "git", @@ -24,6 +24,7 @@ "detect-translations:verbose": "tsx scripts/detect-missing-translations.ts --verbose" }, "dependencies": { + "@babel/standalone": "^7.29.0", "@fabianlars/tauri-plugin-oauth": "^2", "@floating-ui/react": "^0.27.16", "@observablehq/plot": "^0.6.17", @@ -46,6 +47,10 @@ "@tauri-apps/plugin-dialog": "2.5.0", "@tauri-apps/plugin-global-shortcut": "2.3.1", "@tauri-apps/plugin-shell": "2.3.4", + "@tiptap/extension-placeholder": "^3.18.0", + "@tiptap/pm": "^3.18.0", + "@tiptap/react": "^3.18.0", + "@tiptap/starter-kit": "^3.18.0", "@tonejs/midi": "^2.0.28", "@types/lodash-es": "^4.17.12", "@types/styled-components": "^5.1.36", @@ -64,6 +69,7 @@ "jotai": "^2.16.1", "lodash-es": "^4.17.22", "lucide-react": "^0.460.0", + "mermaid": "^11.12.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^16.5.1", @@ -86,6 +92,7 @@ "@eslint/js": "^9.15.0", "@fast-check/vitest": "^0.2.4", "@tauri-apps/cli": "^2.0.0", + "@types/babel__standalone": "^7.1.9", "@types/fabric": "^5.3.11", "@types/node": "^22.9.0", "@types/react": "^18.3.12", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d3871ae0f..f13b51f48 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -202,7 +202,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "aster" -version = "0.4.6" +version = "0.5.1" dependencies = [ "ahash", "anyhow", @@ -218,7 +218,9 @@ dependencies = [ "boa_engine", "boa_gc", "chrono", + "chrono-tz", "clap", + "cron", "dashmap 6.1.0", "dirs 5.0.1", "etcetera 0.11.0", @@ -226,6 +228,7 @@ dependencies = [ "futures", "glob", "hex", + "hmac", "hostname", "ignore", "include_dir", @@ -1067,7 +1070,7 @@ dependencies = [ "bitflags 2.10.0", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.12.1", "proc-macro2", "quote", "regex", @@ -1603,6 +1606,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -2010,6 +2023,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "cron" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5877d3fbf742507b66bc2a1945106bd30dd8504019d596901ddd012a4dd01740" +dependencies = [ + "chrono", + "once_cell", + "winnow 0.6.26", +] + [[package]] name = "croner" version = "2.2.0" @@ -4332,15 +4356,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -6033,6 +6048,15 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf" version = "0.13.1" @@ -6180,6 +6204,15 @@ dependencies = [ "siphasher 1.0.1", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher 1.0.1", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -6602,7 +6635,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.50.0" +version = "0.51.0" dependencies = [ "chrono", "dirs 5.0.1", @@ -6618,7 +6651,7 @@ dependencies = [ [[package]] name = "proxycast-infra" -version = "0.50.0" +version = "0.51.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -10964,6 +10997,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "0.6.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.7.14" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 98a549a25..56478e788 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.50.0" +version = "0.51.0" edition = "2021" authors = ["you"] repository = "https://github.com/aiclientproxy/proxycast" @@ -103,9 +103,9 @@ enigo = "0.3" # Aster Agent Framework # 开发时使用本地 aster-rust,CI/CD 使用远程 GitHub 仓库 # 本地开发: path = "../../../astercloud/aster-rust/crates/aster" (相对 src-tauri/) -# CI/CD: git = "https://github.com/astercloud/aster-rust", tag = "v0.4.3" -# aster = { version = "0.4.3", path = "../../../astercloud/aster-rust/crates/aster" } -aster = { git = "https://github.com/astercloud/aster-rust", tag = "v0.4.3" } +# CI/CD: git = "https://github.com/astercloud/aster-rust", tag = "v0.5.1" +# aster = { version = "0.5.1", path = "../../../astercloud/aster-rust/crates/aster" } +aster = { git = "https://github.com/astercloud/aster-rust", tag = "v0.5.1" } # Tauri diff --git a/src-tauri/proptest-regressions/config/tests.txt b/src-tauri/proptest-regressions/config/tests.txt deleted file mode 100644 index e11cd61d5..000000000 --- a/src-tauri/proptest-regressions/config/tests.txt +++ /dev/null @@ -1,7 +0,0 @@ -# Seeds for failure cases proptest has generated in the past. It is -# automatically read and these particular cases re-run before any -# novel cases are generated. -# -# It is recommended to check this file in to source control so that -# everyone who runs the test benefits from these saved cases. -cc 0d8594955233ffc968ac57a1a1d9dcff20ab597104e3a2787bca02618e9fcf06 # shrinks to provider = "qwen" diff --git a/src-tauri/src/agent/types.rs b/src-tauri/src/agent/types.rs index 728fcdabd..7153db92a 100644 --- a/src-tauri/src/agent/types.rs +++ b/src-tauri/src/agent/types.rs @@ -130,6 +130,8 @@ pub struct AgentSession { pub messages: Vec, /// 系统提示词 pub system_prompt: Option, + /// 会话标题(可选,用于 UI 显示) + pub title: Option, /// 创建时间 pub created_at: String, /// 最后活动时间 diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index c638764c1..bad140d03 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -1061,6 +1061,8 @@ pub fn run() { commands::agent_cmd::agent_get_session, commands::agent_cmd::agent_delete_session, commands::agent_cmd::agent_get_session_messages, + commands::agent_cmd::agent_rename_session, + commands::agent_cmd::agent_generate_title, // TODO: 重新启用这些命令,适配 aster-rust 工具系统 // commands::agent_cmd::agent_terminal_command_response, // commands::agent_cmd::agent_term_scrollback_response, @@ -1234,6 +1236,30 @@ pub fn run() { commands::workspace_cmd::workspace_set_default, commands::workspace_cmd::workspace_get_default, commands::workspace_cmd::workspace_get_by_path, + // Content commands + commands::content_cmd::content_create, + commands::content_cmd::content_get, + commands::content_cmd::content_list, + commands::content_cmd::content_update, + commands::content_cmd::content_delete, + commands::content_cmd::content_reorder, + commands::content_cmd::content_stats, + // Memory commands (Character, WorldBuilding, StyleGuide, Outline) + commands::memory_cmd::character_create, + commands::memory_cmd::character_get, + commands::memory_cmd::character_list, + commands::memory_cmd::character_update, + commands::memory_cmd::character_delete, + commands::memory_cmd::world_building_get, + commands::memory_cmd::world_building_update, + commands::memory_cmd::style_guide_get, + commands::memory_cmd::style_guide_update, + commands::memory_cmd::outline_node_create, + commands::memory_cmd::outline_node_get, + commands::memory_cmd::outline_node_list, + commands::memory_cmd::outline_node_update, + commands::memory_cmd::outline_node_delete, + commands::memory_cmd::project_memory_get, // Context Memory commands commands::context_memory::save_memory_entry, commands::context_memory::get_session_memories, diff --git a/src-tauri/src/commands/agent_cmd.rs b/src-tauri/src/commands/agent_cmd.rs index b03a4f5ec..c8842245d 100644 --- a/src-tauri/src/commands/agent_cmd.rs +++ b/src-tauri/src/commands/agent_cmd.rs @@ -146,6 +146,7 @@ pub async fn agent_create_session( model: model_name.clone(), messages: Vec::new(), system_prompt: final_system_prompt, + title: None, // 初始会话没有标题,后续会自动生成 created_at: now.clone(), updated_at: now, }; @@ -231,6 +232,7 @@ pub struct SessionInfo { pub session_id: String, pub provider_type: String, pub model: Option, + pub title: Option, pub created_at: String, pub last_activity: String, pub messages_count: usize, @@ -252,6 +254,7 @@ pub async fn agent_list_sessions(db: State<'_, DbConnection>) -> Result, + session_id: String, + title: String, +) -> Result<(), String> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {}", e))?; + AgentDao::update_title(&conn, &session_id, &title) + .map_err(|e| format!("更新会话标题失败: {}", e))?; + Ok(()) +} + +/// 生成智能标题 +/// +/// 根据对话内容生成一个简洁的标题 +#[tauri::command] +pub async fn agent_generate_title( + db: State<'_, DbConnection>, + session_id: String, +) -> Result { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {}", e))?; + + // 获取会话的前几条消息(用于生成标题) + let messages = + AgentDao::get_messages(&conn, &session_id).map_err(|e| format!("获取消息失败: {}", e))?; + + // 过滤出 user 和 assistant 消息 + let chat_messages: Vec<_> = messages + .iter() + .filter(|msg| msg.role == "user" || msg.role == "assistant") + .take(4) // 取前 2 轮对话 + .collect(); + + if chat_messages.len() < 2 { + return Ok("新话题".to_string()); + } + + // 构建对话内容用于 AI 生成标题 + let mut conversation = String::new(); + for msg in &chat_messages { + let role = if msg.role == "user" { + "用户" + } else { + "助手" + }; + let content = msg.content.as_text(); + let truncated_content = if content.len() > 100 { + format!("{}...", &content[..100]) + } else { + content + }; + conversation.push_str(&format!("{}:{}\n", role, truncated_content)); + } + + // 使用 AI 生成标题(通过 aster_agent_chat_stream 生成) + // 这里简化处理:使用第一条用户消息的前 15 个字作为默认标题 + if let Some(first_user_msg) = chat_messages.iter().find(|msg| msg.role == "user") { + let content = first_user_msg.content.as_text(); + let title = if content.len() > 15 { + format!("{}...", &content[..15]) + } else { + content + }; + Ok(title) + } else { + Ok("新话题".to_string()) + } +} diff --git a/src-tauri/src/commands/content_cmd.rs b/src-tauri/src/commands/content_cmd.rs new file mode 100644 index 000000000..10bb4e997 --- /dev/null +++ b/src-tauri/src/commands/content_cmd.rs @@ -0,0 +1,237 @@ +//! Content 相关的 Tauri 命令 +//! +//! 提供内容管理的前端 API。 + +use crate::content::{ + Content, ContentCreateRequest, ContentListQuery, ContentManager, ContentStatus, + ContentUpdateRequest, +}; +use crate::database::DbConnection; +use serde::{Deserialize, Serialize}; +use tauri::State; + +/// 内容列表项(用于前端展示) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentListItem { + pub id: String, + pub project_id: String, + pub title: String, + pub content_type: String, + pub status: String, + pub order: i32, + pub word_count: i64, + pub created_at: i64, + pub updated_at: i64, +} + +impl From for ContentListItem { + fn from(content: Content) -> Self { + Self { + id: content.id, + project_id: content.project_id, + title: content.title, + content_type: content.content_type.as_str().to_string(), + status: content.status.as_str().to_string(), + order: content.order, + word_count: content.word_count, + created_at: content.created_at.timestamp_millis(), + updated_at: content.updated_at.timestamp_millis(), + } + } +} + +/// 内容详情(包含正文) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentDetail { + pub id: String, + pub project_id: String, + pub title: String, + pub content_type: String, + pub status: String, + pub order: i32, + pub body: String, + pub word_count: i64, + pub metadata: Option, + pub session_id: Option, + pub created_at: i64, + pub updated_at: i64, +} + +impl From for ContentDetail { + fn from(content: Content) -> Self { + Self { + id: content.id, + project_id: content.project_id, + title: content.title, + content_type: content.content_type.as_str().to_string(), + status: content.status.as_str().to_string(), + order: content.order, + body: content.body, + word_count: content.word_count, + metadata: content.metadata, + session_id: content.session_id, + created_at: content.created_at.timestamp_millis(), + updated_at: content.updated_at.timestamp_millis(), + } + } +} + +/// 创建内容请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateContentRequest { + pub project_id: String, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +/// 更新内容请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateContentRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +/// 内容列表查询请求 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ListContentRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub search: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_order: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +/// 创建内容 +#[tauri::command] +pub async fn content_create( + db: State<'_, DbConnection>, + request: CreateContentRequest, +) -> Result { + let manager = ContentManager::new(db.inner().clone()); + + let create_request = ContentCreateRequest { + project_id: request.project_id, + title: request.title, + content_type: request + .content_type + .map(|s| crate::content::ContentType::from_str(&s)), + order: request.order, + body: request.body, + metadata: request.metadata, + }; + + let content = manager.create(create_request)?; + Ok(content.into()) +} + +/// 获取内容详情 +#[tauri::command] +pub async fn content_get( + db: State<'_, DbConnection>, + id: String, +) -> Result, String> { + let manager = ContentManager::new(db.inner().clone()); + let content = manager.get(&id)?; + Ok(content.map(|c| c.into())) +} + +/// 列出项目的所有内容 +#[tauri::command] +pub async fn content_list( + db: State<'_, DbConnection>, + project_id: String, + query: Option, +) -> Result, String> { + let manager = ContentManager::new(db.inner().clone()); + + let list_query = query.map(|q| ContentListQuery { + status: q.status.map(|s| ContentStatus::from_str(&s)), + content_type: q + .content_type + .map(|s| crate::content::ContentType::from_str(&s)), + search: q.search, + sort_by: q.sort_by, + sort_order: q.sort_order, + offset: q.offset, + limit: q.limit, + }); + + let contents = manager.list_by_project(&project_id, list_query)?; + Ok(contents.into_iter().map(|c| c.into()).collect()) +} + +/// 更新内容 +#[tauri::command] +pub async fn content_update( + db: State<'_, DbConnection>, + id: String, + request: UpdateContentRequest, +) -> Result { + let manager = ContentManager::new(db.inner().clone()); + + let update_request = ContentUpdateRequest { + title: request.title, + status: request.status.map(|s| ContentStatus::from_str(&s)), + order: request.order, + body: request.body, + metadata: request.metadata, + session_id: request.session_id, + }; + + let content = manager.update(&id, update_request)?; + Ok(content.into()) +} + +/// 删除内容 +#[tauri::command] +pub async fn content_delete(db: State<'_, DbConnection>, id: String) -> Result { + let manager = ContentManager::new(db.inner().clone()); + manager.delete(&id) +} + +/// 重新排序内容 +#[tauri::command] +pub async fn content_reorder( + db: State<'_, DbConnection>, + project_id: String, + content_ids: Vec, +) -> Result<(), String> { + let manager = ContentManager::new(db.inner().clone()); + manager.reorder(&project_id, content_ids) +} + +/// 获取项目内容统计 +#[tauri::command] +pub async fn content_stats( + db: State<'_, DbConnection>, + project_id: String, +) -> Result<(i64, i64, i64), String> { + let manager = ContentManager::new(db.inner().clone()); + manager.get_project_stats(&project_id) +} diff --git a/src-tauri/src/commands/memory_cmd.rs b/src-tauri/src/commands/memory_cmd.rs new file mode 100644 index 000000000..472b685c5 --- /dev/null +++ b/src-tauri/src/commands/memory_cmd.rs @@ -0,0 +1,226 @@ +//! Memory 相关的 Tauri 命令 +//! +//! 提供项目记忆系统(角色、世界观、风格指南、大纲)的前端 API。 + +use crate::database::DbConnection; +use crate::memory::{ + Character, CharacterCreateRequest, CharacterUpdateRequest, MemoryManager, OutlineNode, + OutlineNodeCreateRequest, OutlineNodeUpdateRequest, ProjectMemory, StyleGuide, + StyleGuideUpdateRequest, WorldBuilding, WorldBuildingUpdateRequest, +}; +use serde::{Deserialize, Serialize}; +use tauri::State; + +// ==================== 角色相关命令 ==================== + +/// 创建角色请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateCharacterRequest { + pub project_id: String, + pub name: String, + #[serde(default)] + pub aliases: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub personality: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub appearance: Option, + #[serde(default)] + pub is_main: bool, +} + +/// 创建角色 +#[tauri::command] +pub async fn character_create( + db: State<'_, DbConnection>, + request: CreateCharacterRequest, +) -> Result { + let manager = MemoryManager::new(db.inner().clone()); + + let create_request = CharacterCreateRequest { + project_id: request.project_id, + name: request.name, + aliases: request.aliases, + description: request.description, + personality: request.personality, + background: request.background, + appearance: request.appearance, + is_main: request.is_main, + }; + + manager.create_character(create_request) +} + +/// 获取角色 +#[tauri::command] +pub async fn character_get( + db: State<'_, DbConnection>, + id: String, +) -> Result, String> { + let manager = MemoryManager::new(db.inner().clone()); + manager.get_character(&id) +} + +/// 列出项目的所有角色 +#[tauri::command] +pub async fn character_list( + db: State<'_, DbConnection>, + project_id: String, +) -> Result, String> { + let manager = MemoryManager::new(db.inner().clone()); + manager.list_characters(&project_id) +} + +/// 更新角色 +#[tauri::command] +pub async fn character_update( + db: State<'_, DbConnection>, + id: String, + request: CharacterUpdateRequest, +) -> Result { + let manager = MemoryManager::new(db.inner().clone()); + manager.update_character(&id, request) +} + +/// 删除角色 +#[tauri::command] +pub async fn character_delete(db: State<'_, DbConnection>, id: String) -> Result { + let manager = MemoryManager::new(db.inner().clone()); + manager.delete_character(&id) +} + +// ==================== 世界观相关命令 ==================== + +/// 获取世界观 +#[tauri::command] +pub async fn world_building_get( + db: State<'_, DbConnection>, + project_id: String, +) -> Result, String> { + let manager = MemoryManager::new(db.inner().clone()); + manager.get_world_building(&project_id) +} + +/// 更新世界观 +#[tauri::command] +pub async fn world_building_update( + db: State<'_, DbConnection>, + project_id: String, + request: WorldBuildingUpdateRequest, +) -> Result { + let manager = MemoryManager::new(db.inner().clone()); + manager.upsert_world_building(&project_id, request) +} + +// ==================== 风格指南相关命令 ==================== + +/// 获取风格指南 +#[tauri::command] +pub async fn style_guide_get( + db: State<'_, DbConnection>, + project_id: String, +) -> Result, String> { + let manager = MemoryManager::new(db.inner().clone()); + manager.get_style_guide(&project_id) +} + +/// 更新风格指南 +#[tauri::command] +pub async fn style_guide_update( + db: State<'_, DbConnection>, + project_id: String, + request: StyleGuideUpdateRequest, +) -> Result { + let manager = MemoryManager::new(db.inner().clone()); + manager.upsert_style_guide(&project_id, request) +} + +// ==================== 大纲相关命令 ==================== + +/// 创建大纲节点请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateOutlineNodeRequest { + pub project_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, +} + +/// 创建大纲节点 +#[tauri::command] +pub async fn outline_node_create( + db: State<'_, DbConnection>, + request: CreateOutlineNodeRequest, +) -> Result { + let manager = MemoryManager::new(db.inner().clone()); + + let create_request = OutlineNodeCreateRequest { + project_id: request.project_id, + parent_id: request.parent_id, + title: request.title, + content: request.content, + content_id: request.content_id, + order: request.order, + }; + + manager.create_outline_node(create_request) +} + +/// 获取大纲节点 +#[tauri::command] +pub async fn outline_node_get( + db: State<'_, DbConnection>, + id: String, +) -> Result, String> { + let manager = MemoryManager::new(db.inner().clone()); + manager.get_outline_node(&id) +} + +/// 列出项目的所有大纲节点 +#[tauri::command] +pub async fn outline_node_list( + db: State<'_, DbConnection>, + project_id: String, +) -> Result, String> { + let manager = MemoryManager::new(db.inner().clone()); + manager.list_outline_nodes(&project_id) +} + +/// 更新大纲节点 +#[tauri::command] +pub async fn outline_node_update( + db: State<'_, DbConnection>, + id: String, + request: OutlineNodeUpdateRequest, +) -> Result { + let manager = MemoryManager::new(db.inner().clone()); + manager.update_outline_node(&id, request) +} + +/// 删除大纲节点 +#[tauri::command] +pub async fn outline_node_delete(db: State<'_, DbConnection>, id: String) -> Result { + let manager = MemoryManager::new(db.inner().clone()); + manager.delete_outline_node(&id) +} + +// ==================== 聚合查询命令 ==================== + +/// 获取项目的完整记忆 +#[tauri::command] +pub async fn project_memory_get( + db: State<'_, DbConnection>, + project_id: String, +) -> Result { + let manager = MemoryManager::new(db.inner().clone()); + manager.get_project_memory(&project_id) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2ea0abf8b..dcdf185ef 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod browser_interceptor_cmd; pub mod config_cmd; pub mod connect_cmd; pub mod connection_cmd; +pub mod content_cmd; pub mod context_memory; pub mod flow_monitor_cmd; pub mod general_chat_cmd; @@ -14,6 +15,7 @@ pub mod injection_cmd; pub mod kiro_local; pub mod machine_id_cmd; pub mod mcp_cmd; +pub mod memory_cmd; pub mod model_cmd; pub mod model_registry_cmd; pub mod models_cmd; diff --git a/src-tauri/src/commands/workspace_cmd.rs b/src-tauri/src/commands/workspace_cmd.rs index 71a6c9946..45b74ce19 100644 --- a/src-tauri/src/commands/workspace_cmd.rs +++ b/src-tauri/src/commands/workspace_cmd.rs @@ -36,6 +36,11 @@ pub struct WorkspaceListItem { pub is_default: bool, pub created_at: i64, pub updated_at: i64, + pub icon: Option, + pub color: Option, + pub is_favorite: bool, + pub is_archived: bool, + pub tags: Vec, } impl From for WorkspaceListItem { @@ -48,6 +53,11 @@ impl From for WorkspaceListItem { is_default: ws.is_default, created_at: ws.created_at.timestamp_millis(), updated_at: ws.updated_at.timestamp_millis(), + icon: ws.icon, + color: ws.color, + is_favorite: ws.is_favorite, + is_archived: ws.is_archived, + tags: ws.tags, } } } @@ -70,6 +80,16 @@ pub struct UpdateWorkspaceRequest { pub name: Option, #[serde(default)] pub settings: Option, + #[serde(default)] + pub icon: Option, + #[serde(default)] + pub color: Option, + #[serde(default)] + pub is_favorite: Option, + #[serde(default)] + pub is_archived: Option, + #[serde(default)] + pub tags: Option>, } // ==================== Tauri 命令 ==================== @@ -127,6 +147,11 @@ pub async fn workspace_update( let updates = WorkspaceUpdate { name: request.name, settings: request.settings, + icon: request.icon, + color: request.color, + is_favorite: request.is_favorite, + is_archived: request.is_archived, + tags: request.tags, }; let workspace = manager.update(&id, updates)?; @@ -135,8 +160,24 @@ pub async fn workspace_update( /// 删除 workspace #[tauri::command] -pub async fn workspace_delete(db: State<'_, DbConnection>, id: String) -> Result { +pub async fn workspace_delete( + db: State<'_, DbConnection>, + id: String, + delete_directory: Option, +) -> Result { let manager = WorkspaceManager::new(db.inner().clone()); + + // 如果需要删除目录,先获取 workspace 信息 + if delete_directory.unwrap_or(false) { + if let Some(workspace) = manager.get(&id)? { + let root_path = workspace.root_path; + if root_path.exists() && root_path.is_dir() { + std::fs::remove_dir_all(&root_path).map_err(|e| format!("删除目录失败: {}", e))?; + tracing::info!("[Workspace] 删除目录: {:?}", root_path); + } + } + } + manager.delete(&id) } diff --git a/src-tauri/src/content/manager.rs b/src-tauri/src/content/manager.rs new file mode 100644 index 000000000..1e3d162a7 --- /dev/null +++ b/src-tauri/src/content/manager.rs @@ -0,0 +1,462 @@ +//! Content 管理器 +//! +//! 提供 Content 的 CRUD 操作。 + +use super::types::{ + Content, ContentCreateRequest, ContentId, ContentListQuery, ContentStatus, ContentType, + ContentUpdateRequest, +}; +use crate::database::DbConnection; +use crate::workspace::WorkspaceType; +use chrono::Utc; +use rusqlite::params; +use uuid::Uuid; + +/// Content 管理器 +#[derive(Clone)] +pub struct ContentManager { + db: DbConnection, +} + +impl ContentManager { + /// 创建新的 ContentManager + pub fn new(db: DbConnection) -> Self { + Self { db } + } + + /// 创建新内容 + pub fn create(&self, request: ContentCreateRequest) -> Result { + let now = Utc::now(); + let id = Uuid::new_v4().to_string(); + + // 获取下一个排序顺序 + let order = match request.order { + Some(o) => o, + None => self.get_next_order(&request.project_id)?, + }; + + let body = request.body.unwrap_or_default(); + let word_count = count_words(&body); + let content_type = match request.content_type { + Some(content_type) => content_type, + None => self.get_default_content_type(&request.project_id), + }; + let metadata_json = request + .metadata + .as_ref() + .map(|m| serde_json::to_string(m).unwrap_or_default()); + + let content = Content { + id: id.clone(), + project_id: request.project_id.clone(), + title: request.title, + content_type: content_type.clone(), + status: ContentStatus::Draft, + order, + body, + word_count, + metadata: request.metadata, + session_id: None, + created_at: now, + updated_at: now, + }; + + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + conn.execute( + "INSERT INTO contents (id, project_id, title, content_type, status, sort_order, body, word_count, metadata_json, session_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + &content.id, + &content.project_id, + &content.title, + content.content_type.as_str(), + content.status.as_str(), + content.order, + &content.body, + content.word_count, + &metadata_json, + &content.session_id, + content.created_at.timestamp_millis(), + content.updated_at.timestamp_millis(), + ], + ) + .map_err(|e| format!("创建内容失败: {}", e))?; + + tracing::info!( + "[Content] 创建: id={}, project_id={}, title={}", + content.id, + content.project_id, + content.title + ); + + Ok(content) + } + + /// 获取项目默认内容类型(兜底) + fn get_default_content_type(&self, project_id: &str) -> ContentType { + let conn = match self.db.lock() { + Ok(conn) => conn, + Err(_) => return ContentType::Document, + }; + + let workspace_type: Result = conn.query_row( + "SELECT workspace_type FROM workspaces WHERE id = ?", + params![project_id], + |row| row.get(0), + ); + + let workspace_type = match workspace_type { + Ok(value) => WorkspaceType::from_str(&value), + Err(_) => return ContentType::Document, + }; + + match workspace_type { + WorkspaceType::Video => ContentType::Episode, + WorkspaceType::Novel => ContentType::Chapter, + WorkspaceType::SocialMedia => ContentType::Post, + WorkspaceType::General => ContentType::Content, + WorkspaceType::Document => ContentType::Document, + WorkspaceType::Poster + | WorkspaceType::Music + | WorkspaceType::Knowledge + | WorkspaceType::Planning + | WorkspaceType::Persistent + | WorkspaceType::Temporary => ContentType::Document, + } + } + + /// 获取内容 + pub fn get(&self, id: &ContentId) -> Result, String> { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let result = conn.query_row( + "SELECT id, project_id, title, content_type, status, sort_order, body, word_count, metadata_json, session_id, created_at, updated_at + FROM contents WHERE id = ?", + params![id], + |row| Ok(Self::row_to_content(row)?), + ); + + match result { + Ok(content) => Ok(Some(content)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("获取内容失败: {}", e)), + } + } + + /// 列出项目下的所有内容 + pub fn list_by_project( + &self, + project_id: &str, + query: Option, + ) -> Result, String> { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let query = query.unwrap_or_default(); + + // 构建查询 + let mut sql = String::from( + "SELECT id, project_id, title, content_type, status, sort_order, body, word_count, metadata_json, session_id, created_at, updated_at + FROM contents WHERE project_id = ?", + ); + let mut params_vec: Vec> = vec![Box::new(project_id.to_string())]; + + // 状态过滤 + if let Some(ref status) = query.status { + sql.push_str(" AND status = ?"); + params_vec.push(Box::new(status.as_str().to_string())); + } + + // 内容类型过滤 + if let Some(ref content_type) = query.content_type { + sql.push_str(" AND content_type = ?"); + params_vec.push(Box::new(content_type.as_str().to_string())); + } + + // 搜索 + if let Some(ref search) = query.search { + sql.push_str(" AND (title LIKE ? OR body LIKE ?)"); + let search_pattern = format!("%{}%", search); + params_vec.push(Box::new(search_pattern.clone())); + params_vec.push(Box::new(search_pattern)); + } + + // 排序 + let sort_by = query.sort_by.unwrap_or_else(|| "sort_order".to_string()); + let sort_order = query.sort_order.unwrap_or_else(|| "asc".to_string()); + sql.push_str(&format!(" ORDER BY {} {}", sort_by, sort_order)); + + // 分页 + if let Some(limit) = query.limit { + sql.push_str(" LIMIT ?"); + params_vec.push(Box::new(limit)); + } + if let Some(offset) = query.offset { + sql.push_str(" OFFSET ?"); + params_vec.push(Box::new(offset)); + } + + let params_refs: Vec<&dyn rusqlite::ToSql> = + params_vec.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("准备查询失败: {}", e))?; + + let contents = stmt + .query_map(params_refs.as_slice(), |row| Ok(Self::row_to_content(row)?)) + .map_err(|e| format!("查询失败: {}", e))? + .collect::, _>>() + .map_err(|e| format!("解析结果失败: {}", e))?; + + Ok(contents) + } + + /// 更新内容 + pub fn update(&self, id: &ContentId, updates: ContentUpdateRequest) -> Result { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + let now = Utc::now().timestamp_millis(); + + // 构建更新语句 + let mut set_clauses = vec!["updated_at = ?"]; + let mut params_vec: Vec> = vec![Box::new(now)]; + + if let Some(ref title) = updates.title { + set_clauses.push("title = ?"); + params_vec.push(Box::new(title.clone())); + } + + if let Some(ref status) = updates.status { + set_clauses.push("status = ?"); + params_vec.push(Box::new(status.as_str().to_string())); + } + + if let Some(order) = updates.order { + set_clauses.push("sort_order = ?"); + params_vec.push(Box::new(order)); + } + + if let Some(ref body) = updates.body { + set_clauses.push("body = ?"); + params_vec.push(Box::new(body.clone())); + set_clauses.push("word_count = ?"); + params_vec.push(Box::new(count_words(body))); + } + + if let Some(ref metadata) = updates.metadata { + let metadata_json = serde_json::to_string(metadata).map_err(|e| e.to_string())?; + set_clauses.push("metadata_json = ?"); + params_vec.push(Box::new(metadata_json)); + } + + if let Some(ref session_id) = updates.session_id { + set_clauses.push("session_id = ?"); + params_vec.push(Box::new(session_id.clone())); + } + + params_vec.push(Box::new(id.clone())); + + let sql = format!( + "UPDATE contents SET {} WHERE id = ?", + set_clauses.join(", ") + ); + + let params_refs: Vec<&dyn rusqlite::ToSql> = + params_vec.iter().map(|p| p.as_ref()).collect(); + + conn.execute(&sql, params_refs.as_slice()) + .map_err(|e| format!("更新内容失败: {}", e))?; + + drop(conn); + + self.get(id)?.ok_or_else(|| "内容不存在".to_string()) + } + + /// 删除内容 + pub fn delete(&self, id: &ContentId) -> Result { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let affected = conn + .execute("DELETE FROM contents WHERE id = ?", params![id]) + .map_err(|e| format!("删除内容失败: {}", e))?; + + if affected > 0 { + tracing::info!("[Content] 删除: id={}", id); + } + + Ok(affected > 0) + } + + /// 批量删除项目下的所有内容 + pub fn delete_by_project(&self, project_id: &str) -> Result { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let affected = conn + .execute( + "DELETE FROM contents WHERE project_id = ?", + params![project_id], + ) + .map_err(|e| format!("删除内容失败: {}", e))?; + + tracing::info!( + "[Content] 批量删除: project_id={}, count={}", + project_id, + affected + ); + + Ok(affected as i64) + } + + /// 获取项目的内容统计 + pub fn get_project_stats(&self, project_id: &str) -> Result<(i64, i64, i64), String> { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let result = conn.query_row( + "SELECT COUNT(*), COALESCE(SUM(word_count), 0), COUNT(CASE WHEN status = 'completed' THEN 1 END) + FROM contents WHERE project_id = ?", + params![project_id], + |row| { + let count: i64 = row.get(0)?; + let words: i64 = row.get(1)?; + let completed: i64 = row.get(2)?; + Ok((count, words, completed)) + }, + ); + + match result { + Ok(stats) => Ok(stats), + Err(e) => Err(format!("获取统计失败: {}", e)), + } + } + + /// 重新排序内容 + pub fn reorder(&self, project_id: &str, content_ids: Vec) -> Result<(), String> { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + for (index, content_id) in content_ids.iter().enumerate() { + conn.execute( + "UPDATE contents SET sort_order = ?, updated_at = ? WHERE id = ? AND project_id = ?", + params![ + index as i32, + Utc::now().timestamp_millis(), + content_id, + project_id + ], + ) + .map_err(|e| format!("重新排序失败: {}", e))?; + } + + Ok(()) + } + + /// 获取下一个排序顺序 + fn get_next_order(&self, project_id: &str) -> Result { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let result: Result = conn.query_row( + "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM contents WHERE project_id = ?", + params![project_id], + |row| row.get(0), + ); + + result.map_err(|e| format!("获取排序顺序失败: {}", e)) + } + + /// 从数据库行解析 Content + fn row_to_content(row: &rusqlite::Row) -> Result { + let id: String = row.get(0)?; + let project_id: String = row.get(1)?; + let title: String = row.get(2)?; + let content_type_str: String = row.get(3)?; + let status_str: String = row.get(4)?; + let order: i32 = row.get(5)?; + let body: String = row.get(6)?; + let word_count: i64 = row.get(7)?; + let metadata_json: Option = row.get(8)?; + let session_id: Option = row.get(9)?; + let created_at_ms: i64 = row.get(10)?; + let updated_at_ms: i64 = row.get(11)?; + + let metadata = metadata_json.and_then(|s| serde_json::from_str(&s).ok()); + + Ok(Content { + id, + project_id, + title, + content_type: ContentType::from_str(&content_type_str), + status: ContentStatus::from_str(&status_str), + order, + body, + word_count, + metadata, + session_id, + created_at: chrono::DateTime::from_timestamp_millis(created_at_ms) + .unwrap_or_else(Utc::now), + updated_at: chrono::DateTime::from_timestamp_millis(updated_at_ms) + .unwrap_or_else(Utc::now), + }) + } +} + +/// 计算字数(支持中英文混合) +fn count_words(text: &str) -> i64 { + let mut count = 0i64; + let mut in_word = false; + + for c in text.chars() { + if c.is_whitespace() { + in_word = false; + } else if c.is_ascii_alphanumeric() { + if !in_word { + count += 1; + in_word = true; + } + } else if !c.is_ascii_punctuation() { + // 非 ASCII 字符(如中文)每个字符计为一个字 + count += 1; + in_word = false; + } + } + + count +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_count_words() { + assert_eq!(count_words("hello world"), 2); + assert_eq!(count_words("你好世界"), 4); + assert_eq!(count_words("hello 世界"), 3); + assert_eq!(count_words(""), 0); + } +} diff --git a/src-tauri/src/content/mod.rs b/src-tauri/src/content/mod.rs new file mode 100644 index 000000000..a8c29ff2b --- /dev/null +++ b/src-tauri/src/content/mod.rs @@ -0,0 +1,9 @@ +//! Content 模块 +//! +//! 提供项目内容管理功能。 + +pub mod manager; +pub mod types; + +pub use manager::ContentManager; +pub use types::*; diff --git a/src-tauri/src/content/types.rs b/src-tauri/src/content/types.rs new file mode 100644 index 000000000..7c121c6ab --- /dev/null +++ b/src-tauri/src/content/types.rs @@ -0,0 +1,213 @@ +//! Content 类型定义 +//! +//! 定义项目内容相关的数据结构和类型。 + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Content 唯一标识 +pub type ContentId = String; + +/// 内容类型 +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ContentType { + /// 剧集(短剧项目) + Episode, + /// 章节(小说项目) + Chapter, + /// 帖子(社媒项目) + Post, + /// 文档(文档项目) + #[default] + Document, + /// 通用内容 + Content, +} + +impl ContentType { + pub fn as_str(&self) -> &'static str { + match self { + ContentType::Episode => "episode", + ContentType::Chapter => "chapter", + ContentType::Post => "post", + ContentType::Document => "document", + ContentType::Content => "content", + } + } + + pub fn from_str(s: &str) -> Self { + match s { + "episode" => ContentType::Episode, + "chapter" => ContentType::Chapter, + "post" => ContentType::Post, + "document" => ContentType::Document, + "content" => ContentType::Content, + _ => ContentType::Document, + } + } + + /// 获取内容类型的显示名称 + pub fn display_name(&self) -> &'static str { + match self { + ContentType::Episode => "剧集", + ContentType::Chapter => "章节", + ContentType::Post => "帖子", + ContentType::Document => "文档", + ContentType::Content => "内容", + } + } +} + +/// 内容状态 +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ContentStatus { + /// 草稿 + #[default] + Draft, + /// 已完成 + Completed, + /// 已发布 + Published, +} + +impl ContentStatus { + pub fn as_str(&self) -> &'static str { + match self { + ContentStatus::Draft => "draft", + ContentStatus::Completed => "completed", + ContentStatus::Published => "published", + } + } + + pub fn from_str(s: &str) -> Self { + match s { + "draft" => ContentStatus::Draft, + "completed" => ContentStatus::Completed, + "published" => ContentStatus::Published, + _ => ContentStatus::Draft, + } + } +} + +/// 内容实体 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Content { + /// 唯一标识 + pub id: ContentId, + /// 所属项目 ID + pub project_id: String, + /// 标题 + pub title: String, + /// 内容类型 + pub content_type: ContentType, + /// 状态 + pub status: ContentStatus, + /// 排序顺序 + pub order: i32, + /// 正文内容 + pub body: String, + /// 字数统计 + pub word_count: i64, + /// 类型特定的元数据(JSON) + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + /// 关联的 AI 会话 ID + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + /// 创建时间 + pub created_at: DateTime, + /// 更新时间 + pub updated_at: DateTime, +} + +/// 内容创建请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentCreateRequest { + /// 所属项目 ID + pub project_id: String, + /// 标题 + pub title: String, + /// 内容类型(可选,默认根据项目类型推断) + #[serde(skip_serializing_if = "Option::is_none")] + pub content_type: Option, + /// 排序顺序(可选,默认追加到末尾) + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, + /// 初始正文内容 + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + /// 元数据 + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +/// 内容更新请求 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ContentUpdateRequest { + /// 新标题 + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// 新状态 + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + /// 新排序顺序 + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, + /// 新正文内容 + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + /// 新元数据 + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + /// 关联的 AI 会话 ID + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +/// 内容列表查询参数 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ContentListQuery { + /// 按状态过滤 + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + /// 按内容类型过滤 + #[serde(skip_serializing_if = "Option::is_none")] + pub content_type: Option, + /// 搜索关键词 + #[serde(skip_serializing_if = "Option::is_none")] + pub search: Option, + /// 排序字段 + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_by: Option, + /// 排序方向(asc/desc) + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_order: Option, + /// 分页:偏移量 + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, + /// 分页:限制数量 + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +/// 内容版本(用于版本历史) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentVersion { + /// 版本 ID + pub id: String, + /// 内容 ID + pub content_id: ContentId, + /// 版本号 + pub version: i32, + /// 正文内容 + pub body: String, + /// 字数统计 + pub word_count: i64, + /// 创建时间 + pub created_at: DateTime, + /// 备注 + #[serde(skip_serializing_if = "Option::is_none")] + pub note: Option, +} diff --git a/src-tauri/src/converter/README.md b/src-tauri/src/converter/README.md index aa9c213d4..13676b14e 100644 --- a/src-tauri/src/converter/README.md +++ b/src-tauri/src/converter/README.md @@ -15,6 +15,7 @@ - `cw_to_openai.rs` - CodeWhisperer → OpenAI 转换 - `anthropic_to_openai.rs` - Anthropic → OpenAI 转换 - `openai_to_antigravity.rs` - OpenAI → Antigravity (Gemini CLI) 转换 +- `reasoning_handler.rs` - 推理内容处理器(DeepSeek/OpenAI o1 等) ## 工具类型支持 @@ -25,6 +26,29 @@ - `web_search`: 联网搜索工具(Codex/Kiro 格式) - `web_search_20250305`: 联网搜索工具(Claude Code 格式) +## 推理内容处理 + +### 支持的模型 + +| 模型 | 字段名 | 多轮对话处理 | +|------|--------|--------------| +| DeepSeek R1/Reasoner | `reasoning_content` | 丢弃历史,只保留最后一条 | +| OpenAI o1/o3/o4 | `reasoning` | 通过 `previous_response_id` 引用 | + +### 使用方式 + +```rust +use crate::converter::ReasoningHandler; + +// 预处理消息,清理历史 reasoning_content +let processed = ReasoningHandler::preprocess_messages(messages, "deepseek-reasoner"); + +// 检查模型是否需要清理 +if ReasoningHandler::needs_reasoning_cleanup(&model) { + // 执行清理逻辑 +} +``` + ## Antigravity 转换说明 参考 CLIProxyAPI 实现,主要特性: @@ -36,6 +60,7 @@ ## 更新日志 +- 2026-02-01: 添加 reasoning_handler 模块,支持 DeepSeek/OpenAI 推理模型 - 2025-12-28: 修复 Antigravity 转换,对齐 CLIProxyAPI 实现 - 2025-12-27: 添加 web_search 工具支持,修复 Issue #49 diff --git a/src-tauri/src/converter/mod.rs b/src-tauri/src/converter/mod.rs index 0457c28b3..4a73a5795 100644 --- a/src-tauri/src/converter/mod.rs +++ b/src-tauri/src/converter/mod.rs @@ -3,6 +3,7 @@ pub mod cw_to_openai; pub mod openai_to_antigravity; pub mod openai_to_cw; pub mod protocol_selector; +pub mod reasoning_handler; #[allow(unused_imports)] pub use anthropic_to_openai::*; @@ -14,3 +15,5 @@ pub use openai_to_antigravity::*; pub use openai_to_cw::*; #[allow(unused_imports)] pub use protocol_selector::*; +#[allow(unused_imports)] +pub use reasoning_handler::*; diff --git a/src-tauri/src/converter/reasoning_handler.rs b/src-tauri/src/converter/reasoning_handler.rs new file mode 100644 index 000000000..a47607c80 --- /dev/null +++ b/src-tauri/src/converter/reasoning_handler.rs @@ -0,0 +1,219 @@ +//! 推理内容处理器 +//! +//! 处理不同模型的推理/思考内容(reasoning_content)在多轮对话中的传递规则。 +//! +//! # 支持的模型 +//! +//! | 模型 | 字段名 | 多轮对话处理 | +//! |------|--------|--------------| +//! | DeepSeek R1/Reasoner | `reasoning_content` | 丢弃,只保留 `content` | +//! | OpenAI o1/o3/o4 | `reasoning` | 通过 `previous_response_id` 引用 | +//! +//! # 设计原则 +//! +//! 根据 DeepSeek API 文档: +//! - 多轮对话时,`reasoning_content` 不应传递给下一轮 +//! - 只有 `content` 字段需要保留在对话历史中 +//! - Tool Calls 场景下,需要正确处理 reasoning_content 的传递 +//! + +use crate::models::openai::ChatMessage; + +/// 模型类型,用于确定推理内容处理策略 +#[derive(Debug, Clone, PartialEq)] +pub enum ReasoningModelType { + /// DeepSeek R1/Reasoner 系列 + DeepSeek, + /// OpenAI o1/o3/o4 系列 + OpenAI, + /// 其他模型(不处理) + Other, +} + +impl ReasoningModelType { + /// 从模型名称检测模型类型 + pub fn from_model_name(model: &str) -> Self { + let model_lower = model.to_lowercase(); + + if model_lower.contains("deepseek") + && (model_lower.contains("reasoner") || model_lower.contains("r1")) + { + Self::DeepSeek + } else if model_lower.starts_with("o1") + || model_lower.starts_with("o3") + || model_lower.starts_with("o4") + { + Self::OpenAI + } else { + Self::Other + } + } +} + +/// 推理内容处理器 +pub struct ReasoningHandler; + +impl ReasoningHandler { + /// 预处理消息列表,根据模型类型清理 reasoning_content + /// + /// # DeepSeek 处理规则 + /// + /// 根据 DeepSeek API 文档: + /// - 多轮对话时,历史消息中的 `reasoning_content` 应该被丢弃 + /// - 只保留 `content` 字段用于上下文 + /// - 这样可以节省网络带宽,避免 400 错误 + /// + /// # 参数 + /// + /// - `messages`: 消息列表 + /// - `model`: 模型名称 + /// + /// # 返回 + /// + /// 处理后的消息列表 + pub fn preprocess_messages(messages: Vec, model: &str) -> Vec { + let model_type = ReasoningModelType::from_model_name(model); + + match model_type { + ReasoningModelType::DeepSeek => Self::process_deepseek_messages(messages), + ReasoningModelType::OpenAI => Self::process_openai_messages(messages), + ReasoningModelType::Other => messages, + } + } + + /// 处理 DeepSeek 消息 + /// + /// 清除历史消息中的 reasoning_content,只保留最后一条 assistant 消息的 reasoning_content + fn process_deepseek_messages(mut messages: Vec) -> Vec { + let len = messages.len(); + + // 先找出最后一条 assistant 消息的索引 + let last_assistant_idx = messages + .iter() + .enumerate() + .rev() + .find(|(_, m)| m.role == "assistant") + .map(|(i, _)| i); + + for (i, msg) in messages.iter_mut().enumerate() { + // 只处理 assistant 消息 + if msg.role != "assistant" { + continue; + } + + // 保留最后一条 assistant 消息的 reasoning_content(如果有 tool_calls) + // 因为 DeepSeek 在 tool calls 场景下需要这个字段 + let is_last_assistant = Some(i) == last_assistant_idx; + + if !is_last_assistant { + // 清除非最后一条 assistant 消息的 reasoning_content + msg.reasoning_content = None; + } + } + + messages + } + + /// 处理 OpenAI o1/o3 消息 + /// + /// OpenAI 的推理模型使用 previous_response_id 机制, + /// 目前不需要特殊处理消息内容 + fn process_openai_messages(messages: Vec) -> Vec { + // OpenAI 推理模型目前不需要特殊处理 + // 未来可能需要处理 reasoning 字段 + messages + } + + /// 检查模型是否支持推理模式 + pub fn supports_reasoning(model: &str) -> bool { + let model_type = ReasoningModelType::from_model_name(model); + !matches!(model_type, ReasoningModelType::Other) + } + + /// 检查模型是否需要清理历史 reasoning_content + pub fn needs_reasoning_cleanup(model: &str) -> bool { + matches!( + ReasoningModelType::from_model_name(model), + ReasoningModelType::DeepSeek + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::openai::MessageContent; + + #[test] + fn test_model_type_detection() { + assert_eq!( + ReasoningModelType::from_model_name("deepseek-reasoner"), + ReasoningModelType::DeepSeek + ); + assert_eq!( + ReasoningModelType::from_model_name("deepseek-r1"), + ReasoningModelType::DeepSeek + ); + assert_eq!( + ReasoningModelType::from_model_name("DeepSeek-R1-0528"), + ReasoningModelType::DeepSeek + ); + assert_eq!( + ReasoningModelType::from_model_name("o1-preview"), + ReasoningModelType::OpenAI + ); + assert_eq!( + ReasoningModelType::from_model_name("o3-mini"), + ReasoningModelType::OpenAI + ); + assert_eq!( + ReasoningModelType::from_model_name("gpt-4o"), + ReasoningModelType::Other + ); + assert_eq!( + ReasoningModelType::from_model_name("deepseek-chat"), + ReasoningModelType::Other + ); + } + + #[test] + fn test_deepseek_reasoning_cleanup() { + let messages = vec![ + ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::Text("Hello".to_string())), + tool_calls: None, + tool_call_id: None, + reasoning_content: None, + }, + ChatMessage { + role: "assistant".to_string(), + content: Some(MessageContent::Text("Hi there!".to_string())), + tool_calls: None, + tool_call_id: None, + reasoning_content: Some("Thinking about greeting...".to_string()), + }, + ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::Text("How are you?".to_string())), + tool_calls: None, + tool_call_id: None, + reasoning_content: None, + }, + ChatMessage { + role: "assistant".to_string(), + content: Some(MessageContent::Text("I'm doing well!".to_string())), + tool_calls: None, + tool_call_id: None, + reasoning_content: Some("Thinking about response...".to_string()), + }, + ]; + + let processed = ReasoningHandler::preprocess_messages(messages, "deepseek-reasoner"); + + // 第一条 assistant 消息的 reasoning_content 应该被清除 + assert!(processed[1].reasoning_content.is_none()); + // 最后一条 assistant 消息的 reasoning_content 应该保留 + assert!(processed[3].reasoning_content.is_some()); + } +} diff --git a/src-tauri/src/database/dao/agent.rs b/src-tauri/src/database/dao/agent.rs index de5bc1760..0f94e5a69 100644 --- a/src-tauri/src/database/dao/agent.rs +++ b/src-tauri/src/database/dao/agent.rs @@ -14,12 +14,13 @@ impl AgentDao { session: &AgentSession, ) -> Result<(), rusqlite::Error> { conn.execute( - "INSERT INTO agent_sessions (id, model, system_prompt, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5)", + "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![ session.id, session.model, session.system_prompt, + session.title, session.created_at, session.updated_at, ], @@ -33,7 +34,7 @@ impl AgentDao { session_id: &str, ) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( - "SELECT id, model, system_prompt, created_at, updated_at + "SELECT id, model, system_prompt, title, created_at, updated_at FROM agent_sessions WHERE id = ?", )?; @@ -45,8 +46,9 @@ impl AgentDao { model: row.get(1)?, messages: Vec::new(), // 消息需要单独加载 system_prompt: row.get(2)?, - created_at: row.get(3)?, - updated_at: row.get(4)?, + title: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, })) } else { Ok(None) @@ -70,7 +72,7 @@ impl AgentDao { /// 获取所有会话(不包含消息) pub fn list_sessions(conn: &Connection) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( - "SELECT id, model, system_prompt, created_at, updated_at + "SELECT id, model, system_prompt, title, created_at, updated_at FROM agent_sessions ORDER BY updated_at DESC", )?; @@ -80,8 +82,9 @@ impl AgentDao { model: row.get(1)?, messages: Vec::new(), system_prompt: row.get(2)?, - created_at: row.get(3)?, - updated_at: row.get(4)?, + title: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, }) })?; @@ -226,4 +229,32 @@ impl AgentDao { )?; Ok(count > 0) } + + /// 更新会话标题 + pub fn update_title( + conn: &Connection, + session_id: &str, + title: &str, + ) -> Result<(), rusqlite::Error> { + conn.execute( + "UPDATE agent_sessions SET title = ? WHERE id = ?", + params![title, session_id], + )?; + Ok(()) + } + + /// 获取会话标题 + pub fn get_title( + conn: &Connection, + session_id: &str, + ) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare("SELECT title FROM agent_sessions WHERE id = ?")?; + let mut rows = stmt.query([session_id])?; + + if let Some(row) = rows.next()? { + Ok(row.get(0)?) + } else { + Ok(None) + } + } } diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 2d55ffde3..84fdadad8 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -450,12 +450,16 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { id TEXT PRIMARY KEY, model TEXT NOT NULL, system_prompt TEXT, + title TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )", [], )?; + // Migration: 添加 title 列(如果不存在) + let _ = conn.execute("ALTER TABLE agent_sessions ADD COLUMN title TEXT", []); + // Agent 消息表 // 存储每个会话的消息历史 conn.execute( @@ -560,6 +564,171 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { [], )?; + // Migration: 添加项目管理相关字段到 workspaces 表 + let _ = conn.execute("ALTER TABLE workspaces ADD COLUMN icon TEXT", []); + let _ = conn.execute("ALTER TABLE workspaces ADD COLUMN color TEXT", []); + let _ = conn.execute( + "ALTER TABLE workspaces ADD COLUMN is_favorite INTEGER DEFAULT 0", + [], + ); + let _ = conn.execute( + "ALTER TABLE workspaces ADD COLUMN is_archived INTEGER DEFAULT 0", + [], + ); + let _ = conn.execute( + "ALTER TABLE workspaces ADD COLUMN tags_json TEXT DEFAULT '[]'", + [], + ); + + // Migration: 迁移旧的项目类型到新类型 + // drama -> video, social -> social-media + let _ = conn.execute( + "UPDATE workspaces SET workspace_type = 'video' WHERE workspace_type = 'drama'", + [], + ); + let _ = conn.execute( + "UPDATE workspaces SET workspace_type = 'social-media' WHERE workspace_type = 'social'", + [], + ); + + // ============================================================================ + // 项目内容管理相关表 + // ============================================================================ + + // 内容表 + // 存储项目下的内容(剧集、章节、帖子、文档等) + conn.execute( + "CREATE TABLE IF NOT EXISTS contents ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + title TEXT NOT NULL, + content_type TEXT NOT NULL DEFAULT 'document', + status TEXT NOT NULL DEFAULT 'draft', + sort_order INTEGER NOT NULL DEFAULT 0, + body TEXT NOT NULL DEFAULT '', + word_count INTEGER NOT NULL DEFAULT 0, + metadata_json TEXT, + session_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (project_id) REFERENCES workspaces(id) ON DELETE CASCADE + )", + [], + )?; + + // 创建 contents 索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_contents_project_id ON contents(project_id)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_contents_status ON contents(status)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_contents_sort_order ON contents(sort_order)", + [], + )?; + + // ============================================================================ + // 项目记忆系统相关表 + // ============================================================================ + + // 角色表 + // 存储项目的角色设定 + conn.execute( + "CREATE TABLE IF NOT EXISTS characters ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + aliases_json TEXT NOT NULL DEFAULT '[]', + description TEXT, + personality TEXT, + background TEXT, + appearance TEXT, + relationships_json TEXT NOT NULL DEFAULT '[]', + avatar_url TEXT, + is_main INTEGER DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + extra_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (project_id) REFERENCES workspaces(id) ON DELETE CASCADE + )", + [], + )?; + + // 创建 characters 索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_characters_project_id ON characters(project_id)", + [], + )?; + + // 世界观表 + // 存储项目的世界观设定 + conn.execute( + "CREATE TABLE IF NOT EXISTS world_building ( + project_id TEXT PRIMARY KEY, + description TEXT NOT NULL DEFAULT '', + era TEXT, + locations TEXT, + rules TEXT, + extra_json TEXT, + updated_at INTEGER NOT NULL, + FOREIGN KEY (project_id) REFERENCES workspaces(id) ON DELETE CASCADE + )", + [], + )?; + + // 风格指南表 + // 存储项目的写作风格指南 + conn.execute( + "CREATE TABLE IF NOT EXISTS style_guides ( + project_id TEXT PRIMARY KEY, + style TEXT NOT NULL DEFAULT '', + tone TEXT, + forbidden_words_json TEXT NOT NULL DEFAULT '[]', + preferred_words_json TEXT NOT NULL DEFAULT '[]', + examples TEXT, + extra_json TEXT, + updated_at INTEGER NOT NULL, + FOREIGN KEY (project_id) REFERENCES workspaces(id) ON DELETE CASCADE + )", + [], + )?; + + // 大纲节点表 + // 存储项目的大纲结构 + conn.execute( + "CREATE TABLE IF NOT EXISTS outline_nodes ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + parent_id TEXT, + title TEXT NOT NULL, + content TEXT, + content_id TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + expanded INTEGER DEFAULT 1, + extra_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (project_id) REFERENCES workspaces(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES outline_nodes(id) ON DELETE CASCADE, + FOREIGN KEY (content_id) REFERENCES contents(id) ON DELETE SET NULL + )", + [], + )?; + + // 创建 outline_nodes 索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_outline_nodes_project_id ON outline_nodes(project_id)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_outline_nodes_parent_id ON outline_nodes(parent_id)", + [], + )?; + Ok(()) } diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 0da146745..ca63f604d 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -209,6 +209,7 @@ pub async fn handle_command( model: model_name.clone(), messages: Vec::new(), system_prompt, + title: None, // 初始会话没有标题,后续会自动生成 created_at: now.clone(), updated_at: now, }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5b151793d..02f5a75a9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,9 +32,11 @@ pub mod app; pub mod backends; pub mod browser_interceptor; pub mod connect; +pub mod content; pub mod credential; pub mod database; pub mod flow_monitor; +pub mod memory; pub mod orchestrator; pub mod plugin; pub mod screenshot; diff --git a/src-tauri/src/memory/manager.rs b/src-tauri/src/memory/manager.rs new file mode 100644 index 000000000..b3b68ae2f --- /dev/null +++ b/src-tauri/src/memory/manager.rs @@ -0,0 +1,719 @@ +//! Memory 管理器 +//! +//! 提供项目记忆系统的 CRUD 操作(角色、世界观、风格指南、大纲)。 + +use super::types::*; +use crate::database::DbConnection; +use chrono::Utc; +use rusqlite::params; +use uuid::Uuid; + +/// Memory 管理器 +#[derive(Clone)] +pub struct MemoryManager { + db: DbConnection, +} + +impl MemoryManager { + /// 创建新的 MemoryManager + pub fn new(db: DbConnection) -> Self { + Self { db } + } + + // ==================== 角色管理 ==================== + + /// 创建角色 + pub fn create_character(&self, request: CharacterCreateRequest) -> Result { + let now = Utc::now(); + let id = Uuid::new_v4().to_string(); + + let order = self.get_next_character_order(&request.project_id)?; + let aliases_json = serde_json::to_string(&request.aliases).unwrap_or_default(); + + let character = Character { + id: id.clone(), + project_id: request.project_id.clone(), + name: request.name, + aliases: request.aliases, + description: request.description, + personality: request.personality, + background: request.background, + appearance: request.appearance, + relationships: Vec::new(), + avatar_url: None, + is_main: request.is_main, + order, + extra: None, + created_at: now, + updated_at: now, + }; + + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + conn.execute( + "INSERT INTO characters (id, project_id, name, aliases_json, description, personality, background, appearance, relationships_json, avatar_url, is_main, sort_order, extra_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + &character.id, + &character.project_id, + &character.name, + &aliases_json, + &character.description, + &character.personality, + &character.background, + &character.appearance, + "[]", + &character.avatar_url, + character.is_main, + character.order, + Option::::None, + character.created_at.timestamp_millis(), + character.updated_at.timestamp_millis(), + ], + ) + .map_err(|e| format!("创建角色失败: {}", e))?; + + tracing::info!( + "[Memory] 创建角色: id={}, name={}", + character.id, + character.name + ); + Ok(character) + } + + /// 获取角色 + pub fn get_character(&self, id: &CharacterId) -> Result, String> { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let result = conn.query_row( + "SELECT id, project_id, name, aliases_json, description, personality, background, appearance, relationships_json, avatar_url, is_main, sort_order, extra_json, created_at, updated_at + FROM characters WHERE id = ?", + params![id], + |row| Ok(Self::row_to_character(row)?), + ); + + match result { + Ok(character) => Ok(Some(character)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("获取角色失败: {}", e)), + } + } + + /// 列出项目的所有角色 + pub fn list_characters(&self, project_id: &str) -> Result, String> { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let mut stmt = conn + .prepare( + "SELECT id, project_id, name, aliases_json, description, personality, background, appearance, relationships_json, avatar_url, is_main, sort_order, extra_json, created_at, updated_at + FROM characters WHERE project_id = ? ORDER BY sort_order ASC", + ) + .map_err(|e| format!("准备查询失败: {}", e))?; + + let characters = stmt + .query_map(params![project_id], |row| Ok(Self::row_to_character(row)?)) + .map_err(|e| format!("查询失败: {}", e))? + .collect::, _>>() + .map_err(|e| format!("解析结果失败: {}", e))?; + + Ok(characters) + } + + /// 更新角色 + pub fn update_character( + &self, + id: &CharacterId, + updates: CharacterUpdateRequest, + ) -> Result { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + let now = Utc::now().timestamp_millis(); + + let mut set_clauses = vec!["updated_at = ?"]; + let mut params_vec: Vec> = vec![Box::new(now)]; + + if let Some(ref name) = updates.name { + set_clauses.push("name = ?"); + params_vec.push(Box::new(name.clone())); + } + if let Some(ref aliases) = updates.aliases { + set_clauses.push("aliases_json = ?"); + params_vec.push(Box::new(serde_json::to_string(aliases).unwrap_or_default())); + } + if let Some(ref description) = updates.description { + set_clauses.push("description = ?"); + params_vec.push(Box::new(description.clone())); + } + if let Some(ref personality) = updates.personality { + set_clauses.push("personality = ?"); + params_vec.push(Box::new(personality.clone())); + } + if let Some(ref background) = updates.background { + set_clauses.push("background = ?"); + params_vec.push(Box::new(background.clone())); + } + if let Some(ref appearance) = updates.appearance { + set_clauses.push("appearance = ?"); + params_vec.push(Box::new(appearance.clone())); + } + if let Some(ref relationships) = updates.relationships { + set_clauses.push("relationships_json = ?"); + params_vec.push(Box::new( + serde_json::to_string(relationships).unwrap_or_default(), + )); + } + if let Some(ref avatar_url) = updates.avatar_url { + set_clauses.push("avatar_url = ?"); + params_vec.push(Box::new(avatar_url.clone())); + } + if let Some(is_main) = updates.is_main { + set_clauses.push("is_main = ?"); + params_vec.push(Box::new(is_main)); + } + if let Some(order) = updates.order { + set_clauses.push("sort_order = ?"); + params_vec.push(Box::new(order)); + } + if let Some(ref extra) = updates.extra { + set_clauses.push("extra_json = ?"); + params_vec.push(Box::new(serde_json::to_string(extra).unwrap_or_default())); + } + + params_vec.push(Box::new(id.clone())); + + let sql = format!( + "UPDATE characters SET {} WHERE id = ?", + set_clauses.join(", ") + ); + let params_refs: Vec<&dyn rusqlite::ToSql> = + params_vec.iter().map(|p| p.as_ref()).collect(); + + conn.execute(&sql, params_refs.as_slice()) + .map_err(|e| format!("更新角色失败: {}", e))?; + + drop(conn); + self.get_character(id)? + .ok_or_else(|| "角色不存在".to_string()) + } + + /// 删除角色 + pub fn delete_character(&self, id: &CharacterId) -> Result { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let affected = conn + .execute("DELETE FROM characters WHERE id = ?", params![id]) + .map_err(|e| format!("删除角色失败: {}", e))?; + + if affected > 0 { + tracing::info!("[Memory] 删除角色: id={}", id); + } + Ok(affected > 0) + } + + fn get_next_character_order(&self, project_id: &str) -> Result { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + let result: Result = conn.query_row( + "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM characters WHERE project_id = ?", + params![project_id], + |row| row.get(0), + ); + result.map_err(|e| format!("获取排序顺序失败: {}", e)) + } + + fn row_to_character(row: &rusqlite::Row) -> Result { + let id: String = row.get(0)?; + let project_id: String = row.get(1)?; + let name: String = row.get(2)?; + let aliases_json: String = row.get(3)?; + let description: Option = row.get(4)?; + let personality: Option = row.get(5)?; + let background: Option = row.get(6)?; + let appearance: Option = row.get(7)?; + let relationships_json: String = row.get(8)?; + let avatar_url: Option = row.get(9)?; + let is_main: bool = row.get(10)?; + let order: i32 = row.get(11)?; + let extra_json: Option = row.get(12)?; + let created_at_ms: i64 = row.get(13)?; + let updated_at_ms: i64 = row.get(14)?; + + Ok(Character { + id, + project_id, + name, + aliases: serde_json::from_str(&aliases_json).unwrap_or_default(), + description, + personality, + background, + appearance, + relationships: serde_json::from_str(&relationships_json).unwrap_or_default(), + avatar_url, + is_main, + order, + extra: extra_json.and_then(|s| serde_json::from_str(&s).ok()), + created_at: chrono::DateTime::from_timestamp_millis(created_at_ms) + .unwrap_or_else(Utc::now), + updated_at: chrono::DateTime::from_timestamp_millis(updated_at_ms) + .unwrap_or_else(Utc::now), + }) + } + + // ==================== 世界观管理 ==================== + + /// 获取或创建世界观 + pub fn get_world_building(&self, project_id: &str) -> Result, String> { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let result = conn.query_row( + "SELECT project_id, description, era, locations, rules, extra_json, updated_at + FROM world_building WHERE project_id = ?", + params![project_id], + |row| { + let project_id: String = row.get(0)?; + let description: String = row.get(1)?; + let era: Option = row.get(2)?; + let locations: Option = row.get(3)?; + let rules: Option = row.get(4)?; + let extra_json: Option = row.get(5)?; + let updated_at_ms: i64 = row.get(6)?; + + Ok(WorldBuilding { + project_id, + description, + era, + locations, + rules, + extra: extra_json.and_then(|s| serde_json::from_str(&s).ok()), + updated_at: chrono::DateTime::from_timestamp_millis(updated_at_ms) + .unwrap_or_else(Utc::now), + }) + }, + ); + + match result { + Ok(wb) => Ok(Some(wb)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("获取世界观失败: {}", e)), + } + } + + /// 更新或创建世界观 + pub fn upsert_world_building( + &self, + project_id: &str, + updates: WorldBuildingUpdateRequest, + ) -> Result { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + let now = Utc::now(); + + let extra_json = updates + .extra + .as_ref() + .map(|e| serde_json::to_string(e).unwrap_or_default()); + + conn.execute( + "INSERT INTO world_building (project_id, description, era, locations, rules, extra_json, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id) DO UPDATE SET + description = COALESCE(excluded.description, description), + era = COALESCE(excluded.era, era), + locations = COALESCE(excluded.locations, locations), + rules = COALESCE(excluded.rules, rules), + extra_json = COALESCE(excluded.extra_json, extra_json), + updated_at = excluded.updated_at", + params![ + project_id, + updates.description.unwrap_or_default(), + updates.era, + updates.locations, + updates.rules, + extra_json, + now.timestamp_millis(), + ], + ) + .map_err(|e| format!("更新世界观失败: {}", e))?; + + drop(conn); + self.get_world_building(project_id)? + .ok_or_else(|| "世界观不存在".to_string()) + } + + // ==================== 风格指南管理 ==================== + + /// 获取风格指南 + pub fn get_style_guide(&self, project_id: &str) -> Result, String> { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let result = conn.query_row( + "SELECT project_id, style, tone, forbidden_words_json, preferred_words_json, examples, extra_json, updated_at + FROM style_guides WHERE project_id = ?", + params![project_id], + |row| { + let project_id: String = row.get(0)?; + let style: String = row.get(1)?; + let tone: Option = row.get(2)?; + let forbidden_words_json: String = row.get(3)?; + let preferred_words_json: String = row.get(4)?; + let examples: Option = row.get(5)?; + let extra_json: Option = row.get(6)?; + let updated_at_ms: i64 = row.get(7)?; + + Ok(StyleGuide { + project_id, + style, + tone, + forbidden_words: serde_json::from_str(&forbidden_words_json).unwrap_or_default(), + preferred_words: serde_json::from_str(&preferred_words_json).unwrap_or_default(), + examples, + extra: extra_json.and_then(|s| serde_json::from_str(&s).ok()), + updated_at: chrono::DateTime::from_timestamp_millis(updated_at_ms).unwrap_or_else(Utc::now), + }) + }, + ); + + match result { + Ok(sg) => Ok(Some(sg)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("获取风格指南失败: {}", e)), + } + } + + /// 更新或创建风格指南 + pub fn upsert_style_guide( + &self, + project_id: &str, + updates: StyleGuideUpdateRequest, + ) -> Result { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + let now = Utc::now(); + + let forbidden_words_json = updates + .forbidden_words + .as_ref() + .map(|w| serde_json::to_string(w).unwrap_or_default()) + .unwrap_or_else(|| "[]".to_string()); + let preferred_words_json = updates + .preferred_words + .as_ref() + .map(|w| serde_json::to_string(w).unwrap_or_default()) + .unwrap_or_else(|| "[]".to_string()); + let extra_json = updates + .extra + .as_ref() + .map(|e| serde_json::to_string(e).unwrap_or_default()); + + conn.execute( + "INSERT INTO style_guides (project_id, style, tone, forbidden_words_json, preferred_words_json, examples, extra_json, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id) DO UPDATE SET + style = COALESCE(excluded.style, style), + tone = COALESCE(excluded.tone, tone), + forbidden_words_json = excluded.forbidden_words_json, + preferred_words_json = excluded.preferred_words_json, + examples = COALESCE(excluded.examples, examples), + extra_json = COALESCE(excluded.extra_json, extra_json), + updated_at = excluded.updated_at", + params![ + project_id, + updates.style.unwrap_or_default(), + updates.tone, + forbidden_words_json, + preferred_words_json, + updates.examples, + extra_json, + now.timestamp_millis(), + ], + ) + .map_err(|e| format!("更新风格指南失败: {}", e))?; + + drop(conn); + self.get_style_guide(project_id)? + .ok_or_else(|| "风格指南不存在".to_string()) + } + + // ==================== 大纲管理 ==================== + + /// 创建大纲节点 + pub fn create_outline_node( + &self, + request: OutlineNodeCreateRequest, + ) -> Result { + let now = Utc::now(); + let id = Uuid::new_v4().to_string(); + let order = request.order.unwrap_or_else(|| { + self.get_next_outline_order(&request.project_id, request.parent_id.as_deref()) + .unwrap_or(0) + }); + + let node = OutlineNode { + id: id.clone(), + project_id: request.project_id.clone(), + parent_id: request.parent_id, + title: request.title, + content: request.content, + content_id: request.content_id, + order, + expanded: true, + extra: None, + created_at: now, + updated_at: now, + }; + + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + conn.execute( + "INSERT INTO outline_nodes (id, project_id, parent_id, title, content, content_id, sort_order, expanded, extra_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + &node.id, + &node.project_id, + &node.parent_id, + &node.title, + &node.content, + &node.content_id, + node.order, + node.expanded, + Option::::None, + node.created_at.timestamp_millis(), + node.updated_at.timestamp_millis(), + ], + ) + .map_err(|e| format!("创建大纲节点失败: {}", e))?; + + tracing::info!( + "[Memory] 创建大纲节点: id={}, title={}", + node.id, + node.title + ); + Ok(node) + } + + /// 获取大纲节点 + pub fn get_outline_node(&self, id: &OutlineNodeId) -> Result, String> { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let result = conn.query_row( + "SELECT id, project_id, parent_id, title, content, content_id, sort_order, expanded, extra_json, created_at, updated_at + FROM outline_nodes WHERE id = ?", + params![id], + |row| Ok(Self::row_to_outline_node(row)?), + ); + + match result { + Ok(node) => Ok(Some(node)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("获取大纲节点失败: {}", e)), + } + } + + /// 列出项目的所有大纲节点 + pub fn list_outline_nodes(&self, project_id: &str) -> Result, String> { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let mut stmt = conn + .prepare( + "SELECT id, project_id, parent_id, title, content, content_id, sort_order, expanded, extra_json, created_at, updated_at + FROM outline_nodes WHERE project_id = ? ORDER BY sort_order ASC", + ) + .map_err(|e| format!("准备查询失败: {}", e))?; + + let nodes = stmt + .query_map(params![project_id], |row| { + Ok(Self::row_to_outline_node(row)?) + }) + .map_err(|e| format!("查询失败: {}", e))? + .collect::, _>>() + .map_err(|e| format!("解析结果失败: {}", e))?; + + Ok(nodes) + } + + /// 更新大纲节点 + pub fn update_outline_node( + &self, + id: &OutlineNodeId, + updates: OutlineNodeUpdateRequest, + ) -> Result { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + let now = Utc::now().timestamp_millis(); + + let mut set_clauses = vec!["updated_at = ?"]; + let mut params_vec: Vec> = vec![Box::new(now)]; + + if let Some(ref parent_id) = updates.parent_id { + set_clauses.push("parent_id = ?"); + params_vec.push(Box::new(parent_id.clone())); + } + if let Some(ref title) = updates.title { + set_clauses.push("title = ?"); + params_vec.push(Box::new(title.clone())); + } + if let Some(ref content) = updates.content { + set_clauses.push("content = ?"); + params_vec.push(Box::new(content.clone())); + } + if let Some(ref content_id) = updates.content_id { + set_clauses.push("content_id = ?"); + params_vec.push(Box::new(content_id.clone())); + } + if let Some(order) = updates.order { + set_clauses.push("sort_order = ?"); + params_vec.push(Box::new(order)); + } + if let Some(expanded) = updates.expanded { + set_clauses.push("expanded = ?"); + params_vec.push(Box::new(expanded)); + } + if let Some(ref extra) = updates.extra { + set_clauses.push("extra_json = ?"); + params_vec.push(Box::new(serde_json::to_string(extra).unwrap_or_default())); + } + + params_vec.push(Box::new(id.clone())); + + let sql = format!( + "UPDATE outline_nodes SET {} WHERE id = ?", + set_clauses.join(", ") + ); + let params_refs: Vec<&dyn rusqlite::ToSql> = + params_vec.iter().map(|p| p.as_ref()).collect(); + + conn.execute(&sql, params_refs.as_slice()) + .map_err(|e| format!("更新大纲节点失败: {}", e))?; + + drop(conn); + self.get_outline_node(id)? + .ok_or_else(|| "大纲节点不存在".to_string()) + } + + /// 删除大纲节点 + pub fn delete_outline_node(&self, id: &OutlineNodeId) -> Result { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let affected = conn + .execute("DELETE FROM outline_nodes WHERE id = ?", params![id]) + .map_err(|e| format!("删除大纲节点失败: {}", e))?; + + if affected > 0 { + tracing::info!("[Memory] 删除大纲节点: id={}", id); + } + Ok(affected > 0) + } + + fn get_next_outline_order( + &self, + project_id: &str, + parent_id: Option<&str>, + ) -> Result { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let result: Result = match parent_id { + Some(pid) => conn.query_row( + "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM outline_nodes WHERE project_id = ? AND parent_id = ?", + params![project_id, pid], + |row| row.get(0), + ), + None => conn.query_row( + "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM outline_nodes WHERE project_id = ? AND parent_id IS NULL", + params![project_id], + |row| row.get(0), + ), + }; + + result.map_err(|e| format!("获取排序顺序失败: {}", e)) + } + + fn row_to_outline_node(row: &rusqlite::Row) -> Result { + let id: String = row.get(0)?; + let project_id: String = row.get(1)?; + let parent_id: Option = row.get(2)?; + let title: String = row.get(3)?; + let content: Option = row.get(4)?; + let content_id: Option = row.get(5)?; + let order: i32 = row.get(6)?; + let expanded: bool = row.get(7)?; + let extra_json: Option = row.get(8)?; + let created_at_ms: i64 = row.get(9)?; + let updated_at_ms: i64 = row.get(10)?; + + Ok(OutlineNode { + id, + project_id, + parent_id, + title, + content, + content_id, + order, + expanded, + extra: extra_json.and_then(|s| serde_json::from_str(&s).ok()), + created_at: chrono::DateTime::from_timestamp_millis(created_at_ms) + .unwrap_or_else(Utc::now), + updated_at: chrono::DateTime::from_timestamp_millis(updated_at_ms) + .unwrap_or_else(Utc::now), + }) + } + + // ==================== 聚合查询 ==================== + + /// 获取项目的完整记忆 + pub fn get_project_memory(&self, project_id: &str) -> Result { + let characters = self.list_characters(project_id)?; + let world_building = self.get_world_building(project_id)?; + let style_guide = self.get_style_guide(project_id)?; + let outline = self.list_outline_nodes(project_id)?; + + Ok(ProjectMemory { + characters, + world_building, + style_guide, + outline, + }) + } +} diff --git a/src-tauri/src/memory/mod.rs b/src-tauri/src/memory/mod.rs new file mode 100644 index 000000000..bcd1cbb65 --- /dev/null +++ b/src-tauri/src/memory/mod.rs @@ -0,0 +1,9 @@ +//! Memory 模块 +//! +//! 提供项目记忆系统管理功能(角色、世界观、风格指南、大纲)。 + +pub mod manager; +pub mod types; + +pub use manager::MemoryManager; +pub use types::*; diff --git a/src-tauri/src/memory/types.rs b/src-tauri/src/memory/types.rs new file mode 100644 index 000000000..50a5cd58d --- /dev/null +++ b/src-tauri/src/memory/types.rs @@ -0,0 +1,335 @@ +//! Memory 类型定义 +//! +//! 定义项目记忆系统相关的数据结构(角色、世界观、大纲等)。 + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// 角色 ID +pub type CharacterId = String; + +/// 大纲节点 ID +pub type OutlineNodeId = String; + +/// 角色设定 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Character { + /// 唯一标识 + pub id: CharacterId, + /// 所属项目 ID + pub project_id: String, + /// 角色名称 + pub name: String, + /// 角色别名/昵称 + #[serde(default)] + pub aliases: Vec, + /// 角色描述 + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// 角色性格 + #[serde(skip_serializing_if = "Option::is_none")] + pub personality: Option, + /// 角色背景 + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + /// 角色外貌 + #[serde(skip_serializing_if = "Option::is_none")] + pub appearance: Option, + /// 角色关系(与其他角色的关系) + #[serde(default)] + pub relationships: Vec, + /// 角色头像 URL + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + /// 是否为主要角色 + #[serde(default)] + pub is_main: bool, + /// 排序顺序 + #[serde(default)] + pub order: i32, + /// 额外属性(JSON) + #[serde(skip_serializing_if = "Option::is_none")] + pub extra: Option, + /// 创建时间 + pub created_at: DateTime, + /// 更新时间 + pub updated_at: DateTime, +} + +/// 角色关系 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CharacterRelationship { + /// 目标角色 ID + pub target_id: CharacterId, + /// 关系类型(如:朋友、敌人、恋人、家人等) + pub relationship_type: String, + /// 关系描述 + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// 角色创建请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CharacterCreateRequest { + /// 所属项目 ID + pub project_id: String, + /// 角色名称 + pub name: String, + /// 角色别名 + #[serde(default)] + pub aliases: Vec, + /// 角色描述 + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// 角色性格 + #[serde(skip_serializing_if = "Option::is_none")] + pub personality: Option, + /// 角色背景 + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + /// 角色外貌 + #[serde(skip_serializing_if = "Option::is_none")] + pub appearance: Option, + /// 是否为主要角色 + #[serde(default)] + pub is_main: bool, +} + +/// 角色更新请求 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CharacterUpdateRequest { + /// 角色名称 + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// 角色别名 + #[serde(skip_serializing_if = "Option::is_none")] + pub aliases: Option>, + /// 角色描述 + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// 角色性格 + #[serde(skip_serializing_if = "Option::is_none")] + pub personality: Option, + /// 角色背景 + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + /// 角色外貌 + #[serde(skip_serializing_if = "Option::is_none")] + pub appearance: Option, + /// 角色关系 + #[serde(skip_serializing_if = "Option::is_none")] + pub relationships: Option>, + /// 角色头像 URL + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + /// 是否为主要角色 + #[serde(skip_serializing_if = "Option::is_none")] + pub is_main: Option, + /// 排序顺序 + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, + /// 额外属性 + #[serde(skip_serializing_if = "Option::is_none")] + pub extra: Option, +} + +/// 世界观设定 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorldBuilding { + /// 所属项目 ID + pub project_id: String, + /// 世界观描述 + pub description: String, + /// 时代背景 + #[serde(skip_serializing_if = "Option::is_none")] + pub era: Option, + /// 地点设定 + #[serde(skip_serializing_if = "Option::is_none")] + pub locations: Option, + /// 规则/设定 + #[serde(skip_serializing_if = "Option::is_none")] + pub rules: Option, + /// 额外设定(JSON) + #[serde(skip_serializing_if = "Option::is_none")] + pub extra: Option, + /// 更新时间 + pub updated_at: DateTime, +} + +/// 世界观更新请求 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct WorldBuildingUpdateRequest { + /// 世界观描述 + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// 时代背景 + #[serde(skip_serializing_if = "Option::is_none")] + pub era: Option, + /// 地点设定 + #[serde(skip_serializing_if = "Option::is_none")] + pub locations: Option, + /// 规则/设定 + #[serde(skip_serializing_if = "Option::is_none")] + pub rules: Option, + /// 额外设定 + #[serde(skip_serializing_if = "Option::is_none")] + pub extra: Option, +} + +/// 风格指南 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StyleGuide { + /// 所属项目 ID + pub project_id: String, + /// 写作风格描述 + pub style: String, + /// 语气/调性 + #[serde(skip_serializing_if = "Option::is_none")] + pub tone: Option, + /// 禁用词汇 + #[serde(default)] + pub forbidden_words: Vec, + /// 偏好词汇 + #[serde(default)] + pub preferred_words: Vec, + /// 示例文本 + #[serde(skip_serializing_if = "Option::is_none")] + pub examples: Option, + /// 额外设定(JSON) + #[serde(skip_serializing_if = "Option::is_none")] + pub extra: Option, + /// 更新时间 + pub updated_at: DateTime, +} + +/// 风格指南更新请求 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct StyleGuideUpdateRequest { + /// 写作风格描述 + #[serde(skip_serializing_if = "Option::is_none")] + pub style: Option, + /// 语气/调性 + #[serde(skip_serializing_if = "Option::is_none")] + pub tone: Option, + /// 禁用词汇 + #[serde(skip_serializing_if = "Option::is_none")] + pub forbidden_words: Option>, + /// 偏好词汇 + #[serde(skip_serializing_if = "Option::is_none")] + pub preferred_words: Option>, + /// 示例文本 + #[serde(skip_serializing_if = "Option::is_none")] + pub examples: Option, + /// 额外设定 + #[serde(skip_serializing_if = "Option::is_none")] + pub extra: Option, +} + +/// 大纲节点 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutlineNode { + /// 唯一标识 + pub id: OutlineNodeId, + /// 所属项目 ID + pub project_id: String, + /// 父节点 ID(null 表示根节点) + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + /// 节点标题 + pub title: String, + /// 节点内容/描述 + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + /// 关联的内容 ID(如关联到某一集/章节) + #[serde(skip_serializing_if = "Option::is_none")] + pub content_id: Option, + /// 排序顺序 + pub order: i32, + /// 是否展开 + #[serde(default = "default_true")] + pub expanded: bool, + /// 额外属性(JSON) + #[serde(skip_serializing_if = "Option::is_none")] + pub extra: Option, + /// 创建时间 + pub created_at: DateTime, + /// 更新时间 + pub updated_at: DateTime, +} + +fn default_true() -> bool { + true +} + +/// 大纲节点创建请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutlineNodeCreateRequest { + /// 所属项目 ID + pub project_id: String, + /// 父节点 ID + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + /// 节点标题 + pub title: String, + /// 节点内容 + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + /// 关联的内容 ID + #[serde(skip_serializing_if = "Option::is_none")] + pub content_id: Option, + /// 排序顺序 + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, +} + +/// 大纲节点更新请求 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct OutlineNodeUpdateRequest { + /// 父节点 ID + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option>, + /// 节点标题 + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// 节点内容 + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + /// 关联的内容 ID + #[serde(skip_serializing_if = "Option::is_none")] + pub content_id: Option>, + /// 排序顺序 + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, + /// 是否展开 + #[serde(skip_serializing_if = "Option::is_none")] + pub expanded: Option, + /// 额外属性 + #[serde(skip_serializing_if = "Option::is_none")] + pub extra: Option, +} + +/// 项目记忆(聚合所有记忆数据) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectMemory { + /// 角色列表 + pub characters: Vec, + /// 世界观设定 + #[serde(skip_serializing_if = "Option::is_none")] + pub world_building: Option, + /// 风格指南 + #[serde(skip_serializing_if = "Option::is_none")] + pub style_guide: Option, + /// 大纲 + pub outline: Vec, +} + +impl Default for ProjectMemory { + fn default() -> Self { + Self { + characters: Vec::new(), + world_building: None, + style_guide: None, + outline: Vec::new(), + } + } +} diff --git a/src-tauri/src/workspace/manager.rs b/src-tauri/src/workspace/manager.rs index a48e93a40..d30df05ca 100644 --- a/src-tauri/src/workspace/manager.rs +++ b/src-tauri/src/workspace/manager.rs @@ -6,6 +6,7 @@ use super::types::{Workspace, WorkspaceId, WorkspaceSettings, WorkspaceType, Wor use crate::database::DbConnection; use chrono::Utc; use rusqlite::params; +use std::collections::HashSet; use std::path::PathBuf; use uuid::Uuid; @@ -37,6 +38,24 @@ impl WorkspaceManager { let id = Uuid::new_v4().to_string(); let root_path_str = root_path.to_str().ok_or("无效的路径")?.to_string(); + // 根据项目类型设置默认图标 + let icon = if workspace_type.is_project_type() { + Some(match &workspace_type { + WorkspaceType::General => "💬".to_string(), + WorkspaceType::SocialMedia => "📱".to_string(), + WorkspaceType::Poster => "🖼️".to_string(), + WorkspaceType::Music => "🎵".to_string(), + WorkspaceType::Knowledge => "🔍".to_string(), + WorkspaceType::Planning => "📅".to_string(), + WorkspaceType::Document => "📄".to_string(), + WorkspaceType::Video => "🎬".to_string(), + WorkspaceType::Novel => "📖".to_string(), + _ => "📁".to_string(), + }) + } else { + None + }; + let workspace = Workspace { id: id.clone(), name, @@ -46,6 +65,12 @@ impl WorkspaceManager { created_at: now, updated_at: now, settings: WorkspaceSettings::default(), + icon, + color: None, + is_favorite: false, + is_archived: false, + tags: Vec::new(), + stats: None, }; let conn = self @@ -53,6 +78,8 @@ impl WorkspaceManager { .lock() .map_err(|e| format!("数据库锁定失败: {}", e))?; + Self::ensure_workspace_columns(&conn)?; + // 检查路径是否已存在 let exists: bool = conn .query_row( @@ -68,10 +95,11 @@ impl WorkspaceManager { let settings_json = serde_json::to_string(&workspace.settings).map_err(|e| e.to_string())?; + let tags_json = serde_json::to_string(&workspace.tags).map_err(|e| e.to_string())?; conn.execute( - "INSERT INTO workspaces (id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO workspaces (id, name, workspace_type, root_path, is_default, settings_json, icon, color, is_favorite, is_archived, tags_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", params![ &workspace.id, &workspace.name, @@ -79,6 +107,11 @@ impl WorkspaceManager { &root_path_str, workspace.is_default, &settings_json, + &workspace.icon, + &workspace.color, + workspace.is_favorite, + workspace.is_archived, + &tags_json, workspace.created_at.timestamp_millis(), workspace.updated_at.timestamp_millis(), ], @@ -95,6 +128,46 @@ impl WorkspaceManager { Ok(workspace) } + /// 确保 workspaces 表包含项目管理相关字段 + fn ensure_workspace_columns(conn: &rusqlite::Connection) -> Result<(), String> { + let mut stmt = conn + .prepare("PRAGMA table_info(workspaces)") + .map_err(|e| format!("读取 workspaces 表结构失败: {}", e))?; + + let columns = stmt + .query_map([], |row| { + let column_name: String = row.get(1)?; + Ok(column_name) + }) + .map_err(|e| format!("读取 workspaces 表结构失败: {}", e))? + .collect::, _>>() + .map_err(|e| format!("解析 workspaces 表结构失败: {}", e))?; + + let add_column = |sql: &str| -> Result<(), String> { + conn.execute(sql, []) + .map_err(|e| format!("更新 workspaces 表结构失败: {}", e))?; + Ok(()) + }; + + if !columns.contains("icon") { + add_column("ALTER TABLE workspaces ADD COLUMN icon TEXT")?; + } + if !columns.contains("color") { + add_column("ALTER TABLE workspaces ADD COLUMN color TEXT")?; + } + if !columns.contains("is_favorite") { + add_column("ALTER TABLE workspaces ADD COLUMN is_favorite INTEGER DEFAULT 0")?; + } + if !columns.contains("is_archived") { + add_column("ALTER TABLE workspaces ADD COLUMN is_archived INTEGER DEFAULT 0")?; + } + if !columns.contains("tags_json") { + add_column("ALTER TABLE workspaces ADD COLUMN tags_json TEXT DEFAULT '[]'")?; + } + + Ok(()) + } + /// 获取 workspace pub fn get(&self, id: &WorkspaceId) -> Result, String> { let conn = self @@ -103,7 +176,7 @@ impl WorkspaceManager { .map_err(|e| format!("数据库锁定失败: {}", e))?; let result = conn.query_row( - "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at + "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json FROM workspaces WHERE id = ?", params![id], |row| { @@ -128,7 +201,7 @@ impl WorkspaceManager { .map_err(|e| format!("数据库锁定失败: {}", e))?; let result = conn.query_row( - "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at + "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json FROM workspaces WHERE root_path = ?", params![root_path_str], |row| { @@ -152,7 +225,7 @@ impl WorkspaceManager { let mut stmt = conn .prepare( - "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at + "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json FROM workspaces ORDER BY updated_at DESC", ) .map_err(|e| format!("准备查询失败: {}", e))?; @@ -166,6 +239,108 @@ impl WorkspaceManager { Ok(workspaces) } + /// 列出所有项目类型的 workspace + pub fn list_projects(&self) -> Result, String> { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let mut stmt = conn + .prepare( + "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json + FROM workspaces + WHERE workspace_type IN ('drama', 'novel', 'social', 'document', 'general') + ORDER BY updated_at DESC", + ) + .map_err(|e| format!("准备查询失败: {}", e))?; + + let workspaces = stmt + .query_map([], |row| Ok(Self::row_to_workspace(row)?)) + .map_err(|e| format!("查询失败: {}", e))? + .collect::, _>>() + .map_err(|e| format!("解析结果失败: {}", e))?; + + Ok(workspaces) + } + + /// 列出指定类型的项目 + pub fn list_by_type(&self, workspace_type: &WorkspaceType) -> Result, String> { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let mut stmt = conn + .prepare( + "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json + FROM workspaces + WHERE workspace_type = ? + ORDER BY updated_at DESC", + ) + .map_err(|e| format!("准备查询失败: {}", e))?; + + let workspaces = stmt + .query_map(params![workspace_type.as_str()], |row| { + Ok(Self::row_to_workspace(row)?) + }) + .map_err(|e| format!("查询失败: {}", e))? + .collect::, _>>() + .map_err(|e| format!("解析结果失败: {}", e))?; + + Ok(workspaces) + } + + /// 列出收藏的项目 + pub fn list_favorites(&self) -> Result, String> { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let mut stmt = conn + .prepare( + "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json + FROM workspaces + WHERE is_favorite = 1 AND is_archived = 0 + ORDER BY updated_at DESC", + ) + .map_err(|e| format!("准备查询失败: {}", e))?; + + let workspaces = stmt + .query_map([], |row| Ok(Self::row_to_workspace(row)?)) + .map_err(|e| format!("查询失败: {}", e))? + .collect::, _>>() + .map_err(|e| format!("解析结果失败: {}", e))?; + + Ok(workspaces) + } + + /// 列出归档的项目 + pub fn list_archived(&self) -> Result, String> { + let conn = self + .db + .lock() + .map_err(|e| format!("数据库锁定失败: {}", e))?; + + let mut stmt = conn + .prepare( + "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json + FROM workspaces + WHERE is_archived = 1 + ORDER BY updated_at DESC", + ) + .map_err(|e| format!("准备查询失败: {}", e))?; + + let workspaces = stmt + .query_map([], |row| Ok(Self::row_to_workspace(row)?)) + .map_err(|e| format!("查询失败: {}", e))? + .collect::, _>>() + .map_err(|e| format!("解析结果失败: {}", e))?; + + Ok(workspaces) + } + /// 更新 workspace pub fn update(&self, id: &WorkspaceId, updates: WorkspaceUpdate) -> Result { let conn = self @@ -189,6 +364,32 @@ impl WorkspaceManager { params_vec.push(Box::new(settings_json)); } + if let Some(ref icon) = updates.icon { + set_clauses.push("icon = ?"); + params_vec.push(Box::new(icon.clone())); + } + + if let Some(ref color) = updates.color { + set_clauses.push("color = ?"); + params_vec.push(Box::new(color.clone())); + } + + if let Some(is_favorite) = updates.is_favorite { + set_clauses.push("is_favorite = ?"); + params_vec.push(Box::new(is_favorite)); + } + + if let Some(is_archived) = updates.is_archived { + set_clauses.push("is_archived = ?"); + params_vec.push(Box::new(is_archived)); + } + + if let Some(ref tags) = updates.tags { + let tags_json = serde_json::to_string(tags).map_err(|e| e.to_string())?; + set_clauses.push("tags_json = ?"); + params_vec.push(Box::new(tags_json)); + } + params_vec.push(Box::new(id.clone())); let sql = format!( @@ -260,7 +461,7 @@ impl WorkspaceManager { .map_err(|e| format!("数据库锁定失败: {}", e))?; let result = conn.query_row( - "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at + "SELECT id, name, workspace_type, root_path, is_default, settings_json, created_at, updated_at, icon, color, is_favorite, is_archived, tags_json FROM workspaces WHERE is_default = 1", [], |row| { @@ -285,8 +486,16 @@ impl WorkspaceManager { let settings_json: String = row.get(5)?; let created_at_ms: i64 = row.get(6)?; let updated_at_ms: i64 = row.get(7)?; + let icon: Option = row.get(8)?; + let color: Option = row.get(9)?; + let is_favorite: bool = row.get::<_, Option>(10)?.unwrap_or(false); + let is_archived: bool = row.get::<_, Option>(11)?.unwrap_or(false); + let tags_json: Option = row.get(12)?; let settings: WorkspaceSettings = serde_json::from_str(&settings_json).unwrap_or_default(); + let tags: Vec = tags_json + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); Ok(Workspace { id, @@ -299,6 +508,12 @@ impl WorkspaceManager { updated_at: chrono::DateTime::from_timestamp_millis(updated_at_ms) .unwrap_or_else(Utc::now), settings, + icon, + color, + is_favorite, + is_archived, + tags, + stats: None, // 统计信息需要单独查询 }) } } diff --git a/src-tauri/src/workspace/types.rs b/src-tauri/src/workspace/types.rs index bfab0e21d..b81f60daf 100644 --- a/src-tauri/src/workspace/types.rs +++ b/src-tauri/src/workspace/types.rs @@ -11,13 +11,31 @@ pub type WorkspaceId = String; /// Workspace 类型 #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] -#[serde(rename_all = "snake_case")] +#[serde(rename_all = "kebab-case")] pub enum WorkspaceType { /// 持久化 workspace #[default] Persistent, /// 临时 workspace(自动清理) Temporary, + /// 通用对话 + General, + /// 社媒内容 + SocialMedia, + /// 图文海报 + Poster, + /// 歌词曲谱 + Music, + /// 知识探索 + Knowledge, + /// 计划规划 + Planning, + /// 办公文档 + Document, + /// 短视频 + Video, + /// 小说创作 + Novel, } impl WorkspaceType { @@ -25,15 +43,52 @@ impl WorkspaceType { match self { WorkspaceType::Persistent => "persistent", WorkspaceType::Temporary => "temporary", + WorkspaceType::General => "general", + WorkspaceType::SocialMedia => "social-media", + WorkspaceType::Poster => "poster", + WorkspaceType::Music => "music", + WorkspaceType::Knowledge => "knowledge", + WorkspaceType::Planning => "planning", + WorkspaceType::Document => "document", + WorkspaceType::Video => "video", + WorkspaceType::Novel => "novel", } } pub fn from_str(s: &str) -> Self { match s { "temporary" => WorkspaceType::Temporary, + "general" => WorkspaceType::General, + "social-media" => WorkspaceType::SocialMedia, + "poster" => WorkspaceType::Poster, + "music" => WorkspaceType::Music, + "knowledge" => WorkspaceType::Knowledge, + "planning" => WorkspaceType::Planning, + "document" => WorkspaceType::Document, + "video" => WorkspaceType::Video, + "novel" => WorkspaceType::Novel, + // 旧类型兼容映射 + "drama" => WorkspaceType::Video, + "social" => WorkspaceType::SocialMedia, _ => WorkspaceType::Persistent, } } + + /// 判断是否为项目类型 + pub fn is_project_type(&self) -> bool { + matches!( + self, + WorkspaceType::General + | WorkspaceType::SocialMedia + | WorkspaceType::Poster + | WorkspaceType::Music + | WorkspaceType::Knowledge + | WorkspaceType::Planning + | WorkspaceType::Document + | WorkspaceType::Video + | WorkspaceType::Novel + ) + } } /// Workspace 级别设置 @@ -50,6 +105,23 @@ pub struct WorkspaceSettings { pub auto_compact: bool, } +/// 项目统计信息 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProjectStats { + /// 内容数量 + #[serde(default)] + pub content_count: i64, + /// 总字数 + #[serde(default)] + pub total_words: i64, + /// 已完成数量 + #[serde(default)] + pub completed_count: i64, + /// 最后访问时间 + #[serde(skip_serializing_if = "Option::is_none")] + pub last_accessed: Option>, +} + /// Workspace 元数据 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Workspace { @@ -69,6 +141,24 @@ pub struct Workspace { pub updated_at: DateTime, /// Workspace 级别设置 pub settings: WorkspaceSettings, + /// 项目图标(emoji 或图标名称) + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// 项目颜色(hex 格式) + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + /// 是否收藏 + #[serde(default)] + pub is_favorite: bool, + /// 是否归档 + #[serde(default)] + pub is_archived: bool, + /// 标签列表 + #[serde(default)] + pub tags: Vec, + /// 项目统计信息 + #[serde(skip_serializing_if = "Option::is_none")] + pub stats: Option, } /// Workspace 更新请求 @@ -80,6 +170,21 @@ pub struct WorkspaceUpdate { /// 新设置 #[serde(skip_serializing_if = "Option::is_none")] pub settings: Option, + /// 项目图标 + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// 项目颜色 + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + /// 是否收藏 + #[serde(skip_serializing_if = "Option::is_none")] + pub is_favorite: Option, + /// 是否归档 + #[serde(skip_serializing_if = "Option::is_none")] + pub is_archived: Option, + /// 标签列表 + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, } /// Workspace 创建请求 @@ -93,3 +198,160 @@ pub struct WorkspaceCreateRequest { #[serde(default)] pub workspace_type: WorkspaceType, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_workspace_type_as_str() { + assert_eq!(WorkspaceType::Persistent.as_str(), "persistent"); + assert_eq!(WorkspaceType::Temporary.as_str(), "temporary"); + assert_eq!(WorkspaceType::General.as_str(), "general"); + assert_eq!(WorkspaceType::SocialMedia.as_str(), "social-media"); + assert_eq!(WorkspaceType::Poster.as_str(), "poster"); + assert_eq!(WorkspaceType::Music.as_str(), "music"); + assert_eq!(WorkspaceType::Knowledge.as_str(), "knowledge"); + assert_eq!(WorkspaceType::Planning.as_str(), "planning"); + assert_eq!(WorkspaceType::Document.as_str(), "document"); + assert_eq!(WorkspaceType::Video.as_str(), "video"); + assert_eq!(WorkspaceType::Novel.as_str(), "novel"); + } + + #[test] + fn test_workspace_type_from_str() { + assert_eq!( + WorkspaceType::from_str("persistent"), + WorkspaceType::Persistent + ); + assert_eq!( + WorkspaceType::from_str("temporary"), + WorkspaceType::Temporary + ); + assert_eq!(WorkspaceType::from_str("general"), WorkspaceType::General); + assert_eq!( + WorkspaceType::from_str("social-media"), + WorkspaceType::SocialMedia + ); + assert_eq!(WorkspaceType::from_str("poster"), WorkspaceType::Poster); + assert_eq!(WorkspaceType::from_str("music"), WorkspaceType::Music); + assert_eq!( + WorkspaceType::from_str("knowledge"), + WorkspaceType::Knowledge + ); + assert_eq!(WorkspaceType::from_str("planning"), WorkspaceType::Planning); + assert_eq!(WorkspaceType::from_str("document"), WorkspaceType::Document); + assert_eq!(WorkspaceType::from_str("video"), WorkspaceType::Video); + assert_eq!(WorkspaceType::from_str("novel"), WorkspaceType::Novel); + } + + #[test] + fn test_legacy_type_migration() { + // 旧类型应该正确映射到新类型 + assert_eq!(WorkspaceType::from_str("drama"), WorkspaceType::Video); + assert_eq!( + WorkspaceType::from_str("social"), + WorkspaceType::SocialMedia + ); + } + + #[test] + fn test_unknown_type_defaults_to_persistent() { + assert_eq!( + WorkspaceType::from_str("unknown"), + WorkspaceType::Persistent + ); + assert_eq!(WorkspaceType::from_str(""), WorkspaceType::Persistent); + assert_eq!( + WorkspaceType::from_str("invalid"), + WorkspaceType::Persistent + ); + } + + #[test] + fn test_is_project_type() { + // 用户级类型应该返回 true + assert!(WorkspaceType::General.is_project_type()); + assert!(WorkspaceType::SocialMedia.is_project_type()); + assert!(WorkspaceType::Poster.is_project_type()); + assert!(WorkspaceType::Music.is_project_type()); + assert!(WorkspaceType::Knowledge.is_project_type()); + assert!(WorkspaceType::Planning.is_project_type()); + assert!(WorkspaceType::Document.is_project_type()); + assert!(WorkspaceType::Video.is_project_type()); + assert!(WorkspaceType::Novel.is_project_type()); + + // 系统级类型应该返回 false + assert!(!WorkspaceType::Persistent.is_project_type()); + assert!(!WorkspaceType::Temporary.is_project_type()); + } + + #[test] + fn test_serde_serialization() { + // 测试序列化为 kebab-case + let json = serde_json::to_string(&WorkspaceType::SocialMedia).unwrap(); + assert_eq!(json, "\"social-media\""); + + let json = serde_json::to_string(&WorkspaceType::Video).unwrap(); + assert_eq!(json, "\"video\""); + + let json = serde_json::to_string(&WorkspaceType::Persistent).unwrap(); + assert_eq!(json, "\"persistent\""); + } + + #[test] + fn test_serde_deserialization() { + // 测试从 kebab-case 反序列化 + let wt: WorkspaceType = serde_json::from_str("\"social-media\"").unwrap(); + assert_eq!(wt, WorkspaceType::SocialMedia); + + let wt: WorkspaceType = serde_json::from_str("\"video\"").unwrap(); + assert_eq!(wt, WorkspaceType::Video); + + let wt: WorkspaceType = serde_json::from_str("\"persistent\"").unwrap(); + assert_eq!(wt, WorkspaceType::Persistent); + } + + #[test] + fn test_roundtrip_all_types() { + let types = vec![ + WorkspaceType::Persistent, + WorkspaceType::Temporary, + WorkspaceType::General, + WorkspaceType::SocialMedia, + WorkspaceType::Poster, + WorkspaceType::Music, + WorkspaceType::Knowledge, + WorkspaceType::Planning, + WorkspaceType::Document, + WorkspaceType::Video, + WorkspaceType::Novel, + ]; + + for wt in types { + let s = wt.as_str(); + let parsed = WorkspaceType::from_str(s); + assert_eq!(wt, parsed, "Roundtrip failed for {:?}", wt); + } + } + + #[test] + fn test_default_workspace_type() { + let default_type = WorkspaceType::default(); + assert_eq!(default_type, WorkspaceType::Persistent); + } + + #[test] + fn test_workspace_type_clone() { + let original = WorkspaceType::Video; + let cloned = original.clone(); + assert_eq!(original, cloned); + } + + #[test] + fn test_workspace_type_debug() { + let wt = WorkspaceType::SocialMedia; + let debug_str = format!("{:?}", wt); + assert_eq!(debug_str, "SocialMedia"); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 65d348f66..9523d19fe 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.50.0", + "version": "0.51.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 8230d41db..aaf4eaed2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,9 @@ import { ToolsPage } from "./components/tools/ToolsPage"; import { AgentChatPage } from "./components/agent"; import { PluginsPage } from "./components/plugins/PluginsPage"; import { ImageGenPage } from "./components/image-gen"; +import { ProjectsPage } from "./components/projects"; +import { CreateProjectDialog } from "./components/projects/CreateProjectDialog"; +import { ProjectType } from "./lib/api/project"; import { TerminalWorkspace, @@ -36,7 +39,10 @@ import { useRelayRegistry } from "./hooks/useRelayRegistry"; import { ComponentDebugProvider } from "./contexts/ComponentDebugContext"; import { SoundProvider } from "./contexts/SoundProvider"; import { ComponentDebugOverlay } from "./components/dev"; -import { Page } from "./types/page"; +import { Page, PageParams, AgentPageParams } from "./types/page"; +import { open } from "@tauri-apps/plugin-dialog"; +import { createProject, createContent } from "./lib/api/project"; +import { toast } from "sonner"; const AppContainer = styled.div` display: flex; @@ -77,8 +83,114 @@ const FullscreenWrapper = styled.div<{ $isActive: boolean }>` function AppContent() { const [showSplash, setShowSplash] = useState(true); const [currentPage, setCurrentPage] = useState("agent"); + const [pageParams, setPageParams] = useState({}); const { needsOnboarding, completeOnboarding } = useOnboardingState(); + // 推荐标签引导创建项目相关状态 + const [projectDialogOpen, setProjectDialogOpen] = useState(false); + const [pendingRecommendation, setPendingRecommendation] = useState<{ + shortLabel: string; + fullPrompt: string; + projectType: ProjectType; + projectName: string; + } | null>(null); + + // 带参数的页面导航 + const handleNavigate = useCallback((page: Page, params?: PageParams) => { + setCurrentPage(page); + if (params) { + setPageParams(params); + } else { + setPageParams({}); + } + }, []); + + // 推荐标签点击处理 - 打开创建项目对话框 + const _handleRequestRecommendation = useCallback( + (shortLabel: string, fullPrompt: string, currentTheme: string) => { + // 主题标签映射 + const themeLabels: Record = { + "social-media": "社媒", + poster: "海报", + music: "音乐", + knowledge: "知识", + planning: "计划", + novel: "小说", + document: "文档", + video: "视频", + general: "对话", + }; + + const prefix = themeLabels[currentTheme] || "项目"; + const projectName = `${prefix}:${shortLabel}`; + + setPendingRecommendation({ + shortLabel, + fullPrompt, + projectType: currentTheme as ProjectType, + projectName, + }); + setProjectDialogOpen(true); + }, + [], + ); + + // 创建项目并创建初始内容 + const handleCreateProjectFromRecommendation = async ( + name: string, + type: ProjectType, + ) => { + // 选择项目目录 + const selectedPath = await open({ + directory: true, + title: "选择项目目录", + }); + + if (!selectedPath) { + throw new Error("用户取消选择目录"); + } + + const projectPath = Array.isArray(selectedPath) + ? selectedPath.length === 1 + ? selectedPath[0] + : null + : selectedPath; + + if (!projectPath) { + throw new Error("请选择单个项目目录"); + } + + // 创建项目 + const project = await createProject({ + name, + rootPath: projectPath, + workspaceType: type, + }); + + // 如果有待处理的推荐内容,创建初始 Content + if (pendingRecommendation) { + const content = await createContent({ + project_id: project.id, + title: name, + body: pendingRecommendation.fullPrompt, + }); + + // 导航到 Agent 页面 + handleNavigate("agent", { + projectId: project.id, + contentId: content.id, + }); + + // 清除待处理的推荐 + setPendingRecommendation(null); + } else { + // 没有初始内容,直接导航到项目页面 + handleNavigate("projects"); + } + + toast.success("项目创建成功"); + }; + // Deep Link 处理 Hook // _Requirements: 5.2_ const { @@ -168,9 +280,17 @@ function AppContent() { flexDirection: "column", }} > - + + {/* 项目页面 */} + + + + {/* 终端工作区 - 使用 div 包装以支持显示/隐藏 */}
- + {renderAllPages()} {/* ProxyCast Connect 确认弹窗 */} {/* _Requirements: 5.2_ */} @@ -259,6 +379,20 @@ function AppContent() { onConfirm={handleConfirm} onCancel={handleCancel} /> + {/* 创建项目对话框 - 用于推荐标签引导创建 */} + { + setProjectDialogOpen(open); + if (!open) { + // 用户取消,清除待处理的推荐 + setPendingRecommendation(null); + } + }} + onSubmit={handleCreateProjectFromRecommendation} + defaultType={pendingRecommendation?.projectType} + defaultName={pendingRecommendation?.projectName} + /> {/* 组件视图调试覆盖层 */} diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index f99971cf9..53934a8d2 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -24,15 +24,16 @@ import { Activity, Terminal, Image, + FolderKanban, LucideIcon, } from "lucide-react"; import * as LucideIcons from "lucide-react"; import { getPluginsForSurface, PluginUIInfo } from "@/lib/api/pluginUI"; -import { Page } from "@/types/page"; +import { Page, PageParams } from "@/types/page"; interface AppSidebarProps { currentPage: Page; - onNavigate: (page: Page) => void; + onNavigate: (page: Page, params?: PageParams) => void; } const Container = styled.div` @@ -122,6 +123,7 @@ const IconButton = styled.button<{ $active?: boolean }>` const mainMenuItems: { id: Page; label: string; icon: typeof Bot }[] = [ { id: "agent", label: "AI Agent", icon: Bot }, + { id: "projects", label: "项目", icon: FolderKanban }, { id: "image-gen", label: "图片生成", icon: Image }, { id: "api-server", label: "API Server", icon: Globe }, { id: "provider-pool", label: "凭证池", icon: Database }, diff --git a/src/components/agent/chat/components/ArtifactPlaceholder.tsx b/src/components/agent/chat/components/ArtifactPlaceholder.tsx new file mode 100644 index 000000000..15a231466 --- /dev/null +++ b/src/components/agent/chat/components/ArtifactPlaceholder.tsx @@ -0,0 +1,145 @@ +/** + * @file Artifact 占位符组件 + * @description 在聊天消息中显示代码块的占位符卡片,点击可在画布中查看 + * @module components/agent/chat/components/ArtifactPlaceholder + */ + +import React, { memo } from "react"; +import { FileCode, ExternalLink } from "lucide-react"; +import styled from "styled-components"; + +const PlaceholderCard = styled.div` + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + margin: 8px 0; + background: hsl(var(--muted) / 0.5); + border: 1px solid hsl(var(--border)); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: hsl(var(--muted)); + border-color: hsl(var(--primary) / 0.5); + } +`; + +const IconWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: hsl(var(--primary) / 0.1); + border-radius: 8px; + color: hsl(var(--primary)); +`; + +const ContentWrapper = styled.div` + flex: 1; + min-width: 0; +`; + +const Title = styled.div` + font-size: 14px; + font-weight: 500; + color: hsl(var(--foreground)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const Subtitle = styled.div` + font-size: 12px; + color: hsl(var(--muted-foreground)); + margin-top: 2px; +`; + +const ActionIcon = styled.div` + color: hsl(var(--muted-foreground)); + transition: color 0.2s; + + ${PlaceholderCard}:hover & { + color: hsl(var(--primary)); + } +`; + +interface ArtifactPlaceholderProps { + /** 语言类型 */ + language: string; + /** 代码行数 */ + lineCount?: number; + /** 点击回调 */ + onClick?: () => void; +} + +/** + * 获取语言显示名称 + */ +function getLanguageDisplayName(language: string): string { + const displayNames: Record = { + javascript: "JavaScript", + typescript: "TypeScript", + python: "Python", + rust: "Rust", + go: "Go", + java: "Java", + cpp: "C++", + c: "C", + csharp: "C#", + ruby: "Ruby", + php: "PHP", + swift: "Swift", + kotlin: "Kotlin", + html: "HTML", + css: "CSS", + scss: "SCSS", + json: "JSON", + yaml: "YAML", + xml: "XML", + markdown: "Markdown", + sql: "SQL", + shell: "Shell", + bash: "Bash", + tsx: "TypeScript React", + jsx: "JavaScript React", + vue: "Vue", + svelte: "Svelte", + }; + + const lower = language.toLowerCase(); + return displayNames[lower] || language.toUpperCase(); +} + +/** + * Artifact 占位符组件 + * 在聊天消息中显示代码块的简洁卡片 + */ +export const ArtifactPlaceholder: React.FC = memo( + ({ language, lineCount, onClick }) => { + const displayName = getLanguageDisplayName(language); + + return ( + + + + + + {displayName} 代码 + + {lineCount ? `${lineCount} 行` : "点击在画布中查看"} + + + + + + + ); + }, +); + +ArtifactPlaceholder.displayName = "ArtifactPlaceholder"; + +export default ArtifactPlaceholder; diff --git a/src/components/agent/chat/components/ChatSidebar.tsx b/src/components/agent/chat/components/ChatSidebar.tsx index 9a0cd1c76..785d3eb17 100644 --- a/src/components/agent/chat/components/ChatSidebar.tsx +++ b/src/components/agent/chat/components/ChatSidebar.tsx @@ -234,6 +234,7 @@ interface ChatSidebarProps { currentTopicId: string | null; onSwitchTopic: (topicId: string) => void; onDeleteTopic: (topicId: string) => void; + onRenameTopic?: (topicId: string, newTitle: string) => void; } export const ChatSidebar: React.FC = ({ @@ -242,11 +243,15 @@ export const ChatSidebar: React.FC = ({ currentTopicId, onSwitchTopic, onDeleteTopic, + onRenameTopic, }) => { const [activeTab, setActiveTab] = useState<"skills" | "topics">("topics"); const [skills, setSkills] = useState([]); const [loadingSkills, setLoadingSkills] = useState(false); const [actionLoading, setActionLoading] = useState(null); + const [editingTopicId, setEditingTopicId] = useState(null); + const [editTitle, setEditTitle] = useState(""); + const editInputRef = React.useRef(null); const loadSkills = async () => { setLoadingSkills(true); @@ -308,6 +313,49 @@ export const ChatSidebar: React.FC = ({ onDeleteTopic(topicId); }; + // 开始编辑标题 + const handleStartEdit = ( + e: React.MouseEvent, + topicId: string, + currentTitle: string, + ) => { + e.stopPropagation(); + setEditingTopicId(topicId); + setEditTitle(currentTitle); + }; + + // 保存编辑的标题 + const handleSaveEdit = () => { + if (editingTopicId && editTitle.trim() && onRenameTopic) { + onRenameTopic(editingTopicId, editTitle.trim()); + } + setEditingTopicId(null); + setEditTitle(""); + }; + + // 取消编辑 + const handleCancelEdit = () => { + setEditingTopicId(null); + setEditTitle(""); + }; + + // 处理输入框键盘事件 + const handleEditKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSaveEdit(); + } else if (e.key === "Escape") { + handleCancelEdit(); + } + }; + + // 当编辑状态变化时,自动聚焦输入框 + React.useEffect(() => { + if (editingTopicId && editInputRef.current) { + editInputRef.current.focus(); + editInputRef.current.select(); + } + }, [editingTopicId]); + const installedSkills = skills.filter((s) => s.installed); const availableSkills = skills.filter((s) => !s.installed); @@ -350,7 +398,14 @@ export const ChatSidebar: React.FC = ({ onSwitchTopic(topic.id)} + onClick={() => { + if (editingTopicId !== topic.id) { + onSwitchTopic(topic.id); + } + }} + onDoubleClick={(e) => + handleStartEdit(e, topic.id, topic.title) + } > = ({ : "opacity-50" } /> - {topic.title} - + {editingTopicId === topic.id ? ( + setEditTitle(e.target.value)} + onKeyDown={handleEditKeyDown} + onBlur={handleSaveEdit} + onClick={(e) => e.stopPropagation()} + style={{ + flex: 1, + fontSize: "13px", + padding: "2px 6px", + border: "1px solid hsl(var(--primary))", + borderRadius: "4px", + outline: "none", + }} + /> + ) : ( + {topic.title} + )} + {editingTopicId !== topic.id && ( + + )} )) )} diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index 45c73e463..c4dd86fbe 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -346,17 +346,23 @@ interface EmptyStateProps { activeTheme?: string; /** 主题变更回调 */ onThemeChange?: (theme: string) => void; + /** 推荐标签点击回调 */ + onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void; } -// Scenarios Configuration +// Scenarios Configuration - 与 ProjectType 统一 const CATEGORIES = [ { id: "general", label: "通用对话", icon: , }, - { id: "social", label: "社媒内容", icon: }, - { id: "image", label: "图文海报", icon: }, + { + id: "social-media", + label: "社媒内容", + icon: , + }, + { id: "poster", label: "图文海报", icon: }, { id: "music", label: "歌词曲谱", icon: }, { id: "knowledge", @@ -368,12 +374,158 @@ const CATEGORIES = [ label: "计划规划", icon: , }, - { id: "office", label: "办公文档", icon: }, + { id: "document", label: "办公文档", icon: }, { id: "video", label: "短视频", icon: