From 34e6024ba1ac446f28e723c8cfc0a36e73251394 Mon Sep 17 00:00:00 2001 From: stevelliu Date: Mon, 31 Aug 2026 16:00:39 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Grok=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=B9=B6=E5=AE=8C=E5=96=84=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E9=87=87=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- .../src-tauri/src/collector/agent_jsonl.rs | 2 +- .../desktop/src-tauri/src/collector/cursor.rs | 148 +++++++- apps/desktop/src-tauri/src/collector/grok.rs | 321 ++++++++++++++++++ apps/desktop/src-tauri/src/collector/mod.rs | 1 + .../src-tauri/src/collector/scheduler.rs | 9 + apps/desktop/src-tauri/src/config.rs | 18 +- apps/desktop/src/components/AppLayout.vue | 6 +- apps/desktop/src/components/SessionCard.vue | 2 +- apps/desktop/src/components/SettingsModal.vue | 7 +- apps/desktop/src/components/Sidebar.vue | 1 + apps/desktop/src/views/KnowledgeView.vue | 7 +- apps/desktop/src/views/SessionDetailView.vue | 1 + apps/desktop/src/views/SessionsView.vue | 3 +- docs/config.example.toml | 15 + .../src/distiller/cli-provider.test.ts | 9 +- .../sidecar/src/distiller/cli-provider.ts | 7 +- 17 files changed, 542 insertions(+), 21 deletions(-) create mode 100644 apps/desktop/src-tauri/src/collector/grok.rs diff --git a/README.md b/README.md index 0f9e0fc..245a334 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## 简介 -有得是一款本地优先的 AI 对话知识库。它增量采集 Claude Code、Cursor、Codex、CodeBuddy、OMP、Pi 的本地对话,经用户确认后通过 API 或本地 CLI Provider 提取原子知识,并提供草稿治理、全文检索和只读 MCP 回溯。 +有得是一款本地优先的 AI 对话知识库。它增量采集 Claude Code、Cursor、Codex、CodeBuddy、Grok、OMP、Pi 的本地对话,经用户确认后通过 API 或本地 CLI Provider 提取原子知识,并提供草稿治理、全文检索和只读 MCP 回溯。 v0.2.0 采用固定的五类知识(决策、排障、实现、解释、片段)与开放标签,避免 AI 无限生成分类。高价值知识自动发布,中价值进入草稿,重新分析不会覆盖旧知识。 @@ -15,7 +15,7 @@ v0.2.0 采用固定的五类知识(决策、排障、实现、解释、片段 ## v0.2.0 核心能力 -- **六类对话来源**:Claude Code、Cursor、Codex、CodeBuddy、OMP、Pi;增量扫描不自动消耗 Token。 +- **七类对话来源**:Claude Code、Cursor、Codex、CodeBuddy、Grok、OMP、Pi;增量扫描不自动消耗 Token。 - **API 与 CLI 同级 Provider**:可保存多套 OpenAI、DeepSeek、Moonshot、智谱、硅基流动、OpenAI-compatible 或本地 CLI 配置。 - **可控的分析任务**:每个任务绑定启动时的 Provider,展示真实阶段、进度、当前会话、模型和失败项。 - **本地搜索与 MCP**:SQLite FTS5 支持中英文检索;MCP 只读已发布知识,不暴露草稿、API Key 或 Provider 配置。 @@ -105,7 +105,7 @@ bun run build:debug ## 配置 -配置默认保存到 `~/.chattake/config.toml`。可同时保存 OpenAI-compatible API 与 Claude Code、Codex、Cursor、OMP、Pi、CodeBuddy CLI 配置;每个分析任务只绑定启动时选择的一套 Provider,不自动切换。完整示例见 [`docs/config.example.toml`](docs/config.example.toml)。 +配置默认保存到 `~/.chattake/config.toml`。可同时保存 OpenAI-compatible API 与 Claude Code、Codex、Cursor、Grok、OMP、Pi、CodeBuddy CLI 配置;每个分析任务只绑定启动时选择的一套 Provider,不自动切换。完整示例见 [`docs/config.example.toml`](docs/config.example.toml)。 首次运行 v0.2.0 时,如果检测到旧 Schema,会先通过 SQLite `VACUUM INTO` 备份到 `~/.chattake/db/backups/`,备份成功后才重建数据库。 diff --git a/apps/desktop/src-tauri/src/collector/agent_jsonl.rs b/apps/desktop/src-tauri/src/collector/agent_jsonl.rs index f5a8e46..e31dc3a 100644 --- a/apps/desktop/src-tauri/src/collector/agent_jsonl.rs +++ b/apps/desktop/src-tauri/src/collector/agent_jsonl.rs @@ -191,7 +191,7 @@ fn parse_agent_jsonl( })) } -fn visible_text(content: Option<&Value>) -> String { +pub(super) fn visible_text(content: Option<&Value>) -> String { match content { Some(Value::String(text)) => text.trim().to_string(), Some(Value::Array(parts)) => parts diff --git a/apps/desktop/src-tauri/src/collector/cursor.rs b/apps/desktop/src-tauri/src/collector/cursor.rs index 4fe2a4d..ab09cb3 100644 --- a/apps/desktop/src-tauri/src/collector/cursor.rs +++ b/apps/desktop/src-tauri/src/collector/cursor.rs @@ -64,7 +64,7 @@ impl CursorCollector { continue; } batch.found += 1; - let (raw_mtime_ms, raw_size_bytes) = match sqlite_fingerprint(&global_db_path) { + let (raw_mtime_ms, raw_size_bytes) = match cursor_fingerprint(&global_db_path) { Some(fingerprint) => fingerprint, None => continue, }; @@ -109,7 +109,8 @@ impl CursorCollector { raw_size_bytes: i64, ) -> Result> { // Step 1: 扫描所有 workspace,建立 composerId → (project_path, name) 映射 - let workspace_map = scan_workspaces(ws_base)?; + let mut workspace_map = scan_workspaces(ws_base)?; + workspace_map.extend(read_global_composers(global_db_path, ws_base)?); log::debug!("发现 {} 个 workspace", workspace_map.len()); // Step 2: 打开全局数据库(只读),读取所有 composer 的 bubble 数据 @@ -159,6 +160,13 @@ fn sqlite_fingerprint(db_path: &Path) -> Option<(i64, i64)> { Some(fingerprint) } +fn cursor_fingerprint(global_db_path: &Path) -> Option<(i64, i64)> { + let mut fingerprint = sqlite_fingerprint(global_db_path)?; + // ponytail: 指纹版本放在 size 盐值里;新增解析格式时递增即可触发一次补采,无需新增迁移表。 + fingerprint.1 = fingerprint.1.saturating_add(1); + Some(fingerprint) +} + // ── Workspace 扫描 ── /// Workspace 信息(从 workspaceStorage 目录解析) @@ -233,6 +241,62 @@ fn scan_workspaces( Ok(map) } +/// 新版 Cursor 将全部会话头集中到全局库的 composerHeaders 表。 +fn read_global_composers( + global_db_path: &Path, + ws_base: &Path, +) -> Result, Box> { + let conn = Connection::open_with_flags( + global_db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + let mut map = HashMap::new(); + let mut statement = match conn.prepare( + "SELECT composerId,workspaceId,createdAt,value FROM composerHeaders WHERE COALESCE(isSubagent,0)=0", + ) { + Ok(statement) => statement, + Err(_) => return Ok(map), + }; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, String>(3)?, + )) + })?; + for row in rows { + let (id, workspace_id, created_at, value) = row?; + let header: serde_json::Value = serde_json::from_str(&value)?; + let workspace = &header["workspaceIdentifier"]; + let project_path = workspace + .pointer("/uri/fsPath") + .or_else(|| workspace.pointer("/configPath/fsPath")) + .and_then(serde_json::Value::as_str) + .map(String::from) + .or_else(|| { + workspace_id.as_deref().and_then(|workspace_id| { + read_workspace_project_path(&ws_base.join(workspace_id).join("workspace.json")) + }) + }); + let project_name = project_path + .as_deref() + .and_then(|path| Path::new(path).file_name()) + .and_then(|name| name.to_str()) + .map(String::from); + map.insert( + id, + WorkspaceInfo { + project_path, + project_name, + composer_name: header["name"].as_str().map(String::from), + created_at, + }, + ); + } + Ok(map) +} + /// 从 workspace.json 读取项目路径(本地绝对路径字符串) /// /// 解码逻辑见 [`crate::path_local::decode_cursor_folder_to_local_path`]。 @@ -506,6 +570,86 @@ fn millis_to_rfc3339(ms: i64) -> String { mod tests { use super::*; + #[test] + fn collects_modern_global_composer_headers_without_legacy_workspace_index() { + let root = tempfile::tempdir().unwrap(); + let global_dir = root.path().join("User/globalStorage"); + std::fs::create_dir_all(&global_dir).unwrap(); + let db_path = global_dir.join("state.vscdb"); + let conn = Connection::open(&db_path).unwrap(); + conn.execute( + "CREATE TABLE composerHeaders (composerId TEXT PRIMARY KEY, workspaceId TEXT, createdAt INTEGER, isSubagent INTEGER, value TEXT)", + [], + ) + .unwrap(); + conn.execute( + "CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)", + [], + ) + .unwrap(); + let headers = serde_json::json!({"allComposers":[ + { + "composerId":"modern-1", + "name":"Modern chat", + "createdAt":1760000000000_i64, + "workspaceIdentifier":{"uri":{"fsPath":"/tmp/modern-project"}} + }, + { + "composerId":"subagent-1", + "createdAt":1760000000000_i64, + "subagentInfo":{"subagentTypeName":"explore"} + } + ]}); + for header in headers["allComposers"].as_array().unwrap() { + conn.execute( + "INSERT INTO composerHeaders(composerId,workspaceId,createdAt,isSubagent,value) VALUES(?1,?2,?3,?4,?5)", + rusqlite::params![ + header["composerId"].as_str(), + header.pointer("/workspaceIdentifier/id").and_then(serde_json::Value::as_str), + header["createdAt"].as_i64(), + i64::from(header["subagentInfo"].is_object()), + header.to_string(), + ], + ) + .unwrap(); + } + let conversation = serde_json::json!({ + "createdAt":1760000000000_i64, + "fullConversationHeadersOnly":[ + {"bubbleId":"user-1","type":1}, + {"bubbleId":"assistant-1","type":2} + ] + }); + conn.execute( + "INSERT INTO cursorDiskKV(key,value) VALUES('composerData:modern-1',?1)", + [conversation.to_string()], + ) + .unwrap(); + conn.execute( + "INSERT INTO cursorDiskKV(key,value) VALUES('bubbleId:modern-1:user-1',?1)", + [serde_json::json!({"text":"hello","createdAt":1760000001000_i64}).to_string()], + ) + .unwrap(); + conn.execute( + "INSERT INTO cursorDiskKV(key,value) VALUES('bubbleId:modern-1:assistant-1',?1)", + [serde_json::json!({"text":"hi","createdAt":1760000002000_i64}).to_string()], + ) + .unwrap(); + drop(conn); + + let batch = + CursorCollector::new(vec![root.path().to_path_buf()]).collect_changed(|_, _, _| false); + + assert!(batch.failures.is_empty()); + assert_eq!(batch.sessions.len(), 1); + assert_eq!(batch.sessions[0].session_id, "modern-1"); + assert_eq!( + batch.sessions[0].project_name.as_deref(), + Some("modern-project") + ); + assert_eq!(batch.sessions[0].messages.len(), 2); + } + #[test] fn test_extract_lexical_text_simple() { let lexical = serde_json::json!({ diff --git a/apps/desktop/src-tauri/src/collector/grok.rs b/apps/desktop/src-tauri/src/collector/grok.rs new file mode 100644 index 0000000..1483699 --- /dev/null +++ b/apps/desktop/src-tauri/src/collector/grok.rs @@ -0,0 +1,321 @@ +//! Grok 会话采集:读取 `~/.grok/sessions///` 下的摘要与聊天记录。 + +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use serde_json::Value; +use walkdir::WalkDir; + +use super::agent_jsonl::visible_text; +use super::normalizer::{ + file_fingerprint, normalize_user_content, CollectionBatch, CollectionFailure, + NormalizedMessage, NormalizedSession, +}; + +pub struct GrokCollector { + scan_dirs: Vec, +} + +impl GrokCollector { + pub fn new(scan_dirs: Vec) -> Self { + Self { scan_dirs } + } + + pub fn collect_changed(&self, unchanged: F) -> CollectionBatch + where + F: Fn(&Path, i64, i64) -> bool, + { + let mut batch = CollectionBatch::default(); + for root in &self.scan_dirs { + if !root.is_dir() { + continue; + } + for entry in WalkDir::new(root) + .follow_links(false) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); + if !path.is_file() + || path.file_name().and_then(|v| v.to_str()) != Some("chat_history.jsonl") + { + continue; + } + batch.found += 1; + let Some((mtime, size)) = session_fingerprint(path) else { + continue; + }; + if unchanged(path, mtime, size) { + batch.skipped += 1; + batch + .skipped_paths + .push(path.to_string_lossy().into_owned()); + continue; + } + match parse_grok_session(path, mtime, size) { + Ok(Some(session)) => batch.sessions.push(session), + Ok(None) => { + batch.skipped += 1; + batch + .skipped_paths + .push(path.to_string_lossy().into_owned()); + } + Err(error) => batch.failures.push(CollectionFailure { + raw_path: path.to_string_lossy().into_owned(), + error: error.to_string(), + }), + } + } + } + batch + } +} + +fn session_fingerprint(history_path: &Path) -> Option<(i64, i64)> { + let (mut mtime, mut size) = file_fingerprint(history_path)?; + if let Some((summary_mtime, summary_size)) = history_path + .parent() + .and_then(|dir| file_fingerprint(&dir.join("summary.json"))) + { + mtime = mtime.max(summary_mtime); + size = size.saturating_add(summary_size); + } + Some((mtime, size)) +} + +fn parse_grok_session( + history_path: &Path, + mtime: i64, + size: i64, +) -> Result, Box> { + let session_dir = history_path.parent().ok_or("Grok 会话路径缺少父目录")?; + let summary: Value = serde_json::from_reader(File::open(session_dir.join("summary.json"))?)?; + if summary.get("session_kind").and_then(Value::as_str) == Some("subagent") { + return Ok(None); + } + let info = &summary["info"]; + let session_id = info + .get("id") + .and_then(Value::as_str) + .or_else(|| session_dir.file_name().and_then(|v| v.to_str())) + .ok_or("Grok 会话缺少 id")? + .to_string(); + let cwd = info.get("cwd").and_then(Value::as_str).map(str::to_string); + + // ponytail: Grok 尚无无持久化参数;CLI Provider 固定在 chattake-cli-* 临时目录运行,采集时跳过这些自生成会话。 + if cwd + .as_deref() + .and_then(|value| Path::new(value).file_name()) + .and_then(|value| value.to_str()) + .is_some_and(|value| value.starts_with("chattake-cli-")) + { + return Ok(None); + } + + let mut messages = Vec::new(); + let mut has_user_message = false; + let mut lines = BufReader::new(File::open(history_path)?).lines().peekable(); + while let Some(line) = lines.next() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let value: Value = match serde_json::from_str(&line) { + Ok(value) => value, + Err(error) if lines.peek().is_none() => { + log::debug!("忽略活跃 Grok 会话末尾未完成行: {error}"); + continue; + } + Err(error) => return Err(format!("Grok JSONL 中间行损坏: {error}").into()), + }; + let role = match value.get("type").and_then(Value::as_str) { + Some("user") if value.get("synthetic_reason").is_none() => "user", + Some("assistant") => "assistant", + _ => continue, + }; + let content = visible_text(value.get("content")); + if content.is_empty() { + continue; + } + let content = if role == "user" { + let Some(content) = normalize_user_content(&content) else { + continue; + }; + has_user_message = true; + content + } else { + content + }; + let timestamp = value + .get("timestamp") + .or_else(|| value.get("ts")) + .and_then(Value::as_str) + .map(str::to_string); + messages.push(NormalizedMessage { + role: role.to_string(), + content, + timestamp, + tokens_in: 0, + tokens_out: 0, + }); + } + + if !has_user_message { + return Ok(None); + } + let fallback = DateTime::::from_timestamp_millis(mtime) + .unwrap_or_else(Utc::now) + .to_rfc3339(); + let project_name = cwd + .as_deref() + .and_then(|value| Path::new(value).file_name()) + .and_then(|value| value.to_str()) + .map(str::to_string); + Ok(Some(NormalizedSession { + source_id: "grok".to_string(), + session_id, + project_path: cwd, + project_name, + analysis_title: summary + .get("generated_title") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), + messages, + raw_path: history_path.to_string_lossy().into_owned(), + raw_mtime_ms: Some(mtime), + raw_size_bytes: Some(size), + created_at: summary + .get("created_at") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| fallback.clone()), + updated_at: summary + .get("updated_at") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or(fallback), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn collects_real_grok_messages_and_project_metadata() { + let root = tempfile::tempdir().unwrap(); + let session_dir = root.path().join("%2Ftmp%2Fdemo").join("session-1"); + fs::create_dir_all(&session_dir).unwrap(); + fs::write( + session_dir.join("summary.json"), + serde_json::json!({ + "info": { "id": "session-1", "cwd": "/tmp/demo" }, + "created_at": "2026-08-31T00:00:00Z", + "updated_at": "2026-08-31T00:01:00Z", + "generated_title": "修复构建" + }) + .to_string(), + ) + .unwrap(); + let history = [ + serde_json::json!({"type":"system","content":"secret"}), + serde_json::json!({"type":"user","content":[{"type":"text","text":"project rules"}],"synthetic_reason":"project_instructions"}), + serde_json::json!({"type":"user","content":[{"type":"text","text":"为什么失败?"}],"prompt_index":0}), + serde_json::json!({"type":"assistant","content":"因为配置错误。","tool_calls":[]}), + serde_json::json!({"type":"tool_result","content":"private output"}), + ] + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + fs::write(session_dir.join("chat_history.jsonl"), history).unwrap(); + + let batch = + GrokCollector::new(vec![root.path().to_path_buf()]).collect_changed(|_, _, _| false); + + assert!(batch.failures.is_empty()); + assert_eq!(batch.found, 1); + let session = &batch.sessions[0]; + assert_eq!(session.source_id, "grok"); + assert_eq!(session.session_id, "session-1"); + assert_eq!(session.project_path.as_deref(), Some("/tmp/demo")); + assert_eq!(session.project_name.as_deref(), Some("demo")); + assert_eq!(session.analysis_title.as_deref(), Some("修复构建")); + assert_eq!(session.messages.len(), 2); + assert_eq!(session.messages[0].content, "为什么失败?"); + assert_eq!(session.messages[1].content, "因为配置错误。"); + } + + #[test] + fn skips_sessions_created_by_the_grok_provider() { + let root = tempfile::tempdir().unwrap(); + let session_dir = root.path().join("project").join("session-1"); + fs::create_dir_all(&session_dir).unwrap(); + fs::write( + session_dir.join("summary.json"), + serde_json::json!({ + "info": { "id": "session-1", "cwd": "/tmp/chattake-cli-abc123" } + }) + .to_string(), + ) + .unwrap(); + fs::write( + session_dir.join("chat_history.jsonl"), + serde_json::json!({"type":"user","content":"提炼这段对话"}).to_string(), + ) + .unwrap(); + + let batch = + GrokCollector::new(vec![root.path().to_path_buf()]).collect_changed(|_, _, _| false); + + assert!(batch.sessions.is_empty()); + assert_eq!(batch.skipped, 1); + } + + #[test] + fn skips_subagent_sessions() { + let root = tempfile::tempdir().unwrap(); + let session_dir = root.path().join("project").join("subagent-1"); + fs::create_dir_all(&session_dir).unwrap(); + fs::write( + session_dir.join("summary.json"), + serde_json::json!({ + "session_kind": "subagent", + "info": { "id": "subagent-1", "cwd": "/tmp/demo" } + }) + .to_string(), + ) + .unwrap(); + fs::write( + session_dir.join("chat_history.jsonl"), + serde_json::json!({"type":"user","content":"内部探索任务"}).to_string(), + ) + .unwrap(); + + let batch = + GrokCollector::new(vec![root.path().to_path_buf()]).collect_changed(|_, _, _| false); + + assert!(batch.sessions.is_empty()); + assert_eq!(batch.skipped, 1); + } + + #[test] + #[ignore] + fn collects_local_grok_data() { + let root = dirs::home_dir().unwrap().join(".grok/sessions"); + let batch = GrokCollector::new(vec![root]).collect_changed(|_, _, _| false); + assert!(batch.failures.is_empty(), "解析失败: {:?}", batch.failures); + assert!(!batch.sessions.is_empty(), "本地应有 Grok 对话数据"); + println!( + "Grok: found={}, sessions={}, skipped={}", + batch.found, + batch.sessions.len(), + batch.skipped + ); + } +} diff --git a/apps/desktop/src-tauri/src/collector/mod.rs b/apps/desktop/src-tauri/src/collector/mod.rs index 5e96cdb..5744f69 100644 --- a/apps/desktop/src-tauri/src/collector/mod.rs +++ b/apps/desktop/src-tauri/src/collector/mod.rs @@ -14,5 +14,6 @@ pub mod claude_code; pub mod codebuddy; pub mod codex; pub mod cursor; +pub mod grok; pub mod normalizer; pub mod scheduler; diff --git a/apps/desktop/src-tauri/src/collector/scheduler.rs b/apps/desktop/src-tauri/src/collector/scheduler.rs index 81106c6..ee34417 100644 --- a/apps/desktop/src-tauri/src/collector/scheduler.rs +++ b/apps/desktop/src-tauri/src/collector/scheduler.rs @@ -25,6 +25,7 @@ use super::claude_code::ClaudeCodeCollector; use super::codebuddy::CodeBuddyCollector; use super::codex::CodexCollector; use super::cursor::CursorCollector; +use super::grok::GrokCollector; use super::normalizer::{CollectionBatch, NormalizedSession}; /// 同步结果统计 @@ -101,6 +102,14 @@ impl<'a> CollectorScheduler<'a> { .unwrap_or(false) }) } + "grok" => { + let collector = GrokCollector::new(scan_dirs); + collector.collect_changed(|path, mtime, size| { + self.db + .source_file_unchanged(&source.id, path, mtime, size) + .unwrap_or(false) + }) + } "omp" | "pi" => { let collector = AgentJsonlCollector::new( if source.id == "omp" { "omp" } else { "pi" }, diff --git a/apps/desktop/src-tauri/src/config.rs b/apps/desktop/src-tauri/src/config.rs index 2b0fb2d..6422635 100644 --- a/apps/desktop/src-tauri/src/config.rs +++ b/apps/desktop/src-tauri/src/config.rs @@ -158,6 +158,12 @@ fn builtin_sources() -> Vec { enabled: false, scan_dirs: default_codebuddy_scan_dirs(), }, + SourceConfig { + id: "grok".to_string(), + name: "Grok".to_string(), + enabled: true, + scan_dirs: vec!["~/.grok/sessions".to_string()], + }, SourceConfig { id: "omp".to_string(), name: "Oh My Pi".to_string(), @@ -464,7 +470,7 @@ mod tests { use super::*; #[test] - fn defaults_have_six_sources_and_one_profile() { + fn defaults_have_seven_sources_and_one_profile() { let config = AppConfig::default(); let ids: Vec<&str> = config .collector @@ -474,7 +480,15 @@ mod tests { .collect(); assert_eq!( ids, - vec!["claude-code", "cursor", "codex", "codebuddy", "omp", "pi"] + vec![ + "claude-code", + "cursor", + "codex", + "codebuddy", + "grok", + "omp", + "pi" + ] ); assert_eq!(config.distiller.profiles.len(), 1); assert_eq!(config.distiller.active_profile_id, "default"); diff --git a/apps/desktop/src/components/AppLayout.vue b/apps/desktop/src/components/AppLayout.vue index 0f0a43b..415a2fe 100644 --- a/apps/desktop/src/components/AppLayout.vue +++ b/apps/desktop/src/components/AppLayout.vue @@ -22,7 +22,11 @@ onMounted(async () => {
- + + + + +
diff --git a/apps/desktop/src/components/SessionCard.vue b/apps/desktop/src/components/SessionCard.vue index 81cac93..e6921e5 100644 --- a/apps/desktop/src/components/SessionCard.vue +++ b/apps/desktop/src/components/SessionCard.vue @@ -30,7 +30,7 @@ const status = computed(() => { return { label: '已判断', tone: 'quiet' } }) const sourceLabel = computed(() => ({ - 'claude-code': 'Claude Code', codex: 'Codex', cursor: 'Cursor', omp: 'Oh My Pi', pi: 'Pi', codebuddy: 'CodeBuddy', + 'claude-code': 'Claude Code', codex: 'Codex', cursor: 'Cursor', grok: 'Grok', omp: 'Oh My Pi', pi: 'Pi', codebuddy: 'CodeBuddy', }[props.session.sourceId] ?? props.session.sourceId)) function relativeTime(value: string | null | undefined) { diff --git a/apps/desktop/src/components/SettingsModal.vue b/apps/desktop/src/components/SettingsModal.vue index 64f810e..c95bc29 100644 --- a/apps/desktop/src/components/SettingsModal.vue +++ b/apps/desktop/src/components/SettingsModal.vue @@ -25,6 +25,7 @@ const providerPresets = [ { label: 'CLI · Claude Code', value: 'claude-code', kind: 'cli' as const, baseUrl: '', command: 'claude' }, { label: 'CLI · Codex', value: 'codex', kind: 'cli' as const, baseUrl: '', command: 'codex' }, { label: 'CLI · Cursor', value: 'cursor', kind: 'cli' as const, baseUrl: '', command: 'cursor-agent' }, + { label: 'CLI · Grok', value: 'grok', kind: 'cli' as const, baseUrl: '', command: 'grok' }, { label: 'CLI · OMP', value: 'omp', kind: 'cli' as const, baseUrl: '', command: 'omp' }, { label: 'CLI · Pi', value: 'pi', kind: 'cli' as const, baseUrl: '', command: 'pi' }, { label: 'CLI · CodeBuddy', value: 'codebuddy', kind: 'cli' as const, baseUrl: '', command: 'codebuddy' }, @@ -337,18 +338,18 @@ function close() { emit('update:show', false) }
- 支持 Claude Code、Cursor、Codex、CodeBuddy、OMP 和 Pi。启动扫描只发现变化,不会自动调用模型。 + 支持 Claude Code、Cursor、Codex、CodeBuddy、Grok、OMP 和 Pi。启动扫描只发现变化,不会自动调用模型。
{{ source.name }}{{ source.id }}

{{ dir }}

- +
启动时扫描变化

只采集,不分析,不消耗 Token

- +
diff --git a/apps/desktop/src/components/Sidebar.vue b/apps/desktop/src/components/Sidebar.vue index f40d5c1..da83205 100644 --- a/apps/desktop/src/components/Sidebar.vue +++ b/apps/desktop/src/components/Sidebar.vue @@ -60,6 +60,7 @@ const SOURCE_META: Record { const raw = localStorage.getItem(VIEW_MODE_KEY) if (raw === 'list' || raw === 'card') viewMode.value = raw - void load() }) +onActivated(load) + watch( [ () => publicationStatus.value, @@ -331,7 +332,7 @@ function onExportSelect(key: string | number) {
-
+
diff --git a/apps/desktop/src/views/SessionDetailView.vue b/apps/desktop/src/views/SessionDetailView.vue index 12ae1cb..cb82da8 100644 --- a/apps/desktop/src/views/SessionDetailView.vue +++ b/apps/desktop/src/views/SessionDetailView.vue @@ -206,6 +206,7 @@ const sourceIcon = computed(() => { 'claude-code': 'i-lucide-terminal', codex: 'i-lucide-code-2', cursor: 'i-lucide-mouse-pointer-click', + grok: 'i-lucide-sparkles', omp: 'i-lucide-orbit', pi: 'i-lucide-circle-dot', codebuddy: 'i-lucide-code', diff --git a/apps/desktop/src/views/SessionsView.vue b/apps/desktop/src/views/SessionsView.vue index c116e68..09911d0 100644 --- a/apps/desktop/src/views/SessionsView.vue +++ b/apps/desktop/src/views/SessionsView.vue @@ -46,7 +46,6 @@ function plainSnippet(value: string | null) { // ── 生命周期 ───────────────────────────────────────────────────────────────── onMounted(() => { - void sessions.loadPage() void api .countAllCards() .then((n) => { @@ -254,7 +253,7 @@ function openSearchHit(cardId: string, sessionId: string) {