diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0f2662d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +# 在 PR / master 推送时做依赖安装、前端与 Rust 侧编译检查,避免明显破坏合并进主分支。 +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + # 保证 CHANGELOG 与关于页数据源一致(脚本见 scripts/sync-changelog.mjs) + - name: Verify changelog sync + run: | + bun run sync:changelog + git diff --exit-code apps/desktop/src/data/app-changelog.md + + - name: Build sidecar + run: cd packages/sidecar && bun run build + + - name: Build desktop frontend + run: cd apps/desktop && bun run build + + # Tauri 在 Linux 上链接需系统库,否则 cargo check 会失败 + - name: Install Linux build dependencies (Tauri) + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + build-essential \ + curl \ + libssl-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cargo check (desktop) + run: cd apps/desktop/src-tauri && cargo check --all-targets diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..689c49d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,23 @@ +# 推送形如 v0.1.2 的 tag 时自动创建 GitHub Release(含自动生成的提交说明)。 +# 若后续需要上传各平台安装包,可在此 job 中追加 matrix 与 tauri build。 +name: Release + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + +permissions: + contents: write + +jobs: + github-release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true diff --git a/CHANGELOG.md b/CHANGELOG.md index acbb947..76ca9dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,38 +1,65 @@ # 寻迹(XunJi)更新日志 +> 面向使用者的更新说明。实现细节见 `docs/CHANGELOG.md`。 + +## 0.1.2(2026-03-22 – 2026-03-23) + +### 新增 + +- **CodeBuddy**:支持从 CodeBuddy 导入对话记录,与本机扩展数据位置一致(Mac / Linux 常见路径见设置说明)。需在 **设置 → 数据源** 中打开「CodeBuddy」;默认关闭,避免未安装时白白扫盘。 +- **配置迁移**:若你用过旧版配置,应用启动时会**自动补全** CodeBuddy 相关选项,并把过时的扫描路径迁到当前扩展目录。 + +### 体验改进 + +- **CodeBuddy 对话更好认**:导入后,列表和详情会尽量显示与 CodeBuddy 里**相同的会话标题**,不必先「分析」也能分辨是哪一场对话。 +- **项目分组更直观**:按你当时在本机打开的项目文件夹来归类;若文件夹名字是一长串数字或看不懂的编码,会尽量用**上一层文件夹的名字**显示,减少整屏「乱码」分组。 +- **对话回放更清晰**:回看 CodeBuddy 对话时,AI **调用工具**、**思考过程**的展示更整齐,阅读起来更自然。 + +### 修正与调整 + +- **CodeBuddy**:存储位置已按**实际产品**对齐;此前若按旧路径采集会对不上,现已修正。 +- **老版本 CodeBuddy 存的对话**也能正确识别项目与标题,减少「只能看到内部编号」的情况。 +- **修复**部分会话在侧边栏只显示一长串内部 ID、**看不到真实项目名**的问题(与 CodeBuddy 保存消息的方式差异有关)。 + +### 其他 + +- 未做「提炼笔记」的会话,列表上会优先显示上面采集到的**会话标题**;详情页标题也会用上;工具名称在回放里显示更稳定。 + +--- + ## 0.1.1(2026-03-22) -### 新增与优化 +### 新增 -- **会话整理**:在对话记录侧栏可进入「会话整理」,在目录树中多选数据源、主机或项目后,一次性清理所选范围内的会话记录;清理的是应用内数据,**不会删除**本机上的原始对话文件;若一次选了多个范围,同一对话只会被删一次 -- **关于**:在设置中可查看应用版本、运行环境与本次更新说明 -- **导出**:笔记可导出为 Markdown,支持单条、多选或整库导出;整库导出不受当前列表筛选影响 -- **会话与笔记**:列表与详情可查看会话路径与会话标识;列表标题优先展示首条用户消息摘要 -- **分析**:分析失败时可看到原因;分析队列中可查看任务详情 -- **阅读**:笔记正文 Markdown 显示效果优化 -- **体验**:搜索与同步等相关提示与文案优化 +- **会话整理**:在对话记录侧栏可进入「会话整理」,在目录树里多选数据源、本机或项目后,一次性清理**应用里**的记录;**不会删除**你电脑上的原始对话文件;多选时同一条对话只删一次。 +- **关于**:在设置中可查看版本、运行环境和更新说明。 +- **导出**:笔记可导出为 Markdown,支持单条、多选或整库;整库导出不受当前列表筛选影响。 +- **会话与笔记**:列表与详情可查看来源路径和会话标识;列表标题优先用首条用户消息的摘要。 +- **分析**:失败时会提示原因;可在分析队列里查看任务详情。 +- **阅读**:笔记正文在界面上的阅读体验优化。 +- **体验**:搜索、同步等提示与文案优化。 -### 修复与调整 +### 修正与调整 -- 侧栏点选「未关联项目」时,列表能正确显示该分组下的会话 -- 批量删除会话仅通过「会话整理」勾选分组后进行,避免误操作 +- **侧栏**:选择「未关联项目」时,列表能正确显示该分组下的会话。 +- **删除方式**:批量删除会话需通过「会话整理」勾选分组后进行,降低误删风险。 --- ## 0.1.0 -**发布说明**:首个正式版本,可在本机完成从采集对话、整理列表、提炼笔记到知识库浏览与搜索的完整流程。 +**说明**:首个正式版本,可在本机完成从**采集对话 → 整理列表 → 提炼笔记 → 知识库浏览与搜索**的完整流程。 ### 新增 -- **应用**:桌面端应用,支持浅色与深色外观 -- **采集**:支持从 Claude Code、Cursor 等来源导入对话到本地 -- **整理**:对话列表、会话详情(含笔记与对话回放)、按数据源分组的侧栏目录 -- **提炼**:对会话做价值判断并生成结构化笔记,接入本地模型或命令行工具 -- **知识库**:笔记列表、全文搜索、标签与类型筛选 -- **其他**:同步与设置、分析任务队列 +- **应用**:桌面端,支持浅色与深色外观。 +- **采集**:支持从 Claude Code、Cursor 等来源导入对话。 +- **整理**:对话列表、会话详情(含笔记与对话回放)、按数据源分组的侧栏。 +- **提炼**:对会话做价值判断并生成结构化笔记,可接入本机模型或命令行工具。 +- **知识库**:笔记列表、全文搜索、标签与类型筛选。 +- **其他**:同步与设置、分析任务队列。 ### 已知限制 -- 部分导出入口仍为占位或能力受限;个别数据源仅可配置名称与图标,尚不能采集 -- 同步需手动触发;与其他工具的深度联动仍在完善中 +- 部分导出能力仍在完善;个别数据源暂时只能改名称或图标,尚不能采集。 +- 同步需手动触发;与其他工具的深度联动仍在加强中。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 567b830..77f0f44 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@xunji/desktop", "private": true, - "version": "0.1.1", + "version": "0.1.2", "type": "module", "scripts": { "dev": "vite", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 8764d59..f0cf002 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xunji-desktop" -version = "0.1.1" +version = "0.1.2" description = "寻迹 - AI 编程知识管理平台" authors = ["XunJi"] edition = "2021" diff --git a/apps/desktop/src-tauri/src/collector/claude_code.rs b/apps/desktop/src-tauri/src/collector/claude_code.rs index 256a4b1..48c92fd 100644 --- a/apps/desktop/src-tauri/src/collector/claude_code.rs +++ b/apps/desktop/src-tauri/src/collector/claude_code.rs @@ -133,6 +133,7 @@ impl ClaudeCodeCollector { session_id, project_path: final_project_path, project_name: final_project_name, + analysis_title: None, messages: parse_result.messages, raw_path: file_path.to_string_lossy().to_string(), created_at, diff --git a/apps/desktop/src-tauri/src/collector/codebuddy.rs b/apps/desktop/src-tauri/src/collector/codebuddy.rs new file mode 100644 index 0000000..4d76975 --- /dev/null +++ b/apps/desktop/src-tauri/src/collector/codebuddy.rs @@ -0,0 +1,631 @@ +//! CodeBuddy 产品会话采集(与问渠 `scanner/codebuddy.py` 路径一致)。 +//! +//! ```text +//! 扫描根目录(默认): +//! macOS ~/Library/Application Support/CodeBuddyExtension/Data +//! Linux ~/.local/share/CodeBuddyExtension +//! +//! 在根目录下递归查找「会话叶目录」: +//! 含 index.json(且存在顶层 messages 数组)+ messages/*.json +//! (工作区目录仅有 conversations 列表、无 messages,会自动排除) +//! +//! 单条消息:messages/{id}.json 内 message 为 JSON 字符串,解析后取 content(字符串 / text 块数组 / 旧版单字符数组,见 `docs/CodeBuddy 会话记录提取逻辑文档.md`)。 +//! ``` +//! +//! source_id 仍为 `codebuddy-cli`,与现有 config.toml / 侧栏 id 兼容。 + +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use serde_json::Value; +use walkdir::WalkDir; + +use super::normalizer::{NormalizedMessage, NormalizedSession}; + +const SOURCE_ID: &str = "codebuddy-cli"; + +/// CodeBuddy(扩展)采集器 +pub struct CodeBuddyCollector { + /// 来自配置的扫描根目录(已展开 ~),与问渠 LOCALHOST_CANDIDATES 一致 + scan_dirs: Vec, +} + +impl CodeBuddyCollector { + pub fn new(scan_dirs: Vec) -> Self { + Self { scan_dirs } + } + + /// 扫描所有配置根目录下的会话目录,产出标准化会话列表 + pub fn collect(&self) -> Vec { + log::info!( + "CodeBuddy 采集开始,解析后的扫描根目录共 {} 个: {:?}", + self.scan_dirs.len(), + self.scan_dirs + ); + let mut sessions = Vec::new(); + let mut seen = HashSet::::new(); + + for root in &self.scan_dirs { + if !root.is_dir() { + log::info!("CodeBuddy:扫描根目录不存在,跳过: {}", root.display()); + continue; + } + + let mut leaf_candidates = 0u32; + let mut parsed_ok = 0u32; + for entry in WalkDir::new(root) + .max_depth(32) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = entry.path(); + if !path.is_dir() { + continue; + } + if !is_session_leaf_dir(path) { + continue; + } + leaf_candidates += 1; + let key = path.to_string_lossy().to_string(); + if !seen.insert(key) { + continue; + } + + match parse_codebuddy_session_dir(path) { + Ok(Some(s)) => { + sessions.push(s); + parsed_ok += 1; + } + Ok(None) => {} + Err(e) => log::warn!("解析 CodeBuddy 会话目录失败: {} — {}", path.display(), e), + } + } + log::info!( + "CodeBuddy:根 {} — 叶目录 {} 个,成功解析 {} 个会话", + root.display(), + leaf_candidates, + parsed_ok + ); + } + + log::info!("CodeBuddy 扩展采集完成,共 {} 个会话", sessions.len()); + sessions + } +} + +/// 会话叶目录:含 index.json(顶层 `messages` 数组非空)且存在 messages 子目录 +fn is_session_leaf_dir(path: &Path) -> bool { + let index_path = path.join("index.json"); + let msgs_dir = path.join("messages"); + if !index_path.is_file() || !msgs_dir.is_dir() { + return false; + } + let Ok(text) = fs::read_to_string(&index_path) else { + return false; + }; + let Ok(v) = serde_json::from_str::(&text) else { + return false; + }; + match v.get("messages").and_then(|m| m.as_array()) { + Some(arr) if !arr.is_empty() => true, + _ => false, + } +} + +/// 解析单条 message 文件外层 JSON 中的 `message` 字段(JSON 字符串或已解析对象) +fn parse_message_inner(raw: &Value) -> Value { + match raw.get("message") { + Some(Value::String(s)) => serde_json::from_str(s).unwrap_or(Value::Null), + Some(v @ Value::Object(_)) => v.clone(), + _ => Value::Null, + } +} + +/// 从首条 **用户** 消息的 inner `content` 文本中提取工作区路径,返回 (绝对路径, 目录名) +/// +/// ```text +/// messages/{id}.json → raw["message"] → inner_content_to_text(字符串 / text 块数组 / 旧版单字符数组) +/// → Workspace Folder: 或 Workspace Path: → Path::file_name → "proj" +/// ``` +fn try_extract_project_from_first_user_message( + session_dir: &Path, + msg_list: &[Value], +) -> Option<(String, String)> { + for meta in msg_list { + if meta["role"].as_str() != Some("user") { + continue; + } + let id = match meta["id"].as_str() { + Some(s) if !s.is_empty() && !s.starts_with('.') => s, + _ => continue, + }; + let msg_file = session_dir.join("messages").join(format!("{}.json", id)); + if !msg_file.is_file() { + continue; + } + let text = fs::read_to_string(&msg_file).ok()?; + let raw: Value = serde_json::from_str(&text).ok()?; + let inner = parse_message_inner(&raw); + let folder = extract_workspace_folder_from_inner(&inner)?; + let name = Path::new(&folder) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .filter(|s| !s.is_empty())?; + return Some((folder, name)); + } + None +} + +/// 将内层 `message` 的 `content` 展平为一段可搜索文本(与 `docs/CodeBuddy 会话记录提取逻辑文档.md` 一致)。 +/// +/// - **字符串**:整段 payload(新版常见) +/// - **对象数组**:`[{ "type": "text", "text": "..." }, ...]`(新版 block) +/// - **字符串数组**:`["<","u","s",...]` 单字符片段 `join("")`(旧版 ~2025-11) +fn inner_content_to_text(inner: &Value) -> String { + if let Some(s) = inner.get("content").and_then(|c| c.as_str()) { + return s.to_string(); + } + let Some(arr) = inner.get("content").and_then(|c| c.as_array()) else { + return String::new(); + }; + if arr.is_empty() { + return String::new(); + } + // 新版:首项为对象 → 取 type==text 的 text(及 assistant 侧 reasoning/tool 在各自函数中处理) + if matches!(arr.first(), Some(Value::Object(_))) { + let mut buf = String::new(); + for b in arr { + if b.get("type").and_then(|t| t.as_str()) == Some("text") { + if let Some(t) = b.get("text").and_then(|x| x.as_str()) { + buf.push_str(t); + } + } + } + return buf; + } + // 旧版:JSON 字符串数组,每元素多为单字符,等价于 JS `content.join("")` + if arr.iter().all(|v| v.as_str().is_some()) { + return arr.iter().filter_map(|v| v.as_str()).collect(); + } + String::new() +} + +/// 从 inner 展平文本后解析工作区绝对路径(先 `Workspace Folder:`,再兼容旧版 `Workspace Path:`)。 +fn extract_workspace_folder_from_inner(inner: &Value) -> Option { + let text = inner_content_to_text(inner); + extract_workspace_folder_from_text_blob(&text) +} + +/// 工作区目录 `index.json` 中 `conversations[].name`,用于列表/详情标题(与 IDE 侧会话列表一致) +fn try_conversation_title_from_workspace_index( + workspace_dir: &Path, + conversation_id: &str, +) -> Option { + let p = workspace_dir.join("index.json"); + let text = fs::read_to_string(&p).ok()?; + let v: Value = serde_json::from_str(&text).ok()?; + let arr = v.get("conversations")?.as_array()?; + for c in arr { + if c.get("id").and_then(|x| x.as_str()) == Some(conversation_id) { + let name = c.get("name").and_then(|x| x.as_str())?.trim(); + if name.is_empty() { + return None; + } + return Some(truncate_chars(name, 200)); + } + } + None +} + +fn truncate_chars(s: &str, max_chars: usize) -> String { + let count = s.chars().count(); + if count <= max_chars { + return s.to_string(); + } + s.chars().take(max_chars).collect::() + "…" +} + +/// 目录名看起来像日期戳或长 hex(无辨识度)时视为「不友好」 +fn is_ugly_dir_label(s: &str) -> bool { + let t = s.trim(); + if t.is_empty() { + return true; + } + if t.chars().all(|c| c.is_ascii_digit()) && t.len() >= 8 { + return true; + } + if t.len() >= 16 && t.chars().all(|c| c.is_ascii_hexdigit()) { + return true; + } + false +} + +/// 侧栏分组:Workspace Folder 末级为纯数字/长 hex 时,尝试用上一级目录名(如 `…/wenqu/20251201` → wenqu) +fn friendly_project_folder_name(workspace_abs: &str) -> String { + let p = Path::new(workspace_abs); + let base = p + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + if is_ugly_dir_label(&base) { + if let Some(parent) = p.parent() { + if let Some(pname) = parent.file_name() { + let pn = pname.to_string_lossy().to_string(); + if !pn.is_empty() && !is_ugly_dir_label(&pn) { + return pn; + } + } + } + } + base +} + +/// 在一段文本中查找 `key`(如 `Workspace Folder:`)后的绝对路径,截断到换行或 `<`。 +fn extract_line_after_workspace_key(s: &str, key: &str) -> Option { + let idx = s.find(key)?; + let rest = s[idx + key.len()..].trim_start(); + let end = rest + .find('\n') + .or_else(|| rest.find('\r')) + .or_else(|| rest.find('<')) + .unwrap_or(rest.len()); + let path = rest[..end].trim().trim_end_matches('`').trim(); + if path.is_empty() { + return None; + } + Some(path.to_string()) +} + +/// 优先 `Workspace Folder:`(新版),否则 `Workspace Path:`(旧版,见文档 §5、§6) +fn extract_workspace_folder_from_text_blob(s: &str) -> Option { + extract_line_after_workspace_key(s, "Workspace Folder:") + .or_else(|| extract_line_after_workspace_key(s, "Workspace Path:")) +} + +fn parse_codebuddy_session_dir(session_dir: &Path) -> Result, Box> { + let index_path = session_dir.join("index.json"); + let text = fs::read_to_string(&index_path)?; + let index: Value = serde_json::from_str(&text)?; + + let msg_list = match index["messages"].as_array() { + Some(a) if !a.is_empty() => a, + _ => return Ok(None), + }; + + let workspace_parent = session_dir.parent(); + let workspace_id = workspace_parent + .and_then(|p| p.file_name()) + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let leaf = session_dir + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + // 跨工作区避免 session_id 碰撞:workspace_id + 会话目录名 + let session_id = format!("{}__{}", workspace_id, leaf); + + let (created_at, updated_at) = session_times_rfc3339(&index, &index_path)?; + + // 与 IDE 一致:工作区目录下 index.json 里对当前会话(目录名 = conversation id)的 name → 列表/详情主标题 + let analysis_title = workspace_parent + .and_then(|wd| try_conversation_title_from_workspace_index(wd, &leaf)); + + // Workspace Folder 绝对路径 → 侧栏「项目」分组名;末级为日期戳/hex 时尝试用父目录名 + let (project_path, project_name) = + match try_extract_project_from_first_user_message(session_dir, msg_list) { + Some((path, _raw_basename)) => { + let friendly = friendly_project_folder_name(&path); + log::debug!( + "CodeBuddy 会话 {} 工作区路径 {} → 侧栏项目分组名 {:?}", + session_id, path, friendly + ); + (Some(path), Some(friendly)) + } + None => { + log::debug!( + "CodeBuddy 会话 {} 未解析到 Workspace Folder,侧栏项目名回退为工作区目录 id", + session_id + ); + (None, Some(workspace_id.clone())) + } + }; + + let mut messages: Vec = Vec::new(); + for meta in msg_list { + let role = meta["role"].as_str().unwrap_or(""); + if role == "tool" { + continue; + } + if role != "user" && role != "assistant" { + continue; + } + let id = meta["id"].as_str().unwrap_or(""); + if id.is_empty() || id.starts_with('.') { + continue; + } + let msg_file = session_dir.join("messages").join(format!("{}.json", id)); + if !msg_file.is_file() { + continue; + } + let (content, tokens_in, tokens_out, ts) = load_one_message(&msg_file, role)?; + let trimmed = content.trim(); + if trimmed.is_empty() { + continue; + } + messages.push(NormalizedMessage { + role: role.to_string(), + content: trimmed.to_string(), + timestamp: ts, + tokens_in, + tokens_out, + }); + } + + if messages.is_empty() { + return Ok(None); + } + + let raw_path = index_path.to_string_lossy().to_string(); + + Ok(Some(NormalizedSession { + source_id: SOURCE_ID.to_string(), + session_id, + project_path, + project_name, + analysis_title, + messages, + raw_path, + created_at, + updated_at, + })) +} + +/// 从 index.json 的 requests[].startedAt(毫秒)推导时间;失败则用 index 文件 mtime +fn session_times_rfc3339(index: &Value, index_path: &Path) -> Result<(String, String), Box> { + let meta = fs::metadata(index_path)?; + let fallback = file_mtime_rfc3339(&meta)?; + + let reqs = index["requests"].as_array(); + let first_ms = reqs.and_then(|r| r.first()).and_then(|x| x["startedAt"].as_u64()); + let last_ms = reqs.and_then(|r| r.last()).and_then(|x| x["startedAt"].as_u64()); + + let created = first_ms + .and_then(ms_to_rfc3339) + .unwrap_or_else(|| fallback.clone()); + let updated = last_ms + .and_then(ms_to_rfc3339) + .unwrap_or_else(|| fallback.clone()); + + Ok((created, updated)) +} + +fn ms_to_rfc3339(ms: u64) -> Option { + let secs = (ms / 1000) as i64; + let nanos = ((ms % 1000) * 1_000_000) as u32; + DateTime::from_timestamp(secs, nanos).map(|dt| dt.to_rfc3339()) +} + +fn file_mtime_rfc3339(meta: &std::fs::Metadata) -> Result> { + let t = meta.modified().or_else(|_| meta.created())?; + let secs = t.duration_since(std::time::UNIX_EPOCH)?.as_secs() as i64; + let dt = DateTime::::from_timestamp(secs, 0).unwrap_or_else(Utc::now); + Ok(dt.to_rfc3339()) +} + +/// 读取 messages 下单条 JSON,解析内层 message 字符串 → 可见文本;token 优先从 extra.usage 取 +fn load_one_message( + path: &Path, + role: &str, +) -> Result<(String, u32, u32, Option), Box> { + let raw: Value = serde_json::from_str(&fs::read_to_string(path)?)?; + let inner = parse_message_inner(&raw); + + let mut tokens_in = 0u32; + let mut tokens_out = 0u32; + if let Some(extra_s) = raw["extra"].as_str() { + if let Ok(extra) = serde_json::from_str::(extra_s) { + if let Some(u) = extra.get("usage") { + tokens_in = u["inputTokens"].as_u64().unwrap_or(0) as u32; + tokens_out = u["outputTokens"].as_u64().unwrap_or(0) as u32; + } + } + } + + let text = if role == "user" { + extract_user_text(&raw, &inner) + } else { + extract_assistant_text(&inner) + }; + + Ok((text, tokens_in, tokens_out, None)) +} + +/// 用户消息:优先 <user_query>;其次 extra.inputPhrase;最后拼接 text 块(可能含系统上下文,仅作兜底) +fn extract_user_text(outer: &Value, inner: &Value) -> String { + let combined = join_inner_text_blocks(inner); + if let Some(uq) = extract_xml_section(&combined, "user_query") { + return uq; + } + if let Some(extra_s) = outer["extra"].as_str() { + if let Ok(extra) = serde_json::from_str::(extra_s) { + if let Some(phrase) = extra["inputPhrase"].as_array().and_then(|a| a.first()) { + if let Some(c) = phrase["content"].as_str() { + if !c.trim().is_empty() { + return c.to_string(); + } + } + } + if let Some(blocks) = extra["sourceContentBlocks"].as_array() { + let mut parts = Vec::new(); + for b in blocks { + if let Some(t) = b["text"].as_str() { + if !t.trim().is_empty() { + parts.push(t); + } + } + } + if !parts.is_empty() { + return parts.join("\n"); + } + } + } + } + combined +} + +/// 与 `ChatReplay.vue` / Claude 采集约定一致:`[Tool: name]` 供 Markdown 渲染为行内代码;`` 可折叠 +/// +/// 新版为对象数组;旧版可能为单字符字符串数组(与 `inner_content_to_text` 文档一致),无结构化块时拼接为整段正文。 +fn extract_assistant_text(inner: &Value) -> String { + if let Some(s) = inner.get("content").and_then(|c| c.as_str()) { + return s.to_string(); + } + let Some(arr) = inner.get("content").and_then(|c| c.as_array()) else { + return String::new(); + }; + if arr.is_empty() { + return String::new(); + } + if matches!(arr.first(), Some(Value::Object(_))) { + let mut parts = Vec::new(); + for b in arr { + let typ = b["type"].as_str().unwrap_or(""); + match typ { + "text" => { + if let Some(t) = b["text"].as_str() { + if !t.trim().is_empty() { + parts.push(t.to_string()); + } + } + } + "reasoning" => { + if let Some(t) = b["text"].as_str() { + let tt = t.trim(); + if !tt.is_empty() { + parts.push(format!("{}", tt)); + } + } + } + "tool-call" => { + let name = b["toolName"].as_str().unwrap_or("tool"); + parts.push(format!("[Tool: {}]", name)); + } + _ => {} + } + } + return parts.join("\n"); + } + if arr.iter().all(|v| v.as_str().is_some()) { + return arr.iter().filter_map(|v| v.as_str()).collect::(); + } + String::new() +} + +fn join_inner_text_blocks(inner: &Value) -> String { + inner_content_to_text(inner) +} + +fn extract_xml_section(s: &str, tag: &str) -> Option { + let open = format!("<{}>", tag); + let close = format!("", tag); + let start = s.find(&open)?; + let rest = &s[start + open.len()..]; + let end = rest.find(&close)?; + Some(rest[..end].trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn extract_user_query_from_xml() { + let s = "foo你好bar"; + assert_eq!(extract_xml_section(s, "user_query").as_deref(), Some("你好")); + } + + #[test] + fn extract_workspace_folder_from_user_info() { + let s = "\nOS: darwin\nWorkspace Folder: /Users/steve/Code/ssv-ai/wenqu\nNote:\n"; + assert_eq!( + extract_workspace_folder_from_text_blob(s).as_deref(), + Some("/Users/steve/Code/ssv-ai/wenqu") + ); + } + + #[test] + fn extract_workspace_folder_stops_at_angle_bracket() { + let s = "Workspace Folder: /tmp/proj"; + assert_eq!( + extract_workspace_folder_from_text_blob(s).as_deref(), + Some("/tmp/proj") + ); + } + + /// 与 IDE 一致:首条 user 的 inner 里 `content` 为整段字符串(非 text 块数组)时仍能解析工作区 + #[test] + fn extract_workspace_folder_from_inner_string_content() { + let inner: Value = serde_json::from_str( + r#"{"role":"user","content":"\nWorkspace Folder: /Users/x/ssv/activity-service-php\n"}"#, + ) + .unwrap(); + assert_eq!( + extract_workspace_folder_from_inner(&inner).as_deref(), + Some("/Users/x/ssv/activity-service-php") + ); + } + + /// 旧版(~2025-11)字段名 `Workspace Path:`,与文档 §5.2、§6 一致 + #[test] + fn extract_workspace_path_old_key_only() { + let s = "\nWorkspace Path: /Users/legacy/old-key-proj\n"; + assert_eq!( + extract_workspace_folder_from_text_blob(s).as_deref(), + Some("/Users/legacy/old-key-proj") + ); + } + + /// 同时出现时优先 `Workspace Folder:`(新版) + #[test] + fn extract_workspace_prefers_folder_over_path() { + let s = "Workspace Path: /wrong\nWorkspace Folder: /Users/right/correct\n"; + assert_eq!( + extract_workspace_folder_from_text_blob(s).as_deref(), + Some("/Users/right/correct") + ); + } + + /// 旧版 content 为单字符 JSON 字符串数组,拼接后含 `Workspace Path:` + #[test] + fn extract_workspace_from_legacy_char_array() { + let payload = "\nWorkspace Path: /tmp/legacy-char-array\n\n"; + let content: Vec = payload + .chars() + .map(|c| Value::String(c.to_string())) + .collect(); + let inner = json!({ "role": "user", "content": content }); + assert_eq!( + extract_workspace_folder_from_inner(&inner).as_deref(), + Some("/tmp/legacy-char-array") + ); + } + + /// 新版 text 块数组 + 旧版字段名 + #[test] + fn extract_workspace_from_text_blocks_workspace_path() { + let inner = json!({ + "role": "user", + "content": [ + { "type": "text", "text": "\nWorkspace Path: /Users/blocks/ws-path\n" } + ] + }); + assert_eq!( + extract_workspace_folder_from_inner(&inner).as_deref(), + Some("/Users/blocks/ws-path") + ); + } +} diff --git a/apps/desktop/src-tauri/src/collector/cursor.rs b/apps/desktop/src-tauri/src/collector/cursor.rs index 16ff3a6..028e923 100644 --- a/apps/desktop/src-tauri/src/collector/cursor.rs +++ b/apps/desktop/src-tauri/src/collector/cursor.rs @@ -347,6 +347,7 @@ fn read_composer_session( session_id: composer_id.to_string(), project_path: ws_info.project_path.clone(), project_name: ws_info.project_name.clone(), + analysis_title: None, messages, raw_path: format!("cursor://composer/{}", composer_id), created_at, diff --git a/apps/desktop/src-tauri/src/collector/mod.rs b/apps/desktop/src-tauri/src/collector/mod.rs index f60cca6..9f422e5 100644 --- a/apps/desktop/src-tauri/src/collector/mod.rs +++ b/apps/desktop/src-tauri/src/collector/mod.rs @@ -1,15 +1,17 @@ //! 数据采集层 —— 从各 AI IDE 的本地存储中解析对话记录。 //! //! ```text -//! mod.rs 模块入口 + 公共导出 -//! normalizer.rs 统一数据格式(NormalizedSession / NormalizedMessage) -//! claude_code.rs Claude Code JSONL 解析器 -//! cursor.rs Cursor SQLite + Lexical 解析器 -//! scheduler.rs 采集调度器 + 去重写入 + 同步日志 +//! mod.rs 模块入口 + 公共导出 +//! normalizer.rs 统一数据格式(NormalizedSession / NormalizedMessage) +//! claude_code.rs Claude Code JSONL 解析器 +//! cursor.rs Cursor SQLite + Lexical 解析器 +//! codebuddy.rs CodeBuddy 扩展会话(问渠路径:CodeBuddyExtension → history 下会话目录) +//! scheduler.rs 采集调度器 + 去重写入 + 同步日志 //! ``` pub mod normalizer; pub mod claude_code; +pub mod codebuddy; pub mod cursor; pub mod scheduler; diff --git a/apps/desktop/src-tauri/src/collector/normalizer.rs b/apps/desktop/src-tauri/src/collector/normalizer.rs index 29904fc..150b676 100644 --- a/apps/desktop/src-tauri/src/collector/normalizer.rs +++ b/apps/desktop/src-tauri/src/collector/normalizer.rs @@ -15,6 +15,8 @@ pub struct NormalizedSession { pub project_path: Option, /// 项目名称(从路径末段推导) pub project_name: Option, + /// 采集阶段可填写的会话展示标题(如 CodeBuddy 工作区 index 中的会话 name),写入 `sessions.analysis_title` + pub analysis_title: Option, /// 该会话包含的消息列表(按时间顺序) pub messages: Vec, /// 原始数据文件路径(如 JSONL 文件位置) diff --git a/apps/desktop/src-tauri/src/collector/scheduler.rs b/apps/desktop/src-tauri/src/collector/scheduler.rs index 1c7870e..a412a35 100644 --- a/apps/desktop/src-tauri/src/collector/scheduler.rs +++ b/apps/desktop/src-tauri/src/collector/scheduler.rs @@ -3,7 +3,8 @@ //! ```text //! collect_all() //! ├── Claude Code Collector → Vec -//! ├── Cursor Collector → Vec +//! ├── Cursor Collector → Vec +//! ├── CodeBuddy Collector → Vec //! └── (future: other collectors...) //! │ //! ▼ @@ -21,6 +22,7 @@ use crate::storage::models::NewMessage; use crate::storage::Database; use super::claude_code::ClaudeCodeCollector; +use super::codebuddy::CodeBuddyCollector; use super::cursor::CursorCollector; use super::normalizer::NormalizedSession; @@ -53,6 +55,17 @@ impl<'a> CollectorScheduler<'a> { pub fn collect_all(&self) -> SyncResult { let mut total_result = SyncResult::default(); + let enabled_ids: Vec<&str> = self + .config + .enabled_sources() + .iter() + .map(|s| s.id.as_str()) + .collect(); + log::info!( + "本次同步将采集的数据源 id 列表(仅已启用): {:?};未在列表中的数据源不会扫描", + enabled_ids + ); + for source in self.config.enabled_sources() { log::info!("开始采集数据源: {} ({})", source.name, source.id); let scan_dirs = source.resolved_scan_dirs(); @@ -66,6 +79,10 @@ impl<'a> CollectorScheduler<'a> { let collector = CursorCollector::new(scan_dirs); collector.collect() } + "codebuddy-cli" => { + let collector = CodeBuddyCollector::new(scan_dirs); + collector.collect() + } other => { log::warn!("未知数据源类型: {}", other); continue; @@ -166,6 +183,7 @@ impl<'a> CollectorScheduler<'a> { &session.raw_path, &session.created_at, &session.updated_at, + session.analysis_title.as_deref(), )?; self.db.insert_messages(&db_id, &to_new_messages(session))?; @@ -180,7 +198,13 @@ impl<'a> CollectorScheduler<'a> { self.db.delete_session_messages(&existing_db_id)?; self.db.insert_messages(&existing_db_id, &to_new_messages(session))?; self.db.mark_has_updates(&existing_db_id)?; - self.db.update_session_message_count(&existing_db_id, message_count)?; + self.db.update_session_resync_metadata( + &existing_db_id, + message_count, + session.project_path.as_deref(), + session.project_name.as_deref(), + session.analysis_title.as_deref(), + )?; log::info!( "会话消息已更新: session_id={}, {} → {} 条消息", session.session_id, diff --git a/apps/desktop/src-tauri/src/commands/config.rs b/apps/desktop/src-tauri/src/commands/config.rs index 00938ac..32a3c44 100644 --- a/apps/desktop/src-tauri/src/commands/config.rs +++ b/apps/desktop/src-tauri/src/commands/config.rs @@ -171,7 +171,9 @@ pub async fn save_config( state: State<'_, AppState>, config: AppConfigDto, ) -> Result<(), String> { - let new_config = AppConfig::from(config); + let mut new_config = AppConfig::from(config); + // 与启动加载一致:保存前再跑一遍迁移,避免前端传入旧版 ~/.codebuddy 路径 + let _ = new_config.migrate_codebuddy_scan_dirs(); // 便于排查:确认落盘的 CLI command 是否为绝对路径(与 sidecar 报错中的引号内容应一致) if new_config.distiller.mode == "cli" { diff --git a/apps/desktop/src-tauri/src/config.rs b/apps/desktop/src-tauri/src/config.rs index 1a16a97..67ea73e 100644 --- a/apps/desktop/src-tauri/src/config.rs +++ b/apps/desktop/src-tauri/src/config.rs @@ -4,7 +4,9 @@ //! 1. 环境变量 XUNJI_CONFIG 指定的路径 //! 2. ~/.xunji/config.toml(默认) //! -//! 首次启动时自动创建默认配置文件,包含 Claude Code / Cursor 的默认扫描路径。 +//! 首次启动时自动创建默认配置文件,包含 Claude Code / Cursor / CodeBuddy(CodeBuddyExtension)的默认扫描路径。 +//! +//! 加载已有配置时,若缺少新版默认数据源条目,会 **自动追加**(不覆盖用户已配置的同名 id)。 use std::fs; use std::path::{Path, PathBuf}; @@ -121,6 +123,15 @@ fn default_sync_interval() -> u64 { 300 } +/// CodeBuddy 扩展数据根目录(与问渠 `scanner/codebuddy.py` 的 LOCALHOST_CANDIDATES 一致)。 +/// macOS / Linux 各一条,不存在目录时在采集阶段跳过。 +fn default_codebuddy_scan_dirs() -> Vec { + vec![ + "~/Library/Application Support/CodeBuddyExtension/Data".to_string(), + "~/.local/share/CodeBuddyExtension".to_string(), + ] +} + impl Default for SyncConfig { fn default() -> Self { Self { @@ -153,6 +164,13 @@ impl Default for AppConfig { "~/Library/Application Support/Cursor".to_string(), ], }, + SourceConfig { + id: "codebuddy-cli".to_string(), + name: "CodeBuddy".to_string(), + // 默认关闭:未安装 CodeBuddy 的用户无需扫盘;需要时在设置中开启 + enabled: false, + scan_dirs: default_codebuddy_scan_dirs(), + }, ], }, distiller: DistillerConfig { @@ -210,11 +228,59 @@ impl AppConfig { log::info!("加载配置文件: {}", config_path.display()); let content = fs::read_to_string(&config_path)?; - let config: AppConfig = toml::from_str(&content)?; + let mut config: AppConfig = toml::from_str(&content)?; + config.ensure_default_collector_sources(); + if config.migrate_codebuddy_scan_dirs() { + // 将迁移结果写回磁盘,避免用户误以为仍只扫 ~/.codebuddy + if let Err(e) = config.save(&config_path) { + log::warn!("CodeBuddy 扫描路径迁移后写回 config.toml 失败(内存已更正): {}", e); + } else { + log::info!("已将 CodeBuddy 扫描路径迁移结果写回 {}", config_path.display()); + } + } log::debug!("配置加载成功: {:?}", config); Ok(config) } + /// 若配置中尚无 **CodeBuddy CLI** 数据源,则追加默认项(升级用户无需手改 config)。 + /// 不批量注入全部内置源,避免覆盖「仅自定义源」的极简配置。 + pub fn ensure_default_collector_sources(&mut self) { + let has_codebuddy = self + .collector + .sources + .iter() + .any(|s| s.id == "codebuddy-cli"); + if has_codebuddy { + return; + } + log::info!("配置中缺少数据源「codebuddy-cli」,已追加默认项(默认关闭采集)"); + self.collector.sources.push(SourceConfig { + id: "codebuddy-cli".to_string(), + name: "CodeBuddy".to_string(), + enabled: false, + scan_dirs: default_codebuddy_scan_dirs(), + }); + } + + /// 将旧版误配的 `~/.codebuddy` 扫描路径迁移为问渠一致的 CodeBuddyExtension 根目录。 + /// 返回 `true` 表示已修改内存中的配置(调用方可选择写回磁盘)。 + pub fn migrate_codebuddy_scan_dirs(&mut self) -> bool { + let mut changed = false; + for s in &mut self.collector.sources { + if s.id != "codebuddy-cli" { + continue; + } + if s.scan_dirs.len() == 1 && s.scan_dirs[0] == "~/.codebuddy" { + log::info!( + "codebuddy-cli:扫描路径已从 ~/.codebuddy 更新为 CodeBuddyExtension 根目录(与问渠 scanner 一致)" + ); + s.scan_dirs = default_codebuddy_scan_dirs(); + changed = true; + } + } + changed + } + /// 将配置写入指定路径(自动创建父目录) pub fn save(&self, path: &Path) -> ConfigResult<()> { if let Some(parent) = path.parent() { @@ -311,11 +377,13 @@ mod tests { use tempfile::NamedTempFile; #[test] - fn test_default_config_has_two_sources() { + fn test_default_config_has_three_sources() { let config = AppConfig::default(); - assert_eq!(config.collector.sources.len(), 2); + assert_eq!(config.collector.sources.len(), 3); assert_eq!(config.collector.sources[0].id, "claude-code"); assert_eq!(config.collector.sources[1].id, "cursor"); + assert_eq!(config.collector.sources[2].id, "codebuddy-cli"); + assert!(!config.collector.sources[2].enabled); } #[test] @@ -335,7 +403,40 @@ mod tests { let config = AppConfig::load(Some(&path)).unwrap(); assert!(path.exists()); - assert_eq!(config.collector.sources.len(), 2); + assert_eq!(config.collector.sources.len(), 3); + } + + #[test] + fn test_migrate_codebuddy_scan_dirs_from_old_default() { + let mut file = NamedTempFile::new().unwrap(); + write!( + file, + r#" +[collector] +[[collector.sources]] +id = "codebuddy-cli" +name = "CodeBuddy" +enabled = false +scan_dirs = ["~/.codebuddy"] + +[distiller] + +[sync] +mode = "manual" +"# + ) + .unwrap(); + + let config = AppConfig::load(Some(file.path())).unwrap(); + let cb = config + .collector + .sources + .iter() + .find(|s| s.id == "codebuddy-cli") + .unwrap(); + assert_eq!(cb.scan_dirs.len(), 2); + assert!(cb.scan_dirs[0].contains("CodeBuddyExtension")); + assert!(cb.scan_dirs[1].contains("CodeBuddyExtension")); } #[test] @@ -361,8 +462,10 @@ interval_secs = 600 .unwrap(); let config = AppConfig::load(Some(file.path())).unwrap(); - assert_eq!(config.collector.sources.len(), 1); + // 升级合并:自动追加 codebuddy-cli + assert_eq!(config.collector.sources.len(), 2); assert_eq!(config.collector.sources[0].id, "test-source"); + assert!(config.collector.sources.iter().any(|s| s.id == "codebuddy-cli")); assert_eq!(config.sync.interval_secs, 600); } diff --git a/apps/desktop/src-tauri/src/storage/models.rs b/apps/desktop/src-tauri/src/storage/models.rs index 710605b..df03efb 100644 --- a/apps/desktop/src-tauri/src/storage/models.rs +++ b/apps/desktop/src-tauri/src/storage/models.rs @@ -40,6 +40,9 @@ pub struct Session { pub project_path: Option, /// 关联项目名称(从路径推导或手动设置) pub project_name: Option, + /// 采集或分析阶段的可读标题(如 CodeBuddy 工作区 index 中的会话 name) + #[serde(default)] + pub analysis_title: Option, /// 消息总条数 pub message_count: i64, /// 消息内容的 MD5 摘要,用于变更检测 diff --git a/apps/desktop/src-tauri/src/storage/sessions.rs b/apps/desktop/src-tauri/src/storage/sessions.rs index 1e055fb..71b10fb 100644 --- a/apps/desktop/src-tauri/src/storage/sessions.rs +++ b/apps/desktop/src-tauri/src/storage/sessions.rs @@ -168,17 +168,18 @@ impl Database { raw_path: &str, created_at: &str, updated_at: &str, + analysis_title: Option<&str>, ) -> DbResult { let id = Uuid::new_v4().to_string(); let conn = self.conn(); let rows = conn.execute( "INSERT OR IGNORE INTO sessions ( id, source_id, session_id, source_host, project_path, project_name, - message_count, content_hash, raw_path, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + message_count, content_hash, raw_path, created_at, updated_at, analysis_title + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", params![ &id, source_id, session_id, source_host, project_path, project_name, - message_count, content_hash, raw_path, created_at, updated_at, + message_count, content_hash, raw_path, created_at, updated_at, analysis_title, ], )?; @@ -230,7 +231,7 @@ impl Database { conn.query_row( "SELECT id, source_id, session_id, source_host, project_path, project_name, message_count, content_hash, raw_path, created_at, updated_at, - status, value, has_updates, analyzed_at, error_message + status, value, has_updates, analyzed_at, error_message, analysis_title FROM sessions WHERE id = ?1", params![id], |row| { @@ -251,6 +252,7 @@ impl Database { has_updates: row.get::<_, i64>(13)? != 0, analyzed_at: row.get(14)?, error_message: row.get(15)?, + analysis_title: row.get(16)?, }) }, ) @@ -477,6 +479,28 @@ impl Database { Ok(()) } + /// 增量同步后刷新会话元数据(CodeBuddy 等数据源重扫时更新项目名与展示标题) + /// + /// `analysis_title`:采集端有值(如 CodeBuddy 工作区 index)时写入;为 `None` 时不覆盖库内已有列, + /// 避免 Claude/Cursor 等恒为 `None` 的重同步把 `update_session_analysis_meta` 写入的展示标题抹成 NULL。 + pub fn update_session_resync_metadata( + &self, + id: &str, + message_count: i32, + project_path: Option<&str>, + project_name: Option<&str>, + analysis_title: Option<&str>, + ) -> DbResult<()> { + let n = self.conn().execute( + "UPDATE sessions SET message_count = ?1, project_path = ?2, project_name = ?3, analysis_title = COALESCE(?4, analysis_title) WHERE id = ?5", + params![message_count, project_path, project_name, analysis_title, id], + )?; + if n == 0 { + return Err(DbError::NotFound(id.to_string())); + } + Ok(()) + } + /// 将所有残留的 `analyzing` 状态重置为 `pending`。 /// /// 应用启动时调用:如果上一次运行中途退出,可能有会话永远卡在 `analyzing`。 @@ -525,3 +549,76 @@ impl Database { rows.collect::, _>>().map_err(DbError::from) } } + +#[cfg(test)] +mod tests { + use super::Database; + + /// 回归:Claude/Cursor 重同步时采集端 `analysis_title` 为 None,不得抹掉 + /// `update_session_analysis_meta` 已写入的展示标题(见 docs/踩坑 同名文档)。 + #[test] + fn resync_metadata_preserves_analysis_title_when_collector_sends_none() { + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("t.db"); + let db = Database::open(&db_path).expect("open db"); + + let id = db + .insert_session( + "cursor", + "sess-1", + "local", + None, + None, + 1, + None, + "/tmp/raw", + "2025-01-01T00:00:00Z", + "2025-01-01T00:00:00Z", + None, + ) + .expect("insert"); + + db.update_session_analysis_meta(&id, "分析写入的标题", "low", "note") + .expect("analysis meta"); + + db.update_session_resync_metadata(&id, 3, None, None, None) + .expect("resync"); + + let s = db.get_session(&id).expect("get"); + assert_eq!( + s.analysis_title.as_deref(), + Some("分析写入的标题"), + "COALESCE(NULL, analysis_title) 应保留库内标题" + ); + } + + /// 采集端显式提供标题时仍应更新(如 CodeBuddy index 名称)。 + #[test] + fn resync_metadata_updates_analysis_title_when_collector_provides_some() { + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("t2.db"); + let db = Database::open(&db_path).expect("open db"); + + let id = db + .insert_session( + "codebuddy-cli", + "sess-2", + "local", + None, + None, + 1, + None, + "/tmp/raw2", + "2025-01-01T00:00:00Z", + "2025-01-01T00:00:00Z", + None, + ) + .expect("insert"); + + db.update_session_resync_metadata(&id, 2, None, None, Some("来自采集的标题")) + .expect("resync"); + + let s = db.get_session(&id).expect("get"); + assert_eq!(s.analysis_title.as_deref(), Some("来自采集的标题")); + } +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index a8eb1ee..d001c85 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json", "productName": "寻迹", - "version": "0.1.1", + "version": "0.1.2", "identifier": "com.xunji.app", "build": { "frontendDist": "../dist", diff --git a/apps/desktop/src/components/ChatReplay.vue b/apps/desktop/src/components/ChatReplay.vue index 2d91d88..fd7a60e 100644 --- a/apps/desktop/src/components/ChatReplay.vue +++ b/apps/desktop/src/components/ChatReplay.vue @@ -60,8 +60,8 @@ function messageType(role: string, content: string): 'tool-result' | 'tool-use' // ③ assistant 消息中仅有 [Tool: xxx] 标记(无实质文字)→ 工具调用摘要 if (role === 'assistant') { - // 去掉所有 [Tool: xxx] 后,如果剩余内容为空,则为纯工具调用 - const withoutToolTags = trimmed.replace(/\[Tool:\s*\w+\]/g, '').trim() + // 去掉所有 [Tool: …] 后,如果剩余内容为空,则为纯工具调用(工具名可含 _、.、- 等) + const withoutToolTags = trimmed.replace(/\[Tool:\s*[^\]]+\]/g, '').trim() if (trimmed.includes('[Tool:') && withoutToolTags === '') return 'tool-use' } @@ -70,16 +70,18 @@ function messageType(role: string, content: string): 'tool-result' | 'tool-use' /** 从 [Tool: Bash] 格式中提取工具名列表 */ function extractToolNames(content: string): string { - const matches = content.match(/\[Tool:\s*(\w+)\]/g) || [] - return matches.map(m => m.replace(/\[Tool:\s*|\]/g, '')).join(', ') || '工具' + const matches = content.match(/\[Tool:\s*([^\]]+)\]/g) || [] + return matches.map(m => m.replace(/^\[Tool:\s*|\]$/g, '').trim()).join(', ') || '工具' } /** * 预处理气泡内容,将 [Tool: xxx] 标记转换为友好的内联 Markdown 格式。 */ function preprocessContent(content: string): string { - return content - .replace(/\[Tool:\s*(\w+)\]/g, '`🔧 $1`') + return content.replace(/\[Tool:\s*([^\]]+)\]/g, (_m, name: string) => { + const n = String(name).trim() + return n ? `\`🔧 ${n}\`` : '`🔧`' + }) } diff --git a/apps/desktop/src/components/SessionCard.vue b/apps/desktop/src/components/SessionCard.vue index f6593c9..ffb2524 100644 --- a/apps/desktop/src/components/SessionCard.vue +++ b/apps/desktop/src/components/SessionCard.vue @@ -137,10 +137,17 @@ const sessionPathDisplay = computed( () => props.session.rawPath ?? props.session.projectPath ?? null, ) -/** 列表主标题:首条 user → 项目名/路径 → sessionId 前缀 */ +/** + * 列表主标题: + * - 未分析:优先 cardTitle(SQL 中 COALESCE(card.title, analysis_title)),避免 CodeBuddy 首条 user 为 Workspace 元数据占满标题 + * - 已分析:仍以首条 user 预览为主(与「笔记」副标题搭配) + */ const listPrimaryTitle = computed(() => { const preview = props.session.firstUserPreview?.trim() + const title = props.session.cardTitle?.trim() + if (!isAnalyzed.value && title) return title if (preview) return preview + if (title) return title return ( props.session.projectName || props.session.projectPath @@ -151,6 +158,7 @@ const listPrimaryTitle = computed(() => { const listPrimaryTooltip = computed(() => clipForTooltip( props.session.firstUserPreview?.trim() + || props.session.cardTitle?.trim() || props.session.projectName || props.session.projectPath || props.session.sessionId, diff --git a/apps/desktop/src/components/SettingsModal.vue b/apps/desktop/src/components/SettingsModal.vue index 6fbeef8..61bb23a 100644 --- a/apps/desktop/src/components/SettingsModal.vue +++ b/apps/desktop/src/components/SettingsModal.vue @@ -223,12 +223,13 @@ function onClose() { emit('update:show', false) } -function toggleSource(source: SourceConfigDto) { - source.enabled = !source.enabled +/** 与 n-switch 的受控值一致,避免仅「翻转」与 Naive 传入的新值不同步 */ +function setSourceEnabled(source: SourceConfigDto, enabled: boolean) { + source.enabled = enabled } /** 与 `collector/scheduler.rs` 中 `match source.id` 已实现的采集器一致 */ -const IMPLEMENTED_SOURCE_IDS = new Set(['claude-code', 'cursor']) +const IMPLEMENTED_SOURCE_IDS = new Set(['claude-code', 'cursor', 'codebuddy-cli']) function isSourceCollectorImplemented(id: string): boolean { return IMPLEMENTED_SOURCE_IDS.has(id) @@ -460,8 +461,10 @@ const extraArgsStr = computed({

- 当前版本已接入采集的数据源:Claude Code(claude-code,JSONL) - 与 Cursor(cursor,本地库)。 + 当前版本已接入采集的数据源:Claude Code(claude-code,JSONL)、 + Cursor(cursor,本地库)、 + CodeBuddy(codebuddy-cli,CodeBuddyExtension 下会话目录,与问渠路径一致;默认关闭,需在设置中启用)。 + 启用后请先点击保存,再点击顶部同步;侧栏若选了某一数据源筛选,请点「全部对话」或对应 CodeBuddy 节点以免被过滤。 若配置中存在其他 id,侧栏仍可显示品牌名,但同步时会跳过,直至后续版本接入采集器。 @@ -497,7 +500,7 @@ const extraArgsStr = computed({ :value="source.enabled" size="small" class="ml-3 mt-0.5" - @update:value="toggleSource(source)" + @update:value="(v: boolean) => setSourceEnabled(source, v)" /> diff --git a/apps/desktop/src/data/app-changelog.md b/apps/desktop/src/data/app-changelog.md index acbb947..76ca9dd 100644 --- a/apps/desktop/src/data/app-changelog.md +++ b/apps/desktop/src/data/app-changelog.md @@ -1,38 +1,65 @@ # 寻迹(XunJi)更新日志 +> 面向使用者的更新说明。实现细节见 `docs/CHANGELOG.md`。 + +## 0.1.2(2026-03-22 – 2026-03-23) + +### 新增 + +- **CodeBuddy**:支持从 CodeBuddy 导入对话记录,与本机扩展数据位置一致(Mac / Linux 常见路径见设置说明)。需在 **设置 → 数据源** 中打开「CodeBuddy」;默认关闭,避免未安装时白白扫盘。 +- **配置迁移**:若你用过旧版配置,应用启动时会**自动补全** CodeBuddy 相关选项,并把过时的扫描路径迁到当前扩展目录。 + +### 体验改进 + +- **CodeBuddy 对话更好认**:导入后,列表和详情会尽量显示与 CodeBuddy 里**相同的会话标题**,不必先「分析」也能分辨是哪一场对话。 +- **项目分组更直观**:按你当时在本机打开的项目文件夹来归类;若文件夹名字是一长串数字或看不懂的编码,会尽量用**上一层文件夹的名字**显示,减少整屏「乱码」分组。 +- **对话回放更清晰**:回看 CodeBuddy 对话时,AI **调用工具**、**思考过程**的展示更整齐,阅读起来更自然。 + +### 修正与调整 + +- **CodeBuddy**:存储位置已按**实际产品**对齐;此前若按旧路径采集会对不上,现已修正。 +- **老版本 CodeBuddy 存的对话**也能正确识别项目与标题,减少「只能看到内部编号」的情况。 +- **修复**部分会话在侧边栏只显示一长串内部 ID、**看不到真实项目名**的问题(与 CodeBuddy 保存消息的方式差异有关)。 + +### 其他 + +- 未做「提炼笔记」的会话,列表上会优先显示上面采集到的**会话标题**;详情页标题也会用上;工具名称在回放里显示更稳定。 + +--- + ## 0.1.1(2026-03-22) -### 新增与优化 +### 新增 -- **会话整理**:在对话记录侧栏可进入「会话整理」,在目录树中多选数据源、主机或项目后,一次性清理所选范围内的会话记录;清理的是应用内数据,**不会删除**本机上的原始对话文件;若一次选了多个范围,同一对话只会被删一次 -- **关于**:在设置中可查看应用版本、运行环境与本次更新说明 -- **导出**:笔记可导出为 Markdown,支持单条、多选或整库导出;整库导出不受当前列表筛选影响 -- **会话与笔记**:列表与详情可查看会话路径与会话标识;列表标题优先展示首条用户消息摘要 -- **分析**:分析失败时可看到原因;分析队列中可查看任务详情 -- **阅读**:笔记正文 Markdown 显示效果优化 -- **体验**:搜索与同步等相关提示与文案优化 +- **会话整理**:在对话记录侧栏可进入「会话整理」,在目录树里多选数据源、本机或项目后,一次性清理**应用里**的记录;**不会删除**你电脑上的原始对话文件;多选时同一条对话只删一次。 +- **关于**:在设置中可查看版本、运行环境和更新说明。 +- **导出**:笔记可导出为 Markdown,支持单条、多选或整库;整库导出不受当前列表筛选影响。 +- **会话与笔记**:列表与详情可查看来源路径和会话标识;列表标题优先用首条用户消息的摘要。 +- **分析**:失败时会提示原因;可在分析队列里查看任务详情。 +- **阅读**:笔记正文在界面上的阅读体验优化。 +- **体验**:搜索、同步等提示与文案优化。 -### 修复与调整 +### 修正与调整 -- 侧栏点选「未关联项目」时,列表能正确显示该分组下的会话 -- 批量删除会话仅通过「会话整理」勾选分组后进行,避免误操作 +- **侧栏**:选择「未关联项目」时,列表能正确显示该分组下的会话。 +- **删除方式**:批量删除会话需通过「会话整理」勾选分组后进行,降低误删风险。 --- ## 0.1.0 -**发布说明**:首个正式版本,可在本机完成从采集对话、整理列表、提炼笔记到知识库浏览与搜索的完整流程。 +**说明**:首个正式版本,可在本机完成从**采集对话 → 整理列表 → 提炼笔记 → 知识库浏览与搜索**的完整流程。 ### 新增 -- **应用**:桌面端应用,支持浅色与深色外观 -- **采集**:支持从 Claude Code、Cursor 等来源导入对话到本地 -- **整理**:对话列表、会话详情(含笔记与对话回放)、按数据源分组的侧栏目录 -- **提炼**:对会话做价值判断并生成结构化笔记,接入本地模型或命令行工具 -- **知识库**:笔记列表、全文搜索、标签与类型筛选 -- **其他**:同步与设置、分析任务队列 +- **应用**:桌面端,支持浅色与深色外观。 +- **采集**:支持从 Claude Code、Cursor 等来源导入对话。 +- **整理**:对话列表、会话详情(含笔记与对话回放)、按数据源分组的侧栏。 +- **提炼**:对会话做价值判断并生成结构化笔记,可接入本机模型或命令行工具。 +- **知识库**:笔记列表、全文搜索、标签与类型筛选。 +- **其他**:同步与设置、分析任务队列。 ### 已知限制 -- 部分导出入口仍为占位或能力受限;个别数据源仅可配置名称与图标,尚不能采集 -- 同步需手动触发;与其他工具的深度联动仍在完善中 +- 部分导出能力仍在完善;个别数据源暂时只能改名称或图标,尚不能采集。 +- 同步需手动触发;与其他工具的深度联动仍在加强中。 diff --git a/apps/desktop/src/types/index.ts b/apps/desktop/src/types/index.ts index 4d1ad93..81ff841 100644 --- a/apps/desktop/src/types/index.ts +++ b/apps/desktop/src/types/index.ts @@ -76,6 +76,8 @@ export interface Session { sourceHost: string projectPath: string | null projectName: string | null + /** 采集侧可读标题(如 CodeBuddy 工作区 index 中的会话 name),未提炼笔记前可用于详情/列表 */ + analysisTitle?: string | null messageCount: number contentHash: string | null rawPath: string | null diff --git a/apps/desktop/src/views/SessionDetailView.vue b/apps/desktop/src/views/SessionDetailView.vue index 9215f4d..f343096 100644 --- a/apps/desktop/src/views/SessionDetailView.vue +++ b/apps/desktop/src/views/SessionDetailView.vue @@ -100,7 +100,11 @@ async function onExportMarkdown() { function analyze() { analyzeError.value = null if (session.value) session.value.status = 'analyzing' - const title = session.value?.projectName ?? session.value?.projectPath ?? props.sessionId + const title = + session.value?.analysisTitle + ?? session.value?.projectName + ?? session.value?.projectPath + ?? props.sessionId queue.enqueue(props.sessionId, title, { onLowValue: (result) => { if (session.value) { @@ -192,7 +196,7 @@ const valueColors: Record = {
- {{ session.projectName || session.projectPath || '未命名项目' }} + {{ session.analysisTitle || session.projectName || session.projectPath || '未命名项目' }} {{ statusLabel }} 本文档与仓库根目录 `CHANGELOG.md` 的版本一一对应,保留**实现向**表述(字段名、路径、数据结构等),供开发、排查与文档对照使用。 +> **面向最终用户的说明请以根目录 `CHANGELOG.md` 为准。** + +--- + +## 0.1.2(2026-03-22 – 2026-03-23) + +### 新增与优化 + +- **采集**:支持 **CodeBuddy** 对话记录导入,扫描路径与问渠 `scanner/codebuddy.py` 一致:macOS `~/Library/Application Support/CodeBuddyExtension/Data`、Linux `~/.local/share/CodeBuddyExtension`,递归识别其下含 `index.json` 与 `messages/` 的会话目录;需在设置 → 数据源中开启「CodeBuddy」,默认关闭以免未安装时无效扫盘 +- **配置**:旧版配置文件在启动时会自动补充 CodeBuddy 数据源项(若尚无);若仍仅为旧默认 `~/.codebuddy`,启动时会迁移为上述 CodeBuddyExtension 根目录 +- **CodeBuddy 采集与展示** + - 从工作区目录 `index.json` 的 `conversations[].name` 写入会话展示标题(`analysis_title`),与 IDE 会话列表一致;列表/详情在未提炼笔记前即可显示可读标题 + - 侧栏「项目」分组名:由 `Workspace Folder` / `Workspace Path` 推导;若路径末级为纯数字或长 hex 等无辨识度目录名,则尝试使用上一级目录名 + - 助手消息中的工具调用与思考过程统一为 `[Tool: …]`、`…`,便于对话回放与 Markdown 渲染 +- **旧版 CodeBuddy 消息**:兼容 `content` 为**单字符字符串数组**(拼接后与新版等价)、以及旧版字段名 **`Workspace Path:`**(与新版 `Workspace Folder:` 并存,优先匹配 Folder) +- **修复**:首条用户消息中 `content` 为**整段字符串**(非 text 块数组)时,可正确解析工作区路径,避免侧栏项目名退化为 `history` 下哈希目录名 + +### 修正 + +- **CodeBuddy 路径**:此前按 `~/.codebuddy/projects/*.jsonl` 采集与产品实际会话存储不符,已改为扩展数据目录(见上) + +### 其他 + +- 会话列表未分析时优先展示上述采集标题;会话详情顶栏支持 `analysisTitle`;对话回放中工具标签匹配更宽松(含下划线等工具名) + +--- + +## 0.1.1(2026-03-22) + +### 新增与优化 + +- **会话整理**:在对话记录侧栏可进入「会话整理」,在目录树中多选数据源、主机或项目后,一次性清理所选范围内的会话记录;清理的是应用内数据,**不会删除**本机上的原始对话文件;若一次选了多个范围,同一对话只会被删一次 +- **关于**:在设置中可查看应用版本、运行环境与本次更新说明 +- **导出**:笔记可导出为 Markdown,支持单条、多选或整库导出;整库导出不受当前列表筛选影响 +- **会话与笔记**:列表与详情可查看会话路径与会话标识;列表标题优先展示首条用户消息摘要 +- **分析**:分析失败时可看到原因;分析队列中可查看任务详情 +- **阅读**:笔记正文 Markdown 显示效果优化 +- **体验**:搜索与同步等相关提示与文案优化 + +### 修复与调整 + +- 侧栏点选「未关联项目」时,列表能正确显示该分组下的会话 +- 批量删除会话仅通过「会话整理」勾选分组后进行,避免误操作 + +--- + +## 0.1.0 + +**发布说明**:首个正式版本,可在本机完成从采集对话、整理列表、提炼笔记到知识库浏览与搜索的完整流程。 + +### 新增 + +- **应用**:桌面端应用,支持浅色与深色外观 +- **采集**:支持从 Claude Code、Cursor 等来源导入对话到本地 +- **整理**:对话列表、会话详情(含笔记与对话回放)、按数据源分组的侧栏目录 +- **提炼**:对会话做价值判断并生成结构化笔记,接入本地模型或命令行工具 +- **知识库**:笔记列表、全文搜索、标签与类型筛选 +- **其他**:同步与设置、分析任务队列 + +### 已知限制 + +- 部分导出入口仍为占位或能力受限;个别数据源仅可配置名称与图标,尚不能采集 +- 同步需手动触发;与其他工具的深度联动仍在完善中 diff --git "a/docs/CodeBuddy \344\274\232\350\257\235\350\256\260\345\275\225\346\217\220\345\217\226\351\200\273\350\276\221.md" "b/docs/CodeBuddy \344\274\232\350\257\235\350\256\260\345\275\225\346\217\220\345\217\226\351\200\273\350\276\221.md" new file mode 100644 index 0000000..4bedebf --- /dev/null +++ "b/docs/CodeBuddy \344\274\232\350\257\235\350\256\260\345\275\225\346\217\220\345\217\226\351\200\273\350\276\221.md" @@ -0,0 +1,344 @@ +# CodeBuddy 会话记录提取逻辑 + +> 本文档详细说明 CodeBuddy IDE 的本地会话记录文件结构,以及如何从中提取**项目名(project_name)**、**工作区路径(workspace_folder)** 等关键信息。 +> 适用于需要在非 Python 技术栈中复用此逻辑的场景。 + +--- + +## 1. 存储位置 + +CodeBuddy 的会话数据存储在以下路径: + +| 平台 | 路径 | +|------|------| +| **macOS** | `~/Library/Application Support/CodeBuddyExtension/Data/{profile_id}/CodeBuddyIDE/{profile_id}/history/` | +| **Windows** | `%APPDATA%/CodeBuddyExtension/Data/{profile_id}/CodeBuddyIDE/{profile_id}/history/` | +| **Linux** | `~/.config/CodeBuddyExtension/Data/{profile_id}/CodeBuddyIDE/{profile_id}/history/` | + +其中 `{profile_id}` 是一个 UUID 格式的字符串,如 `363d8e66-2fd1-4b66-9de2-072ff116f842`。 + +--- + +## 2. 目录结构 + +``` +{history_dir}/ + └── {workspace_id}/ # 32 位 hex hash 字符串 + ├── index.json # workspace 级索引(含会话列表) + └── {conversation_id}/ # 32 位 hex 字符串 + ├── index.json # 会话级索引(含消息列表) + └── messages/ + └── {msg_id}.json # 单条消息文件 +``` + +### 各级说明 + +| 层级 | 说明 | 示例 | +|------|------|------| +| `{workspace_id}/` | 一个工作区(项目),hash 标识 | `389bc7591f797f51a238cf478d48407c` | +| `{conversation_id}/` | 一次对话会话 | `3f161540dfa14ccf948c7f46b53bdc2c` | +| `messages/{msg_id}.json` | 对话中的单条消息 | `d50d0df94d114d9cbd6eab11f32853f2.json` | + +--- + +## 3. 索引文件格式 + +### 3.1 Workspace 级 `index.json` + +```json +{ + "version": 1, + "conversations": [ + { + "id": "c44ebb41a3f24265a5484b3116b9a96d", + "name": "activity-service-php 项目分析", + "createdAt": 1731036781123, + "lastMessageAt": 1731036890456 + } + ] +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `conversations[].id` | string | 会话 ID,对应子目录名 | +| `conversations[].name` | string | 会话标题(由 AI 自动生成) | +| `conversations[].createdAt` | number | 创建时间戳(毫秒) | +| `conversations[].lastMessageAt` | number | 最后消息时间戳(毫秒) | + +### 3.2 Conversation 级 `index.json` + +```json +{ + "version": 1, + "messages": [ + { + "id": "d50d0df94d114d9cbd6eab11f32853f2", + "role": "user", + "type": "say", + "isComplete": true + }, + { + "id": "a1b2c3d4e5f6...", + "role": "assistant", + "type": "say", + "isComplete": true + } + ] +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `messages[].id` | string | 消息 ID,对应 `messages/` 下的文件名(不含 `.json`) | +| `messages[].role` | string | `"user"` 或 `"assistant"` | +| `messages[].type` | string | 消息类型,通常是 `"say"` | +| `messages[].isComplete` | boolean | 消息是否完整 | + +--- + +## 4. 消息文件格式(两次 JSON 解析) + +消息文件 `messages/{msg_id}.json` 需要**两次 JSON 解析**: + +### 第一层:文件本身 + +```json +{ + "role": "user", + "message": "", + "id": "d50d0df94d114d9cbd6eab11f32853f2", + "references": [], + "extra": {} +} +``` + +> ⚠️ `message` 字段是一个 **JSON 字符串**(string),不是对象!需要二次 `JSON.parse()`。 + +### 第二层:`message` 字段解析后 + +```json +{ + "role": "user", + "content": [...] +} +``` + +> ⚠️ `content` 在新旧版本中格式不同!详见下一节。 + +--- + +## 5. Content 字段的两种格式(新版 vs 旧版) + +### 5.1 新版格式(约 2025-12 之后) + +```json +{ + "content": [ + { + "type": "text", + "text": "\nOS Version: darwin\nShell: zsh\nWorkspace Folder: /Users/steve/Codes/workspace/ssv/ssv-ai/wenqu\nCurrent date: Sunday, Mar 22, 2026\n\n\n\n你好\n" + } + ] +} +``` + +- `content` 是**对象数组**,每个元素有 `type` 和 `text` 字段 +- 工作区字段名为 `Workspace Folder` + +### 5.2 旧版格式(约 2025-11 及之前) + +```json +{ + "content": ["<", "u", "s", "e", "r", "_", "i", "n", "f", "o", ">", "\n", ...] +} +``` + +- `content` 是**单字符字符串数组**,每个元素是一个字符 +- 需要 `join("")` 拼接成完整文本 +- 工作区字段名为 `Workspace Path` + +### 5.3 如何判断版本 + +``` +if content[0] 是 string 且 length == 1: + → 旧版(单字符数组) +else if content[0] 是 object 且有 type 字段: + → 新版(block 对象数组) +``` + +--- + +## 6. 项目名提取逻辑 + +### 6.1 核心原理 + +项目名**不是**存在某个配置或元数据文件里的,而是从**每条会话的第一条 user 消息内容**中,通过正则匹配提取出来的。 + +CodeBuddy 在每次用户发消息时,会自动在消息中注入一段 `` 标签,其中包含 `Workspace Folder`(或旧版的 `Workspace Path`)。 + +### 6.2 提取伪代码 + +``` +function extractWorkspaceFolder(msgFilePath): + // ═══ Step 1: 两次 JSON 解析 ═══ + raw = JSON.parse(readFile(msgFilePath)) + msgBody = JSON.parse(raw.message || "{}") + + if msgBody.role != "user": + return null + + content = msgBody.content + + // ═══ Step 2: 从 content 提取文本(兼容两种格式)═══ + text = "" + + if content is array AND content.length > 0: + firstElement = content[0] + + if typeof firstElement == "string" AND firstElement.length <= 1: + // 旧版:单字符数组 → 拼接 + text = content.join("") + + else if typeof firstElement == "object" AND firstElement.type == "text": + // 新版:block 对象数组 → 遍历取 text + for block in content: + if block.type == "text": + text += block.text + + // ═══ Step 3: 正则提取 Workspace 路径(兼容两个字段名)═══ + match = regex(text, /Workspace Folder:\s*(.+)/) + if !match: + match = regex(text, /Workspace Path:\s*(.+)/) + + if match: + return match.group(1).trim() + + return null + + +function extractProjectName(workspaceFolder): + if workspaceFolder: + return basename(workspaceFolder) // 取路径最后一级目录名 + return "CodeBuddy" // 默认值 +``` + +### 6.3 遍历整个 history 的伪代码 + +``` +function extractAllSessions(historyDir): + results = [] + + for workspaceDir in listDirs(historyDir): + // 读取 workspace 级 index.json(可选,含会话名称等元数据) + wsIndex = JSON.parse(readFile(workspaceDir + "/index.json")) + convMeta = {} + for c in wsIndex.conversations: + convMeta[c.id] = c // { id, name, createdAt, lastMessageAt } + + for convDir in listDirs(workspaceDir): + // 跳过 index.json 文件 + if convDir is not directory: + continue + + // 读取 conversation 级 index.json + convIndex = JSON.parse(readFile(convDir + "/index.json")) + msgList = convIndex.messages + + // 找到第一条 role="user" 的消息,提取 workspace folder + workspaceFolder = null + for msgMeta in msgList: + if msgMeta.role != "user": + continue + msgFile = convDir + "/messages/" + msgMeta.id + ".json" + workspaceFolder = extractWorkspaceFolder(msgFile) + if workspaceFolder: + break + + projectName = extractProjectName(workspaceFolder) + convName = convMeta[convDir.name]?.name || "" + + results.push({ + workspaceId: workspaceDir.name, + conversationId: convDir.name, + workspaceFolder: workspaceFolder, + projectName: projectName, + convName: convName, + createdAt: convMeta[convDir.name]?.createdAt, + lastMessageAt: convMeta[convDir.name]?.lastMessageAt + }) + + return results +``` + +--- + +## 7. 新旧版本差异速查表 + +| 项目 | 旧版(~2025-11 及之前) | 新版(~2025-12 之后) | +|------|------------------------|---------------------| +| `content` 格式 | `["<","u","s","e","r",...]` 单字符数组 | `[{"type":"text","text":"..."}]` 对象数组 | +| `content` 处理方式 | `content.join("")` 拼接 | 遍历取 `block.text` | +| 工作区字段名 | `Workspace Path:` | `Workspace Folder:` | +| 日期格式 | `2025-11-07` | `Sunday, Mar 22, 2026` | + +--- + +## 8. 实际数据示例 + +以本机实际数据为例,提取多个 workspace 的项目名: + +| workspace_id(前12位) | 消息中的 Workspace 路径 | 提取出的项目名 | +|------------------------|------------------------|---------------| +| `1694a20c0947` | `/Users/steve/资料/学习资料/KM 文章/知识库` | **知识库** | +| `1f2b3bada274` | `/Users/steve/资料/学习资料/AI/AI编程资料包` | **AI编程资料包** | +| `337f7f20cfdc` | `/Users/steve/CodeBuddy/AutoPublishStory` | **AutoPublishStory** | +| `389bc7591f79` | `/Users/steve/Codes/workspace/ssv/ssv-ai/wenqu` | **wenqu** | +| `4879786f00cd` | `/Users/steve/杂七杂八/tech4good_docs` | **tech4good_docs** | +| `538ffc8b3206` | `/Users/steve/Codes/workspace/ssv/ssv-ai/统计/ssv_docs` | **ssv_docs** | + +--- + +## 9. 注意事项 + +1. **两次 JSON 解析**:消息文件的 `message` 字段本身是 JSON 字符串,必须解析两次。 +2. **只取第一条 user 消息**:`Workspace Folder` 通常在第一条 user 消息中注入,找到后即停止搜索。 +3. **部分会话可能没有 Workspace Folder**:例如没有打开项目目录时的会话,此时 fallback 为 `"CodeBuddy"`。 +4. **字符编码**:路径可能包含中文等 Unicode 字符,注意编码处理。 +5. **跨平台路径**:macOS/Linux 用 `/`,Windows 用 `\`,提取 basename 时需适配。 + +--- + +## 10. 数据流总结 + +``` +消息文件 messages/{id}.json + │ + │ 第一次 JSON.parse → raw 对象 + │ + ▼ +raw.message(JSON 字符串) + │ + │ 第二次 JSON.parse → msgBody 对象 + │ + ▼ +msgBody.content(数组) + │ + │ 判断 content[0] 的类型 + │ ├── string(len=1) → 旧版,join("") 拼接 + │ └── object → 新版,取 block.text + │ + ▼ +完整文本 + │ + │ 正则匹配:Workspace Folder: (.+) + │ 或:Workspace Path: (.+) + │ + ▼ +workspaceFolder = "/Users/steve/.../wenqu" + │ + │ basename() + │ + ▼ +projectName = "wenqu" +``` diff --git "a/docs/PRD/\345\257\273\350\277\271\357\274\210XunJi\357\274\211\344\272\247\345\223\201\351\234\200\346\261\202\346\226\207\346\241\243 PRD.md" "b/docs/PRD/\345\257\273\350\277\271\357\274\210XunJi\357\274\211\344\272\247\345\223\201\351\234\200\346\261\202\346\226\207\346\241\243 PRD.md" index 7b32161..2596926 100644 --- "a/docs/PRD/\345\257\273\350\277\271\357\274\210XunJi\357\274\211\344\272\247\345\223\201\351\234\200\346\261\202\346\226\207\346\241\243 PRD.md" +++ "b/docs/PRD/\345\257\273\350\277\271\357\274\210XunJi\357\274\211\344\272\247\345\223\201\351\234\200\346\261\202\346\226\207\346\241\243 PRD.md" @@ -59,8 +59,8 @@ |--------|--------|---------|------|---------| | P0 | **Claude Code(内网版)** | `~/.claude-internal/projects/{路径hash}/{session-uuid}.jsonl`
全局历史:`~/.claude-internal/history.jsonl`
子代理:`sessions/{id}/subagents/agent-*.jsonl` | JSONL | ✅ 低 | | P0 | **Cursor** | 全局:`~/Library/Application Support/Cursor/User/globalStorage/state.vscdb`(~208MB)
工作区:`workspaceStorage//state.vscdb`(约 196 个)
Plan 文件:`~/.cursor/plans/*.plan.md` | SQLite + Lexical 富文本 | ⚠️ 高 | -| P1 | **CodeBuddy CLI** | `~/.codebuddy/projects/{项目名}/{session-uuid}.jsonl` | JSONL | ✅ 低 | -| P1 | **CodeBuddy JetBrains 插件** | 完整会话:`~/Library/Application Support/CodeBuddyExtension/Data/{USER_ID}/CodeBuddyIDE/{USER_ID}/history/`
索引库:`~/Library/Application Support/com.tencent.copilot/copilot_index.db`
问题历史:`~/Library/Application Support/JetBrains/{IDE版本}/options/tencent-copilot-question-history.xml` | JSON + SQLite + XML | ✅ 中等 | +| P1 | **CodeBuddy(扩展 / IDE,与问渠扫描一致)** | 根目录:macOS `~/Library/Application Support/CodeBuddyExtension/Data/`,Linux `~/.local/share/CodeBuddyExtension/`(见问渠 `scanner/codebuddy.py`)。会话落盘在目录树中名为 `history` 的路径之下:`.../history/{workspace_id}/{conversation_id}/`,含 `index.json`(消息索引)与 `messages/*.json`(单条消息)。**不作为**主会话来源:`~/.codebuddy/projects/*.jsonl`(CLI JSONL,与扩展存储不同) | JSON | ✅ 中等 | +| P1 | **CodeBuddy 补充数据** | 索引库:`~/Library/Application Support/com.tencent.copilot/copilot_index.db`
问题历史:`~/Library/Application Support/JetBrains/{IDE版本}/options/tencent-copilot-question-history.xml` | SQLite + XML | ✅ 中等 | | P2 | **Windsurf** | `~/Library/Application Support/Windsurf/User/workspaceStorage/` | SQLite | 中 | | P2 | **GitHub Copilot** | VSCode workspaceStorage | SQLite | 中 | @@ -94,7 +94,9 @@ - Plan 文件结构清晰:YAML frontmatter 含 `name`、`overview`、`todos`(含 status/dependencies) - ❌ 无 token 统计,时间戳为毫秒时间戳 -**CodeBuddy CLI** +**CodeBuddy CLI(独立 JSONL 流,与扩展会话目录不同)** + +> 寻迹当前版本对 CodeBuddy 的采集实现为 **CodeBuddyExtension** 根目录下的会话目录(`index.json` + `messages/`,见上表);下表为 CLI 侧 JSONL 行格式,供对照,**非**当前采集主路径。 ```json { diff --git "a/docs/plans/2026-03-20-\345\257\273\350\277\271\346\225\264\344\275\223\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/docs/plans/2026-03-20-\345\257\273\350\277\271\346\225\264\344\275\223\346\236\266\346\236\204\350\256\276\350\256\241.md" index b7d8bdd..2c9db12 100644 --- "a/docs/plans/2026-03-20-\345\257\273\350\277\271\346\225\264\344\275\223\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/docs/plans/2026-03-20-\345\257\273\350\277\271\346\225\264\344\275\223\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -103,7 +103,7 @@ xunji/ │ │ │ │ ├── mod.rs │ │ │ │ ├── claude_code.rs │ │ │ │ ├── cursor.rs -│ │ │ │ ├── codebuddy_cli.rs +│ │ │ │ ├── codebuddy.rs │ │ │ │ ├── codebuddy_jetbrains.rs │ │ │ │ ├── watcher.rs │ │ │ │ └── normalizer.rs diff --git a/docs/plans/2026-03-22-v0.1.2-tasks.md b/docs/plans/2026-03-22-v0.1.2-tasks.md new file mode 100644 index 0000000..55614c4 --- /dev/null +++ b/docs/plans/2026-03-22-v0.1.2-tasks.md @@ -0,0 +1,28 @@ +# 寻迹 v0.1.2 任务跟踪 + +> **关联**:在 v0.1.1 基础上增量发布;核心为 **CodeBuddy CLI 会话采集**。 +> **PRD**:[`docs/PRD/寻迹(XunJi)产品需求文档 PRD.md`](../PRD/寻迹(XunJi)产品需求文档 PRD.md)(CodeBuddy CLI JSONL) + +--- + +## 任务 + +| ID | 任务 | 状态 | 说明 | +|----|------|------|------| +| **T01** | CodeBuddy:问渠路径(CodeBuddyExtension → 会话 `index.json` + `messages/`)解析,接入 `scheduler` | 已完成 | `codebuddy.rs`、`scheduler.rs` | +| **T02** | 默认配置 + 升级合并 `codebuddy-cli`(默认关闭采集) | 已完成 | `config.rs` | +| **T03** | 设置页:已接入采集器说明含 CodeBuddy CLI | 已完成 | `SettingsModal.vue` | +| **T04** | 版本号 0.1.2、CHANGELOG | 已完成 | 各 `package.json`、`Cargo.toml`、`tauri.conf.json` | + +## 变更记录 + +| 日期 | 说明 | +|------|------| +| 2026-03-22 | 初版:CLI 采集与配置合并 | + +--- + +## 与 v0.2.0 的关系 + +- **CodeBuddy CLI** 已从 v0.2.0 计划中的「数据采集」提前在本版本交付。 +- **CodeBuddy JetBrains**(多路径 / SQLite / XML)仍归后续版本(v0.2.x 或独立小版本)。 diff --git "a/docs/\346\225\260\346\215\256\345\272\223/\345\257\273\350\277\271 SQLite \346\225\260\346\215\256\346\250\241\345\236\213.md" "b/docs/\346\225\260\346\215\256\345\272\223/\345\257\273\350\277\271 SQLite \346\225\260\346\215\256\346\250\241\345\236\213.md" new file mode 100644 index 0000000..e4b3e2b --- /dev/null +++ "b/docs/\346\225\260\346\215\256\345\272\223/\345\257\273\350\277\271 SQLite \346\225\260\346\215\256\346\250\241\345\236\213.md" @@ -0,0 +1,245 @@ +# 寻迹(XunJi)SQLite 数据模型与表关系 + +本文档描述桌面端(Tauri)本地库当前 Schema,依据 `apps/desktop/src-tauri/src/storage/migrations.rs` 中的迁移逻辑整理,并用 Mermaid 辅助说明实体关系。 + +## 1. 概述 + +| 项目 | 说明 | +|------|------| +| 引擎 | SQLite,连接时开启 `PRAGMA foreign_keys=ON`、`journal_mode=WAL` | +| 默认路径 | `~/.xunji/db/xunji.db`(见应用配置) | +| 版本 | `PRAGMA user_version`,当前迁移最高为 **v6** | +| 权威来源 | `storage/migrations.rs`(DDL)+ `storage/models.rs`(Rust 结构体与业务语义) | + +**外键策略**:仅在 `messages`、`cards`、`card_tags`、`categories`(自关联)、`token_usage` 上声明 SQLite `FOREIGN KEY`。`sessions.source_id`、`sync_log.source_id`、`cards.category_id` 等在库中为**逻辑关联**,应用层负责一致性,便于数据源与分类的灵活演进。 + +--- + +## 2. 实体关系图(ER) + +下列图中标注「逻辑」的连线在数据库中**没有** `FOREIGN KEY` 约束。 + +**图例**:字段类型在图中使用 Mermaid 支持的写法(`string` / `int` / `float`),与 SQLite 的 `TEXT` / `INTEGER` / `REAL` 一一对应;字段行尾双引号内为「中文说明 + 英文字段名」。 +**说明**:部分预览器对 `erDiagram` 的 `实体["别名"] { ... }` 或 SQL 风格类型名解析不兼容,会导致实体框只显示 “(no attributes)”。下图使用**纯表名 + 标准类型**,以保证属性能渲染;中文表名见下表。 + +| 表名 `identifier` | 中文含义 | +|---------------------|----------| +| `sources` | 数据源 | +| `sessions` | 会话 | +| `messages` | 对话消息 | +| `cards` | 知识卡片 | +| `tags` | 标签 | +| `card_tags` | 卡片与标签关联(多对多) | +| `categories` | 分类 | +| `sync_log` | 同步日志 | +| `token_usage` | Token 用量 / 计费流水 | + +```mermaid +erDiagram + sources { + string id PK "主键 id" + string name "显示名称 name" + int enabled "是否启用 enabled" + string scan_paths "扫描目录列表 JSON scan_paths" + string last_sync "上次同步时间 last_sync" + string config "扩展配置 JSON config" + } + + sessions { + string id PK "主键 id(应用内 UUID)" + string source_id "数据源 id source_id, 逻辑关联 sources.id" + string session_id "数据源侧会话标识 session_id" + string source_host "来源主机 source_host, 去重维度之一" + string project_path "项目路径 project_path" + string project_name "项目名称 project_name" + int message_count "消息条数 message_count" + string content_hash "内容摘要哈希 content_hash" + string raw_path "原始文件路径 raw_path" + string created_at "创建时间 created_at" + string updated_at "更新时间 updated_at" + string status "分析状态 status" + string value "价值等级 value" + int has_updates "是否有新消息 has_updates" + string analyzed_at "分析完成时间 analyzed_at" + string error_message "分析错误信息 error_message" + string analysis_note "低价值说明 analysis_note (v3)" + string analysis_title "列表展示标题 analysis_title (v4)" + string analysis_type "分析类型徽章 analysis_type (v4)" + } + + messages { + string id PK "主键 id" + string session_id "所属会话 id session_id, FK sessions.id" + string role "角色 role" + string content "正文 content" + string timestamp "时间戳 timestamp" + int tokens_in "输入 token tokens_in" + int tokens_out "输出 token tokens_out" + int seq_order "会话内顺序 seq_order" + } + + cards { + string id PK "主键 id" + string session_id "来源会话 id session_id, FK sessions.id" + string title "标题 title" + string type "知识类型 type" + string value "价值等级 value" + string summary "一句话摘要 summary" + string note "笔记正文 Markdown note" + string category_id "分类 id category_id, 逻辑 categories.id" + string memory "Memory 规则 memory" + string skill "Skill 定义 skill" + string source_name "数据源名称冗余 source_name" + string project_name "项目名称冗余 project_name" + int prompt_tokens "提炼提示 token prompt_tokens" + int completion_tokens "提炼补全 token completion_tokens" + float cost_yuan "费用(元) cost_yuan" + string feedback "用户反馈 feedback" + string created_at "创建时间 created_at" + string updated_at "更新时间 updated_at" + string tech_stack "技术栈逗号串 tech_stack (v5)" + } + + tags { + string id PK "主键 id" + string name "标签名 name, 唯一" + string type "标签来源 type" + int count "引用计数 count" + } + + card_tags { + string card_id "卡片 id card_id, FK cards.id" + string tag_id "标签 id tag_id, FK tags.id" + } + + categories { + string id PK "主键 id" + string name "分类名称 name" + string parent_id "父分类 id parent_id, FK categories.id" + int sort_order "排序 sort_order" + } + + sync_log { + string id PK "主键 id" + string source_id "数据源 id source_id, 逻辑 sources.id" + string started_at "开始时间 started_at" + string finished_at "结束时间 finished_at" + int sessions_found "扫描到会话数 sessions_found" + int sessions_new "新导入会话数 sessions_new" + int sessions_updated "检测到更新数 sessions_updated" + string status "任务状态 status" + } + + token_usage { + string id PK "主键 id" + string card_id "关联卡片 id card_id, 可空, FK cards.id" + string provider "模型提供商 provider" + string model "模型名 model" + int prompt_tokens "提示 token prompt_tokens" + int completion_tokens "补全 token completion_tokens" + float cost_yuan "费用(元) cost_yuan" + string created_at "记录时间 created_at" + } + + sources ||--o{ sessions : "按数据源归类(逻辑关联 source_id)" + sources ||--o{ sync_log : "同步任务归属(逻辑关联 source_id)" + sessions ||--o{ messages : "会话包含多条消息(一对多)" + sessions ||--o{ cards : "会话可有多张卡片(一对多)" + cards ||--o{ card_tags : "卡片到关联表" + card_tags }o--|| tags : "关联表到标签" + categories ||--o| categories : "父子分类(自关联 parent_id)" + cards }o--o| categories : "卡片归属分类(逻辑 category_id)" + cards ||--o{ token_usage : "提炼计费流水(card_id 可空)" +``` + +**字段名说明**:`tags` 表中业务列在 SQLite 里名为 `type`;Mermaid 属性名若使用 `type` 易与语法关键字冲突,图中写作 `tag_type`,并在注释中标明「列名 type」。 + +--- + +## 3. 关系说明(业务视角) + +```text +sources(数据源) + │ source_id(逻辑) + ├── sessions(会话,去重键:session_id + source_host UNIQUE) + │ ├── messages(对话消息) + │ └── cards(知识卡片) + │ ├── card_tags ── tags(标签,多对多) + │ └── token_usage(提炼费用,可关联卡片) + └── sync_log(同步任务日志) + +categories(分类树,自引用 parent_id) + +cards_fts(FTS5 虚拟表,见下节,非业务实体表) +``` + +- **会话去重**:同一数据源侧 `session_id` 在不同 `source_host` 下可共存;组合 `(session_id, source_host)` 唯一。 +- **卡片与标签**:标签名在 `tags.name` 上唯一;`card_tags` 为联合主键 `(card_id, tag_id)`。 +- **全文搜索**:`cards_fts` 为 FTS5 独立索引表,字段 `title, summary, note, tags`;`tags` 列内容由 `card_tags` + `tags` 拼接后写入,与 `cards` 表无 `content=` 回链(见迁移 v2 说明)。 + +--- + +## 4. 各表字段概要 + +### 4.1 `sources` + +AI IDE 数据源配置(名称、启用、扫描路径 JSON、上次同步时间、扩展配置 JSON)。 + +### 4.2 `sessions` + +一条记录对应一次采集到的 AI 对话(如一个 JSONL 文件)。含分析流水线字段:`status`、`value`、`analyzed_at`、`error_message`;v3/v4 起增加低价值会话展示用 `analysis_note`、`analysis_title`、`analysis_type`。 + +### 4.3 `messages` + +会话内消息,`seq_order` 为会话内顺序;`role` 如 user / assistant / tool 等。 + +### 4.4 `cards` + +由 LLM 从会话提炼的笔记;`type` / `value` 等为业务枚举字符串;`tech_stack`(v5)为逗号分隔技术栈字符串;展示用标签来自关联查询,非 `cards` 表列。 + +### 4.5 `tags` / `card_tags` + +标签字典与卡片—标签多对多桥表。 + +### 4.6 `categories` + +分类树,`parent_id` 指向同表(可为空表示根)。 + +### 4.7 `sync_log` + +某次同步任务的统计与状态,按 `source_id` 与数据源对应(逻辑关联)。 + +### 4.8 `token_usage` + +LLM 调用计费流水;`card_id` 可空,声明外键指向 `cards(id)`。 + +### 4.9 `cards_fts`(虚拟表) + +FTS5,用于卡片侧栏/列表等全文检索;应用层在卡片写入、更新、删除及标签变更时维护与 `cards` 一致。 + +--- + +## 5. 迁移版本与 Schema 增量 + +| 版本 | 内容摘要 | +|------|----------| +| v1 | 创建全部业务表、`cards_fts`、索引 | +| v2 | 重建 `cards_fts` 为独立 FTS5,并从现有 `cards` + 标签关联回填 | +| v3 | `sessions.analysis_note` | +| v4 | `sessions.analysis_title`、`sessions.analysis_type` | +| v5 | `cards.tech_stack` | +| v6 | 数据修复:解码 `sessions` 中仍含 URL 编码的 `project_path` / `project_name` | + +执行顺序见 `run()`:按 `user_version` 递增直至当前最高版本。 + +--- + +## 6. 代码索引 + +| 模块 | 路径 | +|------|------| +| 迁移 DDL | `apps/desktop/src-tauri/src/storage/migrations.rs` | +| 模型与注释 | `apps/desktop/src-tauri/src/storage/models.rs` | +| 连接与错误类型 | `apps/desktop/src-tauri/src/storage/db.rs` | + +文档随迁移变更时请同步更新本文与 `CHANGELOG`(若项目约定记录 Schema 变更)。 diff --git "a/docs/\350\270\251\345\235\221/\346\241\214\351\235\242\345\272\224\347\224\250-sessions-\351\207\215\345\220\214\346\255\245\350\246\206\347\233\226analysis_title.md" "b/docs/\350\270\251\345\235\221/\346\241\214\351\235\242\345\272\224\347\224\250-sessions-\351\207\215\345\220\214\346\255\245\350\246\206\347\233\226analysis_title.md" new file mode 100644 index 0000000..9211f73 --- /dev/null +++ "b/docs/\350\270\251\345\235\221/\346\241\214\351\235\242\345\272\224\347\224\250-sessions-\351\207\215\345\220\214\346\255\245\350\246\206\347\233\226analysis_title.md" @@ -0,0 +1,38 @@ +# 踩坑:增量重同步把 `sessions.analysis_title` 写成 NULL + +## 问题/背景 + +会话经「低/无价值」分析后,由 `update_session_analysis_meta` 写入 `analysis_title` 等字段;随后若该会话在 Claude Code / Cursor 侧有新消息并触发采集重同步,列表里标题会消失。 + +## 现象 + +- 分析完成后列表有展示标题,同步一次后标题变空或恢复为默认逻辑。 +- 数据库中该行的 `analysis_title` 变为 `NULL`。 + +## 根因 + +```text +dedup_and_write → message_count 增加 + → update_session_resync_metadata(..., analysis_title = session.analysis_title) + NormalizedSession.analysis_title 对 claude-code / cursor 恒为 None + → SQL: UPDATE ... SET analysis_title = ?4 绑定 NULL + → 覆盖掉此前 analysis 步骤写入的标题 +``` + +原先仅调用 `update_session_message_count` 时不会碰 `analysis_title`,引入「重同步元数据」后未区分「采集未提供」与「显式清空」。 + +## 解决方法 + +在 `update_session_resync_metadata` 的 `UPDATE` 中使用: + +`analysis_title = COALESCE(?4, analysis_title)` + +- 采集端传入非 NULL(如 CodeBuddy 工作区 index 中的名称)时仍更新; +- 传入 NULL 时保留库内已有 `analysis_title`。 + +实现位置:`apps/desktop/src-tauri/src/storage/sessions.rs` 中 `update_session_resync_metadata`。 + +## 后续如何避免 + +- 凡「可选字段」若语义为「未提供则不改」,在 SQL 层用 `COALESCE(新值, 列)` 或拆成两条 `UPDATE`,避免把 `NULL` 绑进无条件 `SET col = ?`。 +- 回归:`cargo test resync_metadata`(`storage::sessions` 内单元测试);或手工先跑分析写入标题,再触发该会话增量同步,确认标题仍在。 diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index a2f12d2..8947fb1 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@xunji/mcp-server", - "version": "0.1.1", + "version": "0.1.3", "private": true, "type": "module", "scripts": { diff --git a/packages/shared/package.json b/packages/shared/package.json index 02079ff..bad2103 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@xunji/shared", - "version": "0.1.1", + "version": "0.1.3", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/packages/sidecar/package.json b/packages/sidecar/package.json index f989105..a5eaded 100644 --- a/packages/sidecar/package.json +++ b/packages/sidecar/package.json @@ -1,6 +1,6 @@ { "name": "@xunji/sidecar", - "version": "0.1.1", + "version": "0.1.3", "private": true, "type": "module", "scripts": {