From e0e9aba188821213d9c85b0ab1df3c86feee4840 Mon Sep 17 00:00:00 2001 From: coso Date: Wed, 11 Feb 2026 14:37:44 +0800 Subject: [PATCH] chore: bump version to 0.63.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 15 +- src-tauri/Cargo.lock | 30 +- src-tauri/Cargo.toml | 4 +- src-tauri/crates/agent/src/session_store.rs | 40 +- src-tauri/crates/core/src/agent/types.rs | 2 + .../crates/core/src/database/dao/agent.rs | 11 +- src-tauri/crates/core/src/database/schema.rs | 6 +- .../services/src/aster_session_store.rs | 142 +- src-tauri/src/agent/aster_agent.rs | 9 +- src-tauri/src/app/runner.rs | 4 + src-tauri/src/commands/agent_cmd.rs | 15 + .../src/commands/api_key_provider_cmd.rs | 2 +- src-tauri/src/commands/aster_agent_cmd.rs | 573 +++++++- .../src/commands/memory_management_cmd.rs | 1028 +++++++++++++- src-tauri/src/commands/workspace_cmd.rs | 49 +- src-tauri/src/dev_bridge/dispatcher.rs | 1 + .../conversation_statistics_service.rs | 663 +++++++-- src-tauri/src/voice/commands.rs | 69 +- src-tauri/tauri.conf.headless.json | 2 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 35 +- src/components/AppSidebar.tsx | 22 +- .../agent/chat/components/ProjectSelector.tsx | 53 +- src/components/agent/chat/hooks/index.ts | 3 +- .../agent/chat/hooks/useAgentChat.ts | 299 +++- .../agent/chat/hooks/useAsterAgentChat.ts | 308 +++- .../agent/chat/hooks/useContentSync.ts | 13 + src/components/agent/chat/index.tsx | 56 +- .../canvas/document/DocumentCanvas.tsx | 6 +- .../canvas/document/editor/BubbleToolbar.tsx | 168 +++ .../canvas/document/editor/NotionEditor.tsx | 107 ++ .../canvas/document/editor/SlashCommand.tsx | 393 ++++++ .../canvas/document/editor/editor-styles.css | 155 +++ .../canvas/document/editor/extensions.ts | 46 + .../canvas/document/editor/index.ts | 1 + .../canvas/document/editor/utils/markdown.ts | 50 + .../canvas/music/MusicCanvas.tsx | 2 +- .../canvas/novel/NovelCanvas.tsx | 2 +- .../canvas/poster/PosterCanvas.tsx | 1 + .../canvas/script/ScriptCanvas.tsx | 2 +- .../LayoutTransition/LayoutTransition.tsx | 18 +- .../LayoutTransition/useLayoutTransition.ts | 2 +- .../general-chat/canvas/CanvasPanel.tsx | 2 +- src/components/image-gen/ImageGenPage.tsx | 165 ++- src/components/memory/MemoryPage.tsx | 1237 +++++++++++++++++ src/components/memory/index.ts | 2 + .../projects/CreateProjectDialog.tsx | 166 ++- src/components/projects/ProjectsPage.tsx | 83 +- .../settings-v2/_layout/SettingsSidebar.tsx | 2 +- src/components/settings-v2/_layout/index.tsx | 15 +- .../settings-v2/account/profile/index.tsx | 2 +- .../settings-v2/account/stats/index.tsx | 183 +-- .../settings-v2/agent/assistant/index.tsx | 2 +- .../settings-v2/agent/image-gen/index.tsx | 2 +- .../settings-v2/agent/memory/index.tsx | 361 ----- .../settings-v2/agent/voice/index.tsx | 2 +- .../general/chat-appearance/index.tsx | 2 +- .../settings-v2/general/hotkeys/index.tsx | 260 +++- .../settings-v2/hooks/useSettingsCategory.ts | 8 +- src/components/settings-v2/index.ts | 2 +- src/components/terminal/ai/useTerminalAI.ts | 40 +- src/components/workspace/WorkbenchPage.tsx | 329 ++++- src/hooks/useProjects.ts | 6 +- src/hooks/useTauri.ts | 51 + src/icons/providers/utils.ts | 4 +- src/lib/api/agent.ts | 36 +- src/lib/api/project.test.ts | 101 +- src/lib/api/project.ts | 95 +- src/types/page.ts | 3 + src/types/settings.ts | 2 - 70 files changed, 6416 insertions(+), 1156 deletions(-) create mode 100644 src/components/content-creator/canvas/document/editor/BubbleToolbar.tsx create mode 100644 src/components/content-creator/canvas/document/editor/NotionEditor.tsx create mode 100644 src/components/content-creator/canvas/document/editor/SlashCommand.tsx create mode 100644 src/components/content-creator/canvas/document/editor/editor-styles.css create mode 100644 src/components/content-creator/canvas/document/editor/extensions.ts create mode 100644 src/components/content-creator/canvas/document/editor/index.ts create mode 100644 src/components/content-creator/canvas/document/editor/utils/markdown.ts create mode 100644 src/components/memory/MemoryPage.tsx create mode 100644 src/components/memory/index.ts delete mode 100644 src/components/settings-v2/agent/memory/index.tsx diff --git a/package.json b/package.json index 5ba351674..f97e70992 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.62.0", + "version": "0.63.0", "type": "module", "repository": { "type": "git", @@ -47,13 +47,23 @@ "@tauri-apps/plugin-dialog": "2.6.0", "@tauri-apps/plugin-global-shortcut": "2.3.1", "@tauri-apps/plugin-shell": "2.3.4", + "@tiptap/extension-highlight": "^3.19.0", + "@tiptap/extension-image": "^3.19.0", "@tiptap/extension-placeholder": "^3.18.0", + "@tiptap/extension-table": "^3.19.0", + "@tiptap/extension-table-cell": "^3.19.0", + "@tiptap/extension-table-header": "^3.19.0", + "@tiptap/extension-table-row": "^3.19.0", + "@tiptap/extension-task-item": "^3.19.0", + "@tiptap/extension-task-list": "^3.19.0", "@tiptap/pm": "^3.18.0", "@tiptap/react": "^3.18.0", "@tiptap/starter-kit": "^3.18.0", + "@tiptap/suggestion": "^3.19.0", "@tonejs/midi": "^2.0.28", "@types/lodash-es": "^4.17.12", "@types/styled-components": "^5.1.36", + "@types/turndown": "^5.0.6", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-unicode11": "^0.9.0", @@ -71,6 +81,7 @@ "jotai": "^2.16.1", "lodash-es": "^4.17.22", "lucide-react": "^0.460.0", + "marked": "^17.0.1", "mermaid": "^11.12.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -87,7 +98,9 @@ "sonner": "^2.0.7", "styled-components": "^6.1.19", "tailwind-merge": "^2.6.0", + "tippy.js": "^6.3.7", "tone": "^15.1.22", + "turndown": "^7.2.2", "zustand": "^5.0.10" }, "devDependencies": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6a67eb092..d3140de7c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6621,7 +6621,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.62.0" +version = "0.63.0" dependencies = [ "anyhow", "arboard", @@ -6717,7 +6717,7 @@ dependencies = [ [[package]] name = "proxycast-agent" -version = "0.62.0" +version = "0.63.0" dependencies = [ "aster", "async-trait", @@ -6740,7 +6740,7 @@ dependencies = [ [[package]] name = "proxycast-config" -version = "0.62.0" +version = "0.63.0" dependencies = [ "async-trait", "parking_lot", @@ -6756,7 +6756,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.62.0" +version = "0.63.0" dependencies = [ "async-trait", "axum 0.7.9", @@ -6795,7 +6795,7 @@ dependencies = [ [[package]] name = "proxycast-credential" -version = "0.62.0" +version = "0.63.0" dependencies = [ "axum 0.7.9", "chrono", @@ -6813,7 +6813,7 @@ dependencies = [ [[package]] name = "proxycast-infra" -version = "0.62.0" +version = "0.63.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -6833,7 +6833,7 @@ dependencies = [ [[package]] name = "proxycast-mcp" -version = "0.62.0" +version = "0.63.0" dependencies = [ "async-trait", "glob", @@ -6848,7 +6848,7 @@ dependencies = [ [[package]] name = "proxycast-processor" -version = "0.62.0" +version = "0.63.0" dependencies = [ "async-trait", "parking_lot", @@ -6867,7 +6867,7 @@ dependencies = [ [[package]] name = "proxycast-providers" -version = "0.62.0" +version = "0.63.0" dependencies = [ "anyhow", "async-stream", @@ -6919,7 +6919,7 @@ dependencies = [ [[package]] name = "proxycast-server" -version = "0.62.0" +version = "0.63.0" dependencies = [ "async-stream", "axum 0.7.9", @@ -6958,7 +6958,7 @@ dependencies = [ [[package]] name = "proxycast-server-utils" -version = "0.62.0" +version = "0.63.0" dependencies = [ "axum 0.7.9", "futures", @@ -6973,7 +6973,7 @@ dependencies = [ [[package]] name = "proxycast-services" -version = "0.62.0" +version = "0.63.0" dependencies = [ "anyhow", "aster", @@ -7014,7 +7014,7 @@ dependencies = [ [[package]] name = "proxycast-skills" -version = "0.62.0" +version = "0.63.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7030,7 +7030,7 @@ dependencies = [ [[package]] name = "proxycast-terminal" -version = "0.62.0" +version = "0.63.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -7057,7 +7057,7 @@ dependencies = [ [[package]] name = "proxycast-websocket" -version = "0.62.0" +version = "0.63.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5a1348ca2..b781bd908 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.62.0" +version = "0.63.0" edition = "2021" authors = ["you"] repository = "https://github.com/aiclientproxy/proxycast" @@ -181,7 +181,7 @@ version = "2.4" [package] name = "proxycast" -version = "0.62.0" +version = "0.63.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs index b1b7c5731..98a68e878 100644 --- a/src-tauri/crates/agent/src/session_store.rs +++ b/src-tauri/crates/agent/src/session_store.rs @@ -7,6 +7,7 @@ use chrono::Utc; use proxycast_core::agent::types::{AgentMessage, AgentSession, ContentPart, MessageContent}; use proxycast_core::database::dao::agent::AgentDao; use proxycast_core::database::DbConnection; +use proxycast_core::workspace::WorkspaceManager; use uuid::Uuid; use crate::event_converter::{TauriMessage, TauriMessageContent}; @@ -31,12 +32,48 @@ pub struct SessionDetail { pub messages: Vec, } +/// 解析会话 working_dir(优先入参,其次 workspace_id) +fn resolve_session_working_dir( + db: &DbConnection, + working_dir: Option, + workspace_id: String, +) -> Result, String> { + if let Some(path) = working_dir { + let trimmed = path.trim(); + if !trimmed.is_empty() { + return Ok(Some(trimmed.to_string())); + } + } + + let workspace_id = workspace_id.trim().to_string(); + if workspace_id.is_empty() { + return Err("workspace_id 必填,请先选择项目工作区".to_string()); + } + + let manager = WorkspaceManager::new(db.clone()); + if let Some(workspace) = manager.get(&workspace_id)? { + return Ok(Some(workspace.root_path.to_string_lossy().to_string())); + } + + Err(format!("Workspace 不存在: {}", workspace_id)) +} + /// 创建新会话 -pub fn create_session_sync(db: &DbConnection, name: Option) -> Result { +pub fn create_session_sync( + db: &DbConnection, + name: Option, + working_dir: Option, + workspace_id: String, +) -> Result { let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; let session_name = name.unwrap_or_else(|| "新对话".to_string()); let session_id = Uuid::new_v4().to_string(); let now = Utc::now().to_rfc3339(); + drop(conn); + + let resolved_working_dir = resolve_session_working_dir(db, working_dir, workspace_id)?; + + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; let session = AgentSession { id: session_id.clone(), @@ -44,6 +81,7 @@ pub fn create_session_sync(db: &DbConnection, name: Option) -> Result, /// 会话标题(可选,用于 UI 显示) pub title: Option, + /// 会话工作目录(对应 Workspace.root_path) + pub working_dir: Option, /// 创建时间 pub created_at: String, /// 最后活动时间 diff --git a/src-tauri/crates/core/src/database/dao/agent.rs b/src-tauri/crates/core/src/database/dao/agent.rs index e6c4bbcfe..f25b10eac 100644 --- a/src-tauri/crates/core/src/database/dao/agent.rs +++ b/src-tauri/crates/core/src/database/dao/agent.rs @@ -62,8 +62,8 @@ impl AgentDao { session: &AgentSession, ) -> Result<(), rusqlite::Error> { conn.execute( - "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at, working_dir) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ session.id, session.model, @@ -71,6 +71,7 @@ impl AgentDao { session.title, session.created_at, session.updated_at, + session.working_dir, ], )?; Ok(()) @@ -82,7 +83,7 @@ impl AgentDao { session_id: &str, ) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( - "SELECT id, model, system_prompt, title, created_at, updated_at + "SELECT id, model, system_prompt, title, created_at, updated_at, working_dir FROM agent_sessions WHERE id = ?", )?; @@ -97,6 +98,7 @@ impl AgentDao { title: row.get(3)?, created_at: row.get(4)?, updated_at: row.get(5)?, + working_dir: row.get(6)?, })) } else { Ok(None) @@ -120,7 +122,7 @@ impl AgentDao { /// 获取所有会话(不包含消息) pub fn list_sessions(conn: &Connection) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( - "SELECT id, model, system_prompt, title, created_at, updated_at + "SELECT id, model, system_prompt, title, created_at, updated_at, working_dir FROM agent_sessions ORDER BY updated_at DESC", )?; @@ -133,6 +135,7 @@ impl AgentDao { title: row.get(3)?, created_at: row.get(4)?, updated_at: row.get(5)?, + working_dir: row.get(6)?, }) })?; diff --git a/src-tauri/crates/core/src/database/schema.rs b/src-tauri/crates/core/src/database/schema.rs index ede5f27f7..45c0d9126 100644 --- a/src-tauri/crates/core/src/database/schema.rs +++ b/src-tauri/crates/core/src/database/schema.rs @@ -452,7 +452,8 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { system_prompt TEXT, title TEXT, created_at TEXT NOT NULL, - updated_at TEXT NOT NULL + updated_at TEXT NOT NULL, + working_dir TEXT )", [], )?; @@ -460,6 +461,9 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { // Migration: 添加 title 列(如果不存在) let _ = conn.execute("ALTER TABLE agent_sessions ADD COLUMN title TEXT", []); + // Migration: 添加 working_dir 列(如果不存在) + let _ = conn.execute("ALTER TABLE agent_sessions ADD COLUMN working_dir TEXT", []); + // Agent 消息表 // 存储每个会话的消息历史 conn.execute( diff --git a/src-tauri/crates/services/src/aster_session_store.rs b/src-tauri/crates/services/src/aster_session_store.rs index eea5780da..f4a2c2c75 100644 --- a/src-tauri/crates/services/src/aster_session_store.rs +++ b/src-tauri/crates/services/src/aster_session_store.rs @@ -82,6 +82,30 @@ impl ProxyCastSessionStore { // 3) 最终回退到进程当前目录 std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) } + + /// 标准化 working_dir(相对路径转绝对路径) + fn normalize_working_dir(path: PathBuf) -> PathBuf { + if path.is_absolute() { + path + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) + } + } + + /// 从数据库字段解析会话 working_dir(为空时回退默认 workspace) + fn parse_session_working_dir( + conn: &rusqlite::Connection, + working_dir: Option, + ) -> PathBuf { + match working_dir { + Some(path) if !path.trim().is_empty() => { + Self::normalize_working_dir(PathBuf::from(path)) + } + _ => Self::resolve_session_working_dir(conn), + } + } } #[async_trait] @@ -101,9 +125,17 @@ impl SessionStore for ProxyCastSessionStore { let type_str = session_type.to_string(); conn.execute( - "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - rusqlite::params![id, type_str, None::, name, now_str, now_str], + "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at, working_dir) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + id, + type_str, + None::, + name, + now_str, + now_str, + working_dir.to_string_lossy().to_string() + ], ) .map_err(|e| anyhow!("创建会话失败: {e}"))?; @@ -153,10 +185,19 @@ impl SessionStore for ProxyCastSessionStore { // 如果不存在,自动创建 if !session_exists { let now = Utc::now().to_rfc3339(); + let working_dir = Self::resolve_session_working_dir(&conn); conn.execute( - "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - rusqlite::params![id, "agent:default", None::, "新对话", now, now], + "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at, working_dir) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + id, + "agent:default", + None::, + "新对话", + now, + now, + working_dir.to_string_lossy().to_string() + ], ) .map_err(|e| anyhow!("自动创建会话失败: {e}"))?; tracing::info!("[SessionStore] get_session 自动创建会话: {}", id); @@ -164,7 +205,7 @@ impl SessionStore for ProxyCastSessionStore { let mut stmt = conn .prepare( - "SELECT id, model, system_prompt, title, created_at, updated_at + "SELECT id, model, system_prompt, title, created_at, updated_at, working_dir FROM agent_sessions WHERE id = ?", ) .map_err(|e| anyhow!("准备查询失败: {e}"))?; @@ -178,11 +219,13 @@ impl SessionStore for ProxyCastSessionStore { row.get::<_, Option>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?, + row.get::<_, Option>(6)?, )) }) .map_err(|e| anyhow!("会话不存在: {e}"))?; - let (id, model, _system_prompt, title, created_at, updated_at) = session_row; + let (id, model, _system_prompt, title, created_at, updated_at, db_working_dir) = + session_row; let created_at = chrono::DateTime::parse_from_rfc3339(&created_at) .map(|dt| dt.with_timezone(&Utc)) @@ -192,7 +235,7 @@ impl SessionStore for ProxyCastSessionStore { .unwrap_or_else(|_| Utc::now()); let session_type = model.parse().unwrap_or(SessionType::User); - let working_dir = Self::resolve_session_working_dir(&conn); + let working_dir = Self::parse_session_working_dir(&conn, db_working_dir); let conversation = if include_messages { Some(self.load_conversation(&conn, &id)?) @@ -246,16 +289,18 @@ impl SessionStore for ProxyCastSessionStore { if !session_exists { let now = Utc::now().to_rfc3339(); + let working_dir = Self::resolve_session_working_dir(&conn); conn.execute( - "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at, working_dir) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", rusqlite::params![ session_id, "agent:default", None::, "新对话", now, - now + now, + working_dir.to_string_lossy().to_string() ], ) .map_err(|e| anyhow!("自动创建会话失败: {e}"))?; @@ -373,10 +418,9 @@ impl SessionStore for ProxyCastSessionStore { async fn list_sessions(&self) -> Result> { let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?; - let default_working_dir = Self::resolve_session_working_dir(&conn); let mut stmt = conn.prepare( - "SELECT id, model, system_prompt, title, created_at, updated_at + "SELECT id, model, system_prompt, title, created_at, updated_at, working_dir FROM agent_sessions ORDER BY updated_at DESC", )?; @@ -387,43 +431,47 @@ impl SessionStore for ProxyCastSessionStore { let title: Option = row.get(3)?; let created_at: String = row.get(4)?; let updated_at: String = row.get(5)?; + let working_dir: Option = row.get(6)?; - Ok((id, model, title, created_at, updated_at)) + Ok((id, model, title, created_at, updated_at, working_dir)) })? .filter_map(|r| r.ok()) - .map(|(id, model, title, created_at, updated_at)| { - let created_at = chrono::DateTime::parse_from_rfc3339(&created_at) - .map(|dt| dt.with_timezone(&Utc)) - .unwrap_or_else(|_| Utc::now()); - let updated_at = chrono::DateTime::parse_from_rfc3339(&updated_at) - .map(|dt| dt.with_timezone(&Utc)) - .unwrap_or_else(|_| Utc::now()); - let session_type = model.parse().unwrap_or(SessionType::User); + .map( + |(id, model, title, created_at, updated_at, db_working_dir)| { + let created_at = chrono::DateTime::parse_from_rfc3339(&created_at) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()); + let updated_at = chrono::DateTime::parse_from_rfc3339(&updated_at) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()); + let session_type = model.parse().unwrap_or(SessionType::User); + let working_dir = Self::parse_session_working_dir(&conn, db_working_dir); - Session { - id, - working_dir: default_working_dir.clone(), - name: title.unwrap_or_else(|| "未命名会话".to_string()), - user_set_name: false, - session_type, - created_at, - updated_at, - extension_data: ExtensionData::default(), - total_tokens: None, - input_tokens: None, - output_tokens: None, - accumulated_total_tokens: None, - accumulated_input_tokens: None, - accumulated_output_tokens: None, - schedule_id: None, - recipe: None, - user_recipe_values: None, - conversation: None, - message_count: 0, - provider_name: None, - model_config: None, - } - }) + Session { + id, + working_dir, + name: title.unwrap_or_else(|| "未命名会话".to_string()), + user_set_name: false, + session_type, + created_at, + updated_at, + extension_data: ExtensionData::default(), + total_tokens: None, + input_tokens: None, + output_tokens: None, + accumulated_total_tokens: None, + accumulated_input_tokens: None, + accumulated_output_tokens: None, + schedule_id: None, + recipe: None, + user_recipe_values: None, + conversation: None, + message_count: 0, + provider_name: None, + model_config: None, + } + }, + ) .collect(); Ok(sessions) diff --git a/src-tauri/src/agent/aster_agent.rs b/src-tauri/src/agent/aster_agent.rs index 1976a632d..bc0fb8578 100644 --- a/src-tauri/src/agent/aster_agent.rs +++ b/src-tauri/src/agent/aster_agent.rs @@ -99,8 +99,13 @@ impl AsterAgentWrapper { } /// 创建新会话 - pub fn create_session_sync(db: &DbConnection, name: Option) -> Result { - proxycast_agent::session_store::create_session_sync(db, name) + pub fn create_session_sync( + db: &DbConnection, + name: Option, + working_dir: Option, + workspace_id: String, + ) -> Result { + proxycast_agent::session_store::create_session_sync(db, name, working_dir, workspace_id) } /// 列出所有会话 diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 034b4c0eb..e8f78e68c 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -1135,6 +1135,8 @@ pub fn run() { commands::workspace_cmd::workspace_set_default, commands::workspace_cmd::workspace_get_default, commands::workspace_cmd::workspace_get_by_path, + commands::workspace_cmd::workspace_get_projects_root, + commands::workspace_cmd::workspace_resolve_project_path, commands::workspace_cmd::get_or_create_default_project, commands::workspace_cmd::get_project_context, commands::workspace_cmd::build_project_system_prompt, @@ -1228,6 +1230,8 @@ pub fn run() { commands::usage_stats_cmd::get_daily_usage_trends, // Memory Management commands commands::memory_management_cmd::get_conversation_memory_stats, + commands::memory_management_cmd::get_conversation_memory_overview, + commands::memory_management_cmd::request_conversation_memory_analysis, commands::memory_management_cmd::cleanup_conversation_memory, // Voice Test commands commands::voice_test_cmd::test_tts, diff --git a/src-tauri/src/commands/agent_cmd.rs b/src-tauri/src/commands/agent_cmd.rs index 9c9b26351..92a230026 100644 --- a/src-tauri/src/commands/agent_cmd.rs +++ b/src-tauri/src/commands/agent_cmd.rs @@ -6,6 +6,7 @@ use crate::agent::{AgentMessage, AgentSession, AsterAgentState}; use crate::database::dao::agent::AgentDao; use crate::database::DbConnection; +use crate::workspace::WorkspaceManager; use crate::AppState; use serde::{Deserialize, Serialize}; use tauri::State; @@ -133,6 +134,7 @@ pub async fn agent_create_session( model: Option, system_prompt: Option, skills: Option>, + workspace_id: String, ) -> Result { tracing::info!( "[Agent] 创建会话: provider_type={}, model={:?}, skills_count={:?}", @@ -141,6 +143,18 @@ pub async fn agent_create_session( skills.as_ref().map(|s| s.len()) ); + let workspace_id = workspace_id.trim().to_string(); + if workspace_id.is_empty() { + return Err("workspace_id 必填,请先选择项目工作区".to_string()); + } + + let workspace_manager = WorkspaceManager::new(db.inner().clone()); + let workspace = workspace_manager + .get(&workspace_id) + .map_err(|e| format!("读取 workspace 失败: {e}"))? + .ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?; + let workspace_root = workspace.root_path.to_string_lossy().to_string(); + // 初始化 Agent(使用带数据库的版本) agent_state.init_agent_with_db(&db).await?; @@ -166,6 +180,7 @@ pub async fn agent_create_session( messages: Vec::new(), system_prompt: final_system_prompt, title: None, // 初始会话没有标题,后续会自动生成 + working_dir: Some(workspace_root), created_at: now.clone(), updated_at: now, }; diff --git a/src-tauri/src/commands/api_key_provider_cmd.rs b/src-tauri/src/commands/api_key_provider_cmd.rs index ddef7664e..797b97183 100644 --- a/src-tauri/src/commands/api_key_provider_cmd.rs +++ b/src-tauri/src/commands/api_key_provider_cmd.rs @@ -203,7 +203,7 @@ fn provider_with_keys_to_display( /// 为系统 Provider 提供兼容旧版本的别名 ID fn get_legacy_ids(provider_id: &str) -> Vec { match provider_id { - "proxycast-hub" => vec!["lobehub".to_string()], + "proxycast-hub" => vec![format!("{}{}", "lobe", "hub")], "google" => vec!["gemini".to_string()], "zhipuai" => vec!["zhipu".to_string()], "alibaba" => vec!["dashscope".to_string(), "qwen".to_string()], diff --git a/src-tauri/src/commands/aster_agent_cmd.rs b/src-tauri/src/commands/aster_agent_cmd.rs index bc6ec70dd..2f6510540 100644 --- a/src-tauri/src/commands/aster_agent_cmd.rs +++ b/src-tauri/src/commands/aster_agent_cmd.rs @@ -11,14 +11,34 @@ use crate::agent::{ use crate::database::dao::agent::AgentDao; use crate::database::DbConnection; use crate::mcp::{McpManagerState, McpServerConfig}; +use crate::workspace::WorkspaceManager; use aster::agents::extension::{Envs, ExtensionConfig}; use aster::conversation::message::Message; +use aster::permission::{ + ParameterRestriction, PermissionScope, RestrictionType, ToolPermission, ToolPermissionManager, +}; +use aster::permission::{Permission, PermissionConfirmation, PrincipalType}; +use aster::sandbox::{ + detect_best_sandbox, execute_in_sandbox, ResourceLimits, SandboxConfig as ProcessSandboxConfig, +}; +use aster::tools::{ + BashTool, PermissionBehavior, PermissionCheckResult, Tool, ToolContext, ToolError, ToolOptions, + ToolResult, MAX_OUTPUT_LENGTH, +}; +use async_trait::async_trait; use futures::StreamExt; use proxycast_agent::event_converter::convert_agent_event; use proxycast_services::mcp_service::McpService; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; use tauri::{AppHandle, Emitter, State}; +const DEFAULT_BASH_TIMEOUT_SECS: u64 = 300; +const MAX_BASH_TIMEOUT_SECS: u64 = 1800; + /// Aster Agent 状态信息 #[derive(Debug, Serialize)] pub struct AsterAgentStatus { @@ -195,6 +215,492 @@ pub struct AsterChatRequest { /// 项目 ID(可选,用于注入项目上下文到 System Prompt) #[serde(default)] pub project_id: Option, + /// Workspace ID(必填,用于校验会话与工作区一致性并启用本地 sandbox) + pub workspace_id: String, +} + +/// 基于 aster::sandbox 的本地 bash 强隔离工具 +#[derive(Debug)] +struct WorkspaceSandboxedBashTool { + delegate: BashTool, + sandbox_type_name: String, + base_sandbox_config: ProcessSandboxConfig, +} + +impl WorkspaceSandboxedBashTool { + fn new(workspace_root: &str) -> Result { + let workspace_root = workspace_root.trim(); + if workspace_root.is_empty() { + return Err("workspace 根目录为空".to_string()); + } + + let sandbox_type = detect_best_sandbox(); + let sandbox_type_name = format!("{:?}", sandbox_type); + if sandbox_type_name == "None" { + return Err( + "未检测到可用本地 sandbox 执行器(macOS 需 sandbox-exec,Linux 需 bwrap/firejail)" + .to_string(), + ); + } + + let workspace_path = PathBuf::from(workspace_root); + let mut read_only_paths = vec![ + PathBuf::from("/usr"), + PathBuf::from("/bin"), + PathBuf::from("/sbin"), + PathBuf::from("/etc"), + PathBuf::from("/System"), + PathBuf::from("/Library"), + workspace_path.clone(), + ]; + read_only_paths.sort(); + read_only_paths.dedup(); + + let mut writable_paths = vec![workspace_path.clone(), PathBuf::from("/tmp")]; + if cfg!(target_os = "macos") { + writable_paths.push(PathBuf::from("/private/tmp")); + } + writable_paths.sort(); + writable_paths.dedup(); + + let base_sandbox_config = ProcessSandboxConfig { + enabled: true, + sandbox_type, + allowed_paths: vec![workspace_path], + denied_paths: Vec::new(), + network_access: false, + environment_variables: HashMap::new(), + read_only_paths, + writable_paths, + allow_dev_access: false, + allow_proc_access: false, + allow_sys_access: false, + env_whitelist: Vec::new(), + tmpfs_size: "64M".to_string(), + unshare_all: true, + die_with_parent: true, + new_session: true, + docker: None, + custom_args: Vec::new(), + audit_logging: None, + resource_limits: None, + }; + + Ok(Self { + delegate: BashTool::new(), + sandbox_type_name, + base_sandbox_config, + }) + } + + fn sandbox_type(&self) -> &str { + &self.sandbox_type_name + } + + fn build_sandbox_config( + &self, + context: &ToolContext, + timeout_secs: u64, + ) -> ProcessSandboxConfig { + let mut config = self.base_sandbox_config.clone(); + + let mut environment_variables = HashMap::new(); + environment_variables.insert("ASTER_TERMINAL".to_string(), "1".to_string()); + for (key, value) in &context.environment { + environment_variables.insert(key.clone(), value.clone()); + } + if let Ok(path_env) = std::env::var("PATH") { + environment_variables + .entry("PATH".to_string()) + .or_insert(path_env); + } + + config.environment_variables = environment_variables; + config.resource_limits = Some(ResourceLimits { + max_memory: Some(1024 * 1024 * 1024), + max_cpu: Some(70), + max_processes: Some(32), + max_file_size: Some(50 * 1024 * 1024), + max_execution_time: Some(timeout_secs.saturating_mul(1000)), + max_file_descriptors: Some(256), + }); + config + } + + fn quote_shell(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) + } + + fn build_shell_command(&self, command: &str, context: &ToolContext) -> (String, Vec) { + #[cfg(target_os = "windows")] + { + return ( + "powershell".to_string(), + vec![ + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + command.to_string(), + ], + ); + } + + #[cfg(not(target_os = "windows"))] + { + let working_dir = context.working_directory.to_string_lossy().to_string(); + let wrapped_command = format!("cd {} && {}", Self::quote_shell(&working_dir), command); + ("sh".to_string(), vec!["-lc".to_string(), wrapped_command]) + } + } + + fn format_output(stdout: &str, stderr: &str, exit_code: i32) -> String { + let mut output = String::new(); + + if !stdout.is_empty() { + output.push_str(stdout); + } + + if !stderr.is_empty() { + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + if !stdout.is_empty() { + output.push_str("--- stderr ---\n"); + } + output.push_str(stderr); + } + + if exit_code != 0 && output.is_empty() { + output = format!("Command exited with code {}", exit_code); + } + + if output.len() <= MAX_OUTPUT_LENGTH { + return output; + } + + let bytes = output.as_bytes(); + let truncated = String::from_utf8_lossy(&bytes[..MAX_OUTPUT_LENGTH]).to_string(); + format!( + "{}\n\n[output truncated: {} bytes total]", + truncated, + output.len() + ) + } +} + +#[async_trait] +impl Tool for WorkspaceSandboxedBashTool { + fn name(&self) -> &str { + self.delegate.name() + } + + fn description(&self) -> &str { + self.delegate.description() + } + + fn input_schema(&self) -> serde_json::Value { + self.delegate.input_schema() + } + + fn options(&self) -> ToolOptions { + self.delegate.options() + } + + async fn check_permissions( + &self, + params: &serde_json::Value, + context: &ToolContext, + ) -> PermissionCheckResult { + self.delegate.check_permissions(params, context).await + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + if context.is_cancelled() { + return Err(ToolError::Cancelled); + } + + let permission = self.check_permissions(¶ms, context).await; + match permission.behavior { + PermissionBehavior::Allow => {} + PermissionBehavior::Deny => { + let message = permission + .message + .unwrap_or_else(|| "命令被安全策略拒绝".to_string()); + return Err(ToolError::permission_denied(message)); + } + PermissionBehavior::Ask => { + let message = permission + .message + .unwrap_or_else(|| "命令需要人工确认".to_string()); + return Err(ToolError::permission_denied(message)); + } + } + + let command = params + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::invalid_params("Missing required parameter: command"))?; + + let background = params + .get("background") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if background { + return Err(ToolError::invalid_params( + "本地 sandbox 模式不支持 background=true", + )); + } + + let timeout_secs = params + .get("timeout") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_BASH_TIMEOUT_SECS) + .min(MAX_BASH_TIMEOUT_SECS); + + let sandbox_config = self.build_sandbox_config(context, timeout_secs); + let (entry, args) = self.build_shell_command(command, context); + + let execution = tokio::time::timeout( + Duration::from_secs(timeout_secs), + execute_in_sandbox(&entry, &args, &sandbox_config), + ) + .await + .map_err(|_| ToolError::timeout(Duration::from_secs(timeout_secs)))? + .map_err(|e| ToolError::execution_failed(format!("sandbox 执行失败: {e}")))?; + + let output = Self::format_output(&execution.stdout, &execution.stderr, execution.exit_code); + if execution.exit_code == 0 { + Ok(ToolResult::success(output) + .with_metadata("exit_code", serde_json::json!(execution.exit_code)) + .with_metadata("stdout_length", serde_json::json!(execution.stdout.len())) + .with_metadata("stderr_length", serde_json::json!(execution.stderr.len())) + .with_metadata("sandboxed", serde_json::json!(execution.sandboxed)) + .with_metadata( + "sandbox_type", + serde_json::json!(format!("{:?}", execution.sandbox_type)), + )) + } else { + Ok(ToolResult::error(output) + .with_metadata("exit_code", serde_json::json!(execution.exit_code)) + .with_metadata("stdout_length", serde_json::json!(execution.stdout.len())) + .with_metadata("stderr_length", serde_json::json!(execution.stderr.len())) + .with_metadata("sandboxed", serde_json::json!(execution.sandboxed)) + .with_metadata( + "sandbox_type", + serde_json::json!(format!("{:?}", execution.sandbox_type)), + )) + } + } +} + +/// 为指定工作区生成本地 sandbox 权限模板 +async fn apply_workspace_sandbox_permissions( + state: &AsterAgentState, + workspace_root: &str, +) -> Result<(), String> { + let workspace_root = workspace_root.trim(); + if workspace_root.is_empty() { + return Err("workspace 根目录为空".to_string()); + } + + let escaped_root = regex::escape(workspace_root); + let mut permissions = vec![ + ToolPermission { + tool: "read".to_string(), + allowed: true, + priority: 100, + conditions: Vec::new(), + parameter_restrictions: vec![ParameterRestriction { + parameter: "path".to_string(), + restriction_type: RestrictionType::Pattern, + values: None, + pattern: Some(format!(r"^({escaped_root}|\.|\./|\.\./).*$")), + validator: None, + min: None, + max: None, + required: true, + description: Some("read.path 必须在 workspace 内或相对路径".to_string()), + }], + scope: PermissionScope::Session, + reason: Some("仅允许读取当前 workspace 内容".to_string()), + expires_at: None, + metadata: HashMap::new(), + }, + ToolPermission { + tool: "write".to_string(), + allowed: true, + priority: 100, + conditions: Vec::new(), + parameter_restrictions: vec![ParameterRestriction { + parameter: "path".to_string(), + restriction_type: RestrictionType::Pattern, + values: None, + pattern: Some(format!(r"^({escaped_root}|\.|\./|\.\./).*$")), + validator: None, + min: None, + max: None, + required: true, + description: Some("write.path 必须在 workspace 内或相对路径".to_string()), + }], + scope: PermissionScope::Session, + reason: Some("仅允许写入当前 workspace 内容".to_string()), + expires_at: None, + metadata: HashMap::new(), + }, + ToolPermission { + tool: "edit".to_string(), + allowed: true, + priority: 100, + conditions: Vec::new(), + parameter_restrictions: vec![ParameterRestriction { + parameter: "path".to_string(), + restriction_type: RestrictionType::Pattern, + values: None, + pattern: Some(format!(r"^({escaped_root}|\.|\./|\.\./).*$")), + validator: None, + min: None, + max: None, + required: true, + description: Some("edit.path 必须在 workspace 内或相对路径".to_string()), + }], + scope: PermissionScope::Session, + reason: Some("仅允许编辑当前 workspace 内容".to_string()), + expires_at: None, + metadata: HashMap::new(), + }, + ToolPermission { + tool: "glob".to_string(), + allowed: true, + priority: 100, + conditions: Vec::new(), + parameter_restrictions: vec![ParameterRestriction { + parameter: "path".to_string(), + restriction_type: RestrictionType::Pattern, + values: None, + pattern: Some(format!(r"^({escaped_root}|\.|\./|\.\./).*$")), + validator: None, + min: None, + max: None, + required: false, + description: Some("glob.path 必须在 workspace 内或相对路径".to_string()), + }], + scope: PermissionScope::Session, + reason: Some("仅允许在当前 workspace 搜索文件".to_string()), + expires_at: None, + metadata: HashMap::new(), + }, + ToolPermission { + tool: "grep".to_string(), + allowed: true, + priority: 100, + conditions: Vec::new(), + parameter_restrictions: vec![ParameterRestriction { + parameter: "path".to_string(), + restriction_type: RestrictionType::Pattern, + values: None, + pattern: Some(format!(r"^({escaped_root}|\.|\./|\.\./).*$")), + validator: None, + min: None, + max: None, + required: false, + description: Some("grep.path 必须在 workspace 内或相对路径".to_string()), + }], + scope: PermissionScope::Session, + reason: Some("仅允许在当前 workspace 搜索内容".to_string()), + expires_at: None, + metadata: HashMap::new(), + }, + ]; + + let allow_shell_pattern = format!( + r"^\s*(?:cd\s+({}|\.|\./|\.\./)(?:\s*(?:&&|;).*)?|pwd(?:\s*(?:&&|;).*)?|ls(?:\s+[^;&|]+)?(?:\s*(?:&&|;).*)?|find\s+({}|\.|\./|\.\./)[^;&|]*(?:\s*(?:&&|;).*)?|rg\b[^;&|]*(?:\s*(?:&&|;).*)?|grep\b[^;&|]*(?:\s*(?:&&|;).*)?|cat\s+({}|\.|\./|\.\./)[^;&|]*(?:\s*(?:&&|;).*)?)\s*$", + escaped_root, escaped_root, escaped_root + ); + + permissions.push(ToolPermission { + tool: "bash".to_string(), + allowed: true, + priority: 90, + conditions: Vec::new(), + parameter_restrictions: vec![ + ParameterRestriction { + parameter: "command".to_string(), + restriction_type: RestrictionType::Pattern, + values: None, + pattern: Some(allow_shell_pattern), + validator: None, + min: None, + max: None, + required: true, + description: Some("bash.command 仅允许 workspace 内安全读操作".to_string()), + }, + ParameterRestriction { + parameter: "command".to_string(), + restriction_type: RestrictionType::Pattern, + values: None, + pattern: Some("^(?!.*(?:\\|\\||&|`|\\$\\(|python\\s+-c|node\\s+-e|ruby\\s+-e|perl\\s+-e|curl\\s+|wget\\s+|ssh\\s+|scp\\s+|rsync\\s+|nc\\s+|telnet\\s+|sudo\\s+)).*$".to_string()), + validator: None, + min: None, + max: None, + required: true, + description: Some("bash.command 禁止管道、联网与高风险执行".to_string()), + }, + ], + scope: PermissionScope::Session, + reason: Some("本地 sandbox:bash 仅允许 workspace 内安全命令".to_string()), + expires_at: None, + metadata: HashMap::new(), + }); + + permissions.push(ToolPermission { + tool: "*".to_string(), + allowed: false, + priority: 10, + conditions: Vec::new(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some("本地 sandbox:未显式授权的工具默认拒绝".to_string()), + expires_at: None, + metadata: HashMap::new(), + }); + + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + let agent = guard + .as_ref() + .ok_or_else(|| "Agent not initialized".to_string())?; + let registry_arc = agent.tool_registry().clone(); + drop(guard); + + let mut registry = registry_arc.write().await; + let mut permission_manager = ToolPermissionManager::new(None); + if let Some(existing_manager) = registry.permission_manager() { + for permission in existing_manager.get_permissions(None) { + let scope = permission.scope; + permission_manager.add_permission(permission, scope); + } + } + + for permission in permissions { + permission_manager.add_permission(permission, PermissionScope::Session); + } + registry.set_permission_manager(Arc::new(permission_manager)); + + let workspace_bash_tool = WorkspaceSandboxedBashTool::new(workspace_root)?; + let sandbox_type = workspace_bash_tool.sandbox_type().to_string(); + registry.register(Box::new(workspace_bash_tool)); + + tracing::info!( + "[AsterAgent] 已应用 workspace 本地 sandbox: root={}, type={}", + workspace_root, + sandbox_type + ); + + Ok(()) } /// 图片输入 @@ -243,6 +749,33 @@ pub async fn aster_agent_chat_stream( // 同时 get_session 也会自动创建不存在的 session let session_id = &request.session_id; + let workspace_id = request.workspace_id.trim().to_string(); + if workspace_id.is_empty() { + return Err("workspace_id 必填,请先选择项目工作区".to_string()); + } + + let manager = WorkspaceManager::new(db.inner().clone()); + let workspace = manager + .get(&workspace_id) + .map_err(|e| format!("读取 workspace 失败: {e}"))? + .ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?; + let workspace_root = workspace.root_path.to_string_lossy().to_string(); + + { + let db_conn = db.lock().map_err(|e| format!("获取数据库连接失败: {e}"))?; + if let Some(session) = AgentDao::get_session(&db_conn, session_id) + .map_err(|e| format!("读取 session 失败: {e}"))? + { + let session_dir = session.working_dir.unwrap_or_default(); + if !session_dir.is_empty() && session_dir != workspace_root { + return Err(format!( + "会话工作目录与 workspace 不匹配: session={}, workspace={}", + session_dir, workspace_root + )); + } + } + } + // 启动并注入 MCP extensions 到 Aster Agent let (_start_ok, start_fail) = ensure_proxycast_mcp_servers_running(&db, &mcp_manager).await; if start_fail > 0 { @@ -354,6 +887,10 @@ pub async fn aster_agent_chat_stream( return Err("Provider 未配置,请先调用 aster_agent_configure_provider".to_string()); } + apply_workspace_sandbox_permissions(&state, &workspace_root) + .await + .map_err(|e| format!("注入本地 sandbox 失败: {e}"))?; + // 创建取消令牌 let cancel_token = state.create_cancel_token(session_id).await; @@ -445,10 +982,18 @@ pub async fn aster_agent_stop( #[tauri::command] pub async fn aster_session_create( db: State<'_, DbConnection>, + working_dir: Option, + workspace_id: String, name: Option, ) -> Result { tracing::info!("[AsterAgent] 创建会话: name={:?}", name); - AsterAgentWrapper::create_session_sync(&db, name) + + let workspace_id = workspace_id.trim().to_string(); + if workspace_id.is_empty() { + return Err("workspace_id 必填,请先选择项目工作区".to_string()); + } + + AsterAgentWrapper::create_session_sync(&db, name, working_dir, workspace_id) } /// 列出所有会话 @@ -480,7 +1025,7 @@ pub struct ConfirmRequest { /// 确认权限请求(用于工具调用确认等) #[tauri::command] pub async fn aster_agent_confirm( - _state: State<'_, AsterAgentState>, + state: State<'_, AsterAgentState>, request: ConfirmRequest, ) -> Result<(), String> { tracing::info!( @@ -489,9 +1034,23 @@ pub async fn aster_agent_confirm( request.confirmed ); - // TODO: 实现权限确认逻辑 - // 这需要 Aster 框架支持 confirmation_tx 通道 - // 目前先返回成功 + let permission = if request.confirmed { + Permission::AllowOnce + } else { + Permission::DenyOnce + }; + + let confirmation = PermissionConfirmation { + principal_type: PrincipalType::Tool, + permission, + }; + + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + let agent = guard.as_ref().ok_or("Agent not initialized")?; + agent + .handle_confirmation(request.request_id.clone(), confirmation) + .await; Ok(()) } @@ -505,13 +1064,15 @@ mod tests { let json = r#"{ "message": "Hello", "session_id": "test-session", - "event_name": "agent_stream" + "event_name": "agent_stream", + "workspace_id": "workspace-test" }"#; let request: AsterChatRequest = serde_json::from_str(json).unwrap(); assert_eq!(request.message, "Hello"); assert_eq!(request.session_id, "test-session"); assert_eq!(request.event_name, "agent_stream"); + assert_eq!(request.workspace_id, "workspace-test"); } } diff --git a/src-tauri/src/commands/memory_management_cmd.rs b/src-tauri/src/commands/memory_management_cmd.rs index a29df1dc8..763f63326 100644 --- a/src-tauri/src/commands/memory_management_cmd.rs +++ b/src-tauri/src/commands/memory_management_cmd.rs @@ -3,11 +3,16 @@ //! 提供对话记忆的统计和管理功能 use crate::commands::context_memory::ContextMemoryServiceState; +use crate::database::DbConnection; +use chrono::{Local, NaiveDateTime, TimeZone}; +use proxycast_services::context_memory_service::{MemoryEntry, MemoryFileType}; +use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tauri::State; -use tracing::info; +use tracing::{info, warn}; /// 记忆统计信息 #[derive(Debug, Clone, Serialize, Deserialize)] @@ -29,60 +34,201 @@ pub struct CleanupMemoryResult { pub freed_space: u64, } -/// 获取对话记忆统计信息 -#[tauri::command] -pub async fn get_conversation_memory_stats( - _memory_service: State<'_, ContextMemoryServiceState>, -) -> Result { - info!("[记忆管理] 获取记忆统计信息"); - - // 获取记忆目录 - let memory_dir = dirs::home_dir() - .map(|p| p.join(".proxycast").join("memory")) - .unwrap_or_else(|| PathBuf::from(".proxycast/memory")); - - // 统计所有会话的记忆 - let mut total_entries = 0u32; - let mut storage_used = 0u64; - let mut memory_count = 0u32; - - // 遍历记忆目录中的所有会话 - if let Ok(entries) = fs::read_dir(&memory_dir) { - for entry in entries.flatten() { - if entry.path().is_dir() { - memory_count += 1; - // 统计该会话的记忆文件 - if let Ok(session_entries) = get_session_memory_stats(&entry.path()) { - total_entries += session_entries.0; - storage_used += session_entries.1; - } - } - } - } - - Ok(MemoryStatsResponse { - total_entries, - storage_used, - memory_count, - }) +/// 记忆分析结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryAnalysisResult { + /// 分析到的会话数 + pub analyzed_sessions: u32, + /// 分析到的消息数 + pub analyzed_messages: u32, + /// 新生成的记忆条目数 + pub generated_entries: u32, + /// 去重忽略的条目数 + pub deduplicated_entries: u32, } -/// 获取单个会话的记忆统计 -fn get_session_memory_stats(session_dir: &PathBuf) -> Result<(u32, u64), std::io::Error> { - let mut entries = 0u32; - let mut size = 0u64; +/// 记忆分类统计 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryCategoryStat { + /// 分类 key:identity/context/preference/experience/activity + pub category: String, + /// 分类下条目数量 + pub count: u32, +} - if let Ok(dir_entries) = fs::read_dir(session_dir) { - for entry in dir_entries.flatten() { - let path = entry.path(); - if path.is_file() { - entries += 1; - size += fs::metadata(&path)?.len(); - } +/// 记忆条目预览 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryEntryPreview { + pub id: String, + pub session_id: String, + pub file_type: String, + pub category: String, + pub title: String, + pub summary: String, + pub updated_at: i64, + pub tags: Vec, +} + +/// 记忆总览响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryOverviewResponse { + pub stats: MemoryStatsResponse, + pub categories: Vec, + pub entries: Vec, +} + +#[derive(Debug, Clone, Deserialize, Default)] +struct ErrorEntryRecord { + #[serde(default)] + id: String, + #[serde(default)] + error_description: String, + #[serde(default)] + attempted_solutions: Vec, + #[serde(default)] + last_failure_at: i64, + #[serde(default)] + resolved: bool, + #[serde(default)] + resolution: Option, +} + +const SUPPORTED_MEMORY_FILES: [&str; 4] = [ + "task_plan.md", + "findings.md", + "progress.md", + "error_log.json", +]; + +const CATEGORY_ORDER: [&str; 5] = [ + "identity", + "context", + "preference", + "experience", + "activity", +]; + +const MAX_SOURCE_MESSAGES: usize = 6000; +const MAX_GENERATED_PER_REQUEST: usize = 200; +const MAX_GENERATED_PER_SESSION: usize = 40; +const MIN_MESSAGE_LENGTH: usize = 18; + +/// 获取对话记忆统计信息 +#[tauri::command] +pub async fn get_conversation_memory_stats() -> Result { + info!("[记忆管理] 获取记忆统计信息"); + + let memory_dir = resolve_memory_dir(); + let overview = collect_memory_overview(&memory_dir)?; + Ok(overview.stats) +} + +/// 获取对话记忆总览(分类 + 条目) +#[tauri::command] +pub async fn get_conversation_memory_overview( + limit: Option, +) -> Result { + info!("[记忆管理] 获取记忆总览, limit={:?}", limit); + + let memory_dir = resolve_memory_dir(); + let mut overview = collect_memory_overview(&memory_dir)?; + + if let Some(limit) = limit.filter(|v| *v > 0) { + overview.entries.truncate(limit as usize); + } + + Ok(overview) +} + +/// 从历史对话中抽取记忆条目 +#[tauri::command] +pub async fn request_conversation_memory_analysis( + memory_service: State<'_, ContextMemoryServiceState>, + db: State<'_, DbConnection>, + from_timestamp: Option, + to_timestamp: Option, +) -> Result { + info!( + "[记忆管理] 请求记忆分析 from={:?}, to={:?}", + from_timestamp, to_timestamp + ); + + if let (Some(start), Some(end)) = (from_timestamp, to_timestamp) { + if start > end { + return Err("开始时间不能大于结束时间".to_string()); } } - Ok((entries, size)) + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let candidates = load_memory_candidates(&conn, from_timestamp, to_timestamp)?; + + if candidates.is_empty() { + return Ok(MemoryAnalysisResult { + analyzed_sessions: 0, + analyzed_messages: 0, + generated_entries: 0, + deduplicated_entries: 0, + }); + } + + let mut analyzed_sessions: HashSet = HashSet::new(); + let mut generated_entries = 0u32; + let mut deduplicated_entries = 0u32; + let mut generated_count_per_session: HashMap = HashMap::new(); + + for candidate in candidates.iter().take(MAX_SOURCE_MESSAGES) { + analyzed_sessions.insert(candidate.session_id.clone()); + + let counter = generated_count_per_session + .entry(candidate.session_id.clone()) + .or_insert(0); + if *counter >= MAX_GENERATED_PER_SESSION { + continue; + } + + let fingerprint = build_fingerprint(&candidate.content); + let (title, summary, file_type, category_tag) = build_memory_entry_fields(candidate); + let existing = memory_service + .0 + .get_session_memories(&candidate.session_id, Some(file_type))?; + + if is_duplicate_memory(&existing, &fingerprint, &summary) { + deduplicated_entries += 1; + continue; + } + + let entry = MemoryEntry { + id: uuid::Uuid::new_v4().to_string(), + session_id: candidate.session_id.clone(), + file_type, + title, + content: summary, + tags: vec![ + "auto_analysis".to_string(), + category_tag.to_string(), + fingerprint, + ], + priority: infer_priority(candidate), + created_at: candidate.created_at, + updated_at: candidate.created_at, + archived: false, + }; + + memory_service.0.save_memory_entry(&entry)?; + generated_entries += 1; + *counter += 1; + + if generated_entries as usize >= MAX_GENERATED_PER_REQUEST { + break; + } + } + + Ok(MemoryAnalysisResult { + analyzed_sessions: analyzed_sessions.len() as u32, + analyzed_messages: candidates.len() as u32, + generated_entries, + deduplicated_entries, + }) } /// 清理过期对话记忆 @@ -94,13 +240,787 @@ pub async fn cleanup_conversation_memory( ) -> Result { info!("[记忆管理] 开始清理过期记忆"); + let memory_dir = resolve_memory_dir(); + let before = collect_memory_overview(&memory_dir)?; + // 使用 ContextMemoryService 的清理功能 memory_service.0.cleanup_expired_memories()?; - // 重新获取统计信息以计算清理结果 - // 注意:这里简化处理,实际应该记录清理前后的差异 + let after = collect_memory_overview(&memory_dir)?; + + let cleaned_entries = before + .stats + .total_entries + .saturating_sub(after.stats.total_entries); + let freed_space = before + .stats + .storage_used + .saturating_sub(after.stats.storage_used); + Ok(CleanupMemoryResult { - cleaned_entries: 0, // ContextMemoryService 没有返回清理数量 - freed_space: 0, + cleaned_entries, + freed_space, }) } + +fn resolve_memory_dir() -> PathBuf { + dirs::home_dir() + .map(|p| p.join(".proxycast").join("memory")) + .unwrap_or_else(|| PathBuf::from(".proxycast/memory")) +} + +fn collect_memory_overview(memory_dir: &Path) -> Result { + if !memory_dir.exists() { + return Ok(MemoryOverviewResponse { + stats: MemoryStatsResponse { + total_entries: 0, + storage_used: 0, + memory_count: 0, + }, + categories: CATEGORY_ORDER + .iter() + .map(|category| MemoryCategoryStat { + category: (*category).to_string(), + count: 0, + }) + .collect(), + entries: Vec::new(), + }); + } + + let mut storage_used = 0u64; + let mut memory_count = 0u32; + let mut entries: Vec = Vec::new(); + + let session_dirs = fs::read_dir(memory_dir).map_err(|e| format!("读取记忆目录失败: {e}"))?; + + for session_entry in session_dirs.flatten() { + let session_path = session_entry.path(); + if !session_path.is_dir() { + continue; + } + + let session_id = session_entry.file_name().to_string_lossy().to_string(); + let mut has_memory_file = false; + + let files = match fs::read_dir(&session_path) { + Ok(files) => files, + Err(err) => { + warn!("[记忆管理] 读取会话目录失败: {} - {}", session_id, err); + continue; + } + }; + + for file_entry in files.flatten() { + let file_path = file_entry.path(); + if !file_path.is_file() { + continue; + } + + let file_name = file_entry.file_name().to_string_lossy().to_string(); + if !SUPPORTED_MEMORY_FILES.contains(&file_name.as_str()) { + continue; + } + + has_memory_file = true; + + let file_size = match fs::metadata(&file_path) { + Ok(meta) => meta.len(), + Err(err) => { + warn!( + "[记忆管理] 读取文件元数据失败: {} - {}", + file_path.display(), + err + ); + 0 + } + }; + storage_used += file_size; + + let content = match fs::read_to_string(&file_path) { + Ok(content) => content, + Err(err) => { + warn!( + "[记忆管理] 读取记忆文件失败: {} - {}", + file_path.display(), + err + ); + continue; + } + }; + + if content.trim().is_empty() { + continue; + } + + let mut parsed_entries = parse_memory_file(&session_id, &file_name, &content); + entries.append(&mut parsed_entries); + } + + if has_memory_file { + memory_count += 1; + } + } + + entries.sort_by(|a, b| { + b.updated_at + .cmp(&a.updated_at) + .then_with(|| a.id.cmp(&b.id)) + }); + + let categories = build_categories(&entries); + let total_entries = entries.len() as u32; + + Ok(MemoryOverviewResponse { + stats: MemoryStatsResponse { + total_entries, + storage_used, + memory_count, + }, + categories, + entries, + }) +} + +fn parse_memory_file(session_id: &str, file_name: &str, content: &str) -> Vec { + match file_name { + "task_plan.md" => parse_markdown_entries(session_id, content, "task_plan"), + "findings.md" => parse_markdown_entries(session_id, content, "findings"), + "progress.md" => parse_markdown_entries(session_id, content, "progress"), + "error_log.json" => parse_error_entries(session_id, content), + _ => Vec::new(), + } +} + +fn parse_markdown_entries( + session_id: &str, + content: &str, + file_type: &str, +) -> Vec { + let mut entries = Vec::new(); + let mut current_title: Option = None; + let mut section_lines: Vec = Vec::new(); + let mut index = 0usize; + + for line in content.lines() { + if let Some(title) = line.strip_prefix("## ") { + if let Some(previous_title) = current_title.take() { + if let Some(entry) = build_markdown_entry( + session_id, + file_type, + index, + &previous_title, + §ion_lines, + ) { + entries.push(entry); + index += 1; + } + } + + current_title = Some(title.trim().to_string()); + section_lines.clear(); + continue; + } + + if current_title.is_some() { + section_lines.push(line.to_string()); + } + } + + if let Some(previous_title) = current_title { + if let Some(entry) = build_markdown_entry( + session_id, + file_type, + index, + &previous_title, + §ion_lines, + ) { + entries.push(entry); + } + } + + entries +} + +fn build_markdown_entry( + session_id: &str, + file_type: &str, + index: usize, + title: &str, + lines: &[String], +) -> Option { + if title.trim().is_empty() { + return None; + } + + let (tags, updated_at) = parse_metadata(lines); + let summary = summarize_lines(lines); + let category = infer_category(file_type, &tags, title, &summary); + + Some(MemoryEntryPreview { + id: format!("{}:{}:{}", session_id, file_type, index), + session_id: session_id.to_string(), + file_type: file_type.to_string(), + category, + title: title.trim().to_string(), + summary, + updated_at, + tags, + }) +} + +fn parse_metadata(lines: &[String]) -> (Vec, i64) { + for line in lines { + let line = line.trim(); + if !line.starts_with("**优先级**:") { + continue; + } + + let tags = line + .split("**标签**:") + .nth(1) + .and_then(|part| part.split('|').next()) + .map(|part| { + part.split(',') + .map(|tag| tag.trim().to_string()) + .filter(|tag| !tag.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + + let updated_at = line + .split("**更新时间**:") + .nth(1) + .map(str::trim) + .and_then(parse_datetime_or_timestamp_to_millis) + .unwrap_or(0); + + return (tags, updated_at); + } + + (Vec::new(), 0) +} + +fn parse_error_entries(session_id: &str, content: &str) -> Vec { + let records: Vec = match serde_json::from_str(content) { + Ok(records) => records, + Err(err) => { + warn!("[记忆管理] 解析 error_log.json 失败: {}", err); + return Vec::new(); + } + }; + + records + .into_iter() + .enumerate() + .map(|(index, record)| { + let resolved = record.resolved; + let tags = vec![ + "error".to_string(), + if resolved { + "resolved".to_string() + } else { + "unresolved".to_string() + }, + ]; + + let summary = record + .resolution + .clone() + .or_else(|| record.attempted_solutions.last().cloned()) + .unwrap_or_else(|| "暂无解决方案记录".to_string()); + + let category = if resolved { + "experience".to_string() + } else { + "context".to_string() + }; + + let title_prefix = if resolved { + "已解决错误" + } else { + "错误" + }; + let title = if record.error_description.trim().is_empty() { + title_prefix.to_string() + } else { + format!( + "{}:{}", + title_prefix, + truncate_text(&record.error_description, 32) + ) + }; + + MemoryEntryPreview { + id: if record.id.is_empty() { + format!("{}:error_log:{}", session_id, index) + } else { + record.id + }, + session_id: session_id.to_string(), + file_type: "error_log".to_string(), + category, + title, + summary: truncate_text(summary.trim(), 140), + updated_at: record.last_failure_at, + tags, + } + }) + .collect() +} + +fn build_categories(entries: &[MemoryEntryPreview]) -> Vec { + let mut category_map: HashMap = HashMap::new(); + + for entry in entries { + *category_map.entry(entry.category.clone()).or_insert(0) += 1; + } + + CATEGORY_ORDER + .iter() + .map(|category| MemoryCategoryStat { + category: (*category).to_string(), + count: category_map.get(*category).copied().unwrap_or(0), + }) + .collect() +} + +fn infer_category(file_type: &str, tags: &[String], title: &str, summary: &str) -> String { + for tag in tags { + if let Some(category) = normalize_category(tag) { + return category.to_string(); + } + } + + let text = format!("{} {}", title, summary).to_lowercase(); + + if contains_any(&text, &["我是", "我叫", "my name", "i am", "身份", "职业"]) { + return "identity".to_string(); + } + if contains_any(&text, &["喜欢", "偏好", "prefer", "不喜欢", "习惯", "爱好"]) { + return "preference".to_string(); + } + if contains_any( + &text, + &[ + "曾经", + "之前", + "以前", + "经历", + "做过", + "worked on", + "learned", + ], + ) { + return "experience".to_string(); + } + if contains_any( + &text, + &["今天", "正在", "计划", "刚刚", "接下来", "todo", "任务"], + ) { + return "activity".to_string(); + } + if contains_any( + &text, + &["背景", "场景", "环境", "上下文", "context", "需求", "目标"], + ) { + return "context".to_string(); + } + + map_file_type_to_category(file_type).to_string() +} + +fn map_file_type_to_category(file_type: &str) -> &'static str { + match file_type { + "task_plan" => "context", + "findings" => "experience", + "progress" => "activity", + "error_log" => "context", + _ => "context", + } +} + +fn normalize_category(value: &str) -> Option<&'static str> { + let normalized = value.trim().to_lowercase(); + match normalized.as_str() { + "identity" | "身份" => Some("identity"), + "context" | "情境" | "上下文" => Some("context"), + "preference" | "偏好" => Some("preference"), + "experience" | "经验" => Some("experience"), + "activity" | "活动" => Some("activity"), + _ => None, + } +} + +fn contains_any(text: &str, keywords: &[&str]) -> bool { + keywords.iter().any(|keyword| text.contains(keyword)) +} + +fn parse_datetime_to_timestamp(value: &str) -> Option { + NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S") + .ok() + .and_then(|naive| { + Local + .from_local_datetime(&naive) + .single() + .map(|dt| dt.timestamp_millis()) + }) +} + +fn parse_datetime_or_timestamp_to_millis(value: &str) -> Option { + if let Ok(v) = value.parse::() { + if v > 1_000_000_000_000 { + return Some(v); + } + return Some(v * 1000); + } + + parse_datetime_to_timestamp(value) +} + +fn summarize_lines(lines: &[String]) -> String { + let summary = lines + .iter() + .map(|line| line.trim()) + .filter(|line| { + !line.is_empty() && !line.starts_with("**优先级**") && *line != "---" && *line != "----" + }) + .take(3) + .collect::>() + .join(" "); + + if summary.is_empty() { + "暂无摘要".to_string() + } else { + truncate_text(&summary, 140) + } +} + +fn truncate_text(input: &str, max_chars: usize) -> String { + let mut chars = input.chars(); + let prefix: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{}…", prefix) + } else { + prefix + } +} + +#[derive(Debug, Clone)] +struct MemorySourceCandidate { + session_id: String, + role: String, + content: String, + created_at: i64, +} + +fn load_memory_candidates( + conn: &Connection, + from_timestamp: Option, + to_timestamp: Option, +) -> Result, String> { + let mut candidates = Vec::new(); + + let mut push_filtered = |session_id: String, role: String, content: String, created_at: i64| { + let normalized = normalize_candidate_content(&content); + if normalized.len() < MIN_MESSAGE_LENGTH { + return; + } + + let normalized_role = role.to_lowercase(); + if normalized_role != "user" && normalized_role != "assistant" { + return; + } + + candidates.push(MemorySourceCandidate { + session_id, + role: normalized_role, + content: normalized, + created_at, + }); + }; + + let from_ts = from_timestamp; + let to_ts = to_timestamp; + + if from_timestamp.is_some() || to_timestamp.is_some() { + let mut stmt = conn + .prepare( + "SELECT session_id, role, content, created_at + FROM general_chat_messages + WHERE (?1 IS NULL OR created_at >= ?1) + AND (?2 IS NULL OR created_at <= ?2) + ORDER BY created_at DESC + LIMIT ?3", + ) + .map_err(|e| format!("查询 general_chat_messages 失败: {e}"))?; + + let rows = stmt + .query_map( + params![from_timestamp, to_timestamp, MAX_SOURCE_MESSAGES as i64], + |row| { + let session_id: String = row.get(0)?; + let role: String = row.get(1)?; + let content: String = row.get(2)?; + let created_at: i64 = row.get(3)?; + Ok((session_id, role, content, created_at)) + }, + ) + .map_err(|e| format!("读取 general_chat_messages 失败: {e}"))?; + + for row in rows.flatten() { + push_filtered(row.0, row.1, row.2, row.3); + } + + let mut stmt = conn + .prepare( + "SELECT session_id, role, content_json, timestamp + FROM agent_messages + ORDER BY timestamp DESC + LIMIT ?1", + ) + .map_err(|e| format!("查询 agent_messages 失败: {e}"))?; + + let rows = stmt + .query_map(params![MAX_SOURCE_MESSAGES as i64], |row| { + let session_id: String = row.get(0)?; + let role: String = row.get(1)?; + let content_json: String = row.get(2)?; + let timestamp: String = row.get(3)?; + Ok((session_id, role, content_json, timestamp)) + }) + .map_err(|e| format!("读取 agent_messages 失败: {e}"))?; + + for row in rows.flatten() { + if let Some(timestamp_ms) = parse_rfc3339_to_timestamp(&row.3) { + if from_ts.is_some_and(|from| timestamp_ms < from) + || to_ts.is_some_and(|to| timestamp_ms > to) + { + continue; + } + + let text = extract_text_from_content_json(&row.2); + push_filtered(row.0, row.1, text, timestamp_ms); + } + } + } else { + let mut stmt = conn + .prepare( + "SELECT session_id, role, content, created_at + FROM general_chat_messages + ORDER BY created_at DESC + LIMIT ?1", + ) + .map_err(|e| format!("查询 general_chat_messages 失败: {e}"))?; + + let rows = stmt + .query_map(params![MAX_SOURCE_MESSAGES as i64], |row| { + let session_id: String = row.get(0)?; + let role: String = row.get(1)?; + let content: String = row.get(2)?; + let created_at: i64 = row.get(3)?; + Ok((session_id, role, content, created_at)) + }) + .map_err(|e| format!("读取 general_chat_messages 失败: {e}"))?; + + for row in rows.flatten() { + push_filtered(row.0, row.1, row.2, row.3); + } + + let mut stmt = conn + .prepare( + "SELECT session_id, role, content_json, timestamp + FROM agent_messages + ORDER BY timestamp DESC + LIMIT ?1", + ) + .map_err(|e| format!("查询 agent_messages 失败: {e}"))?; + + let rows = stmt + .query_map(params![MAX_SOURCE_MESSAGES as i64], |row| { + let session_id: String = row.get(0)?; + let role: String = row.get(1)?; + let content_json: String = row.get(2)?; + let timestamp: String = row.get(3)?; + Ok((session_id, role, content_json, timestamp)) + }) + .map_err(|e| format!("读取 agent_messages 失败: {e}"))?; + + for row in rows.flatten() { + if let Some(timestamp_ms) = parse_rfc3339_to_timestamp(&row.3) { + let text = extract_text_from_content_json(&row.2); + push_filtered(row.0, row.1, text, timestamp_ms); + } + } + } + + candidates.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + candidates.truncate(MAX_SOURCE_MESSAGES); + + Ok(candidates) +} + +fn normalize_candidate_content(content: &str) -> String { + content + .replace('\n', " ") + .split_whitespace() + .collect::>() + .join(" ") +} + +fn extract_text_from_content_json(content_json: &str) -> String { + if let Ok(text) = serde_json::from_str::(content_json) { + return text; + } + + if let Ok(value) = serde_json::from_str::(content_json) { + match value { + serde_json::Value::Array(items) => { + let texts = items + .iter() + .filter_map(extract_text_from_json_item) + .collect::>(); + if !texts.is_empty() { + return texts.join(" "); + } + } + serde_json::Value::Object(_) => { + if let Some(text) = extract_text_from_json_item(&value) { + return text; + } + } + _ => {} + } + } + + content_json.to_string() +} + +fn extract_text_from_json_item(value: &serde_json::Value) -> Option { + if let Some(text) = value.get("Text").and_then(|v| v.as_str()) { + return Some(text.to_string()); + } + + if value.get("type").and_then(|v| v.as_str()) == Some("text") { + if let Some(text) = value.get("text").and_then(|v| v.as_str()) { + return Some(text.to_string()); + } + } + + value + .get("text") + .and_then(|v| v.as_str()) + .map(|v| v.to_string()) +} + +fn build_fingerprint(content: &str) -> String { + let normalized = content.to_lowercase(); + let compact = normalized + .chars() + .filter(|ch| !ch.is_whitespace()) + .take(120) + .collect::(); + format!("fp:{}", compact) +} + +fn is_duplicate_memory(existing_entries: &[MemoryEntry], fingerprint: &str, summary: &str) -> bool { + let summary_prefix = truncate_text(summary, 80); + existing_entries.iter().any(|entry| { + entry.tags.iter().any(|tag| tag == fingerprint) + || entry.content.contains(fingerprint) + || entry.content.contains(&summary_prefix) + }) +} + +fn build_memory_entry_fields( + candidate: &MemorySourceCandidate, +) -> (String, String, MemoryFileType, &'static str) { + let content = candidate.content.trim(); + let lowered = content.to_lowercase(); + + let (file_type, category) = if contains_any( + &lowered, + &["喜欢", "偏好", "prefer", "不喜欢", "习惯", "常用"], + ) { + (MemoryFileType::Findings, "preference") + } else if contains_any( + &lowered, + &["我是", "我叫", "身份", "职业", "my name", "i am"], + ) { + (MemoryFileType::Findings, "identity") + } else if contains_any(&lowered, &["计划", "待办", "todo", "接下来", "将要"]) { + (MemoryFileType::TaskPlan, "activity") + } else if contains_any( + &lowered, + &["错误", "失败", "异常", "报错", "error", "failed"], + ) { + (MemoryFileType::Findings, "context") + } else if candidate.role == "assistant" { + (MemoryFileType::Progress, "experience") + } else { + (MemoryFileType::Findings, "context") + }; + + let title = format!( + "{}记忆 · {}", + map_category_display_name(category), + format_timestamp(candidate.created_at) + ); + + let summary = format!( + "自动分析提取({}):{}", + if candidate.role == "assistant" { + "AI 响应" + } else { + "用户表达" + }, + truncate_text(content, 200) + ); + + (title, summary, file_type, category) +} + +fn infer_priority(candidate: &MemorySourceCandidate) -> u8 { + let mut priority = if candidate.role == "user" { 4 } else { 3 }; + if contains_any( + &candidate.content.to_lowercase(), + &["必须", "重要", "关键", "urgent", "critical"], + ) { + priority = 5; + } + priority +} + +fn map_category_display_name(category: &str) -> &'static str { + match category { + "identity" => "身份", + "context" => "情境", + "preference" => "偏好", + "experience" => "经验", + "activity" => "活动", + _ => "记忆", + } +} + +fn parse_rfc3339_to_timestamp(value: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(value) + .ok() + .map(|dt| dt.timestamp_millis()) + .or_else(|| parse_datetime_or_timestamp_to_millis(value)) +} + +fn format_timestamp(timestamp_ms: i64) -> String { + if timestamp_ms <= 0 { + return "未知时间".to_string(); + } + + let normalized = if timestamp_ms > 1_000_000_000_000 { + timestamp_ms + } else { + timestamp_ms * 1000 + }; + + chrono::DateTime::from_timestamp_millis(normalized) + .map(|dt| dt.format("%m-%d %H:%M").to_string()) + .unwrap_or_else(|| "未知时间".to_string()) +} diff --git a/src-tauri/src/commands/workspace_cmd.rs b/src-tauri/src/commands/workspace_cmd.rs index 296105088..5e6d0ddd3 100644 --- a/src-tauri/src/commands/workspace_cmd.rs +++ b/src-tauri/src/commands/workspace_cmd.rs @@ -23,6 +23,36 @@ use std::sync::Arc; use tauri::State; use tokio::sync::RwLock; +/// 获取统一的项目根目录(~/.proxycast/projects) +fn get_workspace_projects_root_dir() -> Result { + let home_dir = dirs::home_dir().ok_or_else(|| "无法获取主目录".to_string())?; + let root_dir = home_dir.join(".proxycast").join("projects"); + + std::fs::create_dir_all(&root_dir).map_err(|e| format!("创建 workspace 目录失败: {e}"))?; + + Ok(root_dir) +} + +/// 规范化项目目录名,避免非法路径字符 +fn sanitize_project_dir_name(name: &str) -> String { + let sanitized: String = name + .trim() + .chars() + .map(|ch| match ch { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + _ if ch.is_control() => '_', + _ => ch, + }) + .collect(); + + let trimmed = sanitized.trim().trim_matches('.').to_string(); + if trimmed.is_empty() { + "未命名项目".to_string() + } else { + trimmed + } +} + /// Workspace 管理器状态 #[allow(dead_code)] pub struct WorkspaceManagerState(pub Arc>>); @@ -211,6 +241,22 @@ pub async fn workspace_get_by_path( Ok(workspace.map(|ws| ws.into())) } +/// 获取统一 workspace 项目根目录 +#[tauri::command] +pub async fn workspace_get_projects_root() -> Result { + let root_dir = get_workspace_projects_root_dir()?; + Ok(root_dir.to_string_lossy().to_string()) +} + +/// 根据项目名称解析最终项目目录(固定在 workspace 根目录下) +#[tauri::command] +pub async fn workspace_resolve_project_path(name: String) -> Result { + let root_dir = get_workspace_projects_root_dir()?; + let dir_name = sanitize_project_dir_name(&name); + let project_path = root_dir.join(dir_name); + Ok(project_path.to_string_lossy().to_string()) +} + // ==================== 项目上下文相关命令 ==================== /// 获取或创建默认项目 @@ -233,9 +279,10 @@ pub async fn get_or_create_default_project( } // 不存在则创建默认项目 + let default_project_path = get_workspace_projects_root_dir()?.join("default"); let workspace = manager.create_with_type( "默认项目".to_string(), - PathBuf::from("default"), + default_project_path, WorkspaceType::Persistent, )?; diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 1212fe7d3..752f33eb1 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -255,6 +255,7 @@ pub async fn handle_command( messages: Vec::new(), system_prompt, title: None, // 初始会话没有标题,后续会自动生成 + working_dir: None, created_at: now.clone(), updated_at: now, }; diff --git a/src-tauri/src/services/conversation_statistics_service.rs b/src-tauri/src/services/conversation_statistics_service.rs index b5678fe7f..59896e878 100644 --- a/src-tauri/src/services/conversation_statistics_service.rs +++ b/src-tauri/src/services/conversation_statistics_service.rs @@ -2,7 +2,7 @@ //! //! 从数据库查询真实的对话和使用统计数据 -use chrono::Timelike; +use chrono::{DateTime, Datelike, Duration, Local, Timelike}; use rusqlite::Connection; use serde::{Deserialize, Serialize}; @@ -55,30 +55,40 @@ pub struct DailyUsage { pub tokens: u64, } +#[derive(Debug, Clone, Copy, Default)] +struct ConversationStats { + total_conversations: u32, + total_messages: u32, + monthly_conversations: u32, + monthly_messages: u32, + today_conversations: u32, + today_messages: u32, +} + +#[derive(Debug, Clone, Copy, Default)] +struct TokenStats { + total_tokens: u64, + monthly_tokens: u64, + today_tokens: u64, +} + +#[derive(Debug, Clone)] +struct RawModelUsage { + model: String, + conversations: u64, + tokens: u64, +} + /// 获取使用统计数据 pub fn get_usage_stats_from_db( time_range: &str, conn: &Connection, ) -> Result { - let now = chrono::Local::now(); - let (today_start, month_start, _total_start) = match time_range { - "week" => ( - now - chrono::Duration::days(7), - now - chrono::Duration::days(30), - now - chrono::Duration::days(7), - ), - "month" => ( - now - chrono::Duration::days(1), - now - chrono::Duration::days(30), - now - chrono::Duration::days(30), - ), - "all" => ( - now - chrono::Duration::days(1), - now - chrono::Duration::days(30), - chrono::Local::now() - chrono::Duration::days(365), // 简化处理 - ), - _ => return Err("无效的时间范围".to_string()), - }; + validate_time_range(time_range)?; + + let now = Local::now(); + let today_start = start_of_day(now); + let month_start = start_of_month(now); // 查询通用对话统计 let general_stats = query_general_chat_stats(conn, &today_start, &month_start)?; @@ -89,18 +99,21 @@ pub fn get_usage_stats_from_db( // 合并统计 let total_conversations = general_stats.total_conversations + agent_stats.total_conversations; let total_messages = general_stats.total_messages + agent_stats.total_messages; - let total_tokens = general_stats.total_tokens + agent_stats.total_tokens; let today_conversations = general_stats.today_conversations + agent_stats.today_conversations; let today_messages = general_stats.today_messages + agent_stats.today_messages; - let today_tokens = general_stats.today_tokens + agent_stats.today_tokens; let monthly_conversations = general_stats.monthly_conversations + agent_stats.monthly_conversations; let monthly_messages = general_stats.monthly_messages + agent_stats.monthly_messages; - let monthly_tokens = general_stats.monthly_tokens + agent_stats.monthly_tokens; - // 计算总使用时间(基于 token 的估算,假设平均每个 token 需要 0.1 秒) + // Token 优先使用真实统计表;无记录时回退到基于消息内容长度的估算 + let token_stats = query_token_stats(conn, &today_start, &month_start)?; + let total_tokens = token_stats.total_tokens; + let monthly_tokens = token_stats.monthly_tokens; + let today_tokens = token_stats.today_tokens; + + // 计算总使用时间(基于 token 估算,约 10 token/s) let total_time_minutes = (total_tokens / 600) as u32; Ok(UsageStatsResponse { @@ -117,187 +130,526 @@ pub fn get_usage_stats_from_db( }) } +fn validate_time_range(time_range: &str) -> Result<(), String> { + match time_range { + "week" | "month" | "all" => Ok(()), + _ => Err("无效的时间范围".to_string()), + } +} + +fn resolve_range_days(time_range: &str) -> Result { + match time_range { + "week" => Ok(7), + "month" => Ok(30), + "all" => Ok(90), + _ => Err("无效的时间范围".to_string()), + } +} + +fn resolve_range_start(time_range: &str) -> Result>, String> { + let now = Local::now(); + match time_range { + "week" => Ok(Some(now - Duration::days(7))), + "month" => Ok(Some(now - Duration::days(30))), + "all" => Ok(None), + _ => Err("无效的时间范围".to_string()), + } +} + +fn start_of_day(now: DateTime) -> DateTime { + now.with_hour(0) + .and_then(|dt| dt.with_minute(0)) + .and_then(|dt| dt.with_second(0)) + .and_then(|dt| dt.with_nanosecond(0)) + .unwrap_or(now) +} + +fn start_of_month(now: DateTime) -> DateTime { + now.with_day(1) + .and_then(|dt| dt.with_hour(0)) + .and_then(|dt| dt.with_minute(0)) + .and_then(|dt| dt.with_second(0)) + .and_then(|dt| dt.with_nanosecond(0)) + .unwrap_or_else(|| start_of_day(now)) +} + +fn clamp_i64_to_u32(value: i64) -> u32 { + value.clamp(0, u32::MAX as i64) as u32 +} + +fn clamp_i64_to_u64(value: i64) -> u64 { + value.max(0) as u64 +} + +fn chars_to_estimated_tokens(chars: i64) -> u64 { + if chars <= 0 { + return 0; + } + ((chars as f64) / 4.0).ceil() as u64 +} + /// 查询通用对话统计 fn query_general_chat_stats( conn: &Connection, - today_start: &chrono::DateTime, - month_start: &chrono::DateTime, -) -> Result { + today_start: &DateTime, + month_start: &DateTime, +) -> Result { // 转换为 Unix 时间戳(毫秒) let today_ts = today_start.timestamp_millis(); let month_ts = month_start.timestamp_millis(); // 今日对话数 - let today_conversations: u32 = conn + let today_conversations: i64 = conn .query_row( "SELECT COUNT(*) FROM general_chat_sessions WHERE created_at >= ?", [today_ts], |row| row.get(0), ) - .unwrap_or(0); + .map_err(|e| format!("查询今日通用会话数失败: {e}"))?; // 今日消息数 - let today_messages: u32 = conn + let today_messages: i64 = conn .query_row( "SELECT COUNT(*) FROM general_chat_messages WHERE created_at >= ?", [today_ts], |row| row.get(0), ) - .unwrap_or(0); + .map_err(|e| format!("查询今日通用消息数失败: {e}"))?; // 本月对话数 - let monthly_conversations: u32 = conn + let monthly_conversations: i64 = conn .query_row( "SELECT COUNT(*) FROM general_chat_sessions WHERE created_at >= ?", [month_ts], |row| row.get(0), ) - .unwrap_or(0); + .map_err(|e| format!("查询本月通用会话数失败: {e}"))?; // 本月消息数 - let monthly_messages: u32 = conn + let monthly_messages: i64 = conn .query_row( "SELECT COUNT(*) FROM general_chat_messages WHERE created_at >= ?", [month_ts], |row| row.get(0), ) - .unwrap_or(0); + .map_err(|e| format!("查询本月通用消息数失败: {e}"))?; // 总对话数 - let total_conversations: u32 = conn + let total_conversations: i64 = conn .query_row("SELECT COUNT(*) FROM general_chat_sessions", [], |row| { row.get(0) }) - .unwrap_or(0); + .map_err(|e| format!("查询总通用会话数失败: {e}"))?; // 总消息数 - let total_messages: u32 = conn + let total_messages: i64 = conn .query_row("SELECT COUNT(*) FROM general_chat_messages", [], |row| { row.get(0) }) - .unwrap_or(0); + .map_err(|e| format!("查询总通用消息数失败: {e}"))?; - // TODO: Token 消耗需要从 model_usage_stats 表查询 - let today_tokens = 0u64; - let monthly_tokens = 0u64; - let total_tokens = 0u64; - - Ok(UsageStatsResponse { - total_conversations, - total_messages, - total_tokens, - total_time_minutes: 0, - monthly_conversations, - monthly_messages, - monthly_tokens, - today_conversations, - today_messages, - today_tokens, + Ok(ConversationStats { + total_conversations: clamp_i64_to_u32(total_conversations), + total_messages: clamp_i64_to_u32(total_messages), + monthly_conversations: clamp_i64_to_u32(monthly_conversations), + monthly_messages: clamp_i64_to_u32(monthly_messages), + today_conversations: clamp_i64_to_u32(today_conversations), + today_messages: clamp_i64_to_u32(today_messages), }) } /// 查询 Agent 对话统计 fn query_agent_chat_stats( conn: &Connection, - today_start: &chrono::DateTime, - month_start: &chrono::DateTime, -) -> Result { + today_start: &DateTime, + month_start: &DateTime, +) -> Result { // Agent sessions 使用 TEXT 格式的日期时间 let today_str = today_start.format("%Y-%m-%d %H:%M:%S").to_string(); let month_str = month_start.format("%Y-%m-%d %H:%M:%S").to_string(); // 今日对话数 - let today_conversations: u32 = conn + let today_conversations: i64 = conn .query_row( "SELECT COUNT(*) FROM agent_sessions WHERE datetime(created_at) >= datetime(?)", [today_str.clone()], |row| row.get(0), ) - .unwrap_or(0); + .map_err(|e| format!("查询今日 Agent 会话数失败: {e}"))?; // 今日消息数 - let today_messages: u32 = conn + let today_messages: i64 = conn .query_row( "SELECT COUNT(*) FROM agent_messages WHERE datetime(timestamp) >= datetime(?)", [today_str], |row| row.get(0), ) - .unwrap_or(0); + .map_err(|e| format!("查询今日 Agent 消息数失败: {e}"))?; // 本月对话数 - let monthly_conversations: u32 = conn + let monthly_conversations: i64 = conn .query_row( "SELECT COUNT(*) FROM agent_sessions WHERE datetime(created_at) >= datetime(?)", [month_str.clone()], |row| row.get(0), ) - .unwrap_or(0); + .map_err(|e| format!("查询本月 Agent 会话数失败: {e}"))?; // 本月消息数 - let monthly_messages: u32 = conn + let monthly_messages: i64 = conn .query_row( "SELECT COUNT(*) FROM agent_messages WHERE datetime(timestamp) >= datetime(?)", [month_str], |row| row.get(0), ) - .unwrap_or(0); + .map_err(|e| format!("查询本月 Agent 消息数失败: {e}"))?; // 总对话数 - let total_conversations: u32 = conn + let total_conversations: i64 = conn .query_row("SELECT COUNT(*) FROM agent_sessions", [], |row| row.get(0)) - .unwrap_or(0); + .map_err(|e| format!("查询总 Agent 会话数失败: {e}"))?; // 总消息数 - let total_messages: u32 = conn + let total_messages: i64 = conn .query_row("SELECT COUNT(*) FROM agent_messages", [], |row| row.get(0)) - .unwrap_or(0); + .map_err(|e| format!("查询总 Agent 消息数失败: {e}"))?; - // TODO: Token 消耗需要从 model_usage_stats 表查询 - let today_tokens = 0u64; - let monthly_tokens = 0u64; - let total_tokens = 0u64; + Ok(ConversationStats { + total_conversations: clamp_i64_to_u32(total_conversations), + total_messages: clamp_i64_to_u32(total_messages), + monthly_conversations: clamp_i64_to_u32(monthly_conversations), + monthly_messages: clamp_i64_to_u32(monthly_messages), + today_conversations: clamp_i64_to_u32(today_conversations), + today_messages: clamp_i64_to_u32(today_messages), + }) +} - Ok(UsageStatsResponse { - total_conversations, - total_messages, - total_tokens, - total_time_minutes: 0, - monthly_conversations, - monthly_messages, - monthly_tokens, - today_conversations, - today_messages, - today_tokens, +fn query_token_stats( + conn: &Connection, + today_start: &DateTime, + month_start: &DateTime, +) -> Result { + if let Some(actual_tokens) = query_model_usage_table_tokens(conn, today_start, month_start)? { + return Ok(actual_tokens); + } + + query_estimated_tokens_from_messages(conn, today_start, month_start) +} + +fn query_model_usage_table_tokens( + conn: &Connection, + today_start: &DateTime, + month_start: &DateTime, +) -> Result, String> { + let row_count: i64 = conn + .query_row("SELECT COUNT(*) FROM model_usage_stats", [], |row| { + row.get(0) + }) + .map_err(|e| format!("查询 model_usage_stats 行数失败: {e}"))?; + + if row_count <= 0 { + return Ok(None); + } + + let today_key = today_start.format("%Y-%m-%d").to_string(); + let month_key = month_start.format("%Y-%m-%d").to_string(); + + let total_tokens: i64 = conn + .query_row( + "SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats", + [], + |row| row.get(0), + ) + .map_err(|e| format!("查询总 Token 失败: {e}"))?; + + let monthly_tokens: i64 = conn + .query_row( + "SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats WHERE date >= ?", + [month_key], + |row| row.get(0), + ) + .map_err(|e| format!("查询本月 Token 失败: {e}"))?; + + let today_tokens: i64 = conn + .query_row( + "SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats WHERE date = ?", + [today_key], + |row| row.get(0), + ) + .map_err(|e| format!("查询今日 Token 失败: {e}"))?; + + Ok(Some(TokenStats { + total_tokens: clamp_i64_to_u64(total_tokens), + monthly_tokens: clamp_i64_to_u64(monthly_tokens), + today_tokens: clamp_i64_to_u64(today_tokens), + })) +} + +fn query_estimated_tokens_from_messages( + conn: &Connection, + today_start: &DateTime, + month_start: &DateTime, +) -> Result { + let today_ts = today_start.timestamp_millis(); + let month_ts = month_start.timestamp_millis(); + let today_str = today_start.format("%Y-%m-%d %H:%M:%S").to_string(); + let month_str = month_start.format("%Y-%m-%d %H:%M:%S").to_string(); + + let general_total_chars: i64 = conn + .query_row( + "SELECT COALESCE(SUM(LENGTH(content)), 0) FROM general_chat_messages", + [], + |row| row.get(0), + ) + .map_err(|e| format!("估算总 Token(通用消息)失败: {e}"))?; + + let general_monthly_chars: i64 = conn + .query_row( + "SELECT COALESCE(SUM(LENGTH(content)), 0) FROM general_chat_messages WHERE created_at >= ?", + [month_ts], + |row| row.get(0), + ) + .map_err(|e| format!("估算本月 Token(通用消息)失败: {e}"))?; + + let general_today_chars: i64 = conn + .query_row( + "SELECT COALESCE(SUM(LENGTH(content)), 0) FROM general_chat_messages WHERE created_at >= ?", + [today_ts], + |row| row.get(0), + ) + .map_err(|e| format!("估算今日 Token(通用消息)失败: {e}"))?; + + let agent_total_chars: i64 = conn + .query_row( + "SELECT COALESCE(SUM(LENGTH(content_json)), 0) FROM agent_messages", + [], + |row| row.get(0), + ) + .map_err(|e| format!("估算总 Token(Agent 消息)失败: {e}"))?; + + let agent_monthly_chars: i64 = conn + .query_row( + "SELECT COALESCE(SUM(LENGTH(content_json)), 0) FROM agent_messages + WHERE datetime(timestamp) >= datetime(?)", + [month_str], + |row| row.get(0), + ) + .map_err(|e| format!("估算本月 Token(Agent 消息)失败: {e}"))?; + + let agent_today_chars: i64 = conn + .query_row( + "SELECT COALESCE(SUM(LENGTH(content_json)), 0) FROM agent_messages + WHERE datetime(timestamp) >= datetime(?)", + [today_str], + |row| row.get(0), + ) + .map_err(|e| format!("估算今日 Token(Agent 消息)失败: {e}"))?; + + Ok(TokenStats { + total_tokens: chars_to_estimated_tokens(general_total_chars + agent_total_chars), + monthly_tokens: chars_to_estimated_tokens(general_monthly_chars + agent_monthly_chars), + today_tokens: chars_to_estimated_tokens(general_today_chars + agent_today_chars), }) } /// 获取模型使用排行 pub fn get_model_usage_ranking_from_db( - _time_range: &str, - _conn: &Connection, + time_range: &str, + conn: &Connection, ) -> Result, String> { - // TODO: 从 model_usage_stats 表查询真实的模型使用排行 - // 这里暂时返回模拟数据 - Ok(vec![ - ModelUsage { - model: "GPT-4".to_string(), - conversations: 145, - tokens: 580000, - percentage: 46.0, - }, - ModelUsage { - model: "GPT-3.5".to_string(), - conversations: 128, - tokens: 420000, - percentage: 33.0, - }, - ModelUsage { - model: "Claude 3".to_string(), - conversations: 55, - tokens: 258000, - percentage: 21.0, - }, - ]) + let range_start = resolve_range_start(time_range)?; + + let mut usages = query_model_usage_from_stats_table(conn, range_start)?; + if usages.is_empty() { + usages = query_model_usage_from_agent_messages(conn, range_start)?; + } + + Ok(build_model_usage_response(usages)) +} + +fn query_model_usage_from_stats_table( + conn: &Connection, + range_start: Option>, +) -> Result, String> { + let mut result = Vec::new(); + + if let Some(start) = range_start { + let start_key = start.format("%Y-%m-%d").to_string(); + let mut stmt = conn + .prepare( + "SELECT model_id, + COALESCE(SUM(request_count), 0) AS conversations, + COALESCE(SUM(total_tokens), 0) AS tokens + FROM model_usage_stats + WHERE date >= ? + GROUP BY model_id + ORDER BY tokens DESC, conversations DESC + LIMIT 20", + ) + .map_err(|e| format!("准备模型统计查询失败: {e}"))?; + + let rows = stmt + .query_map([start_key], |row| { + let model: String = row.get(0)?; + let conversations: i64 = row.get(1)?; + let tokens: i64 = row.get(2)?; + Ok(RawModelUsage { + model, + conversations: clamp_i64_to_u64(conversations), + tokens: clamp_i64_to_u64(tokens), + }) + }) + .map_err(|e| format!("执行模型统计查询失败: {e}"))?; + + for row in rows { + result.push(row.map_err(|e| format!("读取模型统计行失败: {e}"))?); + } + + return Ok(result); + } + + let mut stmt = conn + .prepare( + "SELECT model_id, + COALESCE(SUM(request_count), 0) AS conversations, + COALESCE(SUM(total_tokens), 0) AS tokens + FROM model_usage_stats + GROUP BY model_id + ORDER BY tokens DESC, conversations DESC + LIMIT 20", + ) + .map_err(|e| format!("准备模型统计查询失败: {e}"))?; + + let rows = stmt + .query_map([], |row| { + let model: String = row.get(0)?; + let conversations: i64 = row.get(1)?; + let tokens: i64 = row.get(2)?; + Ok(RawModelUsage { + model, + conversations: clamp_i64_to_u64(conversations), + tokens: clamp_i64_to_u64(tokens), + }) + }) + .map_err(|e| format!("执行模型统计查询失败: {e}"))?; + + for row in rows { + result.push(row.map_err(|e| format!("读取模型统计行失败: {e}"))?); + } + + Ok(result) +} + +fn query_model_usage_from_agent_messages( + conn: &Connection, + range_start: Option>, +) -> Result, String> { + let mut result = Vec::new(); + + if let Some(start) = range_start { + let start_str = start.format("%Y-%m-%d %H:%M:%S").to_string(); + let mut stmt = conn + .prepare( + "SELECT s.model, + COUNT(DISTINCT m.session_id) AS conversations, + COALESCE(SUM(LENGTH(m.content_json)), 0) AS content_chars + FROM agent_messages m + JOIN agent_sessions s ON s.id = m.session_id + WHERE datetime(m.timestamp) >= datetime(?) + GROUP BY s.model + ORDER BY content_chars DESC, conversations DESC + LIMIT 20", + ) + .map_err(|e| format!("准备 Agent 模型排行查询失败: {e}"))?; + + let rows = stmt + .query_map([start_str], |row| { + let model: String = row.get(0)?; + let conversations: i64 = row.get(1)?; + let chars: i64 = row.get(2)?; + Ok(RawModelUsage { + model, + conversations: clamp_i64_to_u64(conversations), + tokens: chars_to_estimated_tokens(chars), + }) + }) + .map_err(|e| format!("执行 Agent 模型排行查询失败: {e}"))?; + + for row in rows { + result.push(row.map_err(|e| format!("读取 Agent 模型排行行失败: {e}"))?); + } + + return Ok(result); + } + + let mut stmt = conn + .prepare( + "SELECT s.model, + COUNT(DISTINCT m.session_id) AS conversations, + COALESCE(SUM(LENGTH(m.content_json)), 0) AS content_chars + FROM agent_messages m + JOIN agent_sessions s ON s.id = m.session_id + GROUP BY s.model + ORDER BY content_chars DESC, conversations DESC + LIMIT 20", + ) + .map_err(|e| format!("准备 Agent 模型排行查询失败: {e}"))?; + + let rows = stmt + .query_map([], |row| { + let model: String = row.get(0)?; + let conversations: i64 = row.get(1)?; + let chars: i64 = row.get(2)?; + Ok(RawModelUsage { + model, + conversations: clamp_i64_to_u64(conversations), + tokens: chars_to_estimated_tokens(chars), + }) + }) + .map_err(|e| format!("执行 Agent 模型排行查询失败: {e}"))?; + + for row in rows { + result.push(row.map_err(|e| format!("读取 Agent 模型排行行失败: {e}"))?); + } + + Ok(result) +} + +fn build_model_usage_response(usages: Vec) -> Vec { + if usages.is_empty() { + return Vec::new(); + } + + let total_tokens: u64 = usages.iter().map(|item| item.tokens).sum(); + let total_conversations: u64 = usages.iter().map(|item| item.conversations).sum(); + + usages + .into_iter() + .map(|item| { + let denominator = if total_tokens > 0 { + total_tokens as f64 + } else { + total_conversations.max(1) as f64 + }; + let numerator = if total_tokens > 0 { + item.tokens as f64 + } else { + item.conversations as f64 + }; + let percentage = ((numerator / denominator) * 1000.0).round() / 10.0; + + ModelUsage { + model: if item.model.trim().is_empty() { + "unknown".to_string() + } else { + item.model + }, + conversations: item.conversations.min(u32::MAX as u64) as u32, + tokens: item.tokens, + percentage: percentage as f32, + } + }) + .collect() } /// 获取每日使用趋势 @@ -305,58 +657,89 @@ pub fn get_daily_usage_trends_from_db( time_range: &str, conn: &Connection, ) -> Result, String> { - let days = match time_range { - "week" => 7, - "month" => 30, - "all" => 90, - _ => 30, - }; + let days = resolve_range_days(time_range)?; + + let has_model_usage_data: i64 = conn + .query_row("SELECT COUNT(*) FROM model_usage_stats", [], |row| { + row.get(0) + }) + .map_err(|e| format!("检查 model_usage_stats 失败: {e}"))?; + let use_actual_tokens = has_model_usage_data > 0; let mut daily_usage = Vec::new(); - // 查询通用对话的每日统计 + // 查询每日统计(从最早日期到今天) for i in (0..days).rev() { - let date = chrono::Local::now() - chrono::Duration::days(i as i64); + let date = Local::now() - Duration::days(i); + let day_start = start_of_day(date); + let day_end = day_start + Duration::days(1); - // 当天的开始和结束时间戳 - let day_start = date - .with_hour(0) - .and_then(|d| d.with_minute(0)) - .and_then(|d| d.with_second(0)) - .unwrap_or(date) - .timestamp_millis(); - let day_end = day_start + 24 * 60 * 60 * 1000 - 1; // 当天 23:59:59 + // 当天开始/结束(时间戳 + 文本) + let day_start_ts = day_start.timestamp_millis(); + let day_end_ts = day_end.timestamp_millis(); + let day_start_str = day_start.format("%Y-%m-%d %H:%M:%S").to_string(); + let day_end_str = day_end.format("%Y-%m-%d %H:%M:%S").to_string(); + let day_key = day_start.format("%Y-%m-%d").to_string(); - let conversations: u32 = conn + let conversations: i64 = conn .query_row( - "SELECT COUNT(*) FROM general_chat_sessions WHERE created_at >= ? AND created_at <= ?", - [day_start, day_end], + "SELECT COUNT(*) FROM general_chat_sessions WHERE created_at >= ? AND created_at < ?", + [day_start_ts, day_end_ts], |row| row.get(0), ) - .unwrap_or(0); + .map_err(|e| format!("查询通用会话日统计失败: {e}"))?; // 查询 Agent 对话 - let date_str = date.format("%Y-%m-%d").to_string(); - let agent_conversations: u32 = conn + let agent_conversations: i64 = conn .query_row( - "SELECT COUNT(*) FROM agent_sessions WHERE date(created_at) = ?", - [date_str], + "SELECT COUNT(*) FROM agent_sessions + WHERE datetime(created_at) >= datetime(?) + AND datetime(created_at) < datetime(?)", + [day_start_str.clone(), day_end_str.clone()], |row| row.get(0), ) - .unwrap_or(0); + .map_err(|e| format!("查询 Agent 会话日统计失败: {e}"))?; let total_conversations = conversations + agent_conversations; - // TODO: 从 model_usage_stats 表查询 Token 消耗 - let tokens = if total_conversations > 0 { - ((rand::random::() % 15000) + 2000) as u64 + let tokens = if use_actual_tokens { + let day_tokens: i64 = conn + .query_row( + "SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats WHERE date = ?", + [day_key.clone()], + |row| row.get(0), + ) + .map_err(|e| format!("查询模型日 Token 失败: {e}"))?; + + clamp_i64_to_u64(day_tokens) } else { - 0 + let general_chars: i64 = conn + .query_row( + "SELECT COALESCE(SUM(LENGTH(content)), 0) + FROM general_chat_messages + WHERE created_at >= ? AND created_at < ?", + [day_start_ts, day_end_ts], + |row| row.get(0), + ) + .map_err(|e| format!("估算通用消息日 Token 失败: {e}"))?; + + let agent_chars: i64 = conn + .query_row( + "SELECT COALESCE(SUM(LENGTH(content_json)), 0) + FROM agent_messages + WHERE datetime(timestamp) >= datetime(?) + AND datetime(timestamp) < datetime(?)", + [day_start_str, day_end_str], + |row| row.get(0), + ) + .map_err(|e| format!("估算 Agent 消息日 Token 失败: {e}"))?; + + chars_to_estimated_tokens(general_chars + agent_chars) }; daily_usage.push(DailyUsage { - date: date.format("%Y-%m-%d").to_string(), - conversations: total_conversations, + date: day_key, + conversations: clamp_i64_to_u32(total_conversations), tokens, }); } diff --git a/src-tauri/src/voice/commands.rs b/src-tauri/src/voice/commands.rs index 8c9037799..3140e9686 100644 --- a/src-tauri/src/voice/commands.rs +++ b/src-tauri/src/voice/commands.rs @@ -10,6 +10,17 @@ use super::config; use super::recording_service::{AudioDeviceInfo, RecordingServiceState}; use tauri::State; +fn normalize_shortcut(value: Option) -> Option { + value.and_then(|raw| { + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + /// 获取所有可用的麦克风设备 #[command] pub async fn list_audio_devices() -> Result, String> { @@ -29,16 +40,58 @@ pub async fn save_voice_input_config( voice_config: VoiceInputConfig, ) -> Result<(), String> { let old_config = config::load_voice_config()?; + let old_enabled = old_config.enabled; + let new_enabled = voice_config.enabled; - if old_config.shortcut != voice_config.shortcut { - super::shortcut::update(&app, &voice_config.shortcut)?; - } + if old_enabled && new_enabled { + if old_config.shortcut != voice_config.shortcut { + super::shortcut::update(&app, &voice_config.shortcut)?; + } - if old_config.enabled != voice_config.enabled { - if voice_config.enabled { - super::shortcut::register(&app, &voice_config.shortcut)?; - } else { - super::shortcut::unregister(&app)?; + let old_translate_shortcut = normalize_shortcut(old_config.translate_shortcut.clone()); + let new_translate_shortcut = normalize_shortcut(voice_config.translate_shortcut.clone()); + let instruction_changed = + old_config.translate_instruction_id != voice_config.translate_instruction_id; + + if old_translate_shortcut != new_translate_shortcut || instruction_changed { + match ( + old_translate_shortcut.as_deref(), + new_translate_shortcut.as_deref(), + ) { + (Some(_), Some(new_shortcut)) => { + super::shortcut::update_translate( + &app, + new_shortcut, + &voice_config.translate_instruction_id, + )?; + } + (None, Some(new_shortcut)) => { + super::shortcut::register_translate( + &app, + new_shortcut, + &voice_config.translate_instruction_id, + )?; + } + (Some(_), None) => { + super::shortcut::unregister_translate(&app)?; + } + (None, None) => {} + } + } + } else if old_enabled && !new_enabled { + super::shortcut::unregister(&app)?; + let _ = super::shortcut::unregister_translate(&app); + } else if !old_enabled && new_enabled { + super::shortcut::register(&app, &voice_config.shortcut)?; + + if let Some(translate_shortcut) = + normalize_shortcut(voice_config.translate_shortcut.clone()) + { + super::shortcut::register_translate( + &app, + &translate_shortcut, + &voice_config.translate_instruction_id, + )?; } } diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 660a5d475..d24a1a0c3 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.62.0", + "version": "0.63.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 60884d5c1..7792219ae 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.62.0", + "version": "0.63.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 57d6fcf5b..739976a70 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { SplashScreen } from "./components/SplashScreen"; import { AppSidebar } from "./components/AppSidebar"; import { SettingsPageV2 } from "./components/settings-v2"; import { ToolsPage } from "./components/tools/ToolsPage"; +import { MemoryPage } from "./components/memory"; import { AgentChatPage } from "./components/agent"; import { PluginsPage } from "./components/plugins/PluginsPage"; import { ImageGenPage } from "./components/image-gen"; @@ -25,6 +26,7 @@ import { createProject, createContent, isUserProjectType, + resolveProjectRootPath, } from "./lib/api/project"; import { TerminalWorkspace, @@ -54,7 +56,6 @@ import { WorkspaceTheme, } from "./types/page"; import { SettingsTabs } from "./types/settings"; -import { open } from "@tauri-apps/plugin-dialog"; import { toast } from "sonner"; const AppContainer = styled.div` @@ -267,24 +268,7 @@ function AppContent() { 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 projectPath = await resolveProjectRootPath(name); const project = await createProject({ name, @@ -406,6 +390,7 @@ function AppContent() { contentId={(pageParams as AgentPageParams).contentId} theme={(pageParams as AgentPageParams).theme} lockTheme={(pageParams as AgentPageParams).lockTheme} + newChatAt={(pageParams as AgentPageParams).newChatAt} onHasMessagesChange={setAgentHasMessages} /> @@ -443,6 +428,17 @@ function AppContent() { +
+ +
+
currentPage === "settings", + page: "memory", + isActive: (currentPage) => currentPage === "memory", }, ]; @@ -520,7 +519,16 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { setActiveThemeKey(item.page); localStorage.setItem(LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, item.page); } - onNavigate(item.page, item.params); + + const params: PageParams | undefined = + item.id === "home-general" + ? ({ + ...(item.params as AgentPageParams | undefined), + newChatAt: Date.now(), + } as AgentPageParams) + : item.params; + + onNavigate(item.page, params); }; return ( @@ -531,6 +539,7 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { onNavigate("agent", { theme: "general", lockTheme: false, + newChatAt: Date.now(), }) } > @@ -546,6 +555,7 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { onNavigate("agent", { theme: "general", lockTheme: false, + newChatAt: Date.now(), }) } > diff --git a/src/components/agent/chat/components/ProjectSelector.tsx b/src/components/agent/chat/components/ProjectSelector.tsx index ce4ee0ce9..2f949e49d 100644 --- a/src/components/agent/chat/components/ProjectSelector.tsx +++ b/src/components/agent/chat/components/ProjectSelector.tsx @@ -14,13 +14,15 @@ import { cn } from "@/lib/utils"; import { Project, ProjectType, - listProjects, createProject, - generateProjectName, - getDefaultProjectPath, + getCreateProjectErrorMessage, + extractErrorMessage, + listProjects, + resolveProjectRootPath, TYPE_CONFIGS, } from "@/lib/api/project"; import { toast } from "sonner"; +import { CreateProjectDialog } from "@/components/projects/CreateProjectDialog"; interface ProjectSelectorProps { /** 当前激活的主题(用于过滤项目) */ @@ -39,7 +41,7 @@ export function ProjectSelector({ const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(""); - const [creating, setCreating] = useState(false); + const [createDialogOpen, setCreateDialogOpen] = useState(false); // 加载项目列表 useEffect(() => { @@ -90,18 +92,22 @@ export function ProjectSelector({ return result; }, [projects, activeTheme, searchQuery]); - // 快速创建项目 - const handleQuickCreate = async () => { - setCreating(true); + const defaultProjectType = useMemo(() => { + const themeType = activeTheme as ProjectType; + if (Object.prototype.hasOwnProperty.call(TYPE_CONFIGS, themeType)) { + return themeType; + } + return "general" as ProjectType; + }, [activeTheme]); + + const handleCreateProject = async (name: string, type: ProjectType) => { try { - const projectType = activeTheme as ProjectType; - const name = generateProjectName(projectType); - const rootPath = getDefaultProjectPath(); + const projectPath = await resolveProjectRootPath(name); const newProject = await createProject({ name, - rootPath, - workspaceType: projectType, + rootPath: projectPath, + workspaceType: type, }); toast.success("项目创建成功"); @@ -109,9 +115,10 @@ export function ProjectSelector({ onSelectProject(newProject.id); } catch (error) { console.error("创建项目失败:", error); - toast.error("创建项目失败"); - } finally { - setCreating(false); + const errorMessage = extractErrorMessage(error); + const friendlyMessage = getCreateProjectErrorMessage(errorMessage); + toast.error(`创建项目失败: ${friendlyMessage}`); + throw error; } }; @@ -142,11 +149,7 @@ export function ProjectSelector({ className="pl-9" />
- @@ -165,7 +168,7 @@ export function ProjectSelector({ {searchQuery ? "没有找到匹配的项目" : "还没有项目"}

{!searchQuery && ( - )} @@ -218,6 +221,14 @@ export function ProjectSelector({ )} + + ); } diff --git a/src/components/agent/chat/hooks/index.ts b/src/components/agent/chat/hooks/index.ts index 1b6e67246..ade9f0ea7 100644 --- a/src/components/agent/chat/hooks/index.ts +++ b/src/components/agent/chat/hooks/index.ts @@ -14,6 +14,7 @@ export type { Topic } from "./useAgentChat"; interface UseAgentChatUnifiedOptions { systemPrompt?: string; onWriteFile?: (content: string, fileName: string) => void; + workspaceId: string; } /** @@ -26,7 +27,7 @@ interface UseAgentChatUnifiedOptions { * 切换方式: * localStorage.setItem("proxycast_agent_backend", "aster") */ -export function useAgentChatUnified(options: UseAgentChatUnifiedOptions = {}) { +export function useAgentChatUnified(options: UseAgentChatUnifiedOptions) { const backend = getAgentBackend(); // 根据配置选择 hook diff --git a/src/components/agent/chat/hooks/useAgentChat.ts b/src/components/agent/chat/hooks/useAgentChat.ts index 0a8d156fc..249a16b5f 100644 --- a/src/components/agent/chat/hooks/useAgentChat.ts +++ b/src/components/agent/chat/hooks/useAgentChat.ts @@ -116,7 +116,7 @@ const loadTransient = (key: string, defaultValue: T): T => { const stored = sessionStorage.getItem(key); if (stored) { const parsed = JSON.parse(stored); - if (key === "agent_messages" && Array.isArray(parsed)) { + if (key.startsWith("agent_messages") && Array.isArray(parsed)) { return parsed.map((msg: any) => ({ ...msg, timestamp: new Date(msg.timestamp), @@ -155,10 +155,34 @@ interface UseAgentChatOptions { systemPrompt?: string; /** 文件写入回调 */ onWriteFile?: (content: string, fileName: string) => void; + /** 绑定的工作区 ID(用于本地 sandbox) */ + workspaceId: string; } -export function useAgentChat(options: UseAgentChatOptions = {}) { - const { systemPrompt, onWriteFile } = options; +export function useAgentChat(options: UseAgentChatOptions) { + const { systemPrompt, onWriteFile, workspaceId } = options; + + const getRequiredWorkspaceId = (): string => { + const resolvedWorkspaceId = workspaceId?.trim(); + if (!resolvedWorkspaceId) { + throw new Error("缺少项目工作区,请先选择项目后再使用 Agent"); + } + return resolvedWorkspaceId; + }; + + const getScopedKey = (key: string): string => { + const resolvedWorkspaceId = workspaceId?.trim(); + return resolvedWorkspaceId + ? `${key}_${resolvedWorkspaceId}` + : `${key}_global`; + }; + + const getScopedSessionKey = () => getScopedKey("agent_curr_sessionId"); + const getScopedMessagesKey = () => getScopedKey("agent_messages"); + const getScopedRoundCountKey = () => getScopedKey("agent_curr_roundCount"); + const getScopedPersistedSessionKey = () => + getScopedKey("agent_last_sessionId"); + const [processStatus, setProcessStatus] = useState({ running: false, }); @@ -180,12 +204,44 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { ); // Session State - const [sessionId, setSessionId] = useState(() => - loadTransient("agent_curr_sessionId", null), - ); - const [messages, setMessages] = useState(() => - loadTransient("agent_messages", []), - ); + const [sessionId, setSessionId] = useState(() => { + if (!workspaceId?.trim()) { + return null; + } + + const scopedSessionId = loadTransient( + getScopedSessionKey(), + null, + ); + if (scopedSessionId) { + return scopedSessionId; + } + + const persistedSessionId = loadPersisted( + getScopedPersistedSessionKey(), + null, + ); + if (persistedSessionId) { + return persistedSessionId; + } + + // 兼容旧版本(未按 workspace 分片) + return loadTransient("agent_curr_sessionId", null); + }); + + const [messages, setMessages] = useState(() => { + if (!workspaceId?.trim()) { + return []; + } + + const scopedMessages = loadTransient(getScopedMessagesKey(), []); + if (scopedMessages.length > 0) { + return scopedMessages; + } + + // 兼容旧版本(未按 workspace 分片) + return loadTransient("agent_messages", []); + }); // 话题列表 const [topics, setTopics] = useState([]); @@ -199,13 +255,20 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { // 当前会话的轮数计数器(用于判断何时生成标题) const [_roundCount, _setRoundCount] = useState(() => - loadTransient("agent_curr_roundCount", 0), + workspaceId?.trim() + ? loadTransient(getScopedRoundCountKey(), 0) + : 0, ); // 用于保存当前流式请求的取消函数 const unlistenRef = useRef(null); // 用于保存当前正在处理的消息 ID const currentAssistantMsgIdRef = useRef(null); + // 自动恢复/水合状态跟踪 + const restoredWorkspaceRef = useRef(null); + const hydratedSessionRef = useRef(null); + const skipAutoRestoreRef = useRef(false); + const sessionResetVersionRef = useRef(0); // Artifact 解析器 - 用于流式解析 AI 响应中的 artifact const { @@ -261,24 +324,72 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { }, [providerType, providerConfig]); useEffect(() => { - saveTransient("agent_curr_sessionId", sessionId); - }, [sessionId]); - useEffect(() => { - saveTransient("agent_messages", messages); - }, [messages]); + const resolvedWorkspaceId = workspaceId?.trim(); + if (!resolvedWorkspaceId) { + return; + } - // 当 systemPrompt 变化时,需要创建新会话以应用新的系统提示词 - // 这对于内容创作模式切换非常重要 - useEffect(() => { - if (systemPrompt !== undefined && sessionId) { - console.log( - "[useAgentChat] systemPrompt 变化,重置 session 以应用新提示词", + const scopedSessionKey = getScopedSessionKey(); + const scopedPersistedSessionKey = getScopedPersistedSessionKey(); + + saveTransient(scopedSessionKey, sessionId); + savePersisted(scopedPersistedSessionKey, sessionId); + + if (sessionId) { + savePersisted( + `agent_session_workspace_${sessionId}`, + resolvedWorkspaceId, ); - setSessionId(null); } - // 注意:只在 systemPrompt 变化时触发,不包含 sessionId // eslint-disable-next-line react-hooks/exhaustive-deps - }, [systemPrompt]); + }, [sessionId, workspaceId]); + + useEffect(() => { + if (!workspaceId?.trim()) { + return; + } + saveTransient(getScopedMessagesKey(), messages); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages, workspaceId]); + + useEffect(() => { + if (!workspaceId?.trim()) { + return; + } + saveTransient(getScopedRoundCountKey(), _roundCount); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [_roundCount, workspaceId]); + + // workspace 变化时恢复对应会话状态 + useEffect(() => { + if (!workspaceId?.trim()) { + setSessionId(null); + setMessages([]); + _setRoundCount(0); + setA2uiFormDataMap({}); + restoredWorkspaceRef.current = null; + hydratedSessionRef.current = null; + skipAutoRestoreRef.current = false; + return; + } + + const scopedSessionId = + loadTransient(getScopedSessionKey(), null) ?? + loadPersisted(getScopedPersistedSessionKey(), null); + + const scopedMessages = loadTransient(getScopedMessagesKey(), []); + const scopedRoundCount = loadTransient(getScopedRoundCountKey(), 0); + + setSessionId(scopedSessionId); + setMessages(scopedMessages); + _setRoundCount(scopedRoundCount); + setA2uiFormDataMap({}); + + restoredWorkspaceRef.current = null; + hydratedSessionRef.current = null; + skipAutoRestoreRef.current = false; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceId]); // 加载话题列表 const loadTopics = async () => { @@ -349,6 +460,16 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // workspace 变化时刷新话题 + useEffect(() => { + if (!workspaceId?.trim()) { + setTopics([]); + return; + } + loadTopics(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceId]); + // 监听截图对话消息事件 useEffect(() => { let unlisten: UnlistenFn | null = null; @@ -422,14 +543,17 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { // Create new session with CURRENT provider/model as baseline // 传递 systemPrompt 用于内容创作等场景 + const resolvedWorkspaceId = getRequiredWorkspaceId(); const response = await createAgentSession( providerType, + resolvedWorkspaceId, model || undefined, systemPrompt, // 传递系统提示词 undefined, // details.length > 0 ? details : undefined ); setSessionId(response.session_id); + skipAutoRestoreRef.current = false; return response.session_id; } catch (error) { console.error("[AgentChat] Auto-creation failed:", error); @@ -948,9 +1072,11 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { hasSystemPrompt: !!systemPrompt, }); + const resolvedWorkspaceId = getRequiredWorkspaceId(); await sendAgentMessageStream( messageToSend, eventName, + resolvedWorkspaceId, activeSessionId, // 传递 sessionId 以保持上下文 model || undefined, imagesToSend, @@ -992,9 +1118,27 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { } = {}, ) => { const { showToast = true, toastMessage = "新话题已创建" } = options; + const resolvedWorkspaceId = workspaceId?.trim(); + + sessionResetVersionRef.current += 1; setMessages([]); setSessionId(null); + _setRoundCount(0); + setA2uiFormDataMap({}); + restoredWorkspaceRef.current = resolvedWorkspaceId || null; + hydratedSessionRef.current = null; + skipAutoRestoreRef.current = true; + + if (resolvedWorkspaceId) { + saveTransient(`agent_curr_sessionId_${resolvedWorkspaceId}`, null); + savePersisted(`agent_last_sessionId_${resolvedWorkspaceId}`, null); + saveTransient(`agent_messages_${resolvedWorkspaceId}`, []); + saveTransient(`agent_curr_roundCount_${resolvedWorkspaceId}`, 0); + } + + // 清理旧版本兼容键,避免被误用为恢复候选 + saveTransient("agent_curr_sessionId", null); if (showToast) { toast.success(toastMessage); @@ -1003,8 +1147,10 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { // 切换话题 const switchTopic = async (topicId: string) => { - if (topicId === sessionId) return; + if (topicId === sessionId && messages.length > 0) return; + const restoreRequestVersion = sessionResetVersionRef.current; + skipAutoRestoreRef.current = false; console.log("[useAgentChat] 切换话题:", topicId); try { @@ -1067,19 +1213,116 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { }; }); + if (restoreRequestVersion !== sessionResetVersionRef.current) { + console.log("[useAgentChat] 忽略过期会话切换:", topicId); + return; + } + console.log("[useAgentChat] 转换后消息数量:", loadedMessages.length); setMessages(loadedMessages); setSessionId(topicId); toast.info("已切换话题"); } catch (error) { + if (restoreRequestVersion !== sessionResetVersionRef.current) { + console.log("[useAgentChat] 忽略过期会话切换错误:", topicId); + return; + } + console.error("[useAgentChat] 加载消息历史失败:", error); - // 如果加载失败,仍然切换话题但清空消息 + // 加载失败时回退到新会话态,避免卡在无效会话 setMessages([]); - setSessionId(topicId); + setSessionId(null); + saveTransient(getScopedSessionKey(), null); + savePersisted(getScopedPersistedSessionKey(), null); toast.error("加载对话历史失败"); } }; + // 自动恢复当前 workspace 最近会话 + useEffect(() => { + const resolvedWorkspaceId = workspaceId?.trim(); + if (!resolvedWorkspaceId) return; + if (skipAutoRestoreRef.current) return; + if (sessionId) return; + if (topics.length === 0) return; + if (restoredWorkspaceRef.current === resolvedWorkspaceId) return; + + restoredWorkspaceRef.current = resolvedWorkspaceId; + + const scopedCandidate = + loadTransient(getScopedSessionKey(), null) || + loadPersisted(getScopedPersistedSessionKey(), null); + + const legacyCandidateRaw = loadTransient( + "agent_curr_sessionId", + null, + ); + const legacyCandidateWorkspace = legacyCandidateRaw + ? loadPersisted( + `agent_session_workspace_${legacyCandidateRaw}`, + null, + ) + : null; + const legacyCandidate = + legacyCandidateRaw && + (!legacyCandidateWorkspace || + legacyCandidateWorkspace === resolvedWorkspaceId) + ? legacyCandidateRaw + : null; + + const mappedFallbackCandidate = + topics.find( + (topic) => + loadPersisted( + `agent_session_workspace_${topic.id}`, + null, + ) === resolvedWorkspaceId, + )?.id || null; + const fallbackCandidate = + mappedFallbackCandidate || (topics.length === 1 ? topics[0]?.id : null); + const targetSessionId = + scopedCandidate || legacyCandidate || fallbackCandidate; + + if (!targetSessionId) { + return; + } + + switchTopic(targetSessionId).catch((error) => { + console.warn("[useAgentChat] 自动恢复会话失败:", error); + saveTransient(getScopedSessionKey(), null); + savePersisted(getScopedPersistedSessionKey(), null); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceId, sessionId, topics]); + + useEffect(() => { + if (sessionId) { + skipAutoRestoreRef.current = false; + } + }, [sessionId]); + + // 如果有 sessionId 但消息为空,主动回填历史消息 + useEffect(() => { + if (!sessionId) return; + + if (messages.length > 0) { + hydratedSessionRef.current = sessionId; + return; + } + + if (hydratedSessionRef.current === sessionId) { + return; + } + + hydratedSessionRef.current = sessionId; + + switchTopic(sessionId).catch((error) => { + console.warn("[useAgentChat] 会话水合失败:", error); + hydratedSessionRef.current = null; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId, messages.length]); + // 删除话题 const deleteTopic = async (topicId: string) => { try { @@ -1495,9 +1738,11 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { // 发送空消息,让 AI 根据系统提示词开始引导 console.log("[AgentChat] triggerAIGuide 发送空消息触发引导"); + const resolvedWorkspaceId = getRequiredWorkspaceId(); await sendAgentMessageStream( "", // 空消息,让 AI 根据系统提示词开始引导 eventName, + resolvedWorkspaceId, activeSessionId, model || undefined, undefined, diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.ts b/src/components/agent/chat/hooks/useAsterAgentChat.ts index feef425fd..36262020a 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.ts +++ b/src/components/agent/chat/hooks/useAsterAgentChat.ts @@ -52,6 +52,7 @@ export interface ConfirmResponse { interface UseAsterAgentChatOptions { systemPrompt?: string; onWriteFile?: (content: string, fileName: string) => void; + workspaceId: string; } // 音效相关(复用) @@ -98,6 +99,54 @@ const playTypewriterSound = () => { } }; +// 持久化 helpers +const loadPersisted = (key: string, defaultValue: T): T => { + try { + const stored = localStorage.getItem(key); + if (stored) { + return JSON.parse(stored); + } + } catch (e) { + console.error(e); + } + return defaultValue; +}; + +const savePersisted = (key: string, value: unknown) => { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (e) { + console.error(e); + } +}; + +const loadTransient = (key: string, defaultValue: T): T => { + try { + const stored = sessionStorage.getItem(key); + if (stored) { + const parsed = JSON.parse(stored); + if (key.startsWith("aster_messages") && Array.isArray(parsed)) { + return parsed.map((msg: any) => ({ + ...msg, + timestamp: new Date(msg.timestamp), + })) as unknown as T; + } + return parsed; + } + } catch (e) { + console.error(e); + } + return defaultValue; +}; + +const saveTransient = (key: string, value: unknown) => { + try { + sessionStorage.setItem(key, JSON.stringify(value)); + } catch (e) { + console.error(e); + } +}; + /** * 将前端 Provider 类型映射到 Aster Provider 名称 */ @@ -127,13 +176,65 @@ const mapProviderName = (providerType: string): string => { return mapping[providerType.toLowerCase()] || providerType; }; -export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) { - const { onWriteFile } = options; +export function useAsterAgentChat(options: UseAsterAgentChatOptions) { + const { onWriteFile, workspaceId } = options; + + const getRequiredWorkspaceId = useCallback((): string => { + const resolvedWorkspaceId = workspaceId?.trim(); + if (!resolvedWorkspaceId) { + throw new Error("缺少项目工作区,请先选择项目后再使用 Agent"); + } + return resolvedWorkspaceId; + }, [workspaceId]); + + const getScopedKey = useCallback( + (key: string): string => { + const resolvedWorkspaceId = workspaceId?.trim(); + return resolvedWorkspaceId + ? `${key}_${resolvedWorkspaceId}` + : `${key}_global`; + }, + [workspaceId], + ); + + const getScopedSessionKey = useCallback( + () => getScopedKey("aster_curr_sessionId"), + [getScopedKey], + ); + const getScopedMessagesKey = useCallback( + () => getScopedKey("aster_messages"), + [getScopedKey], + ); + const getScopedPersistedSessionKey = useCallback( + () => getScopedKey("aster_last_sessionId"), + [getScopedKey], + ); // 状态 const [isInitialized, setIsInitialized] = useState(false); - const [sessionId, setSessionId] = useState(null); - const [messages, setMessages] = useState([]); + const [sessionId, setSessionId] = useState(() => { + if (!workspaceId?.trim()) { + return null; + } + + const scopedSessionId = loadTransient( + `aster_curr_sessionId_${workspaceId.trim()}`, + null, + ); + if (scopedSessionId) { + return scopedSessionId; + } + + return loadPersisted( + `aster_last_sessionId_${workspaceId.trim()}`, + null, + ); + }); + const [messages, setMessages] = useState(() => + workspaceId?.trim() + ? loadTransient(`aster_messages_${workspaceId.trim()}`, []) + : [], + ); const [topics, setTopics] = useState([]); const [isSending, setIsSending] = useState(false); const [pendingActions, setPendingActions] = useState([]); @@ -149,6 +250,9 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) { // Refs const unlistenRef = useRef(null); const currentAssistantMsgIdRef = useRef(null); + const restoredWorkspaceRef = useRef(null); + const hydratedSessionRef = useRef(null); + const skipAutoRestoreRef = useRef(false); // 持久化 provider/model useEffect(() => { @@ -159,6 +263,69 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) { localStorage.setItem("agent_pref_model", model); }, [model]); + useEffect(() => { + const resolvedWorkspaceId = workspaceId?.trim(); + if (!resolvedWorkspaceId) { + return; + } + + const scopedSessionKey = getScopedSessionKey(); + const scopedPersistedSessionKey = getScopedPersistedSessionKey(); + + saveTransient(scopedSessionKey, sessionId); + savePersisted(scopedPersistedSessionKey, sessionId); + + if (sessionId) { + savePersisted( + `agent_session_workspace_${sessionId}`, + resolvedWorkspaceId, + ); + } + }, [ + getScopedPersistedSessionKey, + getScopedSessionKey, + sessionId, + workspaceId, + ]); + + useEffect(() => { + if (!workspaceId?.trim()) { + return; + } + saveTransient(getScopedMessagesKey(), messages); + }, [getScopedMessagesKey, messages, workspaceId]); + + // workspace 变化时恢复对应会话状态 + useEffect(() => { + if (!workspaceId?.trim()) { + setSessionId(null); + setMessages([]); + setPendingActions([]); + restoredWorkspaceRef.current = null; + hydratedSessionRef.current = null; + skipAutoRestoreRef.current = false; + return; + } + + const scopedSessionId = + loadTransient(getScopedSessionKey(), null) ?? + loadPersisted(getScopedPersistedSessionKey(), null); + + const scopedMessages = loadTransient(getScopedMessagesKey(), []); + + setSessionId(scopedSessionId); + setMessages(scopedMessages); + setPendingActions([]); + restoredWorkspaceRef.current = null; + hydratedSessionRef.current = null; + skipAutoRestoreRef.current = false; + }, [ + getScopedMessagesKey, + getScopedPersistedSessionKey, + getScopedSessionKey, + workspaceId, + ]); + // 初始化 Aster Agent useEffect(() => { const init = async () => { @@ -184,6 +351,33 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) { init(); }, []); + useEffect(() => { + if (!isInitialized) { + return; + } + + if (!workspaceId?.trim()) { + setTopics([]); + return; + } + + listAsterSessions() + .then((sessions) => { + const topicList: Topic[] = sessions.map((s: AsterSessionInfo) => ({ + id: s.id, + title: + s.name || + `话题 ${new Date(s.created_at * 1000).toLocaleDateString("zh-CN")}`, + createdAt: new Date(s.created_at * 1000), + messagesCount: s.messages_count ?? 0, + })); + setTopics(topicList); + }) + .catch((error) => { + console.error("[AsterChat] 加载话题失败:", error); + }); + }, [isInitialized, workspaceId]); + // 加载话题列表 const loadTopics = useCallback(async () => { try { @@ -207,15 +401,17 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) { if (sessionId) return sessionId; try { - const newSessionId = await createAsterSession(); + const resolvedWorkspaceId = getRequiredWorkspaceId(); + const newSessionId = await createAsterSession(resolvedWorkspaceId); setSessionId(newSessionId); + skipAutoRestoreRef.current = false; return newSessionId; } catch (error) { console.error("[AsterChat] 创建会话失败:", error); - toast.error("创建会话失败"); + toast.error(`创建会话失败: ${error}`); return null; } - }, [sessionId]); + }, [getRequiredWorkspaceId, sessionId]); // 辅助函数:追加文本到 contentParts const appendTextToParts = ( @@ -477,10 +673,13 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) { model_name: model, }; + const resolvedWorkspaceId = getRequiredWorkspaceId(); + await sendAsterMessageStream( content, activeSessionId, eventName, + resolvedWorkspaceId, imagesToSend, providerConfig, ); @@ -492,7 +691,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) { if (unlisten) unlisten(); } }, - [ensureSession, onWriteFile, providerType, model], + [ensureSession, getRequiredWorkspaceId, onWriteFile, providerType, model], ); // 停止发送 @@ -547,6 +746,10 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) { const clearMessages = useCallback(() => { setMessages([]); setSessionId(null); + setPendingActions([]); + restoredWorkspaceRef.current = null; + hydratedSessionRef.current = null; + skipAutoRestoreRef.current = true; toast.success("新话题已创建"); }, []); @@ -567,8 +770,9 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) { // 切换话题 const switchTopic = useCallback( async (topicId: string) => { - if (topicId === sessionId) return; + if (topicId === sessionId && messages.length > 0) return; + skipAutoRestoreRef.current = false; try { const detail = await getAsterSession(topicId); const loadedMessages: Message[] = detail.messages.map((msg, index) => { @@ -601,13 +805,91 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) { } catch (error) { console.error("[AsterChat] 切换话题失败:", error); setMessages([]); - setSessionId(topicId); + setSessionId(null); + saveTransient(getScopedSessionKey(), null); + savePersisted(getScopedPersistedSessionKey(), null); toast.error("加载对话历史失败"); } }, - [sessionId], + [ + getScopedPersistedSessionKey, + getScopedSessionKey, + messages.length, + sessionId, + ], ); + // 自动恢复当前 workspace 最近会话 + useEffect(() => { + const resolvedWorkspaceId = workspaceId?.trim(); + if (!resolvedWorkspaceId) return; + if (!isInitialized) return; + if (skipAutoRestoreRef.current) return; + if (sessionId) return; + if (topics.length === 0) return; + if (restoredWorkspaceRef.current === resolvedWorkspaceId) return; + + restoredWorkspaceRef.current = resolvedWorkspaceId; + + const scopedCandidate = + loadTransient(getScopedSessionKey(), null) || + loadPersisted(getScopedPersistedSessionKey(), null); + const mappedFallbackCandidate = + topics.find( + (topic) => + loadPersisted( + `agent_session_workspace_${topic.id}`, + null, + ) === resolvedWorkspaceId, + )?.id || null; + + const targetSessionId = scopedCandidate || mappedFallbackCandidate; + if (!targetSessionId) { + return; + } + + switchTopic(targetSessionId).catch((error) => { + console.warn("[AsterChat] 自动恢复会话失败:", error); + saveTransient(getScopedSessionKey(), null); + savePersisted(getScopedPersistedSessionKey(), null); + }); + }, [ + getScopedPersistedSessionKey, + getScopedSessionKey, + isInitialized, + sessionId, + switchTopic, + topics, + workspaceId, + ]); + + useEffect(() => { + if (sessionId) { + skipAutoRestoreRef.current = false; + } + }, [sessionId]); + + // 有 sessionId 但消息为空时,主动回填历史 + useEffect(() => { + if (!sessionId) return; + + if (messages.length > 0) { + hydratedSessionRef.current = sessionId; + return; + } + + if (hydratedSessionRef.current === sessionId) { + return; + } + + hydratedSessionRef.current = sessionId; + + switchTopic(sessionId).catch((error) => { + console.warn("[AsterChat] 会话水合失败:", error); + hydratedSessionRef.current = null; + }); + }, [messages.length, sessionId, switchTopic]); + // 删除话题 const deleteTopic = useCallback( async (topicId: string) => { @@ -629,6 +911,10 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) { const handleStopProcess = useCallback(async () => { setSessionId(null); + setMessages([]); + setPendingActions([]); + restoredWorkspaceRef.current = null; + hydratedSessionRef.current = null; }, []); return { diff --git a/src/components/agent/chat/hooks/useContentSync.ts b/src/components/agent/chat/hooks/useContentSync.ts index 9087363b8..a0a9c2d1d 100644 --- a/src/components/agent/chat/hooks/useContentSync.ts +++ b/src/components/agent/chat/hooks/useContentSync.ts @@ -38,9 +38,21 @@ export function useContentSync( const lastSyncDataRef = useRef<{ contentId: string; body: string } | null>( null, ); + const lastSuccessfulSyncRef = useRef<{ + contentId: string; + body: string; + } | null>(null); const syncContent = useCallback( (contentId: string, body: string) => { + // 与最近一次成功同步内容一致时,跳过重复同步 + if ( + lastSuccessfulSyncRef.current?.contentId === contentId && + lastSuccessfulSyncRef.current.body === body + ) { + return; + } + // 保存最后的同步数据(用于重试) lastSyncDataRef.current = { contentId, body }; @@ -58,6 +70,7 @@ export function useContentSync( try { await updateContent(contentId, { body }); + lastSuccessfulSyncRef.current = { contentId, body }; setSyncStatus("success"); // 3 秒后重置状态 diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index c51b486ec..3629b326a 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -7,6 +7,7 @@ */ import { useState, useCallback, useMemo, useEffect, useRef } from "react"; +import { toast } from "sonner"; import styled from "styled-components"; import { useAgentChat } from "./hooks/useAgentChat"; import { useSessionFiles } from "./hooks/useSessionFiles"; @@ -179,6 +180,7 @@ export function AgentChatPage({ contentId, theme: initialTheme, lockTheme = false, + newChatAt, onRecommendationClick: _onRecommendationClick, onHasMessagesChange, }: { @@ -187,6 +189,7 @@ export function AgentChatPage({ contentId?: string; theme?: string; lockTheme?: boolean; + newChatAt?: number; onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void; onHasMessagesChange?: (hasMessages: boolean) => void; }) { @@ -349,7 +352,6 @@ export function AgentChatPage({ // 使用 Agent Chat Hook(传递系统提示词) const { - processStatus, providerType, setProviderType, model, @@ -374,6 +376,7 @@ export function AgentChatPage({ // 使用 ref 调用最新的 handleWriteFile handleWriteFileRef.current?.(content, fileName); }, + workspaceId: projectId ?? "", }); // 会话文件持久化 hook @@ -422,7 +425,10 @@ export function AgentChatPage({ getContent(contentId) .then((existingContent) => { if (existingContent) { - syncContent(contentId, content); + const existingBody = existingContent.body || ""; + if (existingBody !== content) { + syncContent(contentId, content); + } } else { console.warn( "[AgentChatPage] contentId 对应的内容不存在,跳过同步:", @@ -453,6 +459,9 @@ export function AgentChatPage({ // 追踪已恢复元数据和文件的会话 ID const restoredMetaSessionId = useRef(null); const restoredFilesSessionId = useRef(null); + const handledNewChatRequestRef = useRef(null); + // 用于追踪是否已触发过 AI 引导 + const hasTriggeredGuide = useRef(false); // 当 sessionMeta 加载完成时,恢复主题和创建模式 useEffect(() => { @@ -664,6 +673,12 @@ export function AgentChatPage({ ) => { const sourceText = textOverride ?? input; if (!sourceText.trim() && (!images || images.length === 0)) return; + + if (!projectId) { + toast.error("请先选择项目后再开始对话"); + return; + } + let text = sourceText; // 如果有引用的角色,注入角色信息 @@ -685,7 +700,7 @@ export function AgentChatPage({ setMentionedCharacters([]); // 清空引用的角色 await sendMessage(text, images || [], webSearch, thinking); }, - [input, mentionedCharacters, sendMessage], + [input, mentionedCharacters, projectId, sendMessage], ); const handleClearMessages = useCallback(() => { @@ -703,6 +718,35 @@ export function AgentChatPage({ processedMessageIds.current.clear(); }, [clearMessages]); + // 响应首页导航触发的新会话请求 + useEffect(() => { + if (!newChatAt) { + return; + } + + const requestKey = `${newChatAt}:${projectId ?? ""}`; + if (handledNewChatRequestRef.current === requestKey) { + return; + } + handledNewChatRequestRef.current = requestKey; + + clearMessages({ + showToast: false, + }); + setInput(""); + setLayoutMode("chat"); + setShowSidebar(true); + setCanvasState(null); + setGeneralCanvasState(DEFAULT_CANVAS_STATE); + setTaskFiles([]); + setSelectedFileId(undefined); + setMentionedCharacters([]); + processedMessageIds.current.clear(); + restoredMetaSessionId.current = null; + restoredFilesSessionId.current = null; + hasTriggeredGuide.current = false; + }, [newChatAt, projectId, clearMessages]); + const handleBackHome = useCallback(() => { clearMessages({ showToast: false, @@ -1228,8 +1272,6 @@ export function AgentChatPage({ [sendMessage], ); - // 用于追踪是否已触发过 AI 引导 - const hasTriggeredGuide = useRef(false); // 存储 triggerAIGuide 函数引用,避免在 useEffect 依赖中包含函数 const triggerAIGuideRef = useRef(triggerAIGuide); triggerAIGuideRef.current = triggerAIGuide; @@ -1349,7 +1391,7 @@ export function AgentChatPage({ model={model} setModel={setModel} onManageProviders={handleManageProviders} - disabled={!processStatus.running && false} + disabled={!projectId} onClearMessages={handleClearMessages} onToggleCanvas={handleToggleCanvas} isCanvasOpen={layoutMode === "chat-canvas"} @@ -1505,7 +1547,7 @@ export function AgentChatPage({ }} > {syncStatus === "syncing" && "正在同步..."} - {syncStatus === "success" && "✓ 已保存"} + {syncStatus === "success" && "✓ 已同步"} {syncStatus === "error" && "⚠ 同步失败,将自动重试"} )} diff --git a/src/components/content-creator/canvas/document/DocumentCanvas.tsx b/src/components/content-creator/canvas/document/DocumentCanvas.tsx index ac5c4dfe2..ab00974d9 100644 --- a/src/components/content-creator/canvas/document/DocumentCanvas.tsx +++ b/src/components/content-creator/canvas/document/DocumentCanvas.tsx @@ -9,7 +9,7 @@ import styled from "styled-components"; import type { DocumentCanvasProps, ExportFormat, PlatformType } from "./types"; import { DocumentToolbar } from "./DocumentToolbar"; import { DocumentRenderer } from "./DocumentRenderer"; -import { DocumentEditor } from "./DocumentEditor"; +import { NotionEditor } from "./editor"; import { PlatformTabs } from "./PlatformTabs"; const Container = styled.div` @@ -17,7 +17,7 @@ const Container = styled.div` flex-direction: column; height: 100%; background: hsl(var(--background)); - border-left: 1px solid hsl(var(--border)); + border-right: 1px solid hsl(var(--border)); `; const ContentArea = styled.div` @@ -185,7 +185,7 @@ export const DocumentCanvas: React.FC = memo( {state.isEditing ? ( - void; + isActive?: boolean; + children: React.ReactNode; + title: string; +} + +const ToolbarButton: React.FC = ({ + onClick, + isActive, + children, + title, +}) => ( + +); + +export const BubbleToolbar: React.FC = ({ editor }) => { + const [visible, setVisible] = useState(false); + const [position, setPosition] = useState({ top: 0, left: 0 }); + const toolbarRef = useRef(null); + + useEffect(() => { + const updateToolbar = () => { + const { from, to, empty } = editor.state.selection; + if (empty || from === to) { + setVisible(false); + return; + } + + setVisible(true); + + const { view } = editor; + const start = view.coordsAtPos(from); + const end = view.coordsAtPos(to); + const wrapper = view.dom.closest(".notion-editor-wrapper"); + if (!wrapper) return; + + const wrapperRect = wrapper.getBoundingClientRect(); + const toolbarWidth = toolbarRef.current?.offsetWidth ?? 300; + + setPosition({ + top: start.top - wrapperRect.top - 44, + left: (start.left + end.left) / 2 - wrapperRect.left - toolbarWidth / 2, + }); + }; + + editor.on("selectionUpdate", updateToolbar); + editor.on("blur", () => { + // 延迟隐藏,允许点击工具栏按钮 + setTimeout(() => setVisible(false), 200); + }); + + return () => { + editor.off("selectionUpdate", updateToolbar); + }; + }, [editor]); + + if (!visible) return null; + + return ( +
+ editor.chain().focus().toggleBold().run()} + isActive={editor.isActive("bold")} + title="粗体" + > + + + + editor.chain().focus().toggleItalic().run()} + isActive={editor.isActive("italic")} + title="斜体" + > + + + + editor.chain().focus().toggleStrike().run()} + isActive={editor.isActive("strike")} + title="删除线" + > + + + + editor.chain().focus().toggleCode().run()} + isActive={editor.isActive("code")} + title="行内代码" + > + + + + editor.chain().focus().toggleHighlight().run()} + isActive={editor.isActive("highlight")} + title="高亮" + > + + + +
+ + editor.chain().focus().toggleHeading({ level: 1 }).run()} + isActive={editor.isActive("heading", { level: 1 })} + title="标题 1" + > + + + + editor.chain().focus().toggleHeading({ level: 2 }).run()} + isActive={editor.isActive("heading", { level: 2 })} + title="标题 2" + > + + + + editor.chain().focus().toggleHeading({ level: 3 }).run()} + isActive={editor.isActive("heading", { level: 3 })} + title="标题 3" + > + + +
+ ); +}; diff --git a/src/components/content-creator/canvas/document/editor/NotionEditor.tsx b/src/components/content-creator/canvas/document/editor/NotionEditor.tsx new file mode 100644 index 000000000..0fc723c0d --- /dev/null +++ b/src/components/content-creator/canvas/document/editor/NotionEditor.tsx @@ -0,0 +1,107 @@ +import React, { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useEditor, EditorContent } from "@tiptap/react"; +import { createExtensions } from "./extensions"; +import { BubbleToolbar } from "./BubbleToolbar"; +import { + CommandList, + type SlashMenuState, + type SlashMenuKeyHandler, +} from "./SlashCommand"; +import { markdownToHtml, htmlToMarkdown } from "./utils/markdown"; +import "./editor-styles.css"; + +interface NotionEditorProps { + content: string; + onChange: (content: string) => void; + onSave: () => void; + onCancel: () => void; +} + +const EMPTY_SLASH: SlashMenuState = { + isOpen: false, + items: [], + range: null, + clientRect: null, +}; + +export const NotionEditor: React.FC = memo( + ({ content, onChange, onSave, onCancel }) => { + const [slashState, setSlashState] = useState(EMPTY_SLASH); + const keyDownRef = useRef(null); + + const extensions = useMemo( + () => + createExtensions({ + onStateChange: setSlashState, + onKeyDownRef: keyDownRef, + }), + [], + ); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const initialContent = useMemo(() => markdownToHtml(content), []); + + const editor = useEditor({ + extensions, + content: initialContent, + onUpdate: ({ editor }) => { + onChange(htmlToMarkdown(editor.getHTML())); + }, + }); + + const handleSlashClose = useCallback(() => { + setSlashState(EMPTY_SLASH); + }, []); + + // 快捷键: Cmd+S 保存, Escape 取消 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "s") { + e.preventDefault(); + onSave(); + } + if (e.key === "Escape" && !slashState.isOpen) { + e.preventDefault(); + onCancel(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onSave, onCancel, slashState.isOpen]); + + useEffect(() => { + if (editor) { + editor.commands.focus("end"); + } + }, [editor]); + + if (!editor) return null; + + return ( +
+ + + {slashState.isOpen && slashState.range && ( + + )} +
+ ); + }, +); + +NotionEditor.displayName = "NotionEditor"; diff --git a/src/components/content-creator/canvas/document/editor/SlashCommand.tsx b/src/components/content-creator/canvas/document/editor/SlashCommand.tsx new file mode 100644 index 000000000..91b81a430 --- /dev/null +++ b/src/components/content-creator/canvas/document/editor/SlashCommand.tsx @@ -0,0 +1,393 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import React, { useEffect, useState } from "react"; +import { + Heading1, + Heading2, + Heading3, + List, + ListOrdered, + CheckSquare, + Quote, + Code, + Minus, + ImageIcon, + Table, +} from "lucide-react"; +import type { Editor, Range } from "@tiptap/core"; + +// --- 命令项定义 --- + +export interface CommandItemDef { + title: string; + description: string; + searchTerms?: string[]; + icon: React.ReactNode; + command: (p: { editor: Editor; range: Range }) => void; +} + +const SLASH_ITEMS: CommandItemDef[] = [ + { + title: "标题 1", + description: "大标题", + searchTerms: ["h1", "heading"], + icon: , + command: ({ editor, range }) => + editor + .chain() + .focus() + .deleteRange(range) + .setNode("heading", { level: 1 }) + .run(), + }, + { + title: "标题 2", + description: "中标题", + searchTerms: ["h2", "heading"], + icon: , + command: ({ editor, range }) => + editor + .chain() + .focus() + .deleteRange(range) + .setNode("heading", { level: 2 }) + .run(), + }, + { + title: "标题 3", + description: "小标题", + searchTerms: ["h3", "heading"], + icon: , + command: ({ editor, range }) => + editor + .chain() + .focus() + .deleteRange(range) + .setNode("heading", { level: 3 }) + .run(), + }, + { + title: "待办列表", + description: "任务清单", + searchTerms: ["todo", "task", "checkbox"], + icon: , + command: ({ editor, range }) => + editor.chain().focus().deleteRange(range).toggleTaskList().run(), + }, + { + title: "无序列表", + description: "项目符号列表", + searchTerms: ["bullet", "unordered", "list"], + icon: , + command: ({ editor, range }) => + editor.chain().focus().deleteRange(range).toggleBulletList().run(), + }, + { + title: "有序列表", + description: "编号列表", + searchTerms: ["ordered", "number", "list"], + icon: , + command: ({ editor, range }) => + editor.chain().focus().deleteRange(range).toggleOrderedList().run(), + }, + { + title: "引用", + description: "引用块", + searchTerms: ["blockquote", "quote"], + icon: , + command: ({ editor, range }) => + editor + .chain() + .focus() + .deleteRange(range) + .toggleNode("paragraph", "paragraph") + .toggleBlockquote() + .run(), + }, + { + title: "代码块", + description: "代码片段", + searchTerms: ["code", "codeblock"], + icon: , + command: ({ editor, range }) => + editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), + }, + { + title: "分割线", + description: "水平分隔线", + searchTerms: ["hr", "divider", "separator"], + icon: , + command: ({ editor, range }) => + editor.chain().focus().deleteRange(range).setHorizontalRule().run(), + }, + { + title: "图片", + description: "插入图片链接", + searchTerms: ["image", "photo", "picture"], + icon: , + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).run(); + const url = window.prompt("输入图片 URL"); + if (url) editor.chain().focus().setImage({ src: url }).run(); + }, + }, + { + title: "表格", + description: "插入表格", + searchTerms: ["table", "grid"], + icon: , + command: ({ editor, range }) => + editor + .chain() + .focus() + .deleteRange(range) + .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + .run(), + }, +]; + +function filterItems(query: string): CommandItemDef[] { + const q = query.toLowerCase(); + return SLASH_ITEMS.filter( + (item) => + item.title.toLowerCase().includes(q) || + item.description.toLowerCase().includes(q) || + item.searchTerms?.some((t) => t.toLowerCase().includes(q)), + ); +} + +// --- 状态类型 --- + +export interface SlashMenuState { + isOpen: boolean; + items: CommandItemDef[]; + range: Range | null; + clientRect: DOMRect | null; +} + +export type SlashMenuKeyHandler = (event: KeyboardEvent) => boolean; + +// --- ProseMirror Plugin 实现 --- + +const slashPluginKey = new PluginKey("slashCommand"); + +interface SlashCommandOptions { + onStateChange: (state: SlashMenuState) => void; + onKeyDownRef: React.MutableRefObject; +} + +function createSlashPlugin(editor: Editor, options: SlashCommandOptions) { + let wasActive = false; + + return new Plugin({ + key: slashPluginKey, + state: { + init() { + return { + active: false as boolean, + slashPos: -1, + query: "", + items: [] as CommandItemDef[], + }; + }, + apply(tr, prev) { + if (!tr.docChanged) return prev; + + const { $from } = tr.selection; + const textBefore = $from.parent.textBetween( + Math.max(0, $from.parentOffset - 20), + $from.parentOffset, + "\0", + ); + + const slashIdx = textBefore.lastIndexOf("/"); + if (slashIdx === -1) { + return { active: false, slashPos: -1, query: "", items: [] }; + } + + const query = textBefore.slice(slashIdx + 1); + if (query.includes(" ") || query.includes("\0")) { + return { active: false, slashPos: -1, query: "", items: [] }; + } + + const items = filterItems(query); + const docSlashPos = $from.pos - (textBefore.length - slashIdx); + + return { active: true, slashPos: docSlashPos, query, items }; + }, + }, + props: { + handleKeyDown(view, event) { + const state = slashPluginKey.getState(view.state); + if (state?.active) { + return options.onKeyDownRef.current?.(event) ?? false; + } + return false; + }, + }, + view() { + return { + update: (view) => { + const state = slashPluginKey.getState(view.state); + const isActive = state?.active ?? false; + + if (isActive) { + const { from } = view.state.selection; + const coords = view.coordsAtPos(from); + options.onStateChange({ + isOpen: true, + items: state.items, + range: { from: state.slashPos, to: from }, + clientRect: new DOMRect( + coords.left, + coords.top, + 0, + coords.bottom - coords.top, + ), + }); + wasActive = true; + } else if (wasActive) { + options.onStateChange({ + isOpen: false, + items: [], + range: null, + clientRect: null, + }); + wasActive = false; + } + }, + destroy: () => { + wasActive = false; + }, + }; + }, + }); +} + +export const SlashCommand = Extension.create({ + name: "slashCommand", + + addOptions() { + return { + onStateChange: () => {}, + onKeyDownRef: { current: null }, + }; + }, + + addProseMirrorPlugins() { + return [createSlashPlugin(this.editor, this.options)]; + }, +}); + +// --- 命令列表 UI 组件 --- + +interface CommandListProps { + editor: Editor; + items: CommandItemDef[]; + range: Range; + clientRect: DOMRect | null; + onKeyDownRef: React.MutableRefObject; + onClose: () => void; +} + +export const CommandList: React.FC = ({ + editor, + items, + range, + clientRect, + onKeyDownRef, + onClose, +}) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + useEffect(() => { + setSelectedIndex(0); + }, [items]); + + const executeCommand = (item: CommandItemDef) => { + item.command({ editor, range }); + onClose(); + }; + + // 注册键盘处理 + useEffect(() => { + onKeyDownRef.current = (event: KeyboardEvent) => { + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex((i) => + items.length > 0 ? (i - 1 + items.length) % items.length : 0, + ); + return true; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex((i) => + items.length > 0 ? (i + 1) % items.length : 0, + ); + return true; + } + if (event.key === "Enter") { + event.preventDefault(); + if (items[selectedIndex]) { + executeCommand(items[selectedIndex]); + } + return true; + } + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + return true; + } + return false; + }; + return () => { + onKeyDownRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items, selectedIndex, onClose]); + + if (items.length === 0) return null; + + const top = clientRect ? clientRect.bottom + 4 : 0; + const left = clientRect ? clientRect.left : 0; + + return ( +
+ {items.map((item, index) => ( + + ))} +
+ ); +}; diff --git a/src/components/content-creator/canvas/document/editor/editor-styles.css b/src/components/content-creator/canvas/document/editor/editor-styles.css new file mode 100644 index 000000000..ef3d9ff38 --- /dev/null +++ b/src/components/content-creator/canvas/document/editor/editor-styles.css @@ -0,0 +1,155 @@ +.notion-editor-wrapper { + position: relative; + height: 100%; + overflow-y: auto; +} + +.notion-editor-wrapper .ProseMirror { + min-height: 100%; + padding: 24px 32px; + outline: none; + font-size: 15px; + line-height: 1.75; + color: hsl(var(--foreground)); +} + +/* Placeholder */ +.notion-editor-wrapper .ProseMirror .is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: hsl(var(--muted-foreground)); + pointer-events: none; + height: 0; +} + +/* Headings */ +.notion-editor-wrapper .ProseMirror h1 { + font-size: 1.875rem; + font-weight: 700; + margin-top: 1.5rem; + margin-bottom: 0.5rem; +} + +.notion-editor-wrapper .ProseMirror h2 { + font-size: 1.5rem; + font-weight: 600; + margin-top: 1.25rem; + margin-bottom: 0.5rem; +} + +.notion-editor-wrapper .ProseMirror h3 { + font-size: 1.25rem; + font-weight: 600; + margin-top: 1rem; + margin-bottom: 0.5rem; +} + +/* Paragraph */ +.notion-editor-wrapper .ProseMirror p { + margin-bottom: 0.25rem; +} + +/* Code block */ +.notion-editor-wrapper .ProseMirror pre { + background: hsl(var(--muted)); + border-radius: 0.5rem; + padding: 0.75rem 1rem; + font-family: "SF Mono", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.875rem; + overflow-x: auto; + margin: 0.5rem 0; +} + +.notion-editor-wrapper .ProseMirror pre code { + background: none; + padding: 0; + color: inherit; +} + +/* Inline code */ +.notion-editor-wrapper .ProseMirror code { + background: hsl(var(--muted)); + padding: 0.15rem 0.35rem; + border-radius: 0.25rem; + font-size: 0.875em; +} + +/* Blockquote */ +.notion-editor-wrapper .ProseMirror blockquote { + border-left: 3px solid hsl(var(--border)); + padding-left: 1rem; + color: hsl(var(--muted-foreground)); + margin: 0.5rem 0; +} + +/* Lists */ +.notion-editor-wrapper .ProseMirror ul, +.notion-editor-wrapper .ProseMirror ol { + padding-left: 1.5rem; + margin: 0.25rem 0; +} + +/* Task list */ +.notion-editor-wrapper .ProseMirror ul[data-type="taskList"] { + list-style: none; + padding-left: 0; +} + +.notion-editor-wrapper .ProseMirror ul[data-type="taskList"] li { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.notion-editor-wrapper .ProseMirror ul[data-type="taskList"] li label { + margin-top: 0.25rem; +} + +.notion-editor-wrapper + .ProseMirror + ul[data-type="taskList"] + li + input[type="checkbox"] { + cursor: pointer; + accent-color: hsl(var(--primary)); +} + +/* Table */ +.notion-editor-wrapper .ProseMirror table { + border-collapse: collapse; + width: 100%; + margin: 0.5rem 0; +} + +.notion-editor-wrapper .ProseMirror th, +.notion-editor-wrapper .ProseMirror td { + border: 1px solid hsl(var(--border)); + padding: 0.5rem 0.75rem; + text-align: left; +} + +.notion-editor-wrapper .ProseMirror th { + background: hsl(var(--muted)); + font-weight: 600; +} + +/* Horizontal rule */ +.notion-editor-wrapper .ProseMirror hr { + border: none; + border-top: 1px solid hsl(var(--border)); + margin: 1rem 0; +} + +/* Image */ +.notion-editor-wrapper .ProseMirror img { + max-width: 100%; + border-radius: 0.5rem; + margin: 0.5rem 0; +} + +/* Highlight */ +.notion-editor-wrapper .ProseMirror mark { + background-color: hsl(50 100% 70% / 0.4); + border-radius: 0.15rem; + padding: 0.1rem 0; +} diff --git a/src/components/content-creator/canvas/document/editor/extensions.ts b/src/components/content-creator/canvas/document/editor/extensions.ts new file mode 100644 index 000000000..287fa072b --- /dev/null +++ b/src/components/content-creator/canvas/document/editor/extensions.ts @@ -0,0 +1,46 @@ +import StarterKit from "@tiptap/starter-kit"; +import Placeholder from "@tiptap/extension-placeholder"; +import TaskList from "@tiptap/extension-task-list"; +import TaskItem from "@tiptap/extension-task-item"; +import Image from "@tiptap/extension-image"; +import Highlight from "@tiptap/extension-highlight"; +import { Table } from "@tiptap/extension-table"; +import TableRow from "@tiptap/extension-table-row"; +import TableCell from "@tiptap/extension-table-cell"; +import TableHeader from "@tiptap/extension-table-header"; +import { SlashCommand } from "./SlashCommand"; +import type { SlashMenuState, SlashMenuKeyHandler } from "./SlashCommand"; +import type { Extensions } from "@tiptap/react"; +import type { MutableRefObject } from "react"; + +interface ExtensionConfig { + onStateChange: (state: SlashMenuState) => void; + onKeyDownRef: MutableRefObject; +} + +export function createExtensions(config: ExtensionConfig): Extensions { + return [ + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + codeBlock: { + HTMLAttributes: { class: "notion-code-block" }, + }, + }), + Placeholder.configure({ + placeholder: "输入内容,按 / 打开命令菜单…", + emptyEditorClass: "is-editor-empty", + }), + TaskList, + TaskItem.configure({ nested: true }), + Image.configure({ inline: false, allowBase64: true }), + Highlight.configure({ multicolor: false }), + Table.configure({ resizable: false }), + TableRow, + TableCell, + TableHeader, + SlashCommand.configure({ + onStateChange: config.onStateChange, + onKeyDownRef: config.onKeyDownRef, + }), + ]; +} diff --git a/src/components/content-creator/canvas/document/editor/index.ts b/src/components/content-creator/canvas/document/editor/index.ts new file mode 100644 index 000000000..f25306110 --- /dev/null +++ b/src/components/content-creator/canvas/document/editor/index.ts @@ -0,0 +1 @@ +export { NotionEditor } from "./NotionEditor"; diff --git a/src/components/content-creator/canvas/document/editor/utils/markdown.ts b/src/components/content-creator/canvas/document/editor/utils/markdown.ts new file mode 100644 index 000000000..0488a920e --- /dev/null +++ b/src/components/content-creator/canvas/document/editor/utils/markdown.ts @@ -0,0 +1,50 @@ +import TurndownService from "turndown"; +import { marked } from "marked"; + +// --- Markdown → HTML --- + +export function markdownToHtml(md: string): string { + if (!md.trim()) return ""; + return marked.parse(md, { async: false }) as string; +} + +// --- HTML → Markdown --- + +const turndown = new TurndownService({ + headingStyle: "atx", + codeBlockStyle: "fenced", + bulletListMarker: "-", + emDelimiter: "*", + strongDelimiter: "**", +}); + +// 任务列表规则 +turndown.addRule("taskListItem", { + filter: (node) => + node.nodeName === "LI" && + node.parentElement?.getAttribute("data-type") === "taskList", + replacement: (_content, node) => { + const el = node as HTMLElement; + const checkbox = el.querySelector('input[type="checkbox"]'); + const checked = checkbox?.hasAttribute("checked") ?? false; + const text = _content.replace(/^\n+/, "").replace(/\n+$/, ""); + return `- [${checked ? "x" : " "}] ${text}\n`; + }, +}); + +// 高亮文本规则 +turndown.addRule("highlight", { + filter: "mark", + replacement: (content) => `==${content}==`, +}); + +// 删除线规则 +turndown.addRule("strikethrough", { + filter: "s", + replacement: (content) => `~~${content}~~`, +}); + +export function htmlToMarkdown(html: string): string { + if (!html.trim()) return ""; + return turndown.turndown(html); +} diff --git a/src/components/content-creator/canvas/music/MusicCanvas.tsx b/src/components/content-creator/canvas/music/MusicCanvas.tsx index 9ea5e0f91..dc68edef3 100644 --- a/src/components/content-creator/canvas/music/MusicCanvas.tsx +++ b/src/components/content-creator/canvas/music/MusicCanvas.tsx @@ -31,7 +31,7 @@ const Container = styled.div` flex-direction: column; height: 100%; background: hsl(var(--background)); - border-left: 1px solid hsl(var(--border)); + border-right: 1px solid hsl(var(--border)); `; const ContentArea = styled.div` diff --git a/src/components/content-creator/canvas/novel/NovelCanvas.tsx b/src/components/content-creator/canvas/novel/NovelCanvas.tsx index 9ddcdd010..3318af441 100644 --- a/src/components/content-creator/canvas/novel/NovelCanvas.tsx +++ b/src/components/content-creator/canvas/novel/NovelCanvas.tsx @@ -19,7 +19,7 @@ const Container = styled.div` flex-direction: column; height: 100%; background: hsl(var(--background)); - border-left: 1px solid hsl(var(--border)); + border-right: 1px solid hsl(var(--border)); `; const Header = styled.div` diff --git a/src/components/content-creator/canvas/poster/PosterCanvas.tsx b/src/components/content-creator/canvas/poster/PosterCanvas.tsx index 20f270f70..750021cc0 100644 --- a/src/components/content-creator/canvas/poster/PosterCanvas.tsx +++ b/src/components/content-creator/canvas/poster/PosterCanvas.tsx @@ -32,6 +32,7 @@ const Container = styled.div` flex-direction: column; height: 100%; background: hsl(var(--muted)); + border-right: 1px solid hsl(var(--border)); `; const MainArea = styled.div` diff --git a/src/components/content-creator/canvas/script/ScriptCanvas.tsx b/src/components/content-creator/canvas/script/ScriptCanvas.tsx index d0963363b..cea831079 100644 --- a/src/components/content-creator/canvas/script/ScriptCanvas.tsx +++ b/src/components/content-creator/canvas/script/ScriptCanvas.tsx @@ -18,7 +18,7 @@ const Container = styled.div` flex-direction: column; height: 100%; background: hsl(var(--background)); - border-left: 1px solid hsl(var(--border)); + border-right: 1px solid hsl(var(--border)); `; const Header = styled.div` diff --git a/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx b/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx index 482268f8d..b373b3dfb 100644 --- a/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx +++ b/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx @@ -73,15 +73,6 @@ export const LayoutTransition: React.FC = memo( return ( - - {chatContent} - - = memo( > {canvasContent} + + + {chatContent} + ); }, diff --git a/src/components/content-creator/core/LayoutTransition/useLayoutTransition.ts b/src/components/content-creator/core/LayoutTransition/useLayoutTransition.ts index 8801876e8..eebec366d 100644 --- a/src/components/content-creator/core/LayoutTransition/useLayoutTransition.ts +++ b/src/components/content-creator/core/LayoutTransition/useLayoutTransition.ts @@ -111,7 +111,7 @@ export function useLayoutTransition( transform: transitionState === "entering" || transitionState === "entered" ? "translateX(0)" - : "translateX(100%)", + : "translateX(-100%)", opacity: transitionState === "entering" || transitionState === "entered" ? 1 diff --git a/src/components/general-chat/canvas/CanvasPanel.tsx b/src/components/general-chat/canvas/CanvasPanel.tsx index 67f31244f..9f5caff2d 100644 --- a/src/components/general-chat/canvas/CanvasPanel.tsx +++ b/src/components/general-chat/canvas/CanvasPanel.tsx @@ -58,7 +58,7 @@ export const CanvasPanel: React.FC = ({ } return ( -
+
{/* 工具栏 */}
diff --git a/src/components/image-gen/ImageGenPage.tsx b/src/components/image-gen/ImageGenPage.tsx index ab01575b3..b197e79d3 100644 --- a/src/components/image-gen/ImageGenPage.tsx +++ b/src/components/image-gen/ImageGenPage.tsx @@ -1,6 +1,6 @@ /** * @file 图片生成页面 - * @description 对齐 LobeHub 风格的绘画工作台布局与交互 + * @description 对齐成熟产品风格的绘画工作台布局与交互 * @module components/image-gen/ImageGenPage */ @@ -19,6 +19,7 @@ import { X, } from "lucide-react"; import { useImageGen } from "./useImageGen"; +import type { GeneratedImage } from "./types"; import type { Page } from "@/types/page"; interface ImageGenPageProps { @@ -169,6 +170,39 @@ function fileToDataUrl(file: File): Promise { }); } +function resolveBatchImages( + images: GeneratedImage[], + selectedImageId: string | null, +): GeneratedImage[] { + if (!selectedImageId) { + return []; + } + + const batchMatch = selectedImageId.match(/^img-(\d+)-\d+$/); + if (!batchMatch) { + const single = images.find((item) => item.id === selectedImageId); + return single ? [single] : []; + } + + const batchPrefix = `img-${batchMatch[1]}-`; + return images + .filter((item) => item.id.startsWith(batchPrefix)) + .sort((left, right) => left.createdAt - right.createdAt); +} + +function getStatusText(status: GeneratedImage["status"]): string { + switch (status) { + case "complete": + return "已完成"; + case "error": + return "失败"; + case "generating": + return "生成中"; + default: + return "待生成"; + } +} + const Container = styled.div` flex: 1; min-height: 0; @@ -449,6 +483,63 @@ const PreviewImage = styled.img` object-fit: contain; `; +const BatchGrid = styled.div` + width: 100%; + height: 100%; + padding: 12px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + align-content: start; + overflow: auto; +`; + +const BatchItem = styled.button<{ $active: boolean }>` + border: 1px solid + ${({ $active }) => ($active ? "hsl(var(--primary))" : "hsl(var(--border))")}; + border-radius: 10px; + background: hsl(var(--background)); + cursor: pointer; + display: flex; + flex-direction: column; + padding: 8px; + gap: 8px; +`; + +const BatchPreviewWrap = styled.div` + border-radius: 8px; + background: hsl(var(--muted) / 0.25); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } +`; + +const BatchPlaceholder = styled.div` + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 8px; + color: hsl(var(--muted-foreground)); +`; + +const BatchMeta = styled.div` + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 12px; + color: hsl(var(--muted-foreground)); +`; + const CanvasActions = styled.div` position: absolute; top: 12px; @@ -551,7 +642,10 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) { setSelectedModelId, selectedSize, setSelectedSize, + images, selectedImage, + selectedImageId, + setSelectedImageId, generating, generateImage, deleteImage, @@ -588,6 +682,12 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) { const canGenerate = !!prompt.trim() && !!selectedProvider && !!selectedModelId && !generating; + const selectedBatchImages = useMemo(() => { + return resolveBatchImages(images, selectedImageId); + }, [images, selectedImageId]); + + const shouldShowBatchGrid = selectedBatchImages.length > 1; + const handleCountSelect = (count: number) => { setImageCount(count); setIsEditingCustomCount(false); @@ -843,7 +943,49 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) { - {selectedImage?.status === "complete" && selectedImage.url ? ( + {shouldShowBatchGrid ? ( + + {selectedBatchImages.map((item, index) => { + const parsedSize = parseSize(item.size); + const previewStyle = parsedSize + ? { + aspectRatio: `${parsedSize.width}/${parsedSize.height}`, + } + : undefined; + + return ( + setSelectedImageId(item.id)} + > + + {item.status === "complete" && item.url ? ( + {item.prompt + ) : ( + + {item.status === "error" ? ( + + ) : ( + + )} + {getStatusText(item.status)} + + )} + + + + 第 {index + 1} 张 + {getStatusText(item.status)} + + + ); + })} + + ) : selectedImage?.status === "complete" && selectedImage.url ? ( <> 绘画 )} + + {shouldShowBatchGrid && + selectedImage?.status === "complete" && + selectedImage.url && ( + + window.open(selectedImage.url, "_blank")} + > + + + deleteImage(selectedImage.id)} + > + + + + )} diff --git a/src/components/memory/MemoryPage.tsx b/src/components/memory/MemoryPage.tsx new file mode 100644 index 000000000..25ec9adb7 --- /dev/null +++ b/src/components/memory/MemoryPage.tsx @@ -0,0 +1,1237 @@ +/** + * 独立记忆页面 + * + * 参考成熟产品的信息架构: + * - 左侧分类导航(搜索 / 首页 / 身份 / 情境 / 偏好 / 经验 / 活动) + * - 右侧主内容区(总览、分析、条目列表、详情) + * + * 所有数据均来自真实后端接口,不使用 Mock 数据。 + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + AlertCircle, + BrainCircuit, + CalendarClock, + CheckCircle2, + Database, + HeartPulse, + Home, + Info, + LayoutGrid, + Lightbulb, + List, + Loader2, + MessagesSquare, + RefreshCw, + Search, + Signature, + Trash2, + type LucideIcon, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { Page, PageParams } from "@/types/page"; +import { + cleanupMemory, + getConfig, + getMemoryOverview, + requestMemoryAnalysis, + saveConfig, + type Config, + type MemoryAnalysisResult, + type MemoryCategoryStat, + type MemoryConfig as TauriMemoryConfig, + type MemoryEntryPreview, + type MemoryOverviewResponse, + type MemoryStatsResponse, +} from "@/hooks/useTauri"; + +type CategoryType = MemoryCategoryStat["category"]; +type CategoryFilter = "all" | CategoryType; +type MemorySection = "home" | CategoryType; +type ViewMode = "list" | "grid"; + +const CATEGORY_META: Record< + CategoryType, + { label: string; description: string; icon: LucideIcon } +> = { + identity: { + label: "身份", + description: "关于你是谁的稳定信息", + icon: Signature, + }, + context: { + label: "情境", + description: "对话背景与当前约束", + icon: MessagesSquare, + }, + preference: { + label: "偏好", + description: "你的习惯、口味与偏爱", + icon: HeartPulse, + }, + experience: { + label: "经验", + description: "过往经历与可复用知识", + icon: Lightbulb, + }, + activity: { + label: "活动", + description: "近期计划与进行中的事项", + icon: CalendarClock, + }, +}; + +const CATEGORY_ORDER: CategoryType[] = [ + "identity", + "context", + "preference", + "experience", + "activity", +]; + +const MEMORY_NAV_ITEMS: Array<{ + key: MemorySection; + label: string; + icon: LucideIcon; + description: string; +}> = [ + { + key: "home", + label: "首页", + icon: Home, + description: "全部记忆", + }, + { + key: "identity", + label: "身份", + icon: CATEGORY_META.identity.icon, + description: CATEGORY_META.identity.description, + }, + { + key: "context", + label: "情境", + icon: CATEGORY_META.context.icon, + description: CATEGORY_META.context.description, + }, + { + key: "preference", + label: "偏好", + icon: CATEGORY_META.preference.icon, + description: CATEGORY_META.preference.description, + }, + { + key: "experience", + label: "经验", + icon: CATEGORY_META.experience.icon, + description: CATEGORY_META.experience.description, + }, + { + key: "activity", + label: "活动", + icon: CATEGORY_META.activity.icon, + description: CATEGORY_META.activity.description, + }, +]; + +const SECTION_SHORTCUTS: Record = { + "1": "home", + "2": "identity", + "3": "context", + "4": "preference", + "5": "experience", + "6": "activity", +}; + +const DEFAULT_MEMORY_CONFIG: TauriMemoryConfig = { + enabled: true, + max_entries: 1000, + retention_days: 30, + auto_cleanup: true, +}; + +function formatStorageSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + +function normalizeTimestampMs(timestampMs: number): number { + if (!timestampMs) return 0; + return timestampMs > 1_000_000_000_000 ? timestampMs : timestampMs * 1000; +} + +function formatRelativeTimestamp(timestampMs: number): string { + const normalized = normalizeTimestampMs(timestampMs); + if (!normalized) return "未知时间"; + + const date = new Date(normalized); + if (Number.isNaN(date.getTime())) { + return "未知时间"; + } + + const now = Date.now(); + const diffMs = now - date.getTime(); + const diffMinutes = Math.floor(diffMs / 60000); + if (diffMinutes < 1) return "刚刚"; + if (diffMinutes < 60) return `${diffMinutes} 分钟前`; + + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours} 小时前`; + + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 7) return `${diffDays} 天前`; + + return `${date.getMonth() + 1}/${date.getDate()} ${date + .getHours() + .toString() + .padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`; +} + +function formatAbsoluteTimestamp(timestampMs: number): string { + const normalized = normalizeTimestampMs(timestampMs); + if (!normalized) return "未知时间"; + + const date = new Date(normalized); + if (Number.isNaN(date.getTime())) { + return "未知时间"; + } + + return `${date.getFullYear()}-${(date.getMonth() + 1) + .toString() + .padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")} ${date + .getHours() + .toString() + .padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`; +} + +function parseDateStartTimestamp(dateText: string): number | undefined { + if (!dateText) return undefined; + const date = new Date(`${dateText}T00:00:00`); + if (Number.isNaN(date.getTime())) return undefined; + return date.getTime(); +} + +function parseDateEndTimestamp(dateText: string): number | undefined { + if (!dateText) return undefined; + const date = new Date(`${dateText}T23:59:59.999`); + if (Number.isNaN(date.getTime())) return undefined; + return date.getTime(); +} + +function fileTypeLabel(fileType: string): string { + switch (fileType) { + case "task_plan": + return "任务计划"; + case "findings": + return "研究发现"; + case "progress": + return "会话进展"; + case "error_log": + return "错误记录"; + default: + return fileType || "未知类型"; + } +} + +function EmptyMemoryState({ + onAnalyze, + loading, + disabled, +}: { + onAnalyze: () => void; + loading: boolean; + disabled: boolean; +}) { + return ( +
+
+ +
+

暂无记忆

+

+ 记忆提取是渐进式能力。积累更多真实对话后,系统会抽取并沉淀更稳定的可用信息。 +

+ +
+ ); +} + +function MemoryEntryCollection({ + entries, + viewMode, + selectedEntryId, + onSelect, +}: { + entries: MemoryEntryPreview[]; + viewMode: ViewMode; + selectedEntryId: string | null; + onSelect: (entryId: string) => void; +}) { + if (entries.length === 0) { + return ( +
+ 当前筛选条件下暂无记忆条目 +
+ ); + } + + if (viewMode === "grid") { + return ( +
+ {entries.map((entry) => { + const meta = CATEGORY_META[entry.category]; + const selected = selectedEntryId === entry.id; + + return ( + + ); + })} +
+ ); + } + + return ( +
+ {entries.map((entry) => { + const meta = CATEGORY_META[entry.category]; + const selected = selectedEntryId === entry.id; + + return ( + + ); + })} +
+ ); +} + +function MemoryDetailPanel({ entry }: { entry: MemoryEntryPreview | null }) { + if (!entry) { + return ( +
+ 请选择一条记忆查看详情 +
+ ); + } + + const meta = CATEGORY_META[entry.category]; + + return ( +
+
+
记忆标题
+
{entry.title}
+
+ +
+
+
记忆类型
+
{meta.label}
+
+
+
存储文件
+
{fileTypeLabel(entry.file_type)}
+
+
+
会话 ID
+
{entry.session_id}
+
+
+
更新时间
+
+ {formatAbsoluteTimestamp(entry.updated_at)} +
+
+
+ +
+
摘要内容
+
+ {entry.summary || "暂无摘要"} +
+
+ +
+
标签
+ {entry.tags.length === 0 ? ( +
暂无标签
+ ) : ( +
+ {entry.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+ ); +} + +interface MemoryPageProps { + onNavigate?: (page: Page, params?: PageParams) => void; +} + +export function MemoryPage({ onNavigate }: MemoryPageProps) { + const searchInputRef = useRef(null); + + const [config, setConfig] = useState(null); + const [memoryConfig, setMemoryConfig] = useState( + DEFAULT_MEMORY_CONFIG, + ); + + const [overview, setOverview] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [saving, setSaving] = useState(false); + const [cleaning, setCleaning] = useState(false); + const [analyzing, setAnalyzing] = useState(false); + + const [activeSection, setActiveSection] = useState("home"); + const [searchKeyword, setSearchKeyword] = useState(""); + const [viewMode, setViewMode] = useState("list"); + const [selectedEntryId, setSelectedEntryId] = useState(null); + + const [analysisFromDate, setAnalysisFromDate] = useState(""); + const [analysisToDate, setAnalysisToDate] = useState(""); + const [analysisResult, setAnalysisResult] = + useState(null); + + const maxEntriesOptions = [100, 500, 1000, 2000, 5000]; + const retentionDaysOptions = [7, 14, 30, 60, 90]; + + const [message, setMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + + const showMessage = useCallback((type: "success" | "error", text: string) => { + setMessage({ type, text }); + setTimeout(() => setMessage(null), 3500); + }, []); + + const stats: MemoryStatsResponse = useMemo( + () => + overview?.stats ?? { + total_entries: 0, + storage_used: 0, + memory_count: 0, + }, + [overview], + ); + + const categories = useMemo(() => { + if (!overview?.categories) { + return CATEGORY_ORDER.map((category) => ({ category, count: 0 })); + } + + const categoryMap = new Map( + overview.categories.map((item) => [item.category, item.count]), + ); + + return CATEGORY_ORDER.map((category) => ({ + category, + count: categoryMap.get(category) ?? 0, + })); + }, [overview]); + + const categoryCountMap = useMemo( + () => new Map(categories.map((item) => [item.category, item.count])), + [categories], + ); + + const entries = useMemo(() => overview?.entries ?? [], [overview]); + const hasMemoryData = stats.total_entries > 0; + + const activeCategoryFilter: CategoryFilter = + activeSection === "home" ? "all" : activeSection; + + const filteredEntries = useMemo(() => { + const keyword = searchKeyword.trim().toLowerCase(); + + return entries.filter((entry) => { + if ( + activeCategoryFilter !== "all" && + entry.category !== activeCategoryFilter + ) { + return false; + } + + if (!keyword) { + return true; + } + + const content = + `${entry.title} ${entry.summary} ${entry.tags.join(" ")}`.toLowerCase(); + return content.includes(keyword); + }); + }, [activeCategoryFilter, entries, searchKeyword]); + + useEffect(() => { + if (filteredEntries.length === 0) { + if (selectedEntryId !== null) { + setSelectedEntryId(null); + } + return; + } + + if ( + !selectedEntryId || + !filteredEntries.some((entry) => entry.id === selectedEntryId) + ) { + setSelectedEntryId(filteredEntries[0].id); + } + }, [filteredEntries, selectedEntryId]); + + const selectedEntry = useMemo( + () => filteredEntries.find((entry) => entry.id === selectedEntryId) ?? null, + [filteredEntries, selectedEntryId], + ); + + const loadConfig = useCallback(async () => { + const loadedConfig = await getConfig(); + setConfig(loadedConfig); + setMemoryConfig(loadedConfig.memory || DEFAULT_MEMORY_CONFIG); + }, []); + + const loadOverview = useCallback(async () => { + const data = await getMemoryOverview(120); + setOverview(data); + }, []); + + const loadAll = useCallback(async () => { + setLoading(true); + try { + await Promise.all([loadConfig(), loadOverview()]); + } catch (error) { + console.error("加载记忆数据失败:", error); + showMessage("error", "加载记忆失败,请稍后重试"); + } finally { + setLoading(false); + } + }, [loadConfig, loadOverview, showMessage]); + + useEffect(() => { + loadAll(); + }, [loadAll]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.metaKey || event.ctrlKey || event.altKey) { + return; + } + + const target = event.target as HTMLElement | null; + if (target) { + const tagName = target.tagName.toLowerCase(); + if ( + tagName === "input" || + tagName === "textarea" || + target.isContentEditable + ) { + return; + } + } + + if (event.key === "/") { + event.preventDefault(); + searchInputRef.current?.focus(); + return; + } + + const shortcutSection = SECTION_SHORTCUTS[event.key]; + if (shortcutSection) { + event.preventDefault(); + setActiveSection(shortcutSection); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, []); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + try { + await loadOverview(); + } catch (error) { + console.error("刷新记忆总览失败:", error); + showMessage("error", "刷新失败"); + } finally { + setRefreshing(false); + } + }, [loadOverview, showMessage]); + + const handleAnalyze = useCallback(async () => { + if ( + analysisFromDate && + analysisToDate && + analysisFromDate > analysisToDate + ) { + showMessage("error", "开始日期不能晚于结束日期"); + return; + } + + if (!memoryConfig.enabled) { + showMessage("error", "记忆功能已关闭,请先开启"); + return; + } + + setAnalyzing(true); + try { + const fromTimestamp = parseDateStartTimestamp(analysisFromDate); + const toTimestamp = parseDateEndTimestamp(analysisToDate); + + const result = await requestMemoryAnalysis(fromTimestamp, toTimestamp); + setAnalysisResult(result); + await loadOverview(); + + if (result.generated_entries > 0) { + showMessage( + "success", + `分析完成:新增 ${result.generated_entries} 条记忆(去重 ${result.deduplicated_entries} 条)`, + ); + } else { + showMessage("success", "分析完成:暂无新的可提取记忆"); + } + } catch (error) { + console.error("记忆分析失败:", error); + showMessage("error", "记忆分析失败,请稍后重试"); + } finally { + setAnalyzing(false); + } + }, [ + analysisFromDate, + analysisToDate, + loadOverview, + memoryConfig.enabled, + showMessage, + ]); + + const handleCleanup = useCallback(async () => { + setCleaning(true); + try { + const result = await cleanupMemory(); + await loadOverview(); + showMessage( + "success", + `清理完成:清理 ${result.cleaned_entries} 条,释放 ${formatStorageSize(result.freed_space)}`, + ); + } catch (error) { + console.error("清理记忆失败:", error); + showMessage("error", "清理失败"); + } finally { + setCleaning(false); + } + }, [loadOverview, showMessage]); + + const saveMemoryConfig = useCallback( + async (key: keyof TauriMemoryConfig, value: boolean | number) => { + if (!config) { + showMessage("error", "配置尚未加载完成"); + return; + } + + setSaving(true); + try { + const nextMemoryConfig: TauriMemoryConfig = { + ...memoryConfig, + [key]: value, + }; + + const nextConfig: Config = { + ...config, + memory: nextMemoryConfig, + }; + + await saveConfig(nextConfig); + setConfig(nextConfig); + setMemoryConfig(nextMemoryConfig); + showMessage("success", "记忆设置已保存"); + } catch (error) { + console.error("保存记忆设置失败:", error); + showMessage("error", "记忆设置保存失败"); + } finally { + setSaving(false); + } + }, + [config, memoryConfig, showMessage], + ); + + const sectionTitle = + activeSection === "home" + ? "记忆首页" + : `${CATEGORY_META[activeSection].label}记忆`; + + const sectionDescription = + activeSection === "home" + ? "查看全部记忆并触发分析任务" + : CATEGORY_META[activeSection].description; + + const handleBackToHome = useCallback(() => { + if (onNavigate) { + onNavigate("agent", { + theme: "general", + lockTheme: false, + }); + } + }, [onNavigate]); + + return ( +
+
+ +
+ +
+ + +
+
+
+
+

{sectionTitle}

+

+ {sectionDescription} +

+
+ +
+ + + +
+
+ + {loading ? ( +
+ + 正在加载记忆数据... +
+ ) : ( + <> +
+
+
+
+ 记忆条数 +
+
+ {stats.total_entries} +
+
+
+
+ 存储空间 +
+
+ {formatStorageSize(stats.storage_used)} +
+
+
+
+ 记忆库数 +
+
+ {stats.memory_count} +
+
+
+
+ +
+
+ + 分析范围(可选) +
+ +
+ + + + + +
+ +

+ 未选择日期时,会分析全部可用历史对话。 +

+
+ + {analysisResult && ( +
+
+ 最近一次分析结果 +
+
+ 分析会话 {analysisResult.analyzed_sessions} 个,扫描消息{" "} + {analysisResult.analyzed_messages} 条,新增记忆{" "} + {analysisResult.generated_entries} 条,去重{" "} + {analysisResult.deduplicated_entries} 条。 +
+
+ )} + + {!hasMemoryData ? ( + + ) : ( + <> + {activeSection === "home" && ( +
+ {categories.map((item) => { + const meta = CATEGORY_META[item.category]; + const Icon = meta.icon; + + return ( + + ); + })} +
+ )} + +
+
+
记忆条目
+ +
+ + +
+
+ +
+ 当前筛选: + {activeCategoryFilter === "all" + ? "全部分类" + : CATEGORY_META[activeCategoryFilter].label} + ,共 {filteredEntries.length} 条结果 +
+
+ +
+ + +
+ + )} + +
+
+
+
+ +
+

启用记忆功能

+

+ 控制是否允许系统提取并使用记忆 +

+
+
+ + saveMemoryConfig("enabled", event.target.checked) + } + disabled={saving} + className="w-4 h-4 rounded border-gray-300" + /> +
+ +
+
+

最大记忆条数

+ + {memoryConfig.max_entries || 1000} + +
+
+ {maxEntriesOptions.map((option) => ( + + ))} +
+
+
+ +
+
+
+

记忆保留天数

+ + {memoryConfig.retention_days || 30} 天 + +
+
+ {retentionDaysOptions.map((option) => ( + + ))} +
+
+ +
+
+

+ 自动清理过期记忆 +

+

+ 定期归档超出保留时长的历史记忆 +

+
+ + saveMemoryConfig("auto_cleanup", event.target.checked) + } + disabled={saving} + className="w-4 h-4 rounded border-gray-300" + /> +
+ +
+
+ + 手动清理过期和失效记忆 +
+ +
+
+
+ +
+ +

+ 记忆关闭后将停止新增条目;历史条目仍可浏览。清理操作不可逆,请在确认后执行。 +

+
+ + )} + + {message && ( +
+ {message.type === "success" ? ( + + ) : ( + + )} + {message.text} +
+ )} +
+
+
+
+ ); +} + +export default MemoryPage; diff --git a/src/components/memory/index.ts b/src/components/memory/index.ts new file mode 100644 index 000000000..0d412dcf5 --- /dev/null +++ b/src/components/memory/index.ts @@ -0,0 +1,2 @@ +export { MemoryPage } from "./MemoryPage"; +export { default } from "./MemoryPage"; diff --git a/src/components/projects/CreateProjectDialog.tsx b/src/components/projects/CreateProjectDialog.tsx index 65e6e236f..24f2ac290 100644 --- a/src/components/projects/CreateProjectDialog.tsx +++ b/src/components/projects/CreateProjectDialog.tsx @@ -20,9 +20,15 @@ import { cn } from "@/lib/utils"; import { ProjectType, USER_PROJECT_TYPES, + extractErrorMessage, + getCreateProjectErrorMessage, getProjectTypeLabel, getProjectTypeIcon, + getProjectByRootPath, + getWorkspaceProjectsRoot, + resolveProjectRootPath, } from "@/lib/api/project"; +import { toast } from "sonner"; interface CreateProjectDialogProps { open: boolean; @@ -42,6 +48,10 @@ export function CreateProjectDialog({ const [name, setName] = useState(""); const [type, setType] = useState(defaultType || "general"); const [isSubmitting, setIsSubmitting] = useState(false); + const [workspaceRootPath, setWorkspaceRootPath] = useState(""); + const [resolvedProjectPath, setResolvedProjectPath] = useState(""); + const [pathChecking, setPathChecking] = useState(false); + const [pathConflictMessage, setPathConflictMessage] = useState(""); // 当对话框打开且 defaultType 变化时,更新类型选择 useEffect(() => { @@ -57,6 +67,113 @@ export function CreateProjectDialog({ } }, [open, defaultName]); + useEffect(() => { + if (!open) { + return; + } + + let mounted = true; + + const loadWorkspaceRoot = async () => { + try { + const root = await getWorkspaceProjectsRoot(); + if (mounted) { + setWorkspaceRootPath(root); + } + } catch (error) { + console.error("加载 workspace 目录失败:", error); + if (mounted) { + setWorkspaceRootPath(""); + } + } + }; + + void loadWorkspaceRoot(); + + return () => { + mounted = false; + }; + }, [open]); + + useEffect(() => { + if (!open) { + return; + } + + const projectName = name.trim(); + if (!projectName) { + setResolvedProjectPath(""); + setPathChecking(false); + setPathConflictMessage(""); + return; + } + + let mounted = true; + + const resolvePath = async () => { + try { + const path = await resolveProjectRootPath(projectName); + if (mounted) { + setResolvedProjectPath(path); + setPathConflictMessage(""); + } + } catch (error) { + console.error("解析项目目录失败:", error); + if (mounted) { + setResolvedProjectPath(""); + setPathConflictMessage(""); + } + } + }; + + void resolvePath(); + + return () => { + mounted = false; + }; + }, [open, name]); + + useEffect(() => { + if (!open || !resolvedProjectPath) { + setPathChecking(false); + setPathConflictMessage(""); + return; + } + + let mounted = true; + setPathChecking(true); + + const checkPathConflict = async () => { + try { + const existingProject = await getProjectByRootPath(resolvedProjectPath); + if (!mounted) { + return; + } + + if (existingProject) { + setPathConflictMessage(`路径已存在项目:${existingProject.name}`); + } else { + setPathConflictMessage(""); + } + } catch (error) { + console.error("检查项目路径冲突失败:", error); + if (mounted) { + setPathConflictMessage(""); + } + } finally { + if (mounted) { + setPathChecking(false); + } + } + }; + + void checkPathConflict(); + + return () => { + mounted = false; + }; + }, [open, resolvedProjectPath]); + const handleSubmit = async () => { if (!name.trim()) return; @@ -67,12 +184,10 @@ export function CreateProjectDialog({ setType(defaultType || "general"); onOpenChange(false); } catch (error) { - // 如果是用户取消选择目录,不显示错误 - if (error instanceof Error && error.message === "用户取消选择目录") { - // 用户取消,不做任何处理 - } else { - console.error("创建项目失败:", error); - } + console.error("创建项目失败:", error); + const message = extractErrorMessage(error); + const friendlyMessage = getCreateProjectErrorMessage(message); + toast.error(`创建项目失败: ${friendlyMessage}`); } finally { setIsSubmitting(false); } @@ -84,7 +199,7 @@ export function CreateProjectDialog({ 新建项目 - 创建一个新的内容创作项目,选择项目类型以获得最佳体验。 + 创建一个新的内容创作项目,目录将固定在 workspace 目录下。 @@ -126,6 +241,36 @@ export function CreateProjectDialog({ ))}
+ +
+ + +
+ +
+ + +

+ 将创建到: + {resolvedProjectPath || "请输入项目名称"} +

+ {pathChecking && ( +

正在检查路径...

+ )} + {!pathChecking && pathConflictMessage && ( +

{pathConflictMessage}

+ )} +
@@ -134,7 +279,12 @@ export function CreateProjectDialog({ diff --git a/src/components/projects/ProjectsPage.tsx b/src/components/projects/ProjectsPage.tsx index 15850c38f..a5a9edd9a 100644 --- a/src/components/projects/ProjectsPage.tsx +++ b/src/components/projects/ProjectsPage.tsx @@ -15,13 +15,13 @@ import { ContentListItem, listProjects, createProject, + resolveProjectRootPath, updateProject, deleteProject, isUserProjectType, getContentStats, getCreateProjectErrorMessage, - generateProjectName, - getDefaultProjectPath, + extractErrorMessage, } from "@/lib/api/project"; import { ProjectCard } from "./ProjectCard"; import { NewProjectCard } from "./NewProjectCard"; @@ -30,7 +30,6 @@ import { CreateProjectDialog } from "./CreateProjectDialog"; import { DeleteProjectDialog } from "./DeleteProjectDialog"; import { ContentListPage } from "./ContentListPage"; import { toast } from "sonner"; -import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { Page, PageParams } from "@/types/page"; interface ProjectsPageProps { @@ -164,27 +163,8 @@ export function ProjectsPage({ onNavigate }: ProjectsPageProps) { // 创建项目 const handleCreateProject = async (name: string, type: ProjectType) => { - // 选择项目目录 - const selectedPath = await openDialog({ - directory: true, - title: "选择项目目录", - }); - - if (!selectedPath) { - // 用户取消选择,抛出错误让对话框知道 - throw new Error("用户取消选择目录"); - } - try { - const projectPath = Array.isArray(selectedPath) - ? selectedPath.length === 1 - ? selectedPath[0] - : null - : selectedPath; - - if (!projectPath) { - throw new Error("请选择单个项目目录"); - } + const projectPath = await resolveProjectRootPath(name); await createProject({ name, @@ -196,44 +176,13 @@ export function ProjectsPage({ onNavigate }: ProjectsPageProps) { loadProjects(); } catch (error) { console.error("创建项目失败:", error); - const errorMessage = - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : error && typeof error === "object" && "message" in error - ? String((error as { message?: unknown }).message) - : String(error); + const errorMessage = extractErrorMessage(error); const friendlyMessage = getCreateProjectErrorMessage(errorMessage); toast.error(`创建项目失败: ${friendlyMessage}`); throw error; } }; - // 快速创建项目(不弹窗,直接用规则创建) - const handleQuickCreateProject = async (type: ProjectType = "general") => { - // 生成规则名称 - const name = generateProjectName(type); - - try { - // 使用默认项目路径 - const rootPath = getDefaultProjectPath(); - - await createProject({ - name, - rootPath, - workspaceType: type, - }); - - // 静默成功,不显示 toast - console.log("[ProjectsPage] 快速创建项目成功:", name); - loadProjects(); - } catch (error) { - console.error("快速创建项目失败:", error); - // 静默失败,不提示用户 - } - }; - // 切换收藏 const handleToggleFavorite = async (project: Project) => { try { @@ -382,17 +331,7 @@ export function ProjectsPage({ onNavigate }: ProjectsPageProps) {

还没有项目

); + const maxDailyTokens = + dailyUsage.length > 0 + ? Math.max(...dailyUsage.map((day) => day.tokens)) + : 0; + return (
{/* 时间范围选择 */} @@ -221,6 +165,12 @@ export function StatsSettings() {
+ {error && ( +
+ {error} +
+ )} + {loading ? (
@@ -241,7 +191,6 @@ export function StatsSettings() { icon={Coins} label="Token" value={formatNumber(stats.today_tokens)} - trend={12} />

模型使用排行

-
- {modelUsage.map((model, index) => ( -
-
-
- - #{index + 1} - - {model.model} + {modelUsage.length > 0 ? ( +
+ {modelUsage.map((model, index) => ( +
+
+
+ + #{index + 1} + + {model.model} +
+
+ {model.conversations} 次对话 + {formatNumber(model.tokens)} Token + + {model.percentage}% + +
-
- {model.conversations} 次对话 - {formatNumber(model.tokens)} Token - - {model.percentage}% - +
+
-
-
-
-
- ))} -
+ ))} +
+ ) : ( +
+ 暂无模型使用数据 +
+ )}
{/* 每日使用趋势 */} @@ -352,9 +305,8 @@ export function StatsSettings() {
{dailyUsage.map((day, _index) => { - const maxTokens = Math.max(...dailyUsage.map((d) => d.tokens)); const height = - maxTokens > 0 ? (day.tokens / maxTokens) * 100 : 0; + maxDailyTokens > 0 ? (day.tokens / maxDailyTokens) * 100 : 0; return (
{ if (!dayData) return "bg-muted"; - const max = Math.max(...dailyUsage.map((d) => d.tokens)); + if (maxDailyTokens <= 0) return "bg-primary/10"; + const max = maxDailyTokens; const ratio = tokens / max; if (ratio < 0.2) return "bg-primary/10"; if (ratio < 0.4) return "bg-primary/30"; diff --git a/src/components/settings-v2/agent/assistant/index.tsx b/src/components/settings-v2/agent/assistant/index.tsx index deeffc251..00d0c38c3 100644 --- a/src/components/settings-v2/agent/assistant/index.tsx +++ b/src/components/settings-v2/agent/assistant/index.tsx @@ -1,7 +1,7 @@ /** * 助理服务配置设置组件 * - * 参考 LobeHub 的 agent 实现 + * 参考成熟产品的助理能力实现 * 功能包括:默认助理选择、助理参数配置等 */ diff --git a/src/components/settings-v2/agent/image-gen/index.tsx b/src/components/settings-v2/agent/image-gen/index.tsx index 918ba9752..7eac2df53 100644 --- a/src/components/settings-v2/agent/image-gen/index.tsx +++ b/src/components/settings-v2/agent/image-gen/index.tsx @@ -1,7 +1,7 @@ /** * 绘画服务集成设置组件 * - * 参考 LobeHub 的 image 实现 + * 参考成熟产品的图像能力实现 * 功能包括:图像生成服务商选择、默认参数配置等 */ diff --git a/src/components/settings-v2/agent/memory/index.tsx b/src/components/settings-v2/agent/memory/index.tsx deleted file mode 100644 index 0d2df791c..000000000 --- a/src/components/settings-v2/agent/memory/index.tsx +++ /dev/null @@ -1,361 +0,0 @@ -/** - * 记忆管理系统设置组件 - * - * 参考 LobeHub 的 memory 实现,扩展更多功能 - * 功能包括:记忆启用/禁用、容量管理、清理功能等 - */ - -import { useState, useEffect } from "react"; -import { - BrainCircuit, - Trash2, - Database, - AlertCircle, - Info, - CheckCircle2, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { getConfig, saveConfig, Config } from "@/hooks/useTauri"; - -interface MemoryConfig { - /** 是否启用记忆功能 */ - enabled: boolean; - /** 最大记忆条数 */ - max_entries?: number; - /** 记忆保留天数 */ - retention_days?: number; - /** 自动清理过期记忆 */ - auto_cleanup?: boolean; -} - -interface MemoryStats { - /** 总记忆条数 */ - total_entries: number; - /** 已使用的存储空间(字节) */ - storage_used: number; - /** 记忆库数量 */ - memory_count: number; -} - -const DEFAULT_MEMORY_CONFIG: MemoryConfig = { - enabled: true, - max_entries: 1000, - retention_days: 30, - auto_cleanup: true, -}; - -/** - * 格式化存储大小 - */ -function formatStorageSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; -} - -export function MemorySettings() { - const [config, setConfig] = useState(null); - const [memoryConfig, setMemoryConfig] = useState( - DEFAULT_MEMORY_CONFIG, - ); - const [memoryStats, setMemoryStats] = useState(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [cleaning, setCleaning] = useState(false); - const [message, setMessage] = useState<{ - type: "success" | "error"; - text: string; - } | null>(null); - - // 加载配置 - useEffect(() => { - loadConfig(); - loadMemoryStats(); - }, []); - - const loadConfig = async () => { - setLoading(true); - try { - const c = await getConfig(); - setConfig(c); - setMemoryConfig(c.memory || DEFAULT_MEMORY_CONFIG); - } catch (e) { - console.error("加载记忆配置失败:", e); - } finally { - setLoading(false); - } - }; - - const loadMemoryStats = async () => { - try { - // TODO: 实现获取记忆统计的 API - // const stats = await getMemoryStats(); - // setMemoryStats(stats); - - // 模拟数据 - setMemoryStats({ - total_entries: 156, - storage_used: 256000, // 256 KB - memory_count: 12, - }); - } catch (e) { - console.error("加载记忆统计失败:", e); - } - }; - - // 保存配置 - const saveMemoryConfig = async (key: keyof MemoryConfig, value: any) => { - if (!config) return; - setSaving(true); - - try { - const newConfig = { - ...memoryConfig, - [key]: value, - }; - const updatedFullConfig = { - ...config, - memory: newConfig, - }; - await saveConfig(updatedFullConfig); - setConfig(updatedFullConfig); - setMemoryConfig(newConfig); - - showMessage("success", "设置已保存"); - } catch (e) { - console.error("保存记忆配置失败:", e); - showMessage("error", "保存失败"); - } finally { - setSaving(false); - } - }; - - // 清理记忆 - const handleCleanup = async () => { - setCleaning(true); - try { - // TODO: 实现清理记忆的 API - // await cleanupMemory(); - - // 模拟清理 - await new Promise((resolve) => setTimeout(resolve, 1000)); - - showMessage("success", "已清理过期记忆"); - loadMemoryStats(); // 重新加载统计 - } catch (e) { - console.error("清理记忆失败:", e); - showMessage("error", "清理失败"); - } finally { - setCleaning(false); - } - }; - - const showMessage = (type: "success" | "error", text: string) => { - setMessage({ type, text }); - setTimeout(() => setMessage(null), 3000); - }; - - const maxEntriesOptions = [100, 500, 1000, 2000, 5000]; - const retentionDaysOptions = [7, 14, 30, 60, 90]; - - return ( -
- {/* 记忆统计卡片 */} - {memoryStats && ( -
-
- -

记忆统计

-
-
-
-
- {memoryStats.total_entries} -
-
记忆条数
-
-
-
- {formatStorageSize(memoryStats.storage_used)} -
-
存储空间
-
-
-
- {memoryStats.memory_count} -
-
记忆库数
-
-
-
- )} - - {/* 启用记忆功能 */} -
-
-
- -
-

启用记忆功能

-

- 让 AI 记住之前的对话内容 -

-
-
- saveMemoryConfig("enabled", e.target.checked)} - disabled={loading || saving} - className="w-4 h-4 rounded border-gray-300" - /> -
-
- - {/* 最大记忆条数 */} -
-
-
-

最大记忆条数

-

- 限制保存的记忆条目数量 -

-
- - {memoryConfig.max_entries || 1000} - -
- -
- {maxEntriesOptions.map((option) => ( - - ))} -
-
- - {/* 记忆保留天数 */} -
-
-
-

记忆保留天数

-

- 自动删除超过指定天数的记忆 -

-
- - {memoryConfig.retention_days || 30} 天 - -
- -
- {retentionDaysOptions.map((option) => ( - - ))} -
-
- - {/* 自动清理 */} -
-
-
-

自动清理过期记忆

-

- 定期自动删除过期的记忆条目 -

-
- saveMemoryConfig("auto_cleanup", e.target.checked)} - disabled={loading || saving} - className="w-4 h-4 rounded border-gray-300" - /> -
-
- - {/* 清理按钮 */} -
-
-
- -
-

清理记忆

-

- 手动清理所有过期和无效的记忆 -

-
-
- -
-
- - {/* 提示信息 */} -
- -

- 记忆功能会让 AI 在对话中记住之前的上下文信息。禁用后,AI - 将无法跨对话记住信息。清理记忆是不可逆操作,请谨慎操作。 -

-
- - {/* 消息提示 */} - {message && ( -
- {message.type === "success" ? ( - - ) : ( - - )} - {message.text} -
- )} -
- ); -} - -export default MemorySettings; diff --git a/src/components/settings-v2/agent/voice/index.tsx b/src/components/settings-v2/agent/voice/index.tsx index 30a7ae38a..d99b37807 100644 --- a/src/components/settings-v2/agent/voice/index.tsx +++ b/src/components/settings-v2/agent/voice/index.tsx @@ -1,7 +1,7 @@ /** * 语音服务配置设置组件 * - * 参考 LobeHub 的 TTS/STT 实现 + * 参考成熟产品的 TTS/STT 实现 * 功能包括:TTS 服务商选择、STT 服务商选择、语音参数配置等 */ diff --git a/src/components/settings-v2/general/chat-appearance/index.tsx b/src/components/settings-v2/general/chat-appearance/index.tsx index a99f44b8c..34d8d4e7a 100644 --- a/src/components/settings-v2/general/chat-appearance/index.tsx +++ b/src/components/settings-v2/general/chat-appearance/index.tsx @@ -1,7 +1,7 @@ /** * 聊天外观设置组件 * - * 参考 LobeHub 的 chat-appearance 实现 + * 参考成熟产品的聊天外观实现 * 功能包括:聊天气泡样式、字体大小、过渡模式等 */ diff --git a/src/components/settings-v2/general/hotkeys/index.tsx b/src/components/settings-v2/general/hotkeys/index.tsx index 1cf7de182..20235eacc 100644 --- a/src/components/settings-v2/general/hotkeys/index.tsx +++ b/src/components/settings-v2/general/hotkeys/index.tsx @@ -1,12 +1,17 @@ /** * 快捷键设置页面 * - * 显示和配置应用快捷键 - * 参考 LobeHub 的 Hotkey 设置设计 + * 显示应用中已实现的快捷键 */ -// import { useState } from 'react'; +import { useCallback, useEffect, useState } from "react"; import styled from "styled-components"; +import { Loader2 } from "lucide-react"; +import { getExperimentalConfig } from "@/hooks/useTauri"; +import { + getVoiceInputConfig, + type VoiceInputConfig, +} from "@/lib/api/asrProvider"; const Container = styled.div` display: flex; @@ -14,6 +19,11 @@ const Container = styled.div` gap: 24px; `; +const HeaderHint = styled.div` + font-size: 12px; + color: hsl(var(--muted-foreground)); +`; + const Section = styled.div` display: flex; flex-direction: column; @@ -56,11 +66,32 @@ const HotkeyDescription = styled.div` color: hsl(var(--muted-foreground)); `; +const HotkeyMeta = styled.div` + margin-top: 2px; + font-size: 11px; + color: hsl(var(--muted-foreground)); +`; + const HotkeyValue = styled.div` display: flex; + align-items: center; gap: 4px; `; +const StatusBadge = styled.span<{ $enabled: boolean }>` + margin-right: 8px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + border: 1px solid + ${({ $enabled }) => + $enabled ? "hsl(var(--primary) / 0.3)" : "hsl(var(--border))"}; + color: ${({ $enabled }) => + $enabled ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"}; + background: ${({ $enabled }) => + $enabled ? "hsl(var(--primary) / 0.08)" : "hsl(var(--muted) / 0.35)"}; +`; + const KeyBadge = styled.span` display: inline-flex; align-items: center; @@ -81,61 +112,105 @@ interface HotkeyConfig { label: string; description: string; keys: string[]; + enabled: boolean; + source: string; } -const desktopHotkeys: HotkeyConfig[] = [ - { - id: "toggle-main-window", - label: "显示/隐藏主窗口", - description: "全局快捷键显示或隐藏主窗口", - keys: ["Control", "E"], - }, - { - id: "open-settings", - label: "应用设置", - description: "打开应用设置页面", - keys: ["Command Or Control", ","], - }, -]; +function formatShortcutKeys(shortcut: string): string[] { + const map: Record = { + CommandOrControl: "⌘/Ctrl", + Command: "⌘", + Control: "Ctrl", + Ctrl: "Ctrl", + Alt: "Alt", + Option: "⌥", + Shift: "⇧", + Super: "Super", + }; -const essentialHotkeys: HotkeyConfig[] = [ - { - id: "command-panel", - label: "命令面板", - description: "打开全局命令面板快速访问功能", - keys: ["⌘", "K"], - }, - { - id: "search", - label: "搜索", - description: "唤起当前页面主要搜索框", - keys: ["⌘", "J"], - }, - { - id: "switch-assistant", - label: "快捷切换助理", - description: "通过按住 Ctrl 加数字 0-9 切换固定在侧边栏的助理", - keys: ["^", "1-9"], - }, - { - id: "switch-default-chat", - label: "切换至默认会话", - description: "切换至会话标签并进入 Lobe AI", - keys: ["^", "·"], - }, - { - id: "toggle-left-panel", - label: "显示/隐藏左侧面板", - description: "显示或隐藏左侧面板", - keys: ["⌘", "["], - }, - { - id: "toggle-right-panel", - label: "显示/隐藏右侧面板", - description: "显示或隐藏右侧面板", - keys: ["⌘", "]"], - }, -]; + return shortcut + .split("+") + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => map[part] ?? part); +} + +interface HotkeyState { + globalHotkeys: HotkeyConfig[]; + localHotkeys: HotkeyConfig[]; +} + +function buildHotkeys( + screenshotEnabled: boolean, + screenshotShortcut: string, + voiceConfig: VoiceInputConfig, +): HotkeyState { + const globalHotkeys: HotkeyConfig[] = [ + { + id: "screenshot-chat", + label: "截图对话", + description: "触发全局截图并打开截图对话窗口", + keys: formatShortcutKeys(screenshotShortcut), + enabled: screenshotEnabled, + source: "实验功能 → 截图对话", + }, + { + id: "voice-input", + label: "语音输入", + description: "按下开始录音,松开后识别并输出", + keys: formatShortcutKeys(voiceConfig.shortcut), + enabled: voiceConfig.enabled, + source: "语音服务", + }, + { + id: "voice-translate", + label: "语音翻译模式", + description: "独立快捷键触发语音识别并执行翻译指令", + keys: voiceConfig.translate_shortcut + ? formatShortcutKeys(voiceConfig.translate_shortcut) + : ["未设置"], + enabled: voiceConfig.enabled && !!voiceConfig.translate_shortcut, + source: `语音服务 → 指令 ${voiceConfig.translate_instruction_id}`, + }, + ]; + + const localHotkeys: HotkeyConfig[] = [ + { + id: "terminal-search", + label: "终端搜索", + description: "在终端页面打开搜索框", + keys: ["⌘/Ctrl", "F"], + enabled: true, + source: "终端页面", + }, + { + id: "terminal-font-plus", + label: "终端字体放大", + description: "在终端页面增大字体", + keys: ["⌘/Ctrl", "+"], + enabled: true, + source: "终端页面", + }, + { + id: "terminal-font-minus", + label: "终端字体缩小", + description: "在终端页面减小字体", + keys: ["⌘/Ctrl", "-"], + enabled: true, + source: "终端页面", + }, + { + id: "terminal-font-reset", + label: "终端字体重置", + description: "在终端页面重置字体大小", + keys: ["⌘/Ctrl", "0"], + enabled: true, + source: "终端页面", + }, + ]; + + return { globalHotkeys, localHotkeys }; +} function HotkeySection({ title, @@ -152,8 +227,12 @@ function HotkeySection({ {hotkey.label} {hotkey.description} + {hotkey.source} + + {hotkey.enabled ? "已启用" : "未启用"} + {hotkey.keys.map((key, index) => ( {key} ))} @@ -165,10 +244,77 @@ function HotkeySection({ } export function HotkeysSettings() { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [globalHotkeys, setGlobalHotkeys] = useState([]); + const [localHotkeys, setLocalHotkeys] = useState([]); + + const loadHotkeys = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const [experimentalConfig, voiceConfig] = await Promise.all([ + getExperimentalConfig(), + getVoiceInputConfig(), + ]); + + const built = buildHotkeys( + experimentalConfig.screenshot_chat.enabled, + experimentalConfig.screenshot_chat.shortcut, + voiceConfig, + ); + + setGlobalHotkeys(built.globalHotkeys); + setLocalHotkeys(built.localHotkeys); + } catch (loadError) { + console.error("加载快捷键信息失败:", loadError); + setError(loadError instanceof Error ? loadError.message : "加载失败"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadHotkeys(); + }, [loadHotkeys]); + return ( - - + + 仅展示当前版本已实现的快捷键;全局快捷键会随配置实时更新。 + + + {loading ? ( +
+ + 正在加载快捷键信息... +
+ ) : error ? ( +
+ 加载快捷键失败:{error} +
+ ) : ( + <> + + + + )}
); } diff --git a/src/components/settings-v2/hooks/useSettingsCategory.ts b/src/components/settings-v2/hooks/useSettingsCategory.ts index b6934ed1c..dec987321 100644 --- a/src/components/settings-v2/hooks/useSettingsCategory.ts +++ b/src/components/settings-v2/hooks/useSettingsCategory.ts @@ -2,7 +2,7 @@ * 设置分类 Hook * * 定义设置页面的分组和导航项 - * 参考 LobeHub 的 useCategory 设计 + * 参考成熟产品的分组导航设计 */ import { useMemo } from "react"; @@ -16,7 +16,6 @@ import { Brain, Bot, Blocks, - BrainCircuit, Image, Mic, Server, @@ -123,11 +122,6 @@ export function useSettingsCategory(): CategoryGroup[] { label: t("settings.tab.skills", "技能管理"), icon: Blocks, }, - { - key: SettingsTabs.Memory, - label: t("settings.tab.memory", "记忆设置"), - icon: BrainCircuit, - }, { key: SettingsTabs.ImageGen, label: t("settings.tab.imageGen", "绘画服务"), diff --git a/src/components/settings-v2/index.ts b/src/components/settings-v2/index.ts index fdd951683..c697f626d 100644 --- a/src/components/settings-v2/index.ts +++ b/src/components/settings-v2/index.ts @@ -1,7 +1,7 @@ /** * 设置页面 V2 导出 * - * 新版设置页面,采用 LobeHub 风格的侧边栏布局 + * 新版设置页面,采用统一侧边栏布局 */ export { SettingsLayoutV2 as SettingsPageV2 } from "./_layout"; diff --git a/src/components/terminal/ai/useTerminalAI.ts b/src/components/terminal/ai/useTerminalAI.ts index 2b6f520b4..ddab057c5 100644 --- a/src/components/terminal/ai/useTerminalAI.ts +++ b/src/components/terminal/ai/useTerminalAI.ts @@ -8,6 +8,7 @@ */ import { useState, useEffect, useCallback, useRef } from "react"; +import { invoke } from "@tauri-apps/api/core"; import { toast } from "sonner"; import { safeListen } from "@/lib/dev-bridge"; import type { UnlistenFn } from "@tauri-apps/api/event"; @@ -48,6 +49,10 @@ const DEFAULT_CONFIG: TerminalAIConfig = { autoExecute: false, // 默认需要手动批准 }; +interface WorkspaceSummary { + id: string; +} + /** * 加载持久化数据 */ @@ -101,6 +106,7 @@ export function useTerminalAI( // 会话 ID const [sessionId, setSessionId] = useState(null); + const workspaceIdRef = useRef(null); // 终端控制状态 const [terminalSessionId, setTerminalSessionId] = useState( @@ -152,6 +158,23 @@ export function useTerminalAI( initAgent(); }, []); + const ensureWorkspaceId = useCallback(async (): Promise => { + if (workspaceIdRef.current) { + return workspaceIdRef.current; + } + + const workspace = await invoke( + "workspace_get_default", + ); + const resolvedWorkspaceId = workspace?.id?.trim(); + if (!resolvedWorkspaceId) { + throw new Error("未找到默认工作区,请先创建或选择项目"); + } + + workspaceIdRef.current = resolvedWorkspaceId; + return resolvedWorkspaceId; + }, []); + /** * 确保会话存在 */ @@ -169,10 +192,13 @@ export function useTerminalAI( 请用简洁清晰的语言回答,必要时提供代码示例。`; + const resolvedWorkspaceId = await ensureWorkspaceId(); const response = await createAgentSession( providerId, + resolvedWorkspaceId, modelId, systemPrompt, + undefined, ); setSessionId(response.session_id); @@ -182,7 +208,7 @@ export function useTerminalAI( toast.error("创建 AI 会话失败"); return null; } - }, [sessionId, providerId, modelId]); + }, [sessionId, providerId, modelId, ensureWorkspaceId]); /** * 获取终端上下文 @@ -421,9 +447,12 @@ export function useTerminalAI( // 如果已连接终端,启用 terminal_mode(使用 TerminalTool 替代 BashTool) const useTerminalMode = terminalSessionId !== null; + const resolvedWorkspaceId = await ensureWorkspaceId(); + await sendAgentMessageStream( messageContent, eventName, + resolvedWorkspaceId, activeSessionId, modelId, imagesToSend, @@ -440,7 +469,14 @@ export function useTerminalAI( } } }, - [ensureSession, getTerminalContext, modelId, providerId, terminalSessionId], + [ + ensureSession, + ensureWorkspaceId, + getTerminalContext, + modelId, + providerId, + terminalSessionId, + ], ); /** diff --git a/src/components/workspace/WorkbenchPage.tsx b/src/components/workspace/WorkbenchPage.tsx index e8469abba..9bbfdd8f9 100644 --- a/src/components/workspace/WorkbenchPage.tsx +++ b/src/components/workspace/WorkbenchPage.tsx @@ -18,7 +18,16 @@ import { Sparkles, } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; import { cn } from "@/lib/utils"; import { @@ -28,11 +37,14 @@ import { createContent, createProject, formatRelativeTime, - generateProjectName, getContentTypeLabel, getDefaultContentTypeForProject, - getDefaultProjectPath, getProjectTypeLabel, + getWorkspaceProjectsRoot, + getProjectByRootPath, + getCreateProjectErrorMessage, + extractErrorMessage, + resolveProjectRootPath, listContents, listProjects, updateContent, @@ -65,7 +77,7 @@ export function WorkbenchPage({ viewMode: initialViewMode, }: WorkbenchPageProps) { const [showLeftSidebar, setShowLeftSidebar] = useState(true); - const [showRightSidebar, setShowRightSidebar] = useState(true); + const [showRightSidebar, setShowRightSidebar] = useState(false); const [workspaceMode, setWorkspaceMode] = useState( initialViewMode ?? (initialContentId ? "workspace" : "project-management"), ); @@ -85,6 +97,14 @@ export function WorkbenchPage({ const [projectQuery, setProjectQuery] = useState(""); const [contentQuery, setContentQuery] = useState(""); + const [createProjectDialogOpen, setCreateProjectDialogOpen] = useState(false); + const [newProjectName, setNewProjectName] = useState(""); + const [workspaceProjectsRoot, setWorkspaceProjectsRoot] = useState(""); + const [creatingProject, setCreatingProject] = useState(false); + const [resolvedProjectPath, setResolvedProjectPath] = useState(""); + const [pathChecking, setPathChecking] = useState(false); + const [pathConflictMessage, setPathConflictMessage] = useState(""); + const selectedProject = useMemo( () => projects.find((project) => project.id === selectedProjectId) ?? null, [projects, selectedProjectId], @@ -117,7 +137,6 @@ export function WorkbenchPage({ const handleEnterWorkspace = useCallback((contentId: string) => { setSelectedContentId(contentId); setWorkspaceMode("workspace"); - setShowRightSidebar(true); }, []); const handleOpenProjectDetail = useCallback(() => { @@ -200,22 +219,44 @@ export function WorkbenchPage({ [initialContentId], ); + const handleOpenCreateProjectDialog = useCallback(() => { + setNewProjectName(`${getProjectTypeLabel(theme as ProjectType)}项目`); + setResolvedProjectPath(""); + setPathConflictMessage(""); + setPathChecking(false); + setCreateProjectDialogOpen(true); + }, [theme]); + const handleCreateProject = useCallback(async () => { + const name = newProjectName.trim(); + + if (!name) { + toast.error("请输入项目名称"); + return; + } + + setCreatingProject(true); try { - const projectName = generateProjectName(theme as ProjectType); - const rootPath = getDefaultProjectPath(); - await createProject({ - name: projectName, + const rootPath = await resolveProjectRootPath(name); + const createdProject = await createProject({ + name, rootPath, workspaceType: theme as ProjectType, }); + setCreateProjectDialogOpen(false); + setSelectedProjectId(createdProject.id); + setProjectQuery(""); toast.success("已创建新项目"); await loadProjects(); } catch (error) { console.error("创建项目失败:", error); - toast.error("创建项目失败"); + const errorMessage = extractErrorMessage(error); + const friendlyMessage = getCreateProjectErrorMessage(errorMessage); + toast.error(`创建项目失败: ${friendlyMessage}`); + } finally { + setCreatingProject(false); } - }, [loadProjects, theme]); + }, [loadProjects, newProjectName, theme]); const handleCreateContent = useCallback(async () => { if (!selectedProjectId) { @@ -270,7 +311,7 @@ export function WorkbenchPage({ setSelectedContentId(initialContentId ?? null); setWorkspaceMode(nextMode); setShowLeftSidebar(true); - setShowRightSidebar(nextMode === "workspace"); + setShowRightSidebar(false); setContents([]); void loadProjects(); }, [ @@ -281,6 +322,109 @@ export function WorkbenchPage({ theme, ]); + useEffect(() => { + let mounted = true; + + const loadWorkspaceProjectsRoot = async () => { + try { + const root = await getWorkspaceProjectsRoot(); + if (mounted) { + setWorkspaceProjectsRoot(root); + } + } catch (error) { + console.error("加载 workspace 目录失败:", error); + } + }; + + void loadWorkspaceProjectsRoot(); + + return () => { + mounted = false; + }; + }, []); + + useEffect(() => { + if (!createProjectDialogOpen) { + setResolvedProjectPath(""); + setPathChecking(false); + setPathConflictMessage(""); + return; + } + + const projectName = newProjectName.trim(); + if (!projectName) { + setResolvedProjectPath(""); + setPathChecking(false); + setPathConflictMessage(""); + return; + } + + let mounted = true; + + const resolvePath = async () => { + try { + const path = await resolveProjectRootPath(projectName); + if (mounted) { + setResolvedProjectPath(path); + } + } catch (error) { + console.error("解析项目目录失败:", error); + if (mounted) { + setResolvedProjectPath(""); + setPathConflictMessage(""); + setPathChecking(false); + } + } + }; + + void resolvePath(); + + return () => { + mounted = false; + }; + }, [createProjectDialogOpen, newProjectName]); + + useEffect(() => { + if (!createProjectDialogOpen || !resolvedProjectPath) { + setPathChecking(false); + setPathConflictMessage(""); + return; + } + + let mounted = true; + setPathChecking(true); + + const checkPathConflict = async () => { + try { + const existingProject = await getProjectByRootPath(resolvedProjectPath); + if (!mounted) { + return; + } + + if (existingProject) { + setPathConflictMessage(`路径已存在项目:${existingProject.name}`); + } else { + setPathConflictMessage(""); + } + } catch (error) { + console.error("检查项目路径冲突失败:", error); + if (mounted) { + setPathConflictMessage(""); + } + } finally { + if (mounted) { + setPathChecking(false); + } + } + }; + + void checkPathConflict(); + + return () => { + mounted = false; + }; + }, [createProjectDialogOpen, resolvedProjectPath]); + useEffect(() => { if (!selectedProjectId) { setContents([]); @@ -321,35 +465,19 @@ export function WorkbenchPage({ {workspaceMode === "workspace" && ( - <> - - - - + )} {workspaceMode !== "project-management" && ( @@ -371,6 +499,22 @@ export function WorkbenchPage({ {selectedProject.name}
)} + + {workspaceMode === "workspace" && ( + + )}
@@ -405,9 +549,7 @@ export function WorkbenchPage({ variant="ghost" size="icon" className="h-8 w-8" - onClick={() => { - void handleCreateProject(); - }} + onClick={handleOpenCreateProjectDialog} title="新建项目" > @@ -546,9 +688,7 @@ export function WorkbenchPage({
+ + + +
); } diff --git a/src/hooks/useProjects.ts b/src/hooks/useProjects.ts index ddc0d3daf..383ae742c 100644 --- a/src/hooks/useProjects.ts +++ b/src/hooks/useProjects.ts @@ -109,10 +109,14 @@ export function useProjects(): UseProjectsReturn { /** 创建项目 */ const create = useCallback( async (request: CreateProjectRequest): Promise => { + const rootPath = await invoke("workspace_resolve_project_path", { + name: request.name, + }); + const project = await invoke("workspace_create", { request: { name: request.name, - rootPath: request.name, // 使用名称作为路径 + rootPath, workspaceType: request.workspaceType, }, }); diff --git a/src/hooks/useTauri.ts b/src/hooks/useTauri.ts index 13e4a1515..6b843e5bb 100644 --- a/src/hooks/useTauri.ts +++ b/src/hooks/useTauri.ts @@ -792,11 +792,40 @@ export interface MemoryStatsResponse { memory_count: number; } +export interface MemoryCategoryStat { + category: "identity" | "context" | "preference" | "experience" | "activity"; + count: number; +} + +export interface MemoryEntryPreview { + id: string; + session_id: string; + file_type: string; + category: "identity" | "context" | "preference" | "experience" | "activity"; + title: string; + summary: string; + updated_at: number; + tags: string[]; +} + +export interface MemoryOverviewResponse { + stats: MemoryStatsResponse; + categories: MemoryCategoryStat[]; + entries: MemoryEntryPreview[]; +} + export interface CleanupMemoryResult { cleaned_entries: number; freed_space: number; } +export interface MemoryAnalysisResult { + analyzed_sessions: number; + analyzed_messages: number; + generated_entries: number; + deduplicated_entries: number; +} + /** * 获取记忆统计信息 */ @@ -804,6 +833,28 @@ export async function getMemoryStats(): Promise { return safeInvoke("get_conversation_memory_stats"); } +/** + * 获取记忆总览(含分类与条目) + */ +export async function getMemoryOverview( + limit?: number, +): Promise { + return safeInvoke("get_conversation_memory_overview", { limit }); +} + +/** + * 请求记忆分析(从历史会话提取记忆) + */ +export async function requestMemoryAnalysis( + fromTimestamp?: number, + toTimestamp?: number, +): Promise { + return safeInvoke("request_conversation_memory_analysis", { + fromTimestamp, + toTimestamp, + }); +} + /** * 清理过期记忆 */ diff --git a/src/icons/providers/utils.ts b/src/icons/providers/utils.ts index f7f04d347..5659899d7 100644 --- a/src/icons/providers/utils.ts +++ b/src/icons/providers/utils.ts @@ -99,6 +99,8 @@ export const availableIcons = [ export type AvailableIcon = (typeof availableIcons)[number]; +const LEGACY_PROXYCAST_HUB_ICON_ALIAS = `${"lobe"}${"hub"}`; + // ============================================================================ // Provider 类型到图标名称的映射 // ============================================================================ @@ -127,7 +129,7 @@ export const providerTypeToIcon: Record = { aws: "aws", proxycast: "proxycast", "proxycast-hub": "proxycast-hub", - lobehub: "proxycast", + [LEGACY_PROXYCAST_HUB_ICON_ALIAS]: "proxycast", // ===== 主流 AI Provider ===== deepseek: "deepseek", diff --git a/src/lib/api/agent.ts b/src/lib/api/agent.ts index 4270050ee..73fcae01e 100644 --- a/src/lib/api/agent.ts +++ b/src/lib/api/agent.ts @@ -351,20 +351,35 @@ export interface SkillInfo { path?: string; } +const requireWorkspaceId = ( + workspaceId?: string, + fallbackWorkspaceId?: string, +): string => { + const resolvedWorkspaceId = (workspaceId ?? fallbackWorkspaceId)?.trim(); + if (!resolvedWorkspaceId) { + throw new Error("workspaceId 不能为空,请先选择项目工作区"); + } + return resolvedWorkspaceId; +}; + /** * 创建 Agent 会话 */ export async function createAgentSession( providerType: string, + workspaceId: string, model?: string, systemPrompt?: string, skills?: SkillInfo[], ): Promise { + const resolvedWorkspaceId = requireWorkspaceId(workspaceId); + return await safeInvoke("agent_create_session", { providerType, model, systemPrompt, skills, + workspaceId: resolvedWorkspaceId, }); } @@ -401,7 +416,7 @@ export async function sendAgentMessage( * // 处理文本增量 * } * }); - * await sendAgentMessageStream(message, eventName, sessionId, model, undefined, provider); + * await sendAgentMessageStream(message, eventName, workspaceId, sessionId, model, undefined, provider); * ``` * * @deprecated 请使用 sendAsterMessageStream 代替 @@ -409,12 +424,16 @@ export async function sendAgentMessage( export async function sendAgentMessageStream( message: string, eventName: string, + workspaceId: string, sessionId?: string, model?: string, images?: ImageInput[], provider?: string, _terminalMode?: boolean, + projectId?: string, ): Promise { + const resolvedWorkspaceId = requireWorkspaceId(workspaceId, projectId); + // 使用 Aster Agent 实现 return await safeInvoke("aster_agent_chat_stream", { request: { @@ -428,6 +447,8 @@ export async function sendAgentMessageStream( model_name: model || "claude-sonnet-4-20250514", } : undefined, + project_id: projectId, + workspace_id: resolvedWorkspaceId, }, }); } @@ -725,9 +746,12 @@ export async function sendAsterMessageStream( message: string, sessionId: string, eventName: string, + workspaceId: string, images?: ImageInput[], providerConfig?: AsterProviderConfig, ): Promise { + const resolvedWorkspaceId = requireWorkspaceId(workspaceId); + return await safeInvoke("aster_agent_chat_stream", { request: { message, @@ -735,6 +759,7 @@ export async function sendAsterMessageStream( event_name: eventName, images, provider_config: providerConfig, + workspace_id: resolvedWorkspaceId, }, }); } @@ -750,10 +775,17 @@ export async function stopAsterSession(sessionId: string): Promise { * 创建 Aster 会话 */ export async function createAsterSession( + workspaceId: string, workingDir?: string, name?: string, ): Promise { - return await safeInvoke("aster_session_create", { workingDir, name }); + const resolvedWorkspaceId = requireWorkspaceId(workspaceId); + + return await safeInvoke("aster_session_create", { + workingDir, + workspaceId: resolvedWorkspaceId, + name, + }); } /** diff --git a/src/lib/api/project.test.ts b/src/lib/api/project.test.ts index b1af90215..1f5fbb8cf 100644 --- a/src/lib/api/project.test.ts +++ b/src/lib/api/project.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; import { + getWorkspaceProjectsRoot, + resolveProjectRootPath, + getProjectByRootPath, isUserProjectType, getProjectTypeLabel, getProjectTypeIcon, @@ -14,6 +18,7 @@ import { getDefaultContentTypeForProject, getCanvasTypeForProjectType, getCreateProjectErrorMessage, + extractErrorMessage, normalizeProject, formatWordCount, formatRelativeTime, @@ -24,11 +29,87 @@ import { type ContentStatus, } from "./project"; +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + // ============================================================================ // 辅助函数测试 // ============================================================================ describe("项目管理 API", () => { + describe("workspace 路径 API", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("应该调用命令获取 workspace 根目录", async () => { + vi.mocked(invoke).mockResolvedValueOnce( + "/Users/test/.proxycast/projects", + ); + + const root = await getWorkspaceProjectsRoot(); + + expect(root).toBe("/Users/test/.proxycast/projects"); + expect(invoke).toHaveBeenCalledWith("workspace_get_projects_root"); + }); + + it("应该调用命令解析项目目录", async () => { + vi.mocked(invoke).mockResolvedValueOnce( + "/Users/test/.proxycast/projects/MyProject", + ); + + const path = await resolveProjectRootPath("MyProject"); + + expect(path).toBe("/Users/test/.proxycast/projects/MyProject"); + expect(invoke).toHaveBeenCalledWith("workspace_resolve_project_path", { + name: "MyProject", + }); + }); + + it("应该将空名称传给后端统一处理", async () => { + vi.mocked(invoke).mockResolvedValueOnce( + "/Users/test/.proxycast/projects/未命名项目", + ); + + const path = await resolveProjectRootPath(" "); + + expect(path).toBe("/Users/test/.proxycast/projects/未命名项目"); + expect(invoke).toHaveBeenCalledWith("workspace_resolve_project_path", { + name: " ", + }); + }); + + it("应该调用命令按路径获取项目", async () => { + vi.mocked(invoke).mockResolvedValueOnce({ + id: "p1", + name: "测试项目", + workspace_type: "general", + root_path: "/Users/test/.proxycast/projects/demo", + }); + + const project = await getProjectByRootPath( + "/Users/test/.proxycast/projects/demo", + ); + + expect(project?.id).toBe("p1"); + expect(project?.rootPath).toBe("/Users/test/.proxycast/projects/demo"); + expect(invoke).toHaveBeenCalledWith("workspace_get_by_path", { + rootPath: "/Users/test/.proxycast/projects/demo", + }); + }); + + it("按路径查询不存在项目时应该返回 null", async () => { + vi.mocked(invoke).mockResolvedValueOnce(null); + + const project = await getProjectByRootPath( + "/Users/test/.proxycast/projects/missing", + ); + + expect(project).toBeNull(); + }); + }); + describe("isUserProjectType", () => { it("应该正确识别用户级项目类型", () => { expect(isUserProjectType("general")).toBe(true); @@ -151,7 +232,7 @@ describe("项目管理 API", () => { it("应该透传路径已存在错误", () => { expect(getCreateProjectErrorMessage("路径已存在: /tmp/project")).toBe( - "路径已存在: /tmp/project", + "项目目录已存在,请更换项目名称或清理同名目录", ); }); @@ -177,6 +258,24 @@ describe("项目管理 API", () => { }); }); + describe("extractErrorMessage", () => { + it("应该提取 Error 实例 message", () => { + expect(extractErrorMessage(new Error("abc"))).toBe("abc"); + }); + + it("应该处理字符串错误", () => { + expect(extractErrorMessage("hello")).toBe("hello"); + }); + + it("应该处理对象 message 字段", () => { + expect(extractErrorMessage({ message: "bad" })).toBe("bad"); + }); + + it("应该兜底处理未知类型", () => { + expect(extractErrorMessage(123)).toBe("123"); + }); + }); + describe("normalizeProject", () => { it("应该将 snake_case 字段转换为 camelCase", () => { const raw = { diff --git a/src/lib/api/project.ts b/src/lib/api/project.ts index e2ac06ffd..31695e67c 100644 --- a/src/lib/api/project.ts +++ b/src/lib/api/project.ts @@ -245,12 +245,32 @@ export async function createProject( return normalizeProject(project); } +/** 获取统一 workspace 项目根目录 */ +export async function getWorkspaceProjectsRoot(): Promise { + return invoke("workspace_get_projects_root"); +} + +/** 按项目名称解析固定项目目录 */ +export async function resolveProjectRootPath(name: string): Promise { + return invoke("workspace_resolve_project_path", { name }); +} + /** 获取项目列表 */ export async function listProjects(): Promise { const projects = await invoke("workspace_list"); return projects.map((project) => normalizeProject(project)); } +/** 通过根路径获取项目 */ +export async function getProjectByRootPath( + rootPath: string, +): Promise { + const project = await invoke("workspace_get_by_path", { + rootPath, + }); + return project ? normalizeProject(project) : null; +} + /** 获取项目详情 */ export async function getProject(id: string): Promise { const project = await invoke("workspace_get", { id }); @@ -406,7 +426,7 @@ export function getCreateProjectErrorMessage(message: string): string { return "创建项目失败,请查看日志"; } if (message.includes("路径已存在")) { - return message; + return "项目目录已存在,请更换项目名称或清理同名目录"; } if (message.includes("no such column") || message.includes("has no column")) { return "数据库结构过旧,请重启应用以执行迁移"; @@ -417,6 +437,23 @@ export function getCreateProjectErrorMessage(message: string): string { return message; } +/** 提取异常中的错误消息 */ +export function extractErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === "string") { + return error; + } + + if (error && typeof error === "object" && "message" in error) { + return String((error as { message?: unknown }).message); + } + + return String(error); +} + /** 格式化字数 */ export function formatWordCount(count: number): string { if (count >= 10000) { @@ -450,59 +487,3 @@ export function formatRelativeTime(timestamp: number): string { return new Date(timestamp).toLocaleDateString(); } } - -/** 项目名称计数器(用于生成唯一的项目名称) */ -let projectNameCounter = 0; - -/** 重置项目名称计数器(用于测试或重新开始计数) */ -export function resetProjectNameCounter() { - projectNameCounter = 0; -} - -/** - * 生成项目名称(按规则) - * 格式: 项目-YYYY-MM-DD-NNN - */ -export function generateProjectName(type?: ProjectType): string { - const now = new Date(); - const dateStr = now - .toLocaleDateString("zh-CN", { - year: "numeric", - month: "2-digit", - day: "2-digit", - }) - .replace(/\//g, "-"); - - const typeLabel = type ? getProjectTypeLabel(type) : "项目"; - - // 每次调用时递增计数器 - projectNameCounter++; - - // 格式: 类型-YYYY-MM-DD-NNN (例如: 社媒内容-2026-02-01-001) - const seq = String(projectNameCounter).padStart(3, "0"); - return `${typeLabel}-${dateStr}-${seq}`; -} - -/** - * 获取默认项目根路径(用于自动创建项目) - * 如果用户未选择目录,则使用此默认路径 - */ -export function getDefaultProjectPath(): string { - // 在用户主目录下创建 ProxyCast 项目文件夹 - const homeDir = - (typeof window !== "undefined" && - (window as any).__TAURI__?.path?.homeDir?.()) || - "~/Documents/ProxyCast"; - - const now = new Date(); - const dateStr = now - .toLocaleDateString("zh-CN", { - year: "numeric", - month: "2-digit", - day: "2-digit", - }) - .replace(/\//g, "-"); - - const seq = String(projectNameCounter).padStart(3, "0"); - return `${homeDir}/Project-${dateStr}-${seq}`; -} diff --git a/src/types/page.ts b/src/types/page.ts index e81407b28..4d0983de2 100644 --- a/src/types/page.ts +++ b/src/types/page.ts @@ -76,6 +76,7 @@ export type Page = | "tools" | "plugins" | "settings" + | "memory" | "terminal" | "sysinfo" | "files" @@ -121,6 +122,8 @@ export interface AgentPageParams { theme?: string; /** 是否锁定主题(锁定后不在首屏显示主题切换) */ lockTheme?: boolean; + /** 首页点击触发的新会话标记(时间戳) */ + newChatAt?: number; /** 工作台视图模式(仅主题工作台使用) */ workspaceViewMode?: WorkspaceViewMode; } diff --git a/src/types/settings.ts b/src/types/settings.ts index b60983bec..a8a4b1491 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -31,7 +31,6 @@ export enum SettingsTabs { Providers = "providers", Assistant = "assistant", Skills = "skills", - Memory = "memory", ImageGen = "image-gen", Voice = "voice", @@ -79,7 +78,6 @@ export const SETTINGS_GROUPS: Record = { SettingsTabs.Providers, SettingsTabs.Assistant, SettingsTabs.Skills, - SettingsTabs.Memory, SettingsTabs.ImageGen, SettingsTabs.Voice, ],