diff --git "a/.cursor/plans/tauri_\350\207\252\345\212\250\346\236\204\345\273\272\344\270\216\346\233\264\346\226\260_5a344555.plan.md" "b/.cursor/plans/tauri_\350\207\252\345\212\250\346\236\204\345\273\272\344\270\216\346\233\264\346\226\260_5a344555.plan.md" index 840b483..8202069 100644 --- "a/.cursor/plans/tauri_\350\207\252\345\212\250\346\236\204\345\273\272\344\270\216\346\233\264\346\226\260_5a344555.plan.md" +++ "b/.cursor/plans/tauri_\350\207\252\345\212\250\346\236\204\345\273\272\344\270\216\346\233\264\346\226\260_5a344555.plan.md" @@ -88,7 +88,7 @@ CI Secrets 方向:`TAURI_PRIVATE_KEY`、`TAURI_KEY_PASSWORD`(更新通道, ## 3. 与 sidecar 的关系 -`[tauri.conf.json](apps/desktop/src-tauri/tauri.conf.json)` 已把 `xunji-sidecar` 打进 `bundle.resources`。**完整应用更新**时,新安装包会带上新 sidecar;只要每次发版前 sidecar 构建进同一 pipeline,无需单独更新 sidecar。 +`[tauri.conf.json](apps/desktop/src-tauri/tauri.conf.json)` 已把 `chattake-sidecar` 打进 `bundle.resources`。**完整应用更新**时,新安装包会带上新 sidecar;只要每次发版前 sidecar 构建进同一 pipeline,无需单独更新 sidecar。 ## 4. 风险与注意点 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f2662d..15c1fa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -# 在 PR / master 推送时做依赖安装、前端与 Rust 侧编译检查,避免明显破坏合并进主分支。 +# PR / master 推送统一验证伴随程序、Rust 与前端。 name: CI on: @@ -17,29 +17,21 @@ jobs: timeout-minutes: 30 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.6 - 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 @@ -56,5 +48,8 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@stable - - name: Cargo check (desktop) - run: cd apps/desktop/src-tauri && cargo check --all-targets + - name: Build companions + run: bun run build:companions + + - name: Verify workspace + run: bun run verify diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56cf8d8..3835e24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,72 +1,108 @@ -# 推送语义化版本 tag(如 v0.1.3)时: -# 1) 与原先一致:在 ubuntu 上由 softprops 创建/更新 GitHub Release 并 generate_release_notes(PR 合并后再打 tag 的流程不变)。 -# 2) tauri build(macOS:aarch64 + x86_64,无 Windows,省 CI);latest.json 需含 Intel Mac 所用 darwin-x86_64,否则检查更新会报 platforms 缺失。 -# 依赖 Secrets:TAURI_SIGNING_PRIVATE_KEY(必填);TAURI_SIGNING_PRIVATE_KEY_PASSWORD(可选)。 +# 推送 vX.Y.Z 标签后:验证版本与全仓库,再构建双架构 macOS 更新产物。 name: Release on: push: tags: - 'v[0-9]+.[0-9]+.[0-9]+' - # 指向 master 的 PR:预检 changelog 等与发版相关的条件,避免仅依赖打 tag 后才发现问题(完整构建见 CI) - pull_request: - branches: [master] -permissions: - contents: write +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: - # ── 与历史 release.yml 一致:先建 Release + 自动生成说明(无构建)──────────────── + release-check: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.6 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check release version + run: bun run check:release-version -- "${GITHUB_REF_NAME}" + + - name: Verify changelog sync + run: | + bun run sync:changelog + git diff --exit-code apps/desktop/src/data/app-changelog.md + + - 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: Build companions + run: bun run build:companions + + - name: Verify workspace + run: bun run verify + github-release: - if: github.event_name == 'push' + needs: release-check runs-on: ubuntu-latest + permissions: + contents: write outputs: release_id: ${{ steps.create_release.outputs.id }} steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Create GitHub Release + - name: Create draft GitHub Release id: create_release uses: softprops/action-gh-release@v2 with: + draft: true generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # ── 打 tag 后:构建并上传到上述 Release(双架构 macOS;不跑 Windows 以省 CI)── publish-tauri: - if: github.event_name == 'push' needs: github-release strategy: fail-fast: false matrix: include: - # Apple Silicon:应用内更新主路径 - platform: macos-latest args: --target aarch64-apple-darwin - # Intel Mac / Rosetta x86:与上共存于 latest.json,缺则报 darwin-x86_64 not in platforms + bun_target: bun-darwin-arm64 - platform: macos-latest args: --target x86_64-apple-darwin - + bun_target: bun-darwin-x64 runs-on: ${{ matrix.platform }} timeout-minutes: 45 + permissions: + contents: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.6 - name: Install dependencies run: bun install --frozen-lockfile - - name: Verify changelog sync - run: | - bun run sync:changelog - git diff --exit-code apps/desktop/src/data/app-changelog.md + - name: Check release version + run: bun run check:release-version -- "${GITHUB_REF_NAME}" - name: Require updater signing secret shell: bash @@ -74,51 +110,37 @@ jobs: KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} run: | if [ -z "$KEY" ]; then - echo '::error::未配置 TAURI_SIGNING_PRIVATE_KEY。请阅读 docs/桌面应用-Release与自动更新.md 生成密钥并写入仓库 Secrets。' + echo '::error::未配置 TAURI_SIGNING_PRIVATE_KEY。请阅读 docs/桌面应用-Release与自动更新.md。' exit 1 fi - - name: Install Rust stable - if: matrix.platform != 'macos-latest' - uses: dtolnay/rust-toolchain@stable - - - name: Install Rust stable (macOS aarch64 + x86_64) - if: matrix.platform == 'macos-latest' + - name: Install Rust targets uses: dtolnay/rust-toolchain@stable with: targets: aarch64-apple-darwin,x86_64-apple-darwin - - name: Build and upload to GitHub Release - uses: tauri-apps/tauri-action@v0 + - name: Build and upload to draft Release + uses: tauri-apps/tauri-action@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + CHATTAKE_BUN_TARGET: ${{ matrix.bun_target }} with: projectPath: apps/desktop tauriScript: bun run tauri releaseId: ${{ needs.github-release.outputs.release_id }} - includeUpdaterJson: true - updaterJsonPreferNsis: true + uploadUpdaterJson: true args: ${{ matrix.args }} - # ── PR → master:发版前预检(与 tag 构建共用 changelog 校验逻辑)──────────────── - release-pr-precheck: - if: github.event_name == 'pull_request' + finalize-release: + needs: [github-release, publish-tauri] runs-on: ubuntu-latest + permissions: + contents: write 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 - - - name: Verify changelog sync - run: | - bun run sync:changelog - git diff --exit-code apps/desktop/src/data/app-changelog.md + - name: Publish complete Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_ID: ${{ needs.github-release.outputs.release_id }} + run: gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" -F draft=false -f make_latest=true diff --git a/.gitignore b/.gitignore index d517971..75045c6 100644 --- a/.gitignore +++ b/.gitignore @@ -35,5 +35,5 @@ Thumbs.db .env .env.local -# XunJi runtime data -.xunji/ \ No newline at end of file +# ChatTake runtime data +.chattake/ \ No newline at end of file diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 0000000..5966132 --- /dev/null +++ b/.impeccable.md @@ -0,0 +1,21 @@ +## Design Context + +### Users + +长期使用 Claude Code、Cursor、Codex 等 AI 编程工具的个人开发者。他们需要在不改变现有工作流的前提下,本地采集分散的对话,确认分析任务,并将结果审核、检索和复用为个人技术知识。 + +### Brand Personality + +安静、可信、克制。界面应像可长期使用的研究档案,而不是高饱和的 AI 控制台;任务状态清楚但不抢夺正文注意力。 + +### Aesthetic Direction + +编辑档案感,辅以克制的液态玻璃结构层,同时支持 Light/Dark。使用线、留白、宋体气质标题和既有松绿/朱砂 Token 建立层级;避免满屏卡片、彩色渐变、霓虹描边、厚重阴影和装饰性动效。 + +### Design Principles + +1. 通过线、留白与信息层级组织内容,结构层才使用玻璃效果。 +2. 主内容优先,进度和辅助操作采用渐进披露,不能遮挡列表、分页或正文。 +3. 状态必须由真实后端数据驱动,并同时通过文字或图标表达,不能只依赖颜色。 +4. 控件沿用 32/36px 高度、8px 圆角和既有品牌 Token,不引入新的视觉体系。 +5. 所有交互提供可读名称、键盘焦点,并尊重 reduced motion 与双主题。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 180f4d0..2d32538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,43 @@ -# 寻迹(XunJi)更新日志 +# 有得(ChatTake)更新日志 > 面向使用者的更新说明。实现细节见 `docs/CHANGELOG.md`。 +## 0.2.0(2026-08-30) + +### 新增 + +- **全新品牌**:产品正式更名为「有得 · ChatTake」,应用、数据目录、MCP、安装包和仓库标识同步更新,不保留旧名称兼容层。 +- **六来源采集**:正式支持 Claude Code、Cursor、Codex、CodeBuddy、OMP、Pi,启动时只扫描变化,不会自动消耗模型 Token。 +- **API / CLI Provider**:可同时保存 OpenAI-compatible API 与 Claude Code、Codex、Cursor、OMP、Pi、CodeBuddy CLI 配置;任务固定使用启动时选择的配置。 +- **知识草稿**:高价值知识自动发布,中价值进入草稿;重新分析先生成草稿,不覆盖已有知识。 +- **持久任务中心**:显示真实阶段、文件/会话计数、供应商、模型、耗时和失败项,支持取消与切换配置重试。 +- **只读 MCP**:提供搜索、卡片详情、聚合项和来源对话四个工具,只暴露已发布知识。 + +### 重构与性能 + +- 建立 Light/Dark 双主题与克制的液态玻璃视觉语言,以线性档案索引替代满屏卡片,补齐来源标识和窄窗口布局。 +- 知识固定为决策、排障、实现、解释、片段五类,主题与技术项改为可治理标签。 +- 使用文件指纹与内容哈希增量同步;会话列表改为单次聚合查询,对话按 100 条游标分页。 +- 搜索改为 FTS5 trigram,中英文子串与 2 字短词均可检索;生产 Chunk 最大值低于 500 KB。 + +### 不兼容调整 + +- 删除所有 `*-internal` 路径和旧分类/Memory/Skill 数据模型。 +- 旧数据库会在可恢复备份成功后重建,备份位于 `~/.chattake/db/backups/`。 + +### 修复 + +- **macOS 发布**:改用 Ad Hoc 签名、未公证分发,生成 DMG 不再依赖 Apple Developer 证书;首次启动由用户在「隐私与安全性」确认 +- 修复会话列表按项目筛选时,多表联查可能触发 SQLite `ambiguous column name: project_name` 的问题。 + +## 0.1.7(2026-03-26) + +### 开发体验 + +- **本地 macOS 打包**:`make macos` 在未设置环境变量时,会尝试从 `apps/desktop/chattake.updater.key` 注入 `TAURI_SIGNING_PRIVATE_KEY`(与 CI 一致;私钥文件勿提交)。详见 `docs/macOS 打包说明.md`。 + +--- + ## 0.1.6(2026-03-25) ### 体验改进 @@ -14,7 +50,7 @@ ### 体验改进 -- **桌面端品牌与安装包**:安装包、GitHub Release 资源文件名使用 **XunJi** 前缀(避免部分环境下非 ASCII 文件名异常);macOS 程序坞与启动台等仍显示 **「寻迹」**。 +- **桌面端品牌与安装包**:安装包、GitHub Release 资源文件名使用 **ChatTake** 前缀(避免部分环境下非 ASCII 文件名异常);macOS 程序坞与启动台等仍显示 **「有得」**。 --- diff --git a/Makefile b/Makefile index 6112e6f..0c11c91 100644 --- a/Makefile +++ b/Makefile @@ -1,27 +1,49 @@ -# 寻迹 —— 常用构建入口(macOS 为主,需已安装 Bun / Rust / Xcode CLT) +# 有得 —— 常用构建入口(macOS 为主,需已安装 Bun / Rust / Xcode CLT) # 使用:在仓库根目录执行 `make macos` 或 `make help` REPO := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) -.PHONY: help deps sidecar desktop-frontend macos +.PHONY: help deps sidecar mcp companions desktop-frontend macos help: @echo "目标说明:" @echo " make deps - bun install(workspace)" - @echo " make sidecar - 仅编译 packages/sidecar → dist/xunji-sidecar" - @echo " make macos - 依赖 + 打 macOS 安装包(.app + .dmg,内含 Sidecar)" - @echo "产物:apps/desktop/src-tauri/target/release/bundle/dmg/寻迹_<版本>_.dmg" + @echo " make sidecar - 仅编译 packages/sidecar → dist/chattake-sidecar" + @echo " make mcp - 仅编译 packages/mcp-server → dist/chattake-mcp" + @echo " make companions - 编译 Sidecar 与 MCP" + @echo " make macos - 依赖 + 打 macOS 安装包(.app + .dmg,内含 Sidecar/MCP)" + @echo "产物:apps/desktop/src-tauri/target/release/bundle/dmg/ChatTake_<版本>_.dmg" deps: - cd "$(REPO)" && bun install + @cd "$(REPO)" && bun install sidecar: cd "$(REPO)/packages/sidecar" && bun run build -# 一键:安装依赖并执行 Tauri 发布构建(beforeBuild 会编译 sidecar + 前端,bundle.resources 打入 sidecar) +mcp: + cd "$(REPO)/packages/mcp-server" && bun run build + +companions: sidecar mcp + +# 一键:安装依赖并执行 Tauri 发布构建(beforeBuild 会编译 Sidecar/MCP + 前端并打入资源) +# createUpdaterArtifacts 需要 minisign 私钥:优先用环境变量 TAURI_SIGNING_PRIVATE_KEY,否则从 apps/desktop/chattake.updater.key 读取(勿提交该文件) macos: deps - cd "$(REPO)/apps/desktop" && env -u CI bun run tauri:build + @cd "$(REPO)/apps/desktop" && \ + if [ -z "$$TAURI_SIGNING_PRIVATE_KEY" ] && [ -f chattake.updater.key ]; then \ + export TAURI_SIGNING_PRIVATE_KEY="$$(cat chattake.updater.key)"; \ + elif [ -z "$$TAURI_SIGNING_PRIVATE_KEY" ]; then \ + echo "错误: 未设置 TAURI_SIGNING_PRIVATE_KEY,且当前目录无 chattake.updater.key。请生成密钥或 export,见 docs/桌面应用-Release与自动更新.md" >&2; \ + exit 1; \ + fi; \ + env -u CI bun run tauri:build # 一键:安装依赖并执行 Tauri 开发构建(beforeDev 会编译 sidecar + 前端) dev: cd "$(REPO)/apps/desktop" && bun run tauri dev + +# 清除构建产物 +clean: + cd "$(REPO)/apps/desktop" && rm -rf src-tauri/target + cd "$(REPO)/packages/sidecar" && rm -rf dist + cd "$(REPO)/packages/mcp-server" && rm -rf dist + cd "$(REPO)/packages/shared" && rm -rf dist diff --git a/README.md b/README.md index e312114..318ab10 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,77 @@ -# 寻迹(XunJi) +# 有得 · ChatTake -> AI 编程知识管理平台 —— 从 AI 对话中提炼可复用的技术知识 +> 让每次 AI 对话,都有所得 +> ChatTake — Turn AI chats into reusable knowledge ## 简介 -寻迹自动采集你与 AI 编程助手(Claude Code、Cursor 等)的对话记录,通过 LLM 提炼出有价值的技术笔记、最佳实践和编程技巧,构建个人知识库。 +有得是一款本地优先的 AI 对话知识库。它增量采集 Claude Code、Cursor、Codex、CodeBuddy、OMP、Pi 的本地对话,经用户确认后通过 API 或本地 CLI Provider 提取原子知识,并提供草稿治理、全文检索和只读 MCP 回溯。 + +v0.2.0 采用固定的五类知识(决策、排障、实现、解释、片段)与开放标签,避免 AI 无限生成分类。高价值知识自动发布,中价值进入草稿,重新分析不会覆盖旧知识。 + +![对话档案与实时任务中心](docs/images/chattake-overview-light.jpg) + +截图由当前 v0.2.0 界面使用演示数据渲染,不包含真实会话、路径或密钥。 + +## v0.2.0 核心能力 + +- **六类对话来源**:Claude Code、Cursor、Codex、CodeBuddy、OMP、Pi;增量扫描不自动消耗 Token。 +- **API 与 CLI 同级 Provider**:可保存多套 OpenAI、DeepSeek、Moonshot、智谱、硅基流动、OpenAI-compatible 或本地 CLI 配置。 +- **可控的分析任务**:每个任务绑定启动时的 Provider,展示真实阶段、进度、当前会话、模型和失败项。 +- **本地搜索与 MCP**:SQLite FTS5 支持中英文检索;MCP 只读已发布知识,不暴露草稿、API Key 或 Provider 配置。 + +## 下载与系统要求 + +v0.2.0 当前正式发布目标为 **macOS**,GitHub Release 同时生成: + +- Apple Silicon(arm64):`aarch64` DMG +- Intel Mac:`x86_64` DMG + +请从 [GitHub Releases](https://github.com/CodeHourra/chattake/releases) 下载与芯片匹配的安装包,打开 DMG 后将 ChatTake 拖入「应用程序」。Windows/Linux 尚未进入 v0.2.0 自动发布矩阵。 + +安装包采用 **Ad Hoc 签名、未公证分发**。首次启动若被 Gatekeeper 拦截,请先尝试打开一次,再进入「系统设置 → 隐私与安全性」,点击「仍要打开」并确认;之后可正常双击启动。 + +## 首次使用 + +1. **启用数据源**:打开「设置 → 数据源」,确认本机已安装的 AI 工具与扫描目录。启动扫描只检测变化并写入本地 SQLite,不会调用模型。 +2. **同步会话**:点击顶栏「同步」,再从「进度中心」查看扫描、解析、写入进度和失败文件;取消会在当前文件处理完成后生效。 +3. **配置分析 Provider**:打开「设置 → AI 配置」,新增 API 或 CLI 配置并设为当前。API 支持刷新模型列表或手填完整模型 ID;CLI 的「检测命令」只检查可执行性,不调用模型。 +4. **确认并开始分析**:在「对话档案」预览会话,选择单条或批量分析。运行期间不会自动跨 Provider 切换;失败后可为单项选择其他配置重试。 +5. **审核与复用知识**:在「知识笔记」中编辑类型、摘要、正文、主题标签和技术项。可全文搜索、合并标签、发布草稿,或将选中/全部知识导出为 Markdown。 + +![多 Provider 配置](docs/images/chattake-provider-settings.jpg) + +## 知识库 + +知识页支持 Light/Dark 主题、发布/草稿分流、类型/主题/技术筛选和紧凑/舒展两种密度。默认检索范围只包含已发布知识。 + +![Dark 主题知识库](docs/images/chattake-knowledge-dark.jpg) + +## 本地数据与安全 + +| 内容 | 默认路径 | +|---|---| +| 应用配置 | `~/.chattake/config.toml` | +| SQLite 数据库 | `~/.chattake/db/chattake.db` | +| 升级前备份 | `~/.chattake/db/backups/` | +| 全局 Sidecar/MCP 候选目录 | `~/.chattake/bin/` | + +- API Key 仅保存在本地配置文件,不写入任务表、日志或 MCP 返回值。 +- 分析任务保存 Provider/模型快照,不会因运行期间修改当前配置而改变。 +- v0.2.0 遇到旧 Schema 时会先执行 SQLite `VACUUM INTO` 备份;任何备份失败都会中止重建。 ## 技术栈 - **桌面框架**: Tauri 2.0 -- **前端**: Vue 3 + TypeScript + UnoCSS + Radix Vue + Pinia +- **前端**: Vue 3 + TypeScript + UnoCSS + Naive UI + Pinia - **后端**: Rust (rusqlite, serde, tokio) -- **LLM Sidecar**: TypeScript + Bun (OpenAI-compatible API) -- **MCP Server**: TypeScript + Bun (stdio transport) +- **LLM Sidecar**: TypeScript + Bun(OpenAI-compatible API / 本地 CLI) +- **MCP Server**: 官方 TypeScript SDK v2 + Bun(只读 stdio) ## 项目结构 ``` -xunji/ +chattake/ ├── CHANGELOG.md # 更新日志(唯一维护源,关于页经 sync:changelog 同步) ├── scripts/ # 根目录脚本(如 sync-changelog.mjs) ├── apps/desktop/ # Tauri 桌面应用 @@ -34,20 +88,31 @@ xunji/ ```bash # 安装依赖 -npm install +bun install # 更新日志:编辑根目录 CHANGELOG.md 后同步到关于页数据源(构建 tauri 前也会自动执行) -npm run sync:changelog +bun run sync:changelog # 启动开发模式 -cd apps/desktop -npm run tauri dev +bun --cwd apps/desktop run tauri dev -# 构建 sidecar -cd packages/sidecar -bun build src/index.ts --compile --outfile dist/xunji-sidecar +# 验证 Sidecar、MCP、Rust 与前端 +bun run verify + +# 统一 debug 构建(先编译伴随程序、运行 Rust 测试,再执行 Tauri) +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)。 + +首次运行 v0.2.0 时,如果检测到旧 Schema,会先通过 SQLite `VACUUM INTO` 备份到 `~/.chattake/db/backups/`,备份成功后才重建数据库。 + +## MCP + +先执行 `bun run build:companions`,然后在应用「设置 → MCP」复制配置片段。`chattake-mcp` 只读取已发布知识;草稿、任务、API Key 和供应商配置不会通过 MCP 返回。 + ## 许可证 MIT diff --git a/apps/desktop/xunji.updater.key.pub b/apps/desktop/chattake.updater.key.pub similarity index 100% rename from apps/desktop/xunji.updater.key.pub rename to apps/desktop/chattake.updater.key.pub diff --git a/apps/desktop/index.html b/apps/desktop/index.html index c4bdbf4..d1d547e 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -4,7 +4,7 @@ - 寻迹 + 有得
diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a8932d3..be1f2f1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,12 +1,12 @@ { - "name": "@xunji/desktop", + "name": "@chattake/desktop", "private": true, - "version": "0.1.6", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", "build": "vue-tsc --noEmit && vite build", - "build:tauri": "cd ../.. && npm run sync:changelog && cd packages/sidecar && bun run build && cd ../../apps/desktop && vue-tsc --noEmit && vite build", + "build:tauri": "cd ../.. && bun run sync:changelog && bun run build:companions && bun run --cwd apps/desktop build", "preview": "vite preview", "tauri": "tauri", "tauri:build": "env -u CI tauri build" @@ -17,7 +17,7 @@ "@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-updater": "^2", "@vueuse/core": "^14.2.1", - "@xunji/shared": "workspace:*", + "@chattake/shared": "workspace:*", "highlight.js": "^11.11.1", "marked": "^15.0.7", "naive-ui": "^2.44.1", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index fe28351..ebca3f3 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,12 +1,12 @@ [package] -name = "xunji-desktop" -version = "0.1.6" -description = "寻迹 - AI 编程知识管理平台" -authors = ["XunJi"] +name = "chattake-desktop" +version = "0.2.0" +description = "有得 · AI 对话知识库" +authors = ["ChatTake"] edition = "2021" [lib] -name = "xunji_desktop_lib" +name = "chattake_desktop_lib" crate-type = ["lib", "cdylib", "staticlib"] [build-dependencies] diff --git a/apps/desktop/src-tauri/Info.plist b/apps/desktop/src-tauri/Info.plist index 0d49177..4f32b86 100644 --- a/apps/desktop/src-tauri/Info.plist +++ b/apps/desktop/src-tauri/Info.plist @@ -2,8 +2,8 @@ - + CFBundleDisplayName - 寻迹 + 有得 diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-tauri/icons/128x128.png index f5a6cbb..c14de10 100644 Binary files a/apps/desktop/src-tauri/icons/128x128.png and b/apps/desktop/src-tauri/icons/128x128.png differ diff --git a/apps/desktop/src-tauri/icons/128x128@2x.png b/apps/desktop/src-tauri/icons/128x128@2x.png index d6667cd..d633ade 100644 Binary files a/apps/desktop/src-tauri/icons/128x128@2x.png and b/apps/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-tauri/icons/32x32.png index ef42219..3100575 100644 Binary files a/apps/desktop/src-tauri/icons/32x32.png and b/apps/desktop/src-tauri/icons/32x32.png differ diff --git a/apps/desktop/src-tauri/icons/64x64.png b/apps/desktop/src-tauri/icons/64x64.png index 7b4acb7..e113c1e 100644 Binary files a/apps/desktop/src-tauri/icons/64x64.png and b/apps/desktop/src-tauri/icons/64x64.png differ diff --git a/apps/desktop/src-tauri/icons/Square107x107Logo.png b/apps/desktop/src-tauri/icons/Square107x107Logo.png index 27eea42..a4fd82e 100644 Binary files a/apps/desktop/src-tauri/icons/Square107x107Logo.png and b/apps/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square142x142Logo.png b/apps/desktop/src-tauri/icons/Square142x142Logo.png index 498ef26..3a0be28 100644 Binary files a/apps/desktop/src-tauri/icons/Square142x142Logo.png and b/apps/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square150x150Logo.png b/apps/desktop/src-tauri/icons/Square150x150Logo.png index a0be360..4633447 100644 Binary files a/apps/desktop/src-tauri/icons/Square150x150Logo.png and b/apps/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square284x284Logo.png b/apps/desktop/src-tauri/icons/Square284x284Logo.png index 99ef138..a44ca0c 100644 Binary files a/apps/desktop/src-tauri/icons/Square284x284Logo.png and b/apps/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square30x30Logo.png b/apps/desktop/src-tauri/icons/Square30x30Logo.png index c2665ab..16a1de9 100644 Binary files a/apps/desktop/src-tauri/icons/Square30x30Logo.png and b/apps/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square310x310Logo.png b/apps/desktop/src-tauri/icons/Square310x310Logo.png index 19b0ded..cea9438 100644 Binary files a/apps/desktop/src-tauri/icons/Square310x310Logo.png and b/apps/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square44x44Logo.png b/apps/desktop/src-tauri/icons/Square44x44Logo.png index 7e99a1b..6a00722 100644 Binary files a/apps/desktop/src-tauri/icons/Square44x44Logo.png and b/apps/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square71x71Logo.png b/apps/desktop/src-tauri/icons/Square71x71Logo.png index e121d1d..1610aab 100644 Binary files a/apps/desktop/src-tauri/icons/Square71x71Logo.png and b/apps/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square89x89Logo.png b/apps/desktop/src-tauri/icons/Square89x89Logo.png index a307e8e..7118a81 100644 Binary files a/apps/desktop/src-tauri/icons/Square89x89Logo.png and b/apps/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/apps/desktop/src-tauri/icons/StoreLogo.png b/apps/desktop/src-tauri/icons/StoreLogo.png index e948808..1645e12 100644 Binary files a/apps/desktop/src-tauri/icons/StoreLogo.png and b/apps/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png index e1df1a0..461bcc4 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png index 05b2497..d13f559 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png index c0c9547..ec4649e 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png index 975a333..4f3b6d8 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png index a3c6c5b..c5f83ed 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png index d1273e1..f81d844 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png index abe3418..0365788 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png index ebb999d..d872ba0 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png index b176bb0..b4e7a30 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png index 88f1eb7..01e905e 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png index 825ae0a..4168c64 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png index 35cbf9a..ebd33ca 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png index 494cd19..48c3097 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png index f2ecdf0..9d57eb7 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png index 03648e1..ec8776d 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/chattake-icon.png b/apps/desktop/src-tauri/icons/chattake-icon.png new file mode 100644 index 0000000..9691ba7 Binary files /dev/null and b/apps/desktop/src-tauri/icons/chattake-icon.png differ diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns index d6a0621..3d75e9b 100644 Binary files a/apps/desktop/src-tauri/icons/icon.icns and b/apps/desktop/src-tauri/icons/icon.icns differ diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico index eff3858..225a1bc 100644 Binary files a/apps/desktop/src-tauri/icons/icon.ico and b/apps/desktop/src-tauri/icons/icon.ico differ diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png index 2f1ad10..4300ba7 100644 Binary files a/apps/desktop/src-tauri/icons/icon.png and b/apps/desktop/src-tauri/icons/icon.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png index 56d2139..e320a15 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png index 9bcfaea..8eed6b8 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png index 9bcfaea..8eed6b8 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png index bb68b22..5ad28b3 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png index 6da6a47..2235698 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png index fa9510f..9be5007 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png index fa9510f..9be5007 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png index 55a7296..3415b43 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png index 9bcfaea..8eed6b8 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png index 2ae40bc..c6adb64 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png index 2ae40bc..c6adb64 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png index 0fff82d..4b7e3e7 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png index 3574924..80407af 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png index 0fff82d..4b7e3e7 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png index 80ec189..85376ea 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png index 2a7a835..fb4b646 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png index 84af02d..8419af0 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png index a52f4de..109290e 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/apps/desktop/src-tauri/icons/xunji-rounded.png b/apps/desktop/src-tauri/icons/xunji-rounded.png deleted file mode 100644 index e0cb671..0000000 Binary files a/apps/desktop/src-tauri/icons/xunji-rounded.png and /dev/null differ diff --git a/apps/desktop/src-tauri/icons/xunji.png b/apps/desktop/src-tauri/icons/xunji.png deleted file mode 100644 index f31980c..0000000 Binary files a/apps/desktop/src-tauri/icons/xunji.png and /dev/null differ diff --git a/apps/desktop/src-tauri/src/collector/agent_jsonl.rs b/apps/desktop/src-tauri/src/collector/agent_jsonl.rs new file mode 100644 index 0000000..f5a8e46 --- /dev/null +++ b/apps/desktop/src-tauri/src/collector/agent_jsonl.rs @@ -0,0 +1,256 @@ +//! OMP / Pi 的 JSONL 会话解析器。两者使用相同的 pi-agent 会话格式,但作为独立来源入库。 + +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::normalizer::{ + file_fingerprint, normalize_user_content, CollectionBatch, CollectionFailure, + NormalizedMessage, NormalizedSession, +}; + +pub struct AgentJsonlCollector { + source_id: &'static str, + scan_dirs: Vec, +} + +impl AgentJsonlCollector { + pub fn new(source_id: &'static str, scan_dirs: Vec) -> Self { + Self { + source_id, + 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.extension().and_then(|v| v.to_str()) != Some("jsonl") { + continue; + } + batch.found += 1; + let Some((mtime, size)) = file_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_agent_jsonl(path, self.source_id, 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 parse_agent_jsonl( + path: &Path, + source_id: &str, + mtime: i64, + size: i64, +) -> Result, Box> { + let mut session_id = None; + let mut cwd = None; + let mut title = None; + let mut messages = Vec::new(); + let mut created_at = None; + let mut updated_at = None; + + let mut lines = BufReader::new(File::open(path)?).lines().peekable(); + while let Some(line) = lines.next() { + let value: Value = match serde_json::from_str(&line?) { + Ok(value) => value, + Err(error) if lines.peek().is_none() => { + log::debug!("忽略活跃 {source_id} 会话末尾未完成行: {error}"); + continue; + } + Err(error) => return Err(format!("JSONL 中间行损坏: {error}").into()), + }; + let timestamp = value + .get("timestamp") + .and_then(Value::as_str) + .map(str::to_owned); + match value.get("type").and_then(Value::as_str) { + Some("session") => { + session_id = value + .get("id") + .and_then(Value::as_str) + .map(str::to_owned) + .or(session_id); + cwd = value + .get("cwd") + .and_then(Value::as_str) + .map(str::to_owned) + .or(cwd); + created_at = timestamp.clone().or(created_at); + } + Some("title") | Some("title_change") => { + title = value + .get("title") + .and_then(Value::as_str) + .map(str::to_owned) + .or(title); + } + Some("message") => { + let message = &value["message"]; + let role = match message.get("role").and_then(Value::as_str) { + Some("user") => "user", + Some("assistant") => "assistant", + _ => continue, + }; + let content = visible_text(message.get("content")); + if content.is_empty() { + continue; + } + let content = if role == "user" { + match normalize_user_content(&content) { + Some(content) => content, + None => continue, + } + } else { + content + }; + let timestamp = message + .get("timestamp") + .and_then(Value::as_str) + .map(str::to_owned) + .or(timestamp); + if created_at.is_none() { + created_at = timestamp.clone(); + } + updated_at = timestamp.clone().or(updated_at); + messages.push(NormalizedMessage { + role: role.to_string(), + content, + timestamp, + tokens_in: 0, + tokens_out: 0, + }); + } + _ => {} + } + } + + if messages.is_empty() { + return Ok(None); + } + let session_id = session_id + .or_else(|| path.file_stem().and_then(|v| v.to_str()).map(str::to_owned)) + .ok_or("会话文件缺少 id")?; + 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_owned); + Ok(Some(NormalizedSession { + source_id: source_id.to_string(), + session_id, + project_path: cwd, + project_name, + analysis_title: title.filter(|value| !value.trim().is_empty()), + messages, + raw_path: path.to_string_lossy().into_owned(), + raw_mtime_ms: Some(mtime), + raw_size_bytes: Some(size), + created_at: created_at.unwrap_or_else(|| fallback.clone()), + updated_at: updated_at.unwrap_or(fallback), + })) +} + +fn visible_text(content: Option<&Value>) -> String { + match content { + Some(Value::String(text)) => text.trim().to_string(), + Some(Value::Array(parts)) => parts + .iter() + .filter(|part| part.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .map(str::trim) + .filter(|text| !text.is_empty()) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn parses_visible_pi_agent_messages_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.jsonl"); + let fixture = [ + serde_json::json!({"type":"session","id":"session-1","cwd":"/tmp/demo","timestamp":"2026-08-30T00:00:00Z"}), + serde_json::json!({"type":"title_change","title":"修复构建","timestamp":"2026-08-30T00:00:01Z"}), + serde_json::json!({"type":"message","timestamp":"2026-08-30T00:00:02Z","message":{"role":"user","content":[{"type":"text","text":"为什么失败?"}]}}), + serde_json::json!({"type":"message","message":{"role":"assistant","content":[{"type":"thinking","thinking":"secret"},{"type":"toolCall","name":"bash"},{"type":"text","text":"因为配置错误。"}]}}), + serde_json::json!({"type":"message","message":{"role":"toolResult","content":[{"type":"text","text":"private output"}]}}), + ].into_iter().map(|line| line.to_string()).collect::>().join("\n"); + fs::write(&path, fixture).unwrap(); + let (mtime, size) = file_fingerprint(&path).unwrap(); + let session = parse_agent_jsonl(&path, "pi", mtime, size) + .unwrap() + .unwrap(); + assert_eq!(session.source_id, "pi"); + assert_eq!(session.analysis_title.as_deref(), Some("修复构建")); + assert_eq!(session.messages.len(), 2); + assert_eq!(session.messages[1].content, "因为配置错误。"); + } + + #[test] + fn rejects_broken_middle_line_but_tolerates_incomplete_tail() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.jsonl"); + let session = serde_json::json!({"type":"session","id":"session-1"}).to_string(); + let message = + serde_json::json!({"type":"message","message":{"role":"user","content":"问题"}}) + .to_string(); + + fs::write(&path, format!("{session}\n{{broken\n{message}")).unwrap(); + let (mtime, size) = file_fingerprint(&path).unwrap(); + assert!(parse_agent_jsonl(&path, "pi", mtime, size).is_err()); + + fs::write(&path, format!("{session}\n{message}\n{{broken")).unwrap(); + let (mtime, size) = file_fingerprint(&path).unwrap(); + let parsed = parse_agent_jsonl(&path, "pi", mtime, size) + .unwrap() + .unwrap(); + assert_eq!(parsed.messages.len(), 1); + } +} diff --git a/apps/desktop/src-tauri/src/collector/claude_code.rs b/apps/desktop/src-tauri/src/collector/claude_code.rs index 48c92fd..b6b2643 100644 --- a/apps/desktop/src-tauri/src/collector/claude_code.rs +++ b/apps/desktop/src-tauri/src/collector/claude_code.rs @@ -21,7 +21,10 @@ use std::fs::{self, File}; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; -use super::normalizer::{NormalizedMessage, NormalizedSession}; +use super::normalizer::{ + file_fingerprint, normalize_user_content, CollectionBatch, CollectionFailure, + NormalizedMessage, NormalizedSession, +}; /// Claude Code 数据采集器 pub struct ClaudeCodeCollector { @@ -35,38 +38,63 @@ impl ClaudeCodeCollector { } /// 扫描所有配置的目录,返回解析后的标准化会话列表 + #[cfg(test)] pub fn collect(&self) -> Vec { - let mut sessions = Vec::new(); + self.collect_changed(|_, _, _| false).sessions + } + + pub fn collect_changed(&self, unchanged: F) -> CollectionBatch + where + F: Fn(&Path, i64, i64) -> bool, + { + let mut batch = CollectionBatch::default(); for dir in &self.scan_dirs { let projects_dir = dir.join("projects"); if !projects_dir.exists() { - log::debug!("Claude Code projects 目录不存在,跳过: {}", projects_dir.display()); + log::debug!( + "Claude Code projects 目录不存在,跳过: {}", + projects_dir.display() + ); continue; } - match self.scan_projects_dir(&projects_dir) { + match self.scan_projects_dir(&projects_dir, &unchanged) { Ok(mut found) => { log::info!( "从 {} 扫描到 {} 个会话", projects_dir.display(), - found.len() + found.sessions.len() ); - sessions.append(&mut found); + batch.sessions.append(&mut found.sessions); + batch.failures.append(&mut found.failures); + batch.skipped_paths.append(&mut found.skipped_paths); + batch.found += found.found; + batch.skipped += found.skipped; } Err(e) => { - log::error!("扫描 Claude Code 目录失败: {} - {}", projects_dir.display(), e); + batch.failures.push(CollectionFailure { + raw_path: projects_dir.to_string_lossy().into_owned(), + error: e.to_string(), + }); } } } - log::info!("Claude Code 采集完成,共 {} 个会话", sessions.len()); - sessions + log::info!("Claude Code 采集完成,共 {} 个会话", batch.sessions.len()); + batch } /// 扫描 projects/ 下所有子目录中的 .jsonl 文件 - fn scan_projects_dir(&self, projects_dir: &Path) -> Result, std::io::Error> { - let mut sessions = Vec::new(); + fn scan_projects_dir( + &self, + projects_dir: &Path, + unchanged: &F, + ) -> Result + where + F: Fn(&Path, i64, i64) -> bool, + { + let mut batch = CollectionBatch::default(); for entry in fs::read_dir(projects_dir)? { let entry = entry?; @@ -75,7 +103,7 @@ impl ClaudeCodeCollector { continue; } - // 目录名形如 "-Users-steve-Codes-myspace-ai-project-xunji" + // 目录名形如 "-Users-steve-Codes-myspace-ai-project-chattake" // 可以反推项目路径:将 - 替换为 /(首个 - 是分隔符) let dir_name = entry.file_name().to_string_lossy().to_string(); let (project_path, project_name) = derive_project_info(&dir_name); @@ -88,6 +116,18 @@ impl ClaudeCodeCollector { if file_path.extension().and_then(|e| e.to_str()) != Some("jsonl") { continue; } + batch.found += 1; + let (raw_mtime_ms, raw_size_bytes) = match file_fingerprint(&file_path) { + Some(fingerprint) => fingerprint, + None => continue, + }; + if unchanged(&file_path, raw_mtime_ms, raw_size_bytes) { + batch.skipped += 1; + batch + .skipped_paths + .push(file_path.to_string_lossy().into_owned()); + continue; + } // 文件名(去掉 .jsonl)即为 session_id let session_id = file_path @@ -104,14 +144,16 @@ impl ClaudeCodeCollector { Ok(parse_result) => { if parse_result.messages.is_empty() { log::debug!("会话无有效消息,跳过: {}", file_path.display()); + batch.skipped += 1; + batch + .skipped_paths + .push(file_path.to_string_lossy().into_owned()); continue; } // 优先使用 JSONL 内的 cwd 作为项目路径 - let final_project_path = parse_result - .cwd - .clone() - .or_else(|| project_path.clone()); + let final_project_path = + parse_result.cwd.clone().or_else(|| project_path.clone()); let final_project_name = final_project_path .as_ref() .and_then(|p| Path::new(p).file_name()) @@ -128,7 +170,7 @@ impl ClaudeCodeCollector { .clone() .unwrap_or_else(|| created_at.clone()); - sessions.push(NormalizedSession { + batch.sessions.push(NormalizedSession { source_id: "claude-code".to_string(), session_id, project_path: final_project_path, @@ -136,18 +178,23 @@ impl ClaudeCodeCollector { analysis_title: None, messages: parse_result.messages, raw_path: file_path.to_string_lossy().to_string(), + raw_mtime_ms: Some(raw_mtime_ms), + raw_size_bytes: Some(raw_size_bytes), created_at, updated_at, }); } Err(e) => { - log::warn!("解析 JSONL 文件失败: {} - {}", file_path.display(), e); + batch.failures.push(CollectionFailure { + raw_path: file_path.to_string_lossy().into_owned(), + error: e.to_string(), + }); } } } } - Ok(sessions) + Ok(batch) } } @@ -173,7 +220,8 @@ fn parse_jsonl(path: &Path) -> Result> { let mut first_timestamp: Option = None; let mut last_timestamp: Option = None; - for line in reader.lines() { + let mut lines = reader.lines().peekable(); + while let Some(line) = lines.next() { let line = line?; if line.trim().is_empty() { continue; @@ -181,10 +229,11 @@ fn parse_jsonl(path: &Path) -> Result> { let entry: serde_json::Value = match serde_json::from_str(&line) { Ok(v) => v, - Err(e) => { - log::trace!("跳过无法解析的行: {}", e); + Err(e) if lines.peek().is_none() => { + log::debug!("忽略活跃 Claude 会话末尾未完成行: {e}"); continue; } + Err(e) => return Err(format!("JSONL 中间行损坏: {e}").into()), }; // 跳过子代理(sidechain)内的消息 @@ -205,21 +254,19 @@ fn parse_jsonl(path: &Path) -> Result> { } let message = &entry["message"]; + let role = message["role"].as_str().unwrap_or(msg_type).to_string(); let extracted = extract_content(message); - if extracted.text.trim().is_empty() { - continue; - } - - // 当 API 层 role 为 "user" 但内容全是 tool_result 时,标记为 "tool" - let role = if extracted.is_tool_result { - "tool".to_string() + let content = if role == "user" { + match normalize_user_content(&extracted) { + Some(content) => content, + None => continue, + } } else { - message["role"] - .as_str() - .unwrap_or(msg_type) - .to_string() + extracted }; - + if content.trim().is_empty() { + continue; + } let timestamp = entry["timestamp"].as_str().map(String::from); // 记录首尾时间戳 @@ -237,7 +284,7 @@ fn parse_jsonl(path: &Path) -> Result> { messages.push(NormalizedMessage { role, - content: extracted.text, + content, timestamp, tokens_in, tokens_out, @@ -252,102 +299,39 @@ fn parse_jsonl(path: &Path) -> Result> { }) } -/// extract_content 的返回值 -struct ExtractedContent { - /// 提取的文本内容 - text: String, - /// 该消息内容是否全部由 tool_result 组成(无真正的用户输入) - is_tool_result: bool, -} - /// 从 message 对象中提取文本内容。 /// /// Claude Code 的 content 有两种格式: /// - 纯字符串(user 消息常见) /// - 数组(assistant 消息常见):[{type: "text", text: "..."}, {type: "tool_use", name: "...", input: {...}}, ...] /// -/// 当 content 为数组且仅包含 tool_result 类型时,标记 is_tool_result = true, -/// 调用方据此将 role 从 "user" 改为 "tool"。 -fn extract_content(message: &serde_json::Value) -> ExtractedContent { +fn extract_content(message: &serde_json::Value) -> String { let content = &message["content"]; match content { - serde_json::Value::String(s) => ExtractedContent { - text: s.clone(), - is_tool_result: false, - }, + serde_json::Value::String(s) => s.clone(), serde_json::Value::Array(arr) => { let mut parts = Vec::new(); - let mut has_text = false; - let mut has_tool_result = false; for item in arr { - match item["type"].as_str() { - Some("text") => { - if let Some(text) = item["text"].as_str() { - if !text.trim().is_empty() { - has_text = true; - parts.push(text.to_string()); - } - } - } - Some("tool_use") => { - // 保留工具调用的摘要信息,供 LLM 分析时理解上下文 - let name = item["name"].as_str().unwrap_or("unknown"); - parts.push(format!("[Tool: {}]", name)); - } - Some("tool_result") => { - has_tool_result = true; - // tool_result 可能包含嵌套 content - if let Some(nested) = item["content"].as_str() { - let preview = truncate_str(nested, 200); - parts.push(format!("[Tool Result: {}]", preview)); - } else if let Some(nested_arr) = item["content"].as_array() { - for nested_item in nested_arr { - if nested_item["type"].as_str() == Some("text") { - if let Some(text) = nested_item["text"].as_str() { - let preview = truncate_str(text, 200); - parts.push(format!("[Tool Result: {}]", preview)); - } - } - } + if item["type"].as_str() == Some("text") { + if let Some(text) = item["text"].as_str() { + if !text.trim().is_empty() { + parts.push(text.to_string()); } } - _ => {} } } - - // 没有 text 块且有 tool_result → 整条消息是工具返回值 - let is_tool_result = !has_text && has_tool_result; - - ExtractedContent { - text: parts.join("\n\n"), - is_tool_result, - } + parts.join("\n\n") } - _ => ExtractedContent { - text: String::new(), - is_tool_result: false, - }, - } -} - -/// 截断字符串到指定字符数,返回不超过 max_chars 的前缀切片 -fn truncate_str(s: &str, max_chars: usize) -> &str { - if s.len() <= max_chars { - return s; - } - // 找到不超过 max_chars 的字符边界 - match s.char_indices().nth(max_chars) { - Some((idx, _)) => &s[..idx], - None => s, + _ => String::new(), } } /// 从 Claude Code 的 projects 目录名反推项目路径和名称。 /// -/// 目录名格式: "-Users-steve-Codes-myspace-ai-project-xunji" -/// 转换规则: 将 "-" 替换为 "/",得到 "/Users/steve/Codes/myspace/ai-project/xunji" +/// 目录名格式: "-Users-steve-Codes-myspace-ai-project-chattake" +/// 转换规则: 将 "-" 替换为 "/",得到 "/Users/steve/Codes/myspace/ai-project/chattake" /// /// 注意:此推导不完全准确(路径中的 "-" 会被混淆),所以优先使用 JSONL 内的 cwd 字段。 fn derive_project_info(dir_name: &str) -> (Option, Option) { @@ -378,8 +362,7 @@ mod tests { "content": "Hello, world!" }); let result = extract_content(&msg); - assert_eq!(result.text, "Hello, world!"); - assert!(!result.is_tool_result); + assert_eq!(result, "Hello, world!"); } #[test] @@ -391,8 +374,7 @@ mod tests { ] }); let result = extract_content(&msg); - assert_eq!(result.text, "First paragraph\n\nSecond paragraph"); - assert!(!result.is_tool_result); + assert_eq!(result, "First paragraph\n\nSecond paragraph"); } #[test] @@ -404,9 +386,7 @@ mod tests { ] }); let result = extract_content(&msg); - assert!(result.text.contains("Let me read the file.")); - assert!(result.text.contains("[Tool: Read]")); - assert!(!result.is_tool_result, "有 text 块时不应标记为 tool_result"); + assert_eq!(result, "Let me read the file."); } #[test] @@ -417,23 +397,24 @@ mod tests { ] }); let result = extract_content(&msg); - assert!(result.text.contains("[Tool Result:")); - assert!(result.is_tool_result, "纯 tool_result 内容应标记为 is_tool_result"); + assert!(result.is_empty()); } #[test] fn test_extract_content_empty() { let msg = serde_json::json!({}); let result = extract_content(&msg); - assert_eq!(result.text, ""); - assert!(!result.is_tool_result); + assert_eq!(result, ""); } #[test] fn test_derive_project_info() { - let (path, name) = derive_project_info("-Users-steve-Codes-myspace-ai-project-xunji"); - assert_eq!(path, Some("/Users/steve/Codes/myspace/ai/project/xunji".to_string())); - assert_eq!(name, Some("xunji".to_string())); + let (path, name) = derive_project_info("-Users-steve-Codes-myspace-ai-project-chattake"); + assert_eq!( + path, + Some("/Users/steve/Codes/myspace/ai/project/chattake".to_string()) + ); + assert_eq!(name, Some("chattake".to_string())); } #[test] @@ -465,15 +446,33 @@ mod tests { assert_eq!(result.messages[1].tokens_in, 100); assert_eq!(result.messages[1].tokens_out, 50); assert_eq!(result.cwd, Some("/Users/steve/project".to_string())); - assert_eq!(result.first_timestamp, Some("2026-03-20T05:30:00.000Z".to_string())); - assert_eq!(result.last_timestamp, Some("2026-03-20T05:30:05.000Z".to_string())); + assert_eq!( + result.first_timestamp, + Some("2026-03-20T05:30:00.000Z".to_string()) + ); + assert_eq!( + result.last_timestamp, + Some("2026-03-20T05:30:05.000Z".to_string()) + ); } #[test] - fn test_truncate_str() { - assert_eq!(truncate_str("hello", 10), "hello"); - assert_eq!(truncate_str("hello world", 5), "hello"); - assert_eq!(truncate_str("你好世界", 2), "你好"); + fn test_parse_jsonl_keeps_slash_command_and_args() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("command.jsonl"); + let content = "/understand-anything:understand\nduplicate\n--language zh"; + fs::write( + &file_path, + serde_json::json!({"type":"user","message":{"role":"user","content":content}}) + .to_string(), + ) + .unwrap(); + + let result = parse_jsonl(&file_path).unwrap(); + assert_eq!( + result.messages[0].content, + "/understand-anything:understand --language zh" + ); } /// 使用本地真实 Claude Code 数据验证采集器(需要 --ignored 才会运行) @@ -481,10 +480,7 @@ mod tests { #[ignore] fn test_real_local_data() { let home = dirs::home_dir().unwrap(); - let collector = ClaudeCodeCollector::new(vec![ - home.join(".claude"), - home.join(".claude-internal"), - ]); + let collector = ClaudeCodeCollector::new(vec![home.join(".claude")]); let sessions = collector.collect(); println!("\n=== Claude Code 采集结果 ==="); diff --git a/apps/desktop/src-tauri/src/collector/codebuddy.rs b/apps/desktop/src-tauri/src/collector/codebuddy.rs index 4d76975..cd294c5 100644 --- a/apps/desktop/src-tauri/src/collector/codebuddy.rs +++ b/apps/desktop/src-tauri/src/collector/codebuddy.rs @@ -12,7 +12,7 @@ //! 单条消息:messages/{id}.json 内 message 为 JSON 字符串,解析后取 content(字符串 / text 块数组 / 旧版单字符数组,见 `docs/CodeBuddy 会话记录提取逻辑文档.md`)。 //! ``` //! -//! source_id 仍为 `codebuddy-cli`,与现有 config.toml / 侧栏 id 兼容。 +//! source_id 为 `codebuddy`。 use std::collections::HashSet; use std::fs; @@ -22,9 +22,12 @@ use chrono::{DateTime, Utc}; use serde_json::Value; use walkdir::WalkDir; -use super::normalizer::{NormalizedMessage, NormalizedSession}; +use super::normalizer::{ + file_fingerprint, normalize_user_content, CollectionBatch, CollectionFailure, + NormalizedMessage, NormalizedSession, +}; -const SOURCE_ID: &str = "codebuddy-cli"; +const SOURCE_ID: &str = "codebuddy"; /// CodeBuddy(扩展)采集器 pub struct CodeBuddyCollector { @@ -38,13 +41,16 @@ impl CodeBuddyCollector { } /// 扫描所有配置根目录下的会话目录,产出标准化会话列表 - pub fn collect(&self) -> Vec { + pub fn collect_changed(&self, unchanged: F) -> CollectionBatch + where + F: Fn(&Path, i64, i64) -> bool, + { log::info!( "CodeBuddy 采集开始,解析后的扫描根目录共 {} 个: {:?}", self.scan_dirs.len(), self.scan_dirs ); - let mut sessions = Vec::new(); + let mut batch = CollectionBatch::default(); let mut seen = HashSet::::new(); for root in &self.scan_dirs { @@ -69,18 +75,39 @@ impl CodeBuddyCollector { continue; } leaf_candidates += 1; + batch.found += 1; let key = path.to_string_lossy().to_string(); if !seen.insert(key) { continue; } + let index_path = path.join("index.json"); + let (mtime, size) = match codebuddy_fingerprint(path) { + Some(fingerprint) => fingerprint, + None => continue, + }; + if unchanged(&index_path, mtime, size) { + batch.skipped += 1; + batch + .skipped_paths + .push(index_path.to_string_lossy().into_owned()); + continue; + } match parse_codebuddy_session_dir(path) { Ok(Some(s)) => { - sessions.push(s); + batch.sessions.push(s); parsed_ok += 1; } - Ok(None) => {} - Err(e) => log::warn!("解析 CodeBuddy 会话目录失败: {} — {}", path.display(), e), + Ok(None) => { + batch.skipped += 1; + batch + .skipped_paths + .push(index_path.to_string_lossy().into_owned()); + } + Err(e) => batch.failures.push(CollectionFailure { + raw_path: path.to_string_lossy().into_owned(), + error: e.to_string(), + }), } } log::info!( @@ -91,8 +118,8 @@ impl CodeBuddyCollector { ); } - log::info!("CodeBuddy 扩展采集完成,共 {} 个会话", sessions.len()); - sessions + log::info!("CodeBuddy 扩展采集完成,共 {} 个会话", batch.sessions.len()); + batch } } @@ -285,7 +312,9 @@ fn extract_workspace_folder_from_text_blob(s: &str) -> Option { .or_else(|| extract_line_after_workspace_key(s, "Workspace Path:")) } -fn parse_codebuddy_session_dir(session_dir: &Path) -> Result, Box> { +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)?; @@ -310,8 +339,8 @@ fn parse_codebuddy_session_dir(session_dir: &Path) -> Result Result Result content, + None => continue, + } + } else { + trimmed.to_string() + }; messages.push(NormalizedMessage { role: role.to_string(), - content: trimmed.to_string(), + content, timestamp: ts, tokens_in, tokens_out, @@ -369,6 +408,7 @@ fn parse_codebuddy_session_dir(session_dir: &Path) -> Result Result Option<(i64, i64)> { + let mut fingerprint = file_fingerprint(&session_dir.join("index.json"))?; + let messages_dir = session_dir.join("messages"); + if let Ok(entries) = fs::read_dir(messages_dir) { + for entry in entries.flatten() { + if let Some((mtime, size)) = file_fingerprint(&entry.path()) { + fingerprint.0 = fingerprint.0.max(mtime); + fingerprint.1 = fingerprint.1.saturating_add(size); + } + } + } + Some(fingerprint) +} + /// 从 index.json 的 requests[].startedAt(毫秒)推导时间;失败则用 index 文件 mtime -fn session_times_rfc3339(index: &Value, index_path: &Path) -> Result<(String, String), Box> { +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 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) @@ -408,7 +471,9 @@ fn ms_to_rfc3339(ms: u64) -> Option { DateTime::from_timestamp(secs, nanos).map(|dt| dt.to_rfc3339()) } -fn file_mtime_rfc3339(meta: &std::fs::Metadata) -> Result> { +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); @@ -476,9 +541,7 @@ fn extract_user_text(outer: &Value, inner: &Value) -> String { combined } -/// 与 `ChatReplay.vue` / Claude 采集约定一致:`[Tool: name]` 供 Markdown 渲染为行内代码;`` 可折叠 -/// -/// 新版为对象数组;旧版可能为单字符字符串数组(与 `inner_content_to_text` 文档一致),无结构化块时拼接为整段正文。 +/// 仅保留助手可见文本;reasoning 与 tool-call 不是对话正文。 fn extract_assistant_text(inner: &Value) -> String { if let Some(s) = inner.get("content").and_then(|c| c.as_str()) { return s.to_string(); @@ -492,28 +555,12 @@ fn extract_assistant_text(inner: &Value) -> String { 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()); - } + if b["type"].as_str() == Some("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"); @@ -545,7 +592,22 @@ mod tests { #[test] fn extract_user_query_from_xml() { let s = "foo你好bar"; - assert_eq!(extract_xml_section(s, "user_query").as_deref(), Some("你好")); + assert_eq!( + extract_xml_section(s, "user_query").as_deref(), + Some("你好") + ); + } + + #[test] + fn assistant_excludes_reasoning_and_tools() { + let inner = json!({ + "content": [ + { "type": "reasoning", "text": "secret" }, + { "type": "tool-call", "toolName": "shell" }, + { "type": "text", "text": "可见回答" } + ] + }); + assert_eq!(extract_assistant_text(&inner), "可见回答"); } #[test] diff --git a/apps/desktop/src-tauri/src/collector/codex.rs b/apps/desktop/src-tauri/src/collector/codex.rs new file mode 100644 index 0000000..0775449 --- /dev/null +++ b/apps/desktop/src-tauri/src/collector/codex.rs @@ -0,0 +1,301 @@ +//! Codex JSONL 采集:只保留 response_item 中用户与助手可见文本。 + +use std::collections::HashMap; +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::normalizer::{ + file_fingerprint, normalize_user_content, CollectionBatch, CollectionFailure, + NormalizedMessage, NormalizedSession, +}; + +pub struct CodexCollector { + scan_dirs: Vec, +} + +impl CodexCollector { + 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, + { + // 同一 UUID 同时出现在 active/archive 时,先按文件 mtime 选较新者,再判断是否需要解析。 + let mut candidates: HashMap = HashMap::new(); + 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.extension().and_then(|v| v.to_str()) != Some("jsonl") { + continue; + } + let Some((mtime, size)) = file_fingerprint(path) else { + continue; + }; + let key = session_id_from_filename(path) + .unwrap_or_else(|| path.to_string_lossy().into_owned()); + match candidates.get(&key) { + Some((_, current_mtime, _)) if *current_mtime >= mtime => {} + _ => { + candidates.insert(key, (path.to_path_buf(), mtime, size)); + } + } + } + } + + let mut batch = CollectionBatch { + found: candidates.len() as u32, + ..Default::default() + }; + for (_, (path, mtime, size)) in candidates { + if unchanged(&path, mtime, size) { + batch.skipped += 1; + batch + .skipped_paths + .push(path.to_string_lossy().into_owned()); + continue; + } + match parse_codex_jsonl(&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_id_from_filename(path: &Path) -> Option { + let stem = path.file_stem()?.to_str()?; + let candidate = stem.get(stem.len().checked_sub(36)?..)?; + uuid::Uuid::parse_str(candidate) + .ok() + .map(|id| id.to_string()) +} + +fn parse_codex_jsonl( + path: &Path, + mtime: i64, + size: i64, +) -> Result, Box> { + let mut external_id = None; + let mut cwd = None; + let mut messages = Vec::new(); + let mut first_timestamp = None; + let mut last_timestamp = None; + + let mut lines = BufReader::new(File::open(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!("忽略活跃 Codex 会话末尾未完成行: {error}"); + continue; + } + Err(error) => return Err(format!("JSONL 中间行损坏: {error}").into()), + }; + let timestamp = value + .get("timestamp") + .and_then(Value::as_str) + .map(str::to_string); + match value.get("type").and_then(Value::as_str) { + Some("session_meta") => { + let payload = &value["payload"]; + external_id = payload + .get("id") + .and_then(Value::as_str) + .map(str::to_string) + .or(external_id); + cwd = payload + .get("cwd") + .and_then(Value::as_str) + .map(str::to_string) + .or(cwd); + if first_timestamp.is_none() { + first_timestamp = timestamp.clone().or_else(|| { + payload + .get("timestamp") + .and_then(Value::as_str) + .map(str::to_string) + }); + } + } + Some("response_item") => { + let payload = &value["payload"]; + if payload.get("type").and_then(Value::as_str) != Some("message") { + continue; + } + let role = match payload.get("role").and_then(Value::as_str) { + Some("user") => "user", + Some("assistant") => "assistant", + _ => continue, + }; + let allowed = if role == "user" { + "input_text" + } else { + "output_text" + }; + let content = payload + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|part| part.get("type").and_then(Value::as_str) == Some(allowed)) + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + if content.trim().is_empty() { + continue; + } + let content = if role == "user" { + match normalize_user_content(&content) { + Some(content) => content, + None => continue, + } + } else { + content.trim().to_string() + }; + first_timestamp.get_or_insert_with(|| timestamp.clone().unwrap_or_default()); + if timestamp.is_some() { + last_timestamp = timestamp.clone(); + } + messages.push(NormalizedMessage { + role: role.to_string(), + content, + timestamp, + tokens_in: 0, + tokens_out: 0, + }); + } + _ => {} + } + } + let Some(session_id) = external_id.or_else(|| session_id_from_filename(path)) else { + return Ok(None); + }; + if messages.is_empty() { + return Ok(None); + } + let fallback = DateTime::::from_timestamp_millis(mtime) + .unwrap_or_else(Utc::now) + .to_rfc3339(); + let created_at = first_timestamp + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| fallback.clone()); + let updated_at = last_timestamp.unwrap_or_else(|| fallback.clone()); + let project_name = cwd + .as_ref() + .and_then(|path| Path::new(path).file_name()) + .and_then(|name| name.to_str()) + .map(str::to_string); + Ok(Some(NormalizedSession { + source_id: "codex".to_string(), + session_id, + project_path: cwd, + project_name, + analysis_title: None, + messages, + raw_path: path.to_string_lossy().into_owned(), + raw_mtime_ms: Some(mtime), + raw_size_bytes: Some(size), + created_at, + updated_at, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn parses_visible_messages_and_excludes_internal_events() { + let dir = tempfile::tempdir().unwrap(); + let id = "11111111-1111-4111-8111-111111111111"; + let path = dir + .path() + .join(format!("rollout-2026-08-30T00-00-00-{id}.jsonl")); + let fixture = [ + serde_json::json!({"type":"session_meta","timestamp":"2026-08-30T00:00:00Z","payload":{"id":id,"cwd":"/tmp/demo"}}), + serde_json::json!({"type":"response_item","timestamp":"2026-08-30T00:00:01Z","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"secret"}]}}), + serde_json::json!({"type":"response_item","payload":{"type":"reasoning","summary":[]}}), + serde_json::json!({"type":"event_msg","payload":{"type":"user_message","message":"duplicate"}}), + serde_json::json!({"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"# AGENTS.md instructions\nsecret"}]}}), + serde_json::json!({"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"/private"}]}}), + serde_json::json!({"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"The following is the Codex agent history whose request action you are assessing:\nprivate tool history"}]}}), + serde_json::json!({"type":"response_item","timestamp":"2026-08-30T00:00:02Z","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"问题"},{"type":"image_url","url":"x"}]}}), + serde_json::json!({"type":"response_item","timestamp":"2026-08-30T00:00:03Z","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"回答"}]}}), + serde_json::json!({"type":"response_item","payload":{"type":"function_call","name":"tool"}}), + ].into_iter().map(|line| line.to_string()).collect::>().join("\n"); + fs::write(&path, fixture).unwrap(); + let (mtime, size) = file_fingerprint(&path).unwrap(); + let session = parse_codex_jsonl(&path, mtime, size).unwrap().unwrap(); + assert_eq!(session.session_id, id); + assert_eq!(session.project_name.as_deref(), Some("demo")); + assert_eq!( + session + .messages + .iter() + .map(|m| m.content.as_str()) + .collect::>(), + vec!["问题", "回答"] + ); + } + + #[test] + fn keeps_newer_file_when_active_and_archive_share_session_id() { + let dir = tempfile::tempdir().unwrap(); + let active = dir.path().join("sessions/2026/08/30"); + let archived = dir.path().join("archived_sessions"); + fs::create_dir_all(&active).unwrap(); + fs::create_dir_all(&archived).unwrap(); + let id = "22222222-2222-4222-8222-222222222222"; + let name = format!("rollout-2026-08-30T00-00-00-{id}.jsonl"); + let write_fixture = |path: &Path, answer: &str| { + let rows = [ + serde_json::json!({"type":"session_meta","payload":{"id":id,"cwd":"/tmp/demo"}}), + serde_json::json!({"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":answer}]}}), + ]; + fs::write( + path, + rows.into_iter() + .map(|row| row.to_string()) + .collect::>() + .join("\n"), + ) + .unwrap(); + }; + write_fixture(&archived.join(&name), "旧回答"); + std::thread::sleep(std::time::Duration::from_millis(2)); + write_fixture(&active.join(&name), "新回答"); + + let batch = CodexCollector::new(vec![active, archived]).collect_changed(|_, _, _| false); + assert_eq!(batch.sessions.len(), 1); + assert_eq!(batch.sessions[0].messages[0].content, "新回答"); + } +} diff --git a/apps/desktop/src-tauri/src/collector/cursor.rs b/apps/desktop/src-tauri/src/collector/cursor.rs index 028e923..4fe2a4d 100644 --- a/apps/desktop/src-tauri/src/collector/cursor.rs +++ b/apps/desktop/src-tauri/src/collector/cursor.rs @@ -21,7 +21,10 @@ use rusqlite::Connection; use crate::path_local::decode_cursor_folder_to_local_path; -use super::normalizer::{NormalizedMessage, NormalizedSession}; +use super::normalizer::{ + file_fingerprint, normalize_user_content, CollectionBatch, CollectionFailure, + NormalizedMessage, NormalizedSession, +}; /// Cursor 数据采集器 pub struct CursorCollector { @@ -35,8 +38,16 @@ impl CursorCollector { } /// 扫描所有配置的目录,返回解析后的标准化会话列表 + #[cfg(test)] pub fn collect(&self) -> Vec { - let mut all_sessions = Vec::new(); + self.collect_changed(|_, _, _| false).sessions + } + + pub fn collect_changed(&self, unchanged: F) -> CollectionBatch + where + F: Fn(&Path, i64, i64) -> bool, + { + let mut batch = CollectionBatch::default(); for base_dir in &self.scan_dirs { let global_db_path = base_dir @@ -46,27 +57,47 @@ impl CursorCollector { let ws_base = base_dir.join("User").join("workspaceStorage"); if !global_db_path.exists() { - log::debug!("Cursor 全局数据库不存在,跳过: {}", global_db_path.display()); + log::debug!( + "Cursor 全局数据库不存在,跳过: {}", + global_db_path.display() + ); + continue; + } + batch.found += 1; + let (raw_mtime_ms, raw_size_bytes) = match sqlite_fingerprint(&global_db_path) { + Some(fingerprint) => fingerprint, + None => continue, + }; + if unchanged(&global_db_path, raw_mtime_ms, raw_size_bytes) { + batch.skipped += 1; + batch + .skipped_paths + .push(global_db_path.to_string_lossy().into_owned()); continue; } - match self.collect_from_dir(&global_db_path, &ws_base) { - Ok(mut sessions) => { + match self.collect_from_dir(&global_db_path, &ws_base, raw_mtime_ms, raw_size_bytes) { + Ok(mut found) => { log::info!( "从 {} 扫描到 {} 个 Cursor 会话", base_dir.display(), - sessions.len() + found.sessions.len() ); - all_sessions.append(&mut sessions); + batch.sessions.append(&mut found.sessions); + batch.failures.append(&mut found.failures); + batch.skipped_paths.append(&mut found.skipped_paths); } Err(e) => { - log::error!("扫描 Cursor 目录失败: {} - {}", base_dir.display(), e); + batch.failures.push(CollectionFailure { + raw_path: global_db_path.to_string_lossy().into_owned(), + error: e.to_string(), + }); } } } - log::info!("Cursor 采集完成,共 {} 个会话", all_sessions.len()); - all_sessions + log::info!("Cursor 采集完成,共 {} 个会话", batch.sessions.len()); + batch } /// 从单个 Cursor 安装目录采集会话 @@ -74,7 +105,9 @@ impl CursorCollector { &self, global_db_path: &Path, ws_base: &Path, - ) -> Result, Box> { + raw_mtime_ms: i64, + raw_size_bytes: i64, + ) -> Result> { // Step 1: 扫描所有 workspace,建立 composerId → (project_path, name) 映射 let workspace_map = scan_workspaces(ws_base)?; log::debug!("发现 {} 个 workspace", workspace_map.len()); @@ -85,25 +118,45 @@ impl CursorCollector { rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, )?; - let mut sessions = Vec::new(); + let mut batch = CollectionBatch::default(); // 遍历所有 workspace 中的 composer for (composer_id, ws_info) in &workspace_map { - match read_composer_session(&conn, composer_id, ws_info) { + match read_composer_session( + &conn, + composer_id, + ws_info, + global_db_path, + raw_mtime_ms, + raw_size_bytes, + ) { Ok(Some(session)) => { - sessions.push(session); + batch.sessions.push(session); } Ok(None) => { // 会话无有效消息,跳过 } Err(e) => { - log::warn!("读取 Cursor 会话失败: {} - {}", composer_id, e); + batch.failures.push(CollectionFailure { + raw_path: format!("{}#{}", global_db_path.display(), composer_id), + error: e.to_string(), + }); } } } - Ok(sessions) + Ok(batch) + } +} + +fn sqlite_fingerprint(db_path: &Path) -> Option<(i64, i64)> { + let mut fingerprint = file_fingerprint(db_path)?; + let wal_path = PathBuf::from(format!("{}-wal", db_path.to_string_lossy())); + if let Some((mtime, size)) = file_fingerprint(&wal_path) { + fingerprint.0 = fingerprint.0.max(mtime); + fingerprint.1 = fingerprint.1.saturating_add(size); } + Some(fingerprint) } // ── Workspace 扫描 ── @@ -241,6 +294,9 @@ fn read_composer_session( conn: &Connection, composer_id: &str, ws_info: &WorkspaceInfo, + raw_path: &Path, + raw_mtime_ms: i64, + raw_size_bytes: i64, ) -> Result, Box> { // 读取 composerData 获取 bubble 列表 let key = format!("composerData:{}", composer_id); @@ -266,9 +322,7 @@ fn read_composer_session( } // 从 composerData 中获取 createdAt(毫秒时间戳),作为备选 - let composer_created_at = data["createdAt"] - .as_i64() - .or(ws_info.created_at); + let composer_created_at = data["createdAt"].as_i64().or(ws_info.created_at); // 逐个读取 bubble 内容 let mut messages = Vec::new(); @@ -308,6 +362,14 @@ fn read_composer_session( if content.trim().is_empty() { continue; } + let content = if role == "user" { + match normalize_user_content(&content) { + Some(content) => content, + None => continue, + } + } else { + content + }; let token_count = &bubble_data["tokenCount"]; let tokens_in = token_count["inputTokens"].as_u64().unwrap_or(0) as u32; @@ -349,7 +411,9 @@ fn read_composer_session( project_name: ws_info.project_name.clone(), analysis_title: None, messages, - raw_path: format!("cursor://composer/{}", composer_id), + raw_path: raw_path.to_string_lossy().to_string(), + raw_mtime_ms: Some(raw_mtime_ms), + raw_size_bytes: Some(raw_size_bytes), created_at, updated_at, })) @@ -543,11 +607,7 @@ mod tests { fn test_read_workspace_project_path() { let dir = tempfile::tempdir().unwrap(); let json_path = dir.path().join("workspace.json"); - std::fs::write( - &json_path, - r#"{"folder":"file:///Users/steve/myproject"}"#, - ) - .unwrap(); + std::fs::write(&json_path, r#"{"folder":"file:///Users/steve/myproject"}"#).unwrap(); let result = read_workspace_project_path(&json_path); assert_eq!(result, Some("/Users/steve/myproject".to_string())); @@ -573,9 +633,7 @@ mod tests { #[ignore] fn test_real_local_data() { let home = dirs::home_dir().unwrap(); - let collector = CursorCollector::new(vec![ - home.join("Library/Application Support/Cursor"), - ]); + let collector = CursorCollector::new(vec![home.join("Library/Application Support/Cursor")]); let sessions = collector.collect(); println!("\n=== Cursor 采集结果 ==="); diff --git a/apps/desktop/src-tauri/src/collector/mod.rs b/apps/desktop/src-tauri/src/collector/mod.rs index 9f422e5..5e96cdb 100644 --- a/apps/desktop/src-tauri/src/collector/mod.rs +++ b/apps/desktop/src-tauri/src/collector/mod.rs @@ -9,9 +9,10 @@ //! scheduler.rs 采集调度器 + 去重写入 + 同步日志 //! ``` -pub mod normalizer; +pub mod agent_jsonl; pub mod claude_code; pub mod codebuddy; +pub mod codex; pub mod cursor; +pub mod normalizer; pub mod scheduler; - diff --git a/apps/desktop/src-tauri/src/collector/normalizer.rs b/apps/desktop/src-tauri/src/collector/normalizer.rs index 150b676..287756f 100644 --- a/apps/desktop/src-tauri/src/collector/normalizer.rs +++ b/apps/desktop/src-tauri/src/collector/normalizer.rs @@ -4,6 +4,8 @@ //! 数据源文件 → Source Parser → NormalizedSession → Dedup → SQLite //! ``` +use std::path::Path; + /// 统一的会话格式,各数据源解析器产出此结构后交给调度器去重写入。 #[derive(Debug, Clone)] pub struct NormalizedSession { @@ -21,18 +23,36 @@ pub struct NormalizedSession { pub messages: Vec, /// 原始数据文件路径(如 JSONL 文件位置) pub raw_path: String, + /// 原文件指纹;下次扫描先比较这两个值,命中时不再解析。 + pub raw_mtime_ms: Option, + pub raw_size_bytes: Option, /// 会话创建时间 (RFC 3339) pub created_at: String, /// 会话最后更新时间 (RFC 3339) pub updated_at: String, } +#[derive(Debug, Default)] +pub struct CollectionBatch { + pub sessions: Vec, + pub failures: Vec, + pub found: u32, + pub skipped: u32, + pub skipped_paths: Vec, +} + +#[derive(Debug)] +pub struct CollectionFailure { + pub raw_path: String, + pub error: String, +} + /// 统一的单条消息格式 #[derive(Debug, Clone)] pub struct NormalizedMessage { /// 角色: "user" | "assistant" pub role: String, - /// 消息文本内容(已提取纯文本,tool_use 等保留简要摘要) + /// 消息文本内容(仅保留用户与助手可见正文) pub content: String, /// 消息时间戳 (RFC 3339) pub timestamp: Option, @@ -41,3 +61,101 @@ pub struct NormalizedMessage { /// 该消息消耗的输出 token 数 pub tokens_out: u32, } + +/// 保留用户实际触发的 slash command,丢弃 CLI 伪装成 user 消息的运行时注入。 +pub fn normalize_user_content(content: &str) -> Option { + let content = content.trim_start(); + if content.starts_with("") || content.starts_with("") { + let command = tag_value(content, "command-name") + .or_else(|| tag_value(content, "command-message")) + .unwrap_or_default(); + let args = tag_value(content, "command-args").unwrap_or_default(); + let visible = format!("{command} {args}").trim().to_string(); + return (!visible.is_empty()).then_some(visible); + } + let internal = [ + "# AGENTS.md instructions", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "The following is the Codex agent history", + "Base directory for this skill:", + ] + .iter() + .any(|prefix| content.starts_with(prefix)); + (!internal).then(|| content.trim().to_string()) +} + +fn tag_value<'a>(content: &'a str, tag: &str) -> Option<&'a str> { + let open = format!("<{tag}>"); + let close = format!(""); + let (_, rest) = content.split_once(&open)?; + rest.split_once(&close).map(|(value, _)| value.trim()) +} + +pub fn file_fingerprint(path: &Path) -> Option<(i64, i64)> { + let metadata = path.metadata().ok()?; + let modified = metadata + .modified() + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok()?; + Some(( + modified.as_millis().min(i64::MAX as u128) as i64, + metadata.len().min(i64::MAX as u64) as i64, + )) +} + +#[cfg(test)] +mod tests { + use super::normalize_user_content; + + #[test] + fn identifies_runtime_injections_without_hiding_real_requests() { + assert!(normalize_user_content("secret").is_none()); + assert!(normalize_user_content("# AGENTS.md instructions\nrules").is_none()); + assert!(normalize_user_content("generated").is_none()); + assert!(normalize_user_content("orphan").is_none()); + assert!(normalize_user_content( + "The following is the Codex agent history whose request action you are assessing" + ) + .is_none()); + assert!( + normalize_user_content("done").is_none() + ); + assert!(normalize_user_content("result").is_none()); + assert!(normalize_user_content( + "state" + ) + .is_none()); + assert!( + normalize_user_content("Base directory for this skill: /tmp/skills/demo").is_none() + ); + assert_eq!( + normalize_user_content("/understand\nignored duplicate\n--language zh").as_deref(), + Some("/understand --language zh") + ); + assert_eq!( + normalize_user_content("请检查 AGENTS.md instructions 是否合理").as_deref(), + Some("请检查 AGENTS.md instructions 是否合理") + ); + } +} diff --git a/apps/desktop/src-tauri/src/collector/scheduler.rs b/apps/desktop/src-tauri/src/collector/scheduler.rs index a412a35..81106c6 100644 --- a/apps/desktop/src-tauri/src/collector/scheduler.rs +++ b/apps/desktop/src-tauri/src/collector/scheduler.rs @@ -14,17 +14,18 @@ //! SyncResult { found, new, updated, skipped } //! ``` +use rusqlite::{params, OptionalExtension, Transaction}; use serde::{Deserialize, Serialize}; -use uuid::Uuid; use crate::config::AppConfig; -use crate::storage::models::NewMessage; use crate::storage::Database; +use super::agent_jsonl::AgentJsonlCollector; use super::claude_code::ClaudeCodeCollector; use super::codebuddy::CodeBuddyCollector; +use super::codex::CodexCollector; use super::cursor::CursorCollector; -use super::normalizer::NormalizedSession; +use super::normalizer::{CollectionBatch, NormalizedSession}; /// 同步结果统计 #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -38,6 +39,7 @@ pub struct SyncResult { pub updated: u32, /// 无变化跳过的会话数 pub skipped: u32, + pub failed: u32, } /// 采集调度器,持有配置和数据库引用 @@ -51,70 +53,90 @@ impl<'a> CollectorScheduler<'a> { Self { config, db } } - /// 执行全量采集:遍历所有启用的数据源,采集 → 去重 → 写入 - pub fn collect_all(&self) -> SyncResult { - let mut total_result = SyncResult::default(); - - let enabled_ids: Vec<&str> = self + pub fn collect_source_with_progress( + &self, + source_id: &str, + mut on_file: impl FnMut(&str, &str, Option<&str>), + ) -> SyncResult { + let Some(source) = 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(); - - let sessions: Vec = match source.id.as_str() { - "claude-code" => { - let collector = ClaudeCodeCollector::new(scan_dirs); - collector.collect() - } - "cursor" => { - let collector = CursorCollector::new(scan_dirs); - collector.collect() - } - "codebuddy-cli" => { - let collector = CodeBuddyCollector::new(scan_dirs); - collector.collect() - } - other => { - log::warn!("未知数据源类型: {}", other); - continue; - } - }; - - // 去重写入,并累计 sync_log - let source_result = self.dedup_and_write(&source.id, &sessions); - log::info!( - "数据源 {} 采集完成: 发现={}, 新增={}, 更新={}, 跳过={}", - source.name, source_result.found, source_result.new, - source_result.updated, source_result.skipped - ); - - // 记录 sync_log - if let Err(e) = self.write_sync_log(&source.id, &source_result) { - log::error!("写入同步日志失败: {}", e); + .into_iter() + .find(|source| source.id == source_id) + else { + return SyncResult::default(); + }; + log::info!("开始采集数据源: {} ({})", source.name, source.id); + let scan_dirs = source.resolved_scan_dirs(); + let batch: CollectionBatch = match source.id.as_str() { + "claude-code" => { + let collector = ClaudeCodeCollector::new(scan_dirs); + collector.collect_changed(|path, mtime, size| { + self.db + .source_file_unchanged(&source.id, path, mtime, size) + .unwrap_or(false) + }) + } + "cursor" => { + let collector = CursorCollector::new(scan_dirs); + collector.collect_changed(|path, mtime, size| { + self.db + .source_file_unchanged(&source.id, path, mtime, size) + .unwrap_or(false) + }) + } + "codebuddy" => { + let collector = CodeBuddyCollector::new(scan_dirs); + collector.collect_changed(|path, mtime, size| { + self.db + .source_file_unchanged(&source.id, path, mtime, size) + .unwrap_or(false) + }) + } + "codex" => { + let collector = CodexCollector::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" }, + scan_dirs, + ); + collector.collect_changed(|path, mtime, size| { + self.db + .source_file_unchanged(&source.id, path, mtime, size) + .unwrap_or(false) + }) + } + other => { + log::warn!("未知数据源类型: {}", other); + return SyncResult::default(); + } + }; - total_result.found += source_result.found; - total_result.new += source_result.new; - total_result.updated += source_result.updated; - total_result.skipped += source_result.skipped; + for path in &batch.skipped_paths { + on_file(path, "skipped", None); } - + for failure in &batch.failures { + on_file(&failure.raw_path, "failed", Some(&failure.error)); + } + let mut result = self.dedup_and_write(&batch.sessions, &mut on_file); + result.found = batch.found; + result.skipped += batch.skipped; + result.failed += batch.failures.len() as u32; log::info!( - "全量采集完成: 发现={}, 新增={}, 更新={}, 跳过={}", - total_result.found, total_result.new, - total_result.updated, total_result.skipped + "数据源 {} 采集完成: 发现={}, 新增={}, 更新={}, 跳过={}", + source.name, + result.found, + result.new, + result.updated, + result.skipped ); - - total_result + result } /// 对采集到的会话逐条去重写入。 @@ -123,23 +145,35 @@ impl<'a> CollectorScheduler<'a> { /// - session_id + source_host 不存在 → INSERT(新增) /// - 存在 + message_count 增加 → 标记 has_updates(更新) /// - 存在 + message_count 不变 → SKIP(跳过) - fn dedup_and_write(&self, _source_id: &str, sessions: &[NormalizedSession]) -> SyncResult { - let mut result = SyncResult { - found: sessions.len() as u32, - ..Default::default() - }; + fn dedup_and_write( + &self, + sessions: &[NormalizedSession], + on_file: &mut impl FnMut(&str, &str, Option<&str>), + ) -> SyncResult { + let mut result = SyncResult::default(); for session in sessions { match self.write_one_session(session) { - Ok(WriteAction::New) => result.new += 1, - Ok(WriteAction::Updated) => result.updated += 1, - Ok(WriteAction::Skipped) => result.skipped += 1, + Ok(WriteAction::New) => { + result.new += 1; + on_file(&session.raw_path, "imported", None); + } + Ok(WriteAction::Updated) => { + result.updated += 1; + on_file(&session.raw_path, "updated", None); + } + Ok(WriteAction::Skipped) => { + result.skipped += 1; + on_file(&session.raw_path, "skipped", None); + } Err(e) => { log::error!( "写入会话失败: session_id={}, error={}", - session.session_id, e + session.session_id, + e ); - result.skipped += 1; + result.failed += 1; + on_file(&session.raw_path, "failed", Some(&e.to_string())); } } } @@ -154,96 +188,93 @@ impl<'a> CollectorScheduler<'a> { ) -> Result> { let source_host = "local"; let message_count = session.messages.len() as i32; + let content_hash = session_content_hash(session); + let mut conn = self.db.conn(); + let tx = conn.transaction()?; + let existing: Option<(String, Option, i64)> = tx.query_row( + "SELECT id, content_hash, message_count FROM sessions WHERE source_id=?1 AND external_session_id=?2 AND source_host=?3", + params![session.source_id, session.session_id, source_host], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ).optional()?; - // Step 1: 检查是否已存在 - let existing_id = self.db.check_duplicate(&session.session_id, source_host)?; - - // 将 NormalizedMessage 转换为存储层的 NewMessage - let to_new_messages = |session: &NormalizedSession| -> Vec { - session.messages.iter().map(|m| NewMessage { - role: m.role.clone(), - content: m.content.clone(), - timestamp: m.timestamp.clone(), - tokens_in: m.tokens_in as i32, - tokens_out: m.tokens_out as i32, - }).collect() - }; - - match existing_id { + let action = match existing { None => { - // 新会话 → 写入 session + messages - let db_id = self.db.insert_session( - &session.source_id, - &session.session_id, - source_host, - session.project_path.as_deref(), - session.project_name.as_deref(), - message_count, - None, // content_hash(v0.1 暂不计算) - &session.raw_path, - &session.created_at, - &session.updated_at, - session.analysis_title.as_deref(), + let id = uuid::Uuid::new_v4().to_string(); + tx.execute( + "INSERT INTO sessions (id,source_id,external_session_id,source_host,project_path,project_name,message_count,content_hash,raw_path,raw_mtime_ms,raw_size_bytes,created_at,updated_at,analysis_title) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)", + params![id, session.source_id, session.session_id, source_host, session.project_path, + session.project_name, message_count, content_hash, session.raw_path, + session.raw_mtime_ms, session.raw_size_bytes, session.created_at, + session.updated_at, session.analysis_title], )?; - - self.db.insert_messages(&db_id, &to_new_messages(session))?; - Ok(WriteAction::New) + insert_messages_tx(&tx, &id, session)?; + WriteAction::New } - Some(existing_db_id) => { - // 已存在 → 检查 message_count 是否变化 - let existing_session = self.db.get_session(&existing_db_id)?; - - if message_count as i64 > existing_session.message_count { - // 有新消息 → 删除旧消息并重新全量导入,保证数据一致性 - 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_resync_metadata( - &existing_db_id, - message_count, - session.project_path.as_deref(), - session.project_name.as_deref(), - session.analysis_title.as_deref(), + Some((id, old_hash, old_count)) => { + if old_hash.as_deref() == Some(&content_hash) { + tx.execute( + "UPDATE sessions SET raw_mtime_ms=?1,raw_size_bytes=?2,updated_at=?3 WHERE id=?4", + params![session.raw_mtime_ms, session.raw_size_bytes, session.updated_at, id], + )?; + WriteAction::Skipped + } else { + tx.execute("DELETE FROM messages WHERE session_id=?1", params![id])?; + insert_messages_tx(&tx, &id, session)?; + tx.execute( + "UPDATE sessions SET message_count=?1,content_hash=?2,raw_path=?3,raw_mtime_ms=?4,raw_size_bytes=?5, + project_path=?6,project_name=?7,analysis_title=COALESCE(?8,analysis_title),updated_at=?9,has_updates=1 WHERE id=?10", + params![message_count, content_hash, session.raw_path, session.raw_mtime_ms, + session.raw_size_bytes, session.project_path, session.project_name, + session.analysis_title, session.updated_at, id], )?; log::info!( "会话消息已更新: session_id={}, {} → {} 条消息", session.session_id, - existing_session.message_count, + old_count, message_count ); - Ok(WriteAction::Updated) - } else { - Ok(WriteAction::Skipped) + WriteAction::Updated } } - } + }; + tx.commit()?; + Ok(action) } +} - /// 写入同步日志 - fn write_sync_log( - &self, - source_id: &str, - result: &SyncResult, - ) -> Result<(), Box> { - let id = Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); - let conn = self.db.conn(); - conn.execute( - "INSERT INTO sync_log ( - id, source_id, started_at, finished_at, - sessions_found, sessions_new, sessions_updated, status - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - rusqlite::params![ - id, source_id, now, now, - result.found as i64, result.new as i64, result.updated as i64, - "completed" - ], +fn insert_messages_tx( + tx: &Transaction<'_>, + session_id: &str, + session: &NormalizedSession, +) -> rusqlite::Result<()> { + for (seq, message) in session.messages.iter().enumerate() { + tx.execute( + "INSERT INTO messages(id,session_id,role,content,timestamp,tokens_in,tokens_out,seq_order) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", + params![uuid::Uuid::new_v4().to_string(), session_id, message.role, message.content, + message.timestamp, message.tokens_in, message.tokens_out, seq as i64], )?; - Ok(()) } + Ok(()) +} + +fn session_content_hash(session: &NormalizedSession) -> String { + let mut context = md5::Context::new(); + for message in &session.messages { + context.consume(message.role.as_bytes()); + context.consume([0]); + context.consume(message.content.as_bytes()); + context.consume([0]); + if let Some(timestamp) = &message.timestamp { + context.consume(timestamp.as_bytes()); + } + context.consume([0xff]); + } + format!("{:x}", context.compute()) } /// 去重写入的动作结果 +#[derive(Debug, PartialEq, Eq)] enum WriteAction { /// 新会话,已写入 New, @@ -252,3 +283,72 @@ enum WriteAction { /// 已存在且无变化,跳过 Skipped, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::collector::normalizer::NormalizedMessage; + + fn session(contents: &[&str], size: i64) -> NormalizedSession { + NormalizedSession { + source_id: "codex".into(), + session_id: "same-id".into(), + project_path: Some("/tmp/project".into()), + project_name: Some("project".into()), + analysis_title: None, + messages: contents + .iter() + .enumerate() + .map(|(index, content)| NormalizedMessage { + role: if index % 2 == 0 { + "user".into() + } else { + "assistant".into() + }, + content: (*content).into(), + timestamp: None, + tokens_in: 0, + tokens_out: 0, + }) + .collect(), + raw_path: "/tmp/codex.jsonl".into(), + raw_mtime_ms: Some(size), + raw_size_bytes: Some(size), + created_at: "2026-08-30T00:00:00Z".into(), + updated_at: "2026-08-30T00:00:01Z".into(), + } + } + + #[test] + fn detects_same_count_edits_and_message_removal() { + let dir = tempfile::tempdir().unwrap(); + let db = Database::open(&dir.path().join("test.db")).unwrap(); + let config = AppConfig::default(); + let scheduler = CollectorScheduler::new(&config, &db); + + assert_eq!( + scheduler + .write_one_session(&session(&["a", "b"], 1)) + .unwrap(), + WriteAction::New + ); + assert_eq!( + scheduler + .write_one_session(&session(&["a", "changed"], 2)) + .unwrap(), + WriteAction::Updated + ); + let id = db + .check_duplicate("codex", "same-id", "local") + .unwrap() + .unwrap(); + assert_eq!(db.get_session_messages(&id).unwrap()[1].content, "changed"); + + assert_eq!( + scheduler.write_one_session(&session(&["a"], 3)).unwrap(), + WriteAction::Updated + ); + assert_eq!(db.get_session(&id).unwrap().message_count, 1); + assert_eq!(db.get_session_messages(&id).unwrap().len(), 1); + } +} diff --git a/apps/desktop/src-tauri/src/commands/cards.rs b/apps/desktop/src-tauri/src/commands/cards.rs index 1443630..a8d2689 100644 --- a/apps/desktop/src-tauri/src/commands/cards.rs +++ b/apps/desktop/src-tauri/src/commands/cards.rs @@ -5,7 +5,7 @@ use tauri::State; -use crate::storage::models::{Card, CardFilters, CardSummary, PaginatedResult}; +use crate::storage::models::{Card, CardFilters, CardSummary, CardUpdate, PaginatedResult, Tag}; use crate::AppState; /// FTS5 全文搜索知识卡片。 @@ -19,15 +19,18 @@ pub async fn search_cards( ) -> Result, String> { let db = state.db.clone(); let filters = CardFilters { + publication_status: None, tags, card_type, value: None, search: None, tech_stack, }; - tokio::task::spawn_blocking(move || db.search_cards(&query, &filters).map_err(|e| e.to_string())) - .await - .map_err(|e| format!("search_cards join 失败: {}", e))? + tokio::task::spawn_blocking(move || { + db.search_cards(&query, &filters).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("search_cards join 失败: {}", e))? } /// 分页查询知识卡片列表。 @@ -38,11 +41,13 @@ pub async fn list_cards( card_type: Option, value: Option, tech_stack: Option>, + publication_status: Option, page: Option, page_size: Option, ) -> Result, String> { let db = state.db.clone(); let filters = CardFilters { + publication_status, tags, card_type, value, @@ -67,3 +72,86 @@ pub async fn get_card(state: State<'_, AppState>, id: String) -> Result, + id: String, + title: String, + card_type: String, + summary: String, + note: String, + tags: Vec, + technologies: Vec, +) -> Result { + if title.trim().is_empty() || summary.trim().is_empty() || note.trim().is_empty() { + return Err("标题、摘要和正文不能为空".into()); + } + if !matches!( + card_type.as_str(), + "decision" | "troubleshooting" | "implementation" | "explanation" | "snippet" + ) { + return Err("知识类型无效".into()); + } + if tags.len() > 3 || technologies.len() > 5 { + return Err("主题标签最多 3 个,技术项最多 5 个".into()); + } + let db = state.db.clone(); + tokio::task::spawn_blocking(move || { + db.update_card( + &id, + &CardUpdate { + title: title.trim(), + card_type: &card_type, + summary: summary.trim(), + note: note.trim(), + tags: &tags, + technologies: &technologies, + }, + ) + .and_then(|_| db.get_card(&id)) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("update_card join 失败: {e}"))? +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn publish_card( + state: State<'_, AppState>, + id: String, + replace_existing: bool, +) -> Result { + let db = state.db.clone(); + tokio::task::spawn_blocking(move || { + db.publish_card(&id, replace_existing) + .and_then(|_| db.get_card(&id)) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("publish_card join 失败: {e}"))? +} + +#[tauri::command] +pub async fn list_tag_records(state: State<'_, AppState>) -> Result, String> { + let db = state.db.clone(); + tokio::task::spawn_blocking(move || db.list_tag_records().map_err(|e| e.to_string())) + .await + .map_err(|e| format!("list_tag_records join 失败: {e}"))? +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn merge_tags( + state: State<'_, AppState>, + kind: String, + sources: Vec, + target: String, +) -> Result<(), String> { + let db = state.db.clone(); + tokio::task::spawn_blocking(move || { + db.merge_tags(&kind, &sources, &target) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("merge_tags join 失败: {e}"))? +} diff --git a/apps/desktop/src-tauri/src/commands/cli_probe.rs b/apps/desktop/src-tauri/src/commands/cli_probe.rs deleted file mode 100644 index b5cef13..0000000 --- a/apps/desktop/src-tauri/src/commands/cli_probe.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! 在桌面应用环境中探测本机已安装的 AI CLI 可执行文件路径。 -//! -//! ```text -//! GUI 启动时进程 PATH 往往不含 nvm / Homebrew,与终端不一致。 -//! 此处与 sidecar 的 cli-provider 思路一致:在 Unix 上通过「登录 shell」执行 -//! `command -v `,拿到与终端一致的绝对路径,便于直接写入配置。 -//! ``` - -use std::process::{Command, Stdio}; - -/// 前端展示用:每个候选命令及其解析结果(绝对路径或未发现) -#[derive(Debug, Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CliProbeResultDto { - /// 探测用的命令名(如 claude-internal) - pub name: String, - /// `command -v` 成功时的绝对路径;未发现则为 null - pub resolved_path: Option, -} - -/// 与 UI 快捷标签对齐:先 *-internal 再正式发行名,固定顺序便于行为一致 -const PROBE_NAMES: &[&str] = &[ - "claude-internal", - "gemini-internal", - "codex-internal", - "claude", - "gemini", - "codex", -]; - -/// 在「交互 + 登录」shell 下解析单个命令名 → 绝对路径(失败返回 None) -fn resolve_via_login_shell(name: &str) -> Option { - #[cfg(unix)] - { - // 与 sidecar cli-provider 一致:-il 才会读 .zshrc/.bashrc(nvm 常写在此处,仅 -l 不够) - let (shell, shell_arg) = if cfg!(target_os = "macos") { - ("/bin/zsh", "-il") - } else { - ("/bin/bash", "-il") - }; - let script = format!("command -v {}", name); - let output = Command::new(shell) - .arg(shell_arg) - .arg("-c") - .arg(&script) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if s.is_empty() { - None - } else { - Some(s) - } - } - #[cfg(windows)] - { - // `where` 返回 PATH 中第一个匹配的可执行文件路径 - let output = Command::new("cmd") - .args(["/C", "where", name]) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .ok()?; - if !output.status.success() { - return None; - } - output - .stdout - .split(|&b| b == b'\r' || b == b'\n') - .next() - .and_then(|line| { - let s = String::from_utf8_lossy(line).trim().to_string(); - if s.is_empty() { - None - } else { - Some(s) - } - }) - } -} - -/// 依次探测常见 CLI,返回每张「名字 → 路径」表(路径可为空) -fn probe_all() -> Vec { - let v: Vec = PROBE_NAMES - .iter() - .map(|&name| CliProbeResultDto { - name: name.to_string(), - resolved_path: resolve_via_login_shell(name), - }) - .collect(); - let found = v.iter().filter(|x| x.resolved_path.is_some()).count(); - log::info!( - "CLI 探测完成:{}/{} 个候选在登录 shell 中解析到路径", - found, - v.len() - ); - v -} - -/// 供设置页「自动检测」:在阻塞线程中执行 shell,避免卡住 async runtime -#[tauri::command(rename_all = "camelCase")] -pub async fn probe_cli_tools() -> Result, String> { - tokio::task::spawn_blocking(probe_all) - .await - .map_err(|e| format!("probe_cli_tools join 失败: {}", e)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn probe_all_returns_one_row_per_candidate() { - let v = probe_all(); - assert_eq!(v.len(), PROBE_NAMES.len()); - } -} diff --git a/apps/desktop/src-tauri/src/commands/config.rs b/apps/desktop/src-tauri/src/commands/config.rs index 32a3c44..a1c5393 100644 --- a/apps/desktop/src-tauri/src/commands/config.rs +++ b/apps/desktop/src-tauri/src/commands/config.rs @@ -1,20 +1,14 @@ -//! 配置读取与保存命令 —— 支持从前端设置页热更新 config.toml。 -//! -//! ```text -//! get_config → 返回当前 AppConfig(JSON,snake_case) -//! save_config → 写入磁盘 + 更新内存中的 Arc> -//! ``` +//! 配置读取、保存与供应商连通性检查。 + +use std::time::Duration; use tauri::State; -use crate::config::{ApiConfig, AppConfig, CliConfig, CollectorConfig, DistillerConfig, SourceConfig, SyncConfig}; +use crate::config::{ + AppConfig, CollectorConfig, DistillerConfig, ProviderProfile, SourceConfig, SyncConfig, +}; use crate::AppState; -// ─── IPC DTO ──────────────────────────────────────────────────────────────── -// -// 与前端 TypeScript 接口对应,字段使用 camelCase(通过 rename_all)。 -// 独立于 AppConfig(AppConfig 使用 snake_case 以兼容 TOML 格式)。 - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct AppConfigDto { @@ -26,34 +20,22 @@ pub struct AppConfigDto { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct DistillerConfigDto { - /// 提炼模式: "api" | "cli" - pub mode: String, - pub api: Option, - pub cli: Option, + pub active_profile_id: String, + pub profiles: Vec, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] -pub struct ApiConfigDto { - /// 提供商标识(如 "openai"、"deepseek"、"openai-compatible") +pub struct ProviderProfileDto { + pub id: String, + pub name: String, + pub kind: String, pub provider: String, - /// API Base URL(可选,为空时使用默认值) - pub base_url: Option, - /// API 密钥 + pub base_url: String, pub api_key: String, - /// 模型名称(如 "gpt-4o-mini"、"deepseek-chat") pub model: String, - /// 请求超时(秒) - pub timeout_secs: u64, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CliConfigDto { - /// CLI 可执行文件名(如 "claude")或本机绝对路径(如 nvm 下的 shim) pub command: String, - /// 附加参数 - pub extra_args: Vec, + pub timeout_secs: u64, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -74,28 +56,20 @@ pub struct SourceConfigDto { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct SyncConfigDto { - pub mode: String, - pub interval_secs: u64, + pub scan_on_startup: bool, } -// ─── DTO 转换 ──────────────────────────────────────────────────────────────── - impl From<&AppConfig> for AppConfigDto { fn from(c: &AppConfig) -> Self { - AppConfigDto { + Self { distiller: DistillerConfigDto { - mode: c.distiller.mode.clone(), - api: c.distiller.api.as_ref().map(|a| ApiConfigDto { - provider: a.provider.clone(), - base_url: a.base_url.clone(), - api_key: a.api_key.clone(), - model: a.model.clone(), - timeout_secs: a.timeout_secs, - }), - cli: c.distiller.cli.as_ref().map(|cl| CliConfigDto { - command: cl.command.clone(), - extra_args: cl.extra_args.clone(), - }), + active_profile_id: c.distiller.active_profile_id.clone(), + profiles: c + .distiller + .profiles + .iter() + .map(ProviderProfileDto::from) + .collect(), }, collector: CollectorConfigDto { sources: c @@ -111,29 +85,56 @@ impl From<&AppConfig> for AppConfigDto { .collect(), }, sync: SyncConfigDto { - mode: c.sync.mode.clone(), - interval_secs: c.sync.interval_secs, + scan_on_startup: c.sync.scan_on_startup, }, } } } +impl From<&ProviderProfile> for ProviderProfileDto { + fn from(p: &ProviderProfile) -> Self { + Self { + id: p.id.clone(), + name: p.name.clone(), + kind: p.kind.clone(), + provider: p.provider.clone(), + base_url: p.base_url.clone(), + api_key: p.api_key.clone(), + model: p.model.clone(), + command: p.command.clone(), + timeout_secs: p.timeout_secs, + } + } +} + +impl From for ProviderProfile { + fn from(p: ProviderProfileDto) -> Self { + Self { + id: p.id, + name: p.name, + kind: p.kind, + provider: p.provider, + base_url: p.base_url, + api_key: p.api_key, + model: p.model, + command: p.command, + timeout_secs: p.timeout_secs, + } + } +} + impl From for AppConfig { fn from(dto: AppConfigDto) -> Self { - AppConfig { + Self { distiller: DistillerConfig { - mode: dto.distiller.mode, - api: dto.distiller.api.map(|a| ApiConfig { - provider: a.provider, - base_url: a.base_url, - api_key: a.api_key, - model: a.model, - timeout_secs: a.timeout_secs, - }), - cli: dto.distiller.cli.map(|cl| CliConfig { - command: cl.command, - extra_args: cl.extra_args, - }), + active_profile_id: dto.distiller.active_profile_id, + profiles: dto + .distiller + .profiles + .into_iter() + .map(ProviderProfile::from) + .collect(), + legacy_api: None, }, collector: CollectorConfig { sources: dto @@ -149,68 +150,108 @@ impl From for AppConfig { .collect(), }, sync: SyncConfig { - mode: dto.sync.mode, - interval_secs: dto.sync.interval_secs, + scan_on_startup: dto.sync.scan_on_startup, }, } } } -// ─── Tauri 命令 ────────────────────────────────────────────────────────────── +fn profile_params(profile: ProviderProfileDto) -> Result { + let profile = ProviderProfile::from(profile); + if profile.kind == "cli" { + if profile.command.trim().is_empty() { + return Err("请先填写 CLI 命令".to_string()); + } + return Ok(serde_json::json!({ + "kind": "cli", "provider": profile.provider, "command": profile.command, + "model": profile.model, "timeout_secs": profile.timeout_secs, + })); + } + if profile.api_key.trim().is_empty() || profile.base_url.trim().is_empty() { + return Err("请先填写 API Key 和 Base URL".to_string()); + } + Ok(serde_json::json!({ + "kind": "api", "provider": profile.provider, "base_url": profile.base_url, + "api_key": profile.api_key, "model": profile.model, "timeout_secs": profile.timeout_secs, + })) +} -/// 读取当前配置并返回给前端。 #[tauri::command] pub async fn get_config(state: State<'_, AppState>) -> Result { - let config = state.config_snapshot(); - Ok(AppConfigDto::from(&config)) + Ok(AppConfigDto::from(&state.config_snapshot())) } -/// 接收前端配置、写入磁盘并更新内存中的 AppConfig(热更新,无需重启)。 #[tauri::command] -pub async fn save_config( +pub async fn get_database_backup_path( state: State<'_, AppState>, - config: AppConfigDto, -) -> Result<(), String> { - let mut new_config = AppConfig::from(config); - // 与启动加载一致:保存前再跑一遍迁移,避免前端传入旧版 ~/.codebuddy 路径 - let _ = new_config.migrate_codebuddy_scan_dirs(); - - // 便于排查:确认落盘的 CLI command 是否为绝对路径(与 sidecar 报错中的引号内容应一致) - if new_config.distiller.mode == "cli" { - if let Some(ref cli) = new_config.distiller.cli { - log::info!("保存提炼配置 CLI: command={}", cli.command); - } - } - - // 先写磁盘,成功后再更新内存(保证两者一致) - let path = AppConfig::default_path(); - new_config.save(&path).map_err(|e| format!("配置保存失败: {}", e))?; +) -> Result, String> { + Ok(state + .db + .last_backup_path() + .map(|path| path.display().to_string())) +} - // 写内存(写锁) - let mut guard = state +#[tauri::command] +pub async fn save_config(state: State<'_, AppState>, config: AppConfigDto) -> Result<(), String> { + let new_config = AppConfig::from(config); + new_config.validate().map_err(|e| e.to_string())?; + new_config + .save(&AppConfig::default_path()) + .map_err(|e| format!("配置保存失败: {e}"))?; + *state .config .write() - .map_err(|_| "配置锁异常,请重启应用".to_string())?; - *guard = new_config; - drop(guard); - - log::info!("配置已热更新"); + .map_err(|_| "配置锁异常,请重启应用".to_string())? = new_config; Ok(()) } +#[tauri::command] +pub async fn list_provider_models( + state: State<'_, AppState>, + profile: ProviderProfileDto, +) -> Result, String> { + let sidecar = state.sidecar.as_ref().ok_or("Sidecar 未就绪")?.clone(); + let timeout = Duration::from_secs(profile.timeout_secs.saturating_add(5)); + let params = profile_params(profile)?; + tokio::task::spawn_blocking(move || sidecar.call_with_timeout("list_models", params, timeout)) + .await + .map_err(|e| format!("模型列表任务失败:{e}"))? + .map_err(|e| format!("模型列表加载失败:{e}")) +} + +#[tauri::command] +pub async fn test_provider( + state: State<'_, AppState>, + profile: ProviderProfileDto, +) -> Result { + let sidecar = state.sidecar.as_ref().ok_or("Sidecar 未就绪")?.clone(); + let timeout = Duration::from_secs(profile.timeout_secs.saturating_add(5)); + let params = profile_params(profile)?; + tokio::task::spawn_blocking(move || sidecar.call_with_timeout("test_provider", params, timeout)) + .await + .map_err(|e| format!("连接测试任务失败:{e}"))? + .map_err(|e| format!("连接测试失败:{e}")) +} + #[cfg(test)] mod tests { use super::*; - /// 确认前端 camelCase JSON 中长路径 command 不会被截断(若失败则 IPC 反序列化有问题) #[test] - fn cli_config_dto_deserializes_abs_path_from_frontend_json() { - let json = r#"{"command":"/Users/foo/.nvm/versions/node/v20.19.4/bin/claude-internal","extraArgs":[]}"#; - let dto: CliConfigDto = serde_json::from_str(json).unwrap(); - assert_eq!( - dto.command, - "/Users/foo/.nvm/versions/node/v20.19.4/bin/claude-internal" - ); - assert!(dto.extra_args.is_empty()); + fn profile_dto_uses_camel_case() { + let json = serde_json::to_value(ProviderProfileDto { + id: "sf".into(), + name: "硅基流动".into(), + kind: "api".into(), + provider: "siliconflow".into(), + base_url: "https://api.siliconflow.cn/v1".into(), + api_key: "secret".into(), + model: "deepseek-ai/DeepSeek-V3".into(), + command: String::new(), + timeout_secs: 120, + }) + .unwrap(); + assert_eq!(json["baseUrl"], "https://api.siliconflow.cn/v1"); + assert!(json.get("base_url").is_none()); } } diff --git a/apps/desktop/src-tauri/src/commands/jobs.rs b/apps/desktop/src-tauri/src/commands/jobs.rs new file mode 100644 index 0000000..debd9cb --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/jobs.rs @@ -0,0 +1,411 @@ +use std::sync::Arc; +use std::time::Instant; + +use tauri::{AppHandle, Emitter, State}; + +use crate::collector::scheduler::CollectorScheduler; +use crate::config::AppConfig; +use crate::sidecar::SidecarManager; +use crate::storage::models::{Job, NewJobItem}; +use crate::storage::Database; +use crate::AppState; + +fn emit_job(app: &AppHandle, db: &Database, job_id: &str) { + if let Ok(job) = db.get_job_snapshot(job_id) { + let _ = app.emit("job://updated", job); + } +} + +fn spawn_job( + app: AppHandle, + db: Arc, + config: AppConfig, + sidecar: Option>, + job_lock: Arc>, + job_id: String, +) { + tauri::async_runtime::spawn(async move { + let _guard = job_lock.lock().await; + let Ok(job) = db.get_job(&job_id) else { return }; + if job.cancel_requested { + let _ = db.cancel_queued_items(&job_id); + let _ = db.finish_job(&job_id, "cancelled", None); + emit_job(&app, &db, &job_id); + return; + } + let phase = if job.kind == "sync" { + "scanning" + } else { + "judging" + }; + if db.mark_job_running(&job_id, phase).is_err() { + return; + } + emit_job(&app, &db, &job_id); + + // 一个分析任务只初始化一次供应商;任务内所有条目共享同一 Sidecar 与配置快照。 + if job.kind == "analysis" { + let init_result = sidecar + .as_ref() + .ok_or_else(|| "Sidecar 未就绪".to_string()) + .and_then(|sidecar| { + let params = config.sidecar_init_params(job.provider_profile_id.as_deref())?; + sidecar + .call_with_timeout::( + "init", + params, + std::time::Duration::from_secs(30), + ) + .map_err(|error| format!("Sidecar init 失败:{error}")) + }); + if let Err(error) = init_result { + for item in &job.items { + let _ = db.finish_item(&job_id, &item.id, "failed", "failed", 0, Some(&error)); + } + let _ = db.finish_job(&job_id, "failed", Some(&error)); + emit_job(&app, &db, &job_id); + return; + } + } + + let mut failed = false; + for item in job.items { + if db.job_cancel_requested(&job_id).unwrap_or(false) { + break; + } + let item_phase = if job.kind == "sync" { + "scanning" + } else { + "judging" + }; + if db.mark_item_running(&item.id, item_phase).is_err() { + failed = true; + continue; + } + emit_job(&app, &db, &job_id); + let started = Instant::now(); + + let result: Result<(), String> = if job.kind == "sync" { + let db = db.clone(); + let config = config.clone(); + let source_id = item.source_id.clone().unwrap_or_default(); + let progress_db = db.clone(); + let progress_app = app.clone(); + let progress_job_id = job_id.clone(); + let progress_source_id = source_id.clone(); + tokio::task::spawn_blocking(move || { + let result = CollectorScheduler::new(&config, &db) + .collect_source_with_progress(&source_id, |raw_path, phase, error| { + if let Ok(id) = progress_db.append_job_item( + &progress_job_id, + &NewJobItem { + session_id: None, + source_id: Some(&progress_source_id), + raw_path: Some(raw_path), + }, + ) { + let status = if error.is_some() { + "failed" + } else { + "succeeded" + }; + let _ = progress_db.finish_item( + &progress_job_id, + &id, + status, + phase, + 0, + error, + ); + emit_job(&progress_app, &progress_db, &progress_job_id); + } + }); + if result.failed > 0 { + Err(format!("{} 个文件同步失败", result.failed)) + } else { + Ok(()) + } + }) + .await + .map_err(|error| error.to_string()) + .and_then(|value| value) + } else { + let db = db.clone(); + let config = config.clone(); + let sidecar = sidecar.clone().ok_or_else(|| "Sidecar 未就绪".to_string()); + let session_id = item + .session_id + .clone() + .ok_or_else(|| "任务条目缺少会话".to_string()); + let profile_id = job.provider_profile_id.clone(); + match (sidecar, session_id) { + (Ok(sidecar), Ok(session_id)) => { + let phase_db = db.clone(); + let phase_app = app.clone(); + let phase_job_id = job_id.clone(); + let phase_item_id = item.id.clone(); + tokio::task::spawn_blocking(move || { + let on_phase = |phase: &str| { + let _ = phase_db.update_job_item_phase( + &phase_job_id, + &phase_item_id, + phase, + ); + emit_job(&phase_app, &phase_db, &phase_job_id); + }; + super::sessions::run_distill_pipeline( + &db, + &config, + &sidecar, + &session_id, + &phase_job_id, + profile_id.as_deref(), + Some(&on_phase), + ) + .map(|_| ()) + }) + .await + .map_err(|error| error.to_string()) + .and_then(|value| value) + } + (Err(error), _) | (_, Err(error)) => Err(error), + } + }; + + let duration = started.elapsed().as_millis().min(i64::MAX as u128) as i64; + let cancelled = db.job_cancel_requested(&job_id).unwrap_or(false); + match (result, cancelled) { + (_, true) => { + let _ = db.finish_item( + &job_id, + &item.id, + "cancelled", + "cancelled", + duration, + Some("用户取消"), + ); + } + (Ok(()), false) => { + let _ = + db.finish_item(&job_id, &item.id, "succeeded", "completed", duration, None); + } + (Err(error), false) => { + failed = true; + let _ = db.finish_item( + &job_id, + &item.id, + "failed", + "failed", + duration, + Some(&error), + ); + } + } + emit_job(&app, &db, &job_id); + } + + if db.job_cancel_requested(&job_id).unwrap_or(false) { + let _ = db.cancel_queued_items(&job_id); + let _ = db.finish_job(&job_id, "cancelled", None); + } else if failed { + let _ = db.finish_job(&job_id, "failed", Some("部分条目执行失败")); + } else { + let _ = db.finish_job(&job_id, "succeeded", None); + } + emit_job(&app, &db, &job_id); + }); +} + +pub(crate) fn start_sync_internal( + app: AppHandle, + db: Arc, + config: AppConfig, + sidecar: Option>, + job_lock: Arc>, + scope: Option<&str>, +) -> Result { + let sources = config + .enabled_sources() + .into_iter() + .filter(|source| scope.map(|wanted| wanted == source.id).unwrap_or(true)) + .collect::>(); + if sources.is_empty() { + return Err("没有匹配的已启用数据源".into()); + } + let items = sources + .iter() + .map(|source| NewJobItem { + session_id: None, + source_id: Some(source.id.as_str()), + raw_path: None, + }) + .collect::>(); + let job = db + .create_job("sync", "queued", None, &items) + .map_err(|error| error.to_string())?; + spawn_job(app, db, config, sidecar, job_lock, job.id.clone()); + Ok(job) +} + +fn start_analysis_internal( + app: AppHandle, + db: Arc, + config: AppConfig, + sidecar: Option>, + job_lock: Arc>, + session_ids: &[String], + provider_profile_id: Option<&str>, +) -> Result { + if sidecar.is_none() { + return Err("Sidecar 未就绪".into()); + } + if session_ids.is_empty() { + return Err("请至少选择一个会话".into()); + } + for id in session_ids { + db.get_session(id).map_err(|error| error.to_string())?; + } + let profile = config.provider_profile(provider_profile_id)?; + let target = if profile.kind == "cli" { + profile.command.as_str() + } else { + profile.base_url.as_str() + }; + let items = session_ids + .iter() + .map(|id| NewJobItem { + session_id: Some(id), + source_id: None, + raw_path: None, + }) + .collect::>(); + let job = db + .create_job( + "analysis", + "queued", + Some((&profile.id, &profile.provider, target, &profile.model)), + &items, + ) + .map_err(|error| error.to_string())?; + spawn_job(app, db, config, sidecar, job_lock, job.id.clone()); + Ok(job) +} + +#[tauri::command] +pub async fn start_sync( + app: AppHandle, + state: State<'_, AppState>, + scope: Option, +) -> Result { + start_sync_internal( + app, + state.db.clone(), + state.config_snapshot(), + state.sidecar.clone(), + state.job_lock.clone(), + scope.as_deref(), + ) +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn start_analysis( + app: AppHandle, + state: State<'_, AppState>, + session_ids: Vec, + provider_profile_id: Option, +) -> Result { + start_analysis_internal( + app, + state.db.clone(), + state.config_snapshot(), + state.sidecar.clone(), + state.job_lock.clone(), + &session_ids, + provider_profile_id.as_deref(), + ) +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn list_jobs(state: State<'_, AppState>, active_only: bool) -> Result, String> { + state + .db + .list_jobs(active_only) + .map_err(|error| error.to_string()) +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn get_job(state: State<'_, AppState>, job_id: String) -> Result { + state.db.get_job(&job_id).map_err(|error| error.to_string()) +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn cancel_job( + app: AppHandle, + state: State<'_, AppState>, + job_id: String, +) -> Result { + state + .db + .request_job_cancel(&job_id) + .map_err(|error| error.to_string())?; + let job = state + .db + .get_job(&job_id) + .map_err(|error| error.to_string())?; + if job.kind == "analysis" && job.items.iter().any(|item| item.status == "running") { + if let Some(sidecar) = state.sidecar.clone() { + tokio::task::spawn_blocking(move || sidecar.stop()) + .await + .map_err(|error| error.to_string())? + .map_err(|error| error.to_string())?; + } + } + emit_job(&app, &state.db, &job_id); + state.db.get_job(&job_id).map_err(|error| error.to_string()) +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn retry_job_item( + app: AppHandle, + state: State<'_, AppState>, + job_id: String, + item_id: String, + provider_profile_id: Option, +) -> Result { + let old = state + .db + .get_job(&job_id) + .map_err(|error| error.to_string())?; + let item = old + .items + .iter() + .find(|item| item.id == item_id) + .ok_or("任务条目不存在")?; + if !matches!(item.status.as_str(), "failed" | "cancelled" | "interrupted") { + return Err("只能重试失败、取消或中断的条目".into()); + } + if old.kind == "sync" { + start_sync_internal( + app, + state.db.clone(), + state.config_snapshot(), + state.sidecar.clone(), + state.job_lock.clone(), + item.source_id.as_deref(), + ) + } else { + let session_id = item.session_id.clone().ok_or("任务条目缺少会话")?; + let profile_id = provider_profile_id + .as_deref() + .or(old.provider_profile_id.as_deref()); + start_analysis_internal( + app, + state.db.clone(), + state.config_snapshot(), + state.sidecar.clone(), + state.job_lock.clone(), + &[session_id], + profile_id, + ) + } +} diff --git a/apps/desktop/src-tauri/src/commands/mcp.rs b/apps/desktop/src-tauri/src/commands/mcp.rs new file mode 100644 index 0000000..a631919 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/mcp.rs @@ -0,0 +1,37 @@ +use serde::Serialize; +use tauri::{AppHandle, State}; + +use crate::{sidecar::SidecarManager, AppState}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct McpInfo { + available: bool, + binary_path: Option, + database_path: String, + config_snippet: Option, +} + +#[tauri::command] +pub fn get_mcp_info(app: AppHandle, state: State<'_, AppState>) -> Result { + let database_path = state.db.path().to_string_lossy().into_owned(); + let binary = + SidecarManager::find_companion_binary(app.package_info(), "chattake-mcp", "mcp-server"); + let config_snippet = binary.as_ref().map(|path| { + serde_json::to_string_pretty(&serde_json::json!({ + "mcpServers": { + "chattake": { + "command": path.to_string_lossy(), + "env": { "CHATTAKE_DB": database_path } + } + } + })) + .expect("MCP 配置序列化失败") + }); + Ok(McpInfo { + available: binary.is_some(), + binary_path: binary.map(|path| path.to_string_lossy().into_owned()), + database_path, + config_snippet, + }) +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index abffdf9..29f10e9 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -4,9 +4,9 @@ //! 以便 `#[tauri::command]` 生成的 `__cmd__*` 符号解析正确。 pub mod cards; -pub mod export; -pub mod cli_probe; pub mod config; +pub mod export; +pub mod jobs; +pub mod mcp; pub mod sessions; pub mod sidebar; -pub mod sync; diff --git a/apps/desktop/src-tauri/src/commands/sessions.rs b/apps/desktop/src-tauri/src/commands/sessions.rs index 1619041..9123f58 100644 --- a/apps/desktop/src-tauri/src/commands/sessions.rs +++ b/apps/desktop/src-tauri/src/commands/sessions.rs @@ -1,7 +1,7 @@ //! 会话列表 / 详情 / 提炼(Sidecar + DB)命令。 //! //! ```text -//! distill_session (spawn_blocking) +//! 会话查询与后台分析流水线 //! ├── 读 Session + Messages → 拼接 content(仅 user / assistant,排除 tool 等) //! ├── RPC init → judge_value //! ├── value ∈ {medium, high} → distill_full → insert_card @@ -13,11 +13,14 @@ //! 自动映射到 Rust snake_case 参数,无需 `args` 包装结构体。 use tauri::State; +use url::Url; use uuid::Uuid; use crate::config::AppConfig; use crate::sidecar::SidecarManager; -use crate::storage::models::{Card, Message, NewCard, PaginatedResult, Session, SessionFilters, SessionSummary}; +use crate::storage::models::{ + CursorPage, Message, NewCard, PaginatedResult, Session, SessionFilters, SessionSummary, +}; use crate::storage::Database; use crate::AppState; @@ -27,11 +30,7 @@ fn validate_session_filter_groups(groups: &[SessionFilters]) -> Result<(), Strin return Err("请至少指定一组筛选条件".to_string()); } for (i, g) in groups.iter().enumerate() { - if g.source.is_none() - && g.host.is_none() - && g.project.is_none() - && g.status.is_none() - { + if g.source.is_none() && g.host.is_none() && g.project.is_none() && g.status.is_none() { return Err(format!("第 {} 组筛选条件为空", i + 1)); } } @@ -42,30 +41,32 @@ fn validate_session_filter_groups(groups: &[SessionFilters]) -> Result<(), Strin #[derive(Debug, serde::Deserialize)] struct JudgeValueResult { value: String, - #[serde(rename = "type")] - card_type: String, - /// 低/无价值时的原因(也用于构造标题和摘要) reason: String, - #[allow(dead_code)] prompt_tokens: i64, - #[allow(dead_code)] completion_tokens: i64, } -/// Sidecar `distill_full` 返回结构 #[derive(Debug, serde::Deserialize)] -struct DistillFullResult { +struct PreprocessResult { + content: String, +} + +#[derive(Debug, serde::Deserialize)] +struct KnowledgeItem { title: String, #[serde(rename = "type")] card_type: String, - value: String, summary: String, note: String, #[serde(default)] - tags: Vec, - /// 与 prompt 约定为 snake_case;兼容少数模型输出 camelCase `techStack` + topic_tags: Vec, #[serde(default, alias = "techStack")] - tech_stack: Vec, + technologies: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct ExtractKnowledgeResult { + items: Vec, prompt_tokens: i64, completion_tokens: i64, } @@ -142,82 +143,35 @@ pub async fn get_session(state: State<'_, AppState>, id: String) -> Result, session_id: String, -) -> Result, String> { + cursor: Option, + limit: Option, +) -> Result, String> { let db = state.db.clone(); tokio::task::spawn_blocking(move || { - db.get_session_messages(&session_id).map_err(|e| e.to_string()) + db.get_session_messages_page(&session_id, cursor, limit.unwrap_or(100)) + .map_err(|e| e.to_string()) }) .await .map_err(|e| format!("get_session_messages join 失败: {}", e))? } -/// `distill_session` 返回结果 -/// -/// ```text -/// is_low_value = true → low / none,DB 已记录价值,无 Card 产出 -/// is_low_value = false → medium / high,Card 已写库 -/// ``` -#[derive(Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DistillSessionResult { - /// 本次提炼流水线 id,与 sidecar / API 日志中的 traceId 一致,便于串联排查(如 tech_stack) - pub trace_id: String, - /// 分析后的价值等级(high / medium / low / none) - pub value: String, - /// true = 低/无价值,未生成笔记;false = 已生成笔记 - pub is_low_value: bool, - /// 低/无价值时:由 reason 构造的简短标题 - pub card_title: Option, - /// 低/无价值时:judge_value 返回的对话类型(debug / learning / …) - pub card_type: Option, - /// 低/无价值时的原因说明(作为摘要展示) - pub reason: Option, - /// 生成的卡片(仅 is_low_value = false 时有值) - pub card: Option, -} - -/// 对指定会话执行价值判断 +(可选)完整提炼,并写入 `cards` 表。 -#[tauri::command(rename_all = "camelCase")] -pub async fn distill_session( - state: State<'_, AppState>, - session_id: String, -) -> Result { - let sidecar = state - .sidecar - .clone() - .ok_or_else(|| "未找到 xunji-sidecar 可执行文件,请先构建 packages/sidecar 或安装到 ~/.xunji/bin/".to_string())?; - - let db = state.db.clone(); - // 取当前配置快照(RwLock → Clone),传入 spawn_blocking - let config = state.config_snapshot(); - - tokio::task::spawn_blocking(move || { - run_distill_pipeline(&db, &config, sidecar.as_ref(), &session_id) - }) - .await - .map_err(|e| format!("distill_session join 失败: {}", e))? -} - +const PROMPT_VERSION: &str = "v0.2.0-knowledge-1"; -/// 在阻塞线程内跑完 DB + Sidecar 全流程(避免阻塞 tokio worker)。 -/// -/// 流程: -/// ```text -/// analyzing 状态 → init → judge_value (PROMPT_B_LIGHT) -/// ├── low / none → 更新 DB 为 analyzed + value → Ok(isLowValue=true) -/// └── medium / high → distill_full (PROMPT_B_FULL) → insert_card → Ok(isLowValue=false) -/// ``` -fn run_distill_pipeline( +pub(crate) fn run_distill_pipeline( db: &Database, config: &AppConfig, sidecar: &SidecarManager, session_db_id: &str, -) -> Result { + job_id: &str, + provider_profile_id: Option<&str>, + on_phase: Option<&dyn Fn(&str)>, +) -> Result<(), String> { + let started = std::time::Instant::now(); let session = db.get_session(session_db_id).map_err(|e| e.to_string())?; let messages = db .get_session_messages(session_db_id) @@ -234,7 +188,10 @@ fn run_distill_pipeline( session_db_id ); - let included_count = messages.iter().filter(|m| is_included_distill_message(m)).count(); + let included_count = messages + .iter() + .filter(|m| is_included_distill_message(m)) + .count(); log::info!( "trace_id={} distill transcript: 数据库消息 {} 条,纳入 user/assistant/model {} 条", trace_id, @@ -252,32 +209,70 @@ fn run_distill_pipeline( return Err("会话正文为空,无法提炼".to_string()); } - // 设置 analyzing 状态 - db.update_session_status(session_db_id, "analyzing", None) + let profile = config.provider_profile(provider_profile_id)?; + let base_url_host = if profile.kind == "cli" { + "local-cli".to_string() + } else { + Url::parse(&profile.base_url) + .ok() + .and_then(|url| url.host_str().map(str::to_owned)) + .unwrap_or_else(|| "invalid-base-url".to_string()) + }; + let content_hash = session + .content_hash + .clone() + .unwrap_or_else(|| format!("{:x}", md5::compute(content.as_bytes()))); + let run_id = db + .create_analysis_run( + job_id, + session_db_id, + &profile.id, + &profile.provider, + &base_url_host, + &profile.model, + &content_hash, + PROMPT_VERSION, + ) .map_err(|e| e.to_string())?; - // 用闭包包裹后续逻辑,确保任何真正失败都能将状态回退为 error - let result = (|| -> Result { - let init_params = config.sidecar_init_params()?; + db.update_session_status(session_db_id, "analyzing", None) + .map_err(|e| e.to_string())?; - sidecar - .call_with_timeout::( - "init", - init_params, + let mut value: Option = None; + let mut reason: Option = None; + let mut judge_tokens = (0, 0); + let mut extract_tokens = (0, 0); + let model_timeout = std::time::Duration::from_secs(profile.timeout_secs.saturating_add(5)); + let result = (|| -> Result<(), String> { + if let Some(callback) = on_phase { + callback("preparing"); + } + let processed: PreprocessResult = sidecar + .call_with_timeout( + "preprocess", + serde_json::json!({ "content": content, "traceId": trace_id }), std::time::Duration::from_secs(30), ) - .map_err(|e| format!("Sidecar init 失败:{}", e))?; + .map_err(|e| format!("对话预处理失败:{e}"))?; - // ── 第一步:轻量价值判断(PROMPT_B_LIGHT) ── + if let Some(callback) = on_phase { + callback("judging"); + } let judge: JudgeValueResult = sidecar .call_with_timeout( "judge_value", - serde_json::json!({ "content": content, "traceId": trace_id }), - std::time::Duration::from_secs(120), + serde_json::json!({ "content": processed.content, "traceId": trace_id }), + model_timeout, ) - .map_err(|e| format!("价值判断(judge_value)失败:{}", e))?; + .map_err(|e| format!("价值判断失败:{e}"))?; let v_norm = judge.value.to_lowercase(); + if !matches!(v_norm.as_str(), "high" | "medium" | "low" | "none") { + return Err(format!("响应格式错误:未知价值等级 {}", judge.value)); + } + value = Some(v_norm.clone()); + reason = Some(judge.reason.clone()); + judge_tokens = (judge.prompt_tokens, judge.completion_tokens); log::info!( "trace_id={} 价值判断结果: {} (session={})", trace_id, @@ -285,123 +280,121 @@ fn run_distill_pipeline( session_db_id ); - // ── low / none:更新价值 + 持久化标题/类型/原因 → 正常返回(非错误) ── if v_norm == "low" || v_norm == "none" { - if let Err(e) = - db.update_session_status(session_db_id, "analyzed", Some(&judge.value)) - { - log::error!("低/无价值会话状态回写失败: {}", e); - } - - // 由 reason 截取前 30 字符作为显示标题(不截断词,保留语义) - let title = build_analysis_title(&judge.reason); - - // 将标题、类型、原因一并持久化,刷新后列表仍可正确展示 - if let Err(e) = db.update_session_analysis_meta( - session_db_id, - &title, - &judge.card_type, - &judge.reason, - ) { - log::error!("写入 analysis_meta 失败: {}", e); - } - - return Ok(DistillSessionResult { - trace_id: trace_id.clone(), - value: judge.value, - is_low_value: true, - card_title: Some(title), - card_type: Some(judge.card_type), - reason: Some(judge.reason), - card: None, - }); + db.update_session_status(session_db_id, "analyzed", Some(&v_norm)) + .map_err(|e| e.to_string())?; + return Ok(()); } - if v_norm != "medium" && v_norm != "high" { - return Err(format!("未知的价值等级: {}", judge.value)); + if let Some(callback) = on_phase { + callback("extracting"); } - - // ── 第二步:完整笔记提炼(PROMPT_B_FULL,仅 medium / high) ── - let full: DistillFullResult = sidecar + let extracted: ExtractKnowledgeResult = sidecar .call_with_timeout( - "distill_full", - serde_json::json!({ "content": content, "traceId": trace_id }), - std::time::Duration::from_secs(300), + "extract_knowledge", + serde_json::json!({ "content": processed.content, "traceId": trace_id }), + model_timeout, ) - .map_err(|e| format!("完整提炼(distill_full)失败:{}", e))?; - - log::info!( - "trace_id={} distill_full 解析入库: tags 条目数={} {:?} | tech_stack 条目数={} {:?}", - trace_id, - full.tags.len(), - full.tags, - full.tech_stack.len(), - full.tech_stack - ); + .map_err(|e| format!("知识提取失败:{e}"))?; + if extracted.items.is_empty() || extracted.items.len() > 3 { + return Err("响应格式错误:知识项数量必须为 1–3".into()); + } + extract_tokens = (extracted.prompt_tokens, extracted.completion_tokens); - db.delete_cards_for_session(session_db_id) - .map_err(|e| e.to_string())?; + if let Some(callback) = on_phase { + callback("saving"); + } let source_name = config.source_display_name(&session.source_id); let project_name = session.project_name.clone(); - - let new_card = NewCard { - session_id: session_db_id, - title: full.title.as_str(), - card_type: Some(full.card_type.as_str()), - value: Some(full.value.as_str()), - summary: Some(full.summary.as_str()), - note: full.note.as_str(), - source_name: source_name.as_deref(), - project_name: project_name.as_deref(), - prompt_tokens: full.prompt_tokens.clamp(0, i32::MAX as i64) as i32, - completion_tokens: full.completion_tokens.clamp(0, i32::MAX as i64) as i32, - cost_yuan: 0.0, - tags: &full.tags, - tech_stack: &full.tech_stack, + let publication_status = if v_norm == "high" + && !db + .session_has_cards(session_db_id) + .map_err(|e| e.to_string())? + { + "published" + } else { + "draft" }; - - let card_id = db.insert_card(&new_card).map_err(|e| e.to_string())?; - - db.update_session_status(session_db_id, "analyzed", Some(&full.value)) + for item in &extracted.items { + db.insert_card(&NewCard { + session_id: session_db_id, + analysis_run_id: &run_id, + title: &item.title, + card_type: &item.card_type, + value: &v_norm, + summary: &item.summary, + note: &item.note, + publication_status, + source_name: source_name.as_deref(), + project_name: project_name.as_deref(), + prompt_tokens: extracted.prompt_tokens.clamp(0, i32::MAX as i64) as i32, + completion_tokens: extracted.completion_tokens.clamp(0, i32::MAX as i64) as i32, + cost_yuan: 0.0, + tags: &item.topic_tags, + tech_stack: &item.technologies, + }) .map_err(|e| e.to_string())?; + } - let card = db.get_card(&card_id).map_err(|e| e.to_string())?; - Ok(DistillSessionResult { - trace_id: trace_id.clone(), - value: full.value, - is_low_value: false, - card_title: None, - card_type: None, - reason: None, - card: Some(card), - }) + db.update_session_status(session_db_id, "analyzed", Some(&v_norm)) + .map_err(|e| e.to_string())?; + Ok(()) })(); - // 真正失败时(非低价值场景),状态回退为 error - if let Err(ref e) = result { - if let Ok(s) = db.get_session(session_db_id) { - if s.status == "analyzing" { - let _ = db.update_session_error(session_db_id, e); - } - } - } + let error = result.as_ref().err().map(String::as_str); + let error_kind = error.map(classify_analysis_error); + db.finish_analysis_run( + &run_id, + value.as_deref(), + reason.as_deref(), + judge_tokens, + extract_tokens, + started.elapsed().as_millis().min(i64::MAX as u128) as i64, + error_kind, + error, + ) + .map_err(|e| e.to_string())?; - result + if let Err(error) = result { + let kind = classify_analysis_error(&error); + let visible = format!( + "{}/{} · {}:{}", + profile.provider, profile.model, kind, error + ); + let _ = db.update_session_error(session_db_id, &visible); + Err(visible) + } else { + Ok(()) + } } -/// 从 LLM 判断原因截取简短标题(最多 30 个 Unicode 字符,超出时附加省略号)。 -/// -/// 示例: -/// "问题过于简单,答案是常识" → "问题过于简单,答案是常识"(未超长) -/// "内容高度重复,没有新知识点,用户反复询问相同内容,建议归档。" → "内容高度重复,没有新知识点,用户反…" -fn build_analysis_title(reason: &str) -> String { - const MAX_CHARS: usize = 30; - let chars: Vec = reason.chars().collect(); - if chars.len() <= MAX_CHARS { - reason.to_string() +fn classify_analysis_error(error: &str) -> &'static str { + let text = error.to_ascii_lowercase(); + if text.contains("401") + || text.contains("403") + || text.contains("api key") + || text.contains("鉴权") + { + "鉴权失败" + } else if text.contains("429") || text.contains("rate limit") || text.contains("限流") { + "请求限流" + } else if text.contains("model") + && (text.contains("404") || text.contains("not found") || text.contains("不存在")) + { + "模型不存在" + } else if text.contains("timeout") || text.contains("超时") { + "请求超时" + } else if text.contains("json") || text.contains("响应格式") { + "响应格式错误" + } else if text.contains("network") + || text.contains("connect") + || text.contains("dns") + || text.contains("网络") + { + "网络错误" } else { - chars[..MAX_CHARS].iter().collect::() + "…" + "供应商请求失败" } } @@ -469,7 +462,7 @@ fn utf8_safe_suffix(s: &str, max_bytes: usize) -> &str { /// 记录 JSON-RPC 中传给 sidecar 的 `content`(未经 clean/truncate)。 /// 与 sidecar 日志里 `user.md5[0:16]`:若预处理未改长度则应与 API 侧 user 一致。 -/// 设置环境变量 `XUNJI_LOG_DISTILL_PAYLOAD=1` 可打印完整正文(排查后关闭)。 +/// 设置环境变量 `CHATTAKE_LOG_DISTILL_PAYLOAD=1` 可打印完整正文(排查后关闭)。 fn log_rpc_distill_payload(trace_id: &str, label: &str, content: &str) { let digest = md5::compute(content.as_bytes()); let hex_full = format!("{:x}", digest); @@ -500,12 +493,12 @@ fn log_rpc_distill_payload(trace_id: &str, label: &str, content: &str) { tail ); } - if std::env::var("XUNJI_LOG_DISTILL_PAYLOAD") + if std::env::var("CHATTAKE_LOG_DISTILL_PAYLOAD") .map(|v| v == "1") .unwrap_or(false) { log::info!( - "trace_id={} | {}: [FULL 由 XUNJI_LOG_DISTILL_PAYLOAD=1 开启]\n{}", + "trace_id={} | {}: [FULL 由 CHATTAKE_LOG_DISTILL_PAYLOAD=1 开启]\n{}", trace_id, label, content diff --git a/apps/desktop/src-tauri/src/commands/sidebar.rs b/apps/desktop/src-tauri/src/commands/sidebar.rs index 7173d1f..cd165a8 100644 --- a/apps/desktop/src-tauri/src/commands/sidebar.rs +++ b/apps/desktop/src-tauri/src/commands/sidebar.rs @@ -11,44 +11,34 @@ pub async fn get_session_groups( state: State<'_, AppState>, ) -> Result, String> { let db = state.db.clone(); - tokio::task::spawn_blocking(move || { - db.get_session_groups().map_err(|e| e.to_string()) - }) - .await - .map_err(|e| format!("get_session_groups join 失败: {}", e))? + tokio::task::spawn_blocking(move || db.get_session_groups().map_err(|e| e.to_string())) + .await + .map_err(|e| format!("get_session_groups join 失败: {}", e))? } /// 返回所有标签及关联卡片数量(按数量降序),用于知识库侧栏标签筛选。 #[tauri::command] pub async fn list_tags(state: State<'_, AppState>) -> Result, String> { let db = state.db.clone(); - tokio::task::spawn_blocking(move || { - db.list_all_tags().map_err(|e| e.to_string()) - }) - .await - .map_err(|e| format!("list_tags join 失败: {}", e))? + tokio::task::spawn_blocking(move || db.list_all_tags().map_err(|e| e.to_string())) + .await + .map_err(|e| format!("list_tags join 失败: {}", e))? } /// 返回 `cards.tech_stack` 聚合后的技术栈及卡片命中次数(知识库侧栏展示)。 #[tauri::command] -pub async fn list_tech_stack_counts( - state: State<'_, AppState>, -) -> Result, String> { +pub async fn list_tech_stack_counts(state: State<'_, AppState>) -> Result, String> { let db = state.db.clone(); - tokio::task::spawn_blocking(move || { - db.list_all_tech_stack_counts().map_err(|e| e.to_string()) - }) - .await - .map_err(|e| format!("list_tech_stack_counts join 失败: {}", e))? + tokio::task::spawn_blocking(move || db.list_all_tech_stack_counts().map_err(|e| e.to_string())) + .await + .map_err(|e| format!("list_tech_stack_counts join 失败: {}", e))? } /// 返回各知识类型的卡片数量统计,用于知识库侧栏类型筛选。 #[tauri::command] pub async fn list_card_types(state: State<'_, AppState>) -> Result, String> { let db = state.db.clone(); - tokio::task::spawn_blocking(move || { - db.list_card_type_counts().map_err(|e| e.to_string()) - }) - .await - .map_err(|e| format!("list_card_types join 失败: {}", e))? + tokio::task::spawn_blocking(move || db.list_card_type_counts().map_err(|e| e.to_string())) + .await + .map_err(|e| format!("list_card_types join 失败: {}", e))? } diff --git a/apps/desktop/src-tauri/src/commands/sync.rs b/apps/desktop/src-tauri/src/commands/sync.rs deleted file mode 100644 index 053d397..0000000 --- a/apps/desktop/src-tauri/src/commands/sync.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! 同步相关 Tauri 命令 —— 触发采集调度器,阻塞 I/O 放在 `spawn_blocking` 中执行。 - -use tauri::State; - -use crate::collector::scheduler::CollectorScheduler; -use crate::collector::scheduler::SyncResult; -use crate::AppState; - -/// 手动全量同步:扫描已启用数据源并写入数据库。 -#[tauri::command] -pub async fn sync_all(state: State<'_, AppState>) -> Result { - let db = state.db.clone(); - // 取当前配置快照,传入 spawn_blocking(RwLock → Clone) - let config = state.config_snapshot(); - - tokio::task::spawn_blocking(move || { - let scheduler = CollectorScheduler::new(&config, db.as_ref()); - scheduler.collect_all() - }) - .await - .map_err(|e| format!("同步任务 join 失败: {}", e)) -} diff --git a/apps/desktop/src-tauri/src/config.rs b/apps/desktop/src-tauri/src/config.rs index 67ea73e..7869cbd 100644 --- a/apps/desktop/src-tauri/src/config.rs +++ b/apps/desktop/src-tauri/src/config.rs @@ -1,130 +1,121 @@ -//! 应用配置管理 —— 解析 ~/.xunji/config.toml,提供默认值。 -//! -//! 配置文件路径优先级: -//! 1. 环境变量 XUNJI_CONFIG 指定的路径 -//! 2. ~/.xunji/config.toml(默认) -//! -//! 首次启动时自动创建默认配置文件,包含 Claude Code / Cursor / CodeBuddy(CodeBuddyExtension)的默认扫描路径。 -//! -//! 加载已有配置时,若缺少新版默认数据源条目,会 **自动追加**(不覆盖用户已配置的同名 id)。 +//! 应用配置管理:内置采集源 + 多分析 Provider 配置、单配置激活。 +use std::collections::HashSet; use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use serde_json::Value; -/// 应用顶层配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { - /// 数据采集配置 pub collector: CollectorConfig, - /// LLM 提炼配置 + #[serde(default)] pub distiller: DistillerConfig, - /// 同步行为配置 #[serde(default)] pub sync: SyncConfig, } -/// 数据采集配置 —— 管理各数据源的扫描路径 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CollectorConfig { - /// 数据源列表(Claude Code、Cursor 等) pub sources: Vec, } -/// 单个数据源配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SourceConfig { - /// 数据源唯一标识(如 "claude-code"、"cursor") pub id: String, - /// 数据源显示名称 pub name: String, - /// 是否启用该数据源 #[serde(default = "default_true")] pub enabled: bool, - /// 扫描目录列表(支持 ~ 展开为用户主目录) pub scan_dirs: Vec, } -/// LLM 提炼配置 —— 控制 Sidecar 调用 LLM 的方式 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DistillerConfig { - /// 提炼模式: "api"(HTTP API)| "cli"(本地 CLI 工具) - /// 默认为 "api" - #[serde(default = "default_distiller_mode")] - pub mode: String, - /// API 模式配置(mode = "api" 时必填) - pub api: Option, - /// CLI 模式配置(mode = "cli" 时必填) - pub cli: Option, + #[serde(default)] + pub active_profile_id: String, + #[serde(default)] + pub profiles: Vec, + /// 仅用于把 v0.1 的单 API 配置迁移成 profile;落盘时移除。 + #[serde(default, rename = "api", skip_serializing)] + pub legacy_api: Option, } -/// 本地 CLI 工具配置(如 claude、gemini 等 AI 编程助手 CLI) -/// -/// CLI 工具通过 stdin 接收提示词,从 stdout 输出响应, -/// 命令行参数与各工具官方用法保持一致(-p 非交互模式)。 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CliConfig { - /// CLI 命令名称,可自定义(如 "claude"、"claude3"、"gemini"、"codex") - pub command: String, - /// 附加参数列表,追加在 -p prompt 之前(如 ["--no-history"]) - #[serde(default)] - pub extra_args: Vec, +#[derive(Debug, Clone, Deserialize)] +pub struct LegacyApiConfig { + pub provider: String, + pub base_url: Option, + pub api_key: String, + pub model: String, + #[serde(default = "default_timeout")] + pub timeout_secs: u64, +} + +impl Default for DistillerConfig { + fn default() -> Self { + Self { + active_profile_id: "default".to_string(), + profiles: vec![ProviderProfile { + id: "default".to_string(), + name: "OpenAI".to_string(), + kind: default_provider_kind(), + provider: "openai".to_string(), + base_url: "https://api.openai.com/v1".to_string(), + api_key: String::new(), + model: String::new(), + command: String::new(), + timeout_secs: default_timeout(), + }], + legacy_api: None, + } + } } -/// LLM API 连接配置(兼容 OpenAI-compatible API) #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ApiConfig { - /// API 提供商标识(如 "openai"、"deepseek"、"openai-compatible") +pub struct ProviderProfile { + /// 稳定本机标识;重命名配置不会改变该值。 + pub id: String, + pub name: String, + #[serde(default = "default_provider_kind")] + pub kind: String, pub provider: String, - /// API 基础 URL(如 "https://api.openai.com/v1") - pub base_url: Option, - /// API 密钥 + #[serde(default)] + pub base_url: String, + #[serde(default)] pub api_key: String, - /// 模型名称(如 "gpt-4o-mini"、"deepseek-chat") + #[serde(default)] pub model: String, - /// 请求超时秒数 + #[serde(default)] + pub command: String, #[serde(default = "default_timeout")] pub timeout_secs: u64, } -/// 同步行为配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SyncConfig { - /// 同步模式:"manual"(手动)| "ask"(询问)| "auto"(自动) - /// v0.1 仅支持 manual - #[serde(default = "default_sync_mode")] - pub mode: String, - /// 自动同步间隔(秒),仅 auto 模式生效 - #[serde(default = "default_sync_interval")] - pub interval_secs: u64, + #[serde(default = "default_true")] + pub scan_on_startup: bool, } -// ── 默认值辅助函数 ── +impl Default for SyncConfig { + fn default() -> Self { + Self { + scan_on_startup: true, + } + } +} fn default_true() -> bool { true } - fn default_timeout() -> u64 { 120 } - -fn default_distiller_mode() -> String { +fn default_provider_kind() -> String { "api".to_string() } -fn default_sync_mode() -> String { - "manual".to_string() -} - -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(), @@ -132,60 +123,62 @@ fn default_codebuddy_scan_dirs() -> Vec { ] } -impl Default for SyncConfig { - fn default() -> Self { - Self { - mode: default_sync_mode(), - interval_secs: default_sync_interval(), - } - } +fn builtin_sources() -> Vec { + vec![ + SourceConfig { + id: "claude-code".to_string(), + name: "Claude Code".to_string(), + enabled: true, + scan_dirs: vec!["~/.claude".to_string()], + }, + SourceConfig { + id: "cursor".to_string(), + name: "Cursor".to_string(), + enabled: true, + scan_dirs: vec!["~/Library/Application Support/Cursor".to_string()], + }, + SourceConfig { + id: "codex".to_string(), + name: "Codex".to_string(), + enabled: true, + scan_dirs: vec![ + "~/.codex/sessions".to_string(), + "~/.codex/archived_sessions".to_string(), + ], + }, + SourceConfig { + id: "codebuddy".to_string(), + name: "CodeBuddy".to_string(), + enabled: false, + scan_dirs: default_codebuddy_scan_dirs(), + }, + SourceConfig { + id: "omp".to_string(), + name: "Oh My Pi".to_string(), + enabled: true, + scan_dirs: vec!["~/.omp/agent/sessions".to_string()], + }, + SourceConfig { + id: "pi".to_string(), + name: "Pi".to_string(), + enabled: true, + scan_dirs: vec!["~/.pi/agent/sessions".to_string()], + }, + ] } impl Default for AppConfig { - /// 生成默认配置,包含 Claude Code 和 Cursor 的常见扫描路径 fn default() -> Self { Self { collector: CollectorConfig { - sources: vec![ - SourceConfig { - id: "claude-code".to_string(), - name: "Claude Code".to_string(), - enabled: true, - scan_dirs: vec![ - "~/.claude".to_string(), - "~/.claude-internal".to_string(), - ], - }, - SourceConfig { - id: "cursor".to_string(), - name: "Cursor".to_string(), - enabled: true, - scan_dirs: vec![ - "~/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 { - mode: default_distiller_mode(), - api: None, - cli: None, + sources: builtin_sources(), }, + distiller: DistillerConfig::default(), sync: SyncConfig::default(), } } } -// ── 配置加载 / 保存 ── - -/// 配置相关错误 #[derive(Debug, thiserror::Error)] pub enum ConfigError { #[error("IO 错误: {0}")] @@ -194,105 +187,189 @@ pub enum ConfigError { Parse(#[from] toml::de::Error), #[error("TOML 序列化错误: {0}")] Serialize(#[from] toml::ser::Error), + #[error("配置无效: {0}")] + Invalid(String), } pub type ConfigResult = Result; impl AppConfig { - /// 获取默认配置文件路径: ~/.xunji/config.toml pub fn default_path() -> PathBuf { - let home = dirs::home_dir().expect("无法获取用户主目录"); - home.join(".xunji").join("config.toml") + dirs::home_dir() + .expect("无法获取用户主目录") + .join(".chattake/config.toml") } - /// 加载配置文件。文件不存在时自动创建默认配置。 - /// - /// 优先级: - /// 1. 传入的 path 参数 - /// 2. 环境变量 XUNJI_CONFIG - /// 3. ~/.xunji/config.toml pub fn load(path: Option<&Path>) -> ConfigResult { - let config_path = match path { - Some(p) => p.to_path_buf(), - None => std::env::var("XUNJI_CONFIG") - .map(PathBuf::from) - .unwrap_or_else(|_| Self::default_path()), - }; + let config_path = path + .map(Path::to_path_buf) + .or_else(|| std::env::var("CHATTAKE_CONFIG").ok().map(PathBuf::from)) + .unwrap_or_else(Self::default_path); if !config_path.exists() { - log::info!("配置文件不存在,创建默认配置: {}", config_path.display()); - let default_config = Self::default(); - default_config.save(&config_path)?; - return Ok(default_config); + let config = Self::default(); + config.save(&config_path)?; + return Ok(config); } - log::info!("加载配置文件: {}", config_path.display()); let content = fs::read_to_string(&config_path)?; 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()); - } + let changed = config.normalize(); + config.validate()?; + if changed { + config.save(&config_path)?; } - log::debug!("配置加载成功: {:?}", config); + #[cfg(unix)] + Self::secure_permissions(&config_path)?; Ok(config) } - /// 若配置中尚无 **CodeBuddy CLI** 数据源,则追加默认项(升级用户无需手改 config)。 - /// 不批量注入全部内置源,避免覆盖「仅自定义源」的极简配置。 - pub fn ensure_default_collector_sources(&mut self) { - let has_codebuddy = self - .collector - .sources + /// 只保留 v0.2 支持的内置来源;不保留 internal 路径。 + fn normalize(&mut self) -> bool { + let mut changed = false; + let old = std::mem::take(&mut self.collector.sources); + let mut normalized = Vec::new(); + + for default in builtin_sources() { + if let Some(mut existing) = old.iter().find(|s| s.id == default.id).cloned() { + let before = existing.scan_dirs.len(); + existing + .scan_dirs + .retain(|p| !p.to_ascii_lowercase().contains("-internal")); + if existing.scan_dirs.is_empty() { + existing.scan_dirs = default.scan_dirs.clone(); + } + changed |= before != existing.scan_dirs.len(); + existing.name = default.name; + normalized.push(existing); + } else { + normalized.push(default); + changed = true; + } + } + changed |= normalized.len() != old.len(); + self.collector.sources = normalized; + + if self.distiller.profiles.is_empty() { + self.distiller = if let Some(api) = self.distiller.legacy_api.take() { + DistillerConfig { + active_profile_id: "migrated-api".to_string(), + profiles: vec![ProviderProfile { + id: "migrated-api".to_string(), + name: "已迁移 API".to_string(), + kind: default_provider_kind(), + provider: api.provider, + base_url: api + .base_url + .unwrap_or_else(|| "https://api.openai.com/v1".to_string()), + api_key: api.api_key, + model: api.model, + command: String::new(), + timeout_secs: api.timeout_secs, + }], + legacy_api: None, + } + } else { + DistillerConfig::default() + }; + changed = true; + } else if !self + .distiller + .profiles .iter() - .any(|s| s.id == "codebuddy-cli"); - if has_codebuddy { - return; + .any(|p| p.id == self.distiller.active_profile_id) + { + self.distiller.active_profile_id = self.distiller.profiles[0].id.clone(); + changed = true; } - 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(), - }); + changed } - /// 将旧版误配的 `~/.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; + pub fn validate(&self) -> ConfigResult<()> { + if self.distiller.profiles.is_empty() { + return Err(ConfigError::Invalid( + "至少需要一套分析 Provider 配置".to_string(), + )); + } + let mut ids = HashSet::new(); + for profile in &self.distiller.profiles { + if profile.id.trim().is_empty() || !ids.insert(profile.id.as_str()) { + return Err(ConfigError::Invalid( + "Provider 配置 id 不能为空或重复".to_string(), + )); } - 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; + if profile.name.trim().is_empty() { + return Err(ConfigError::Invalid(format!( + "Provider 配置 {} 缺少显示名称", + profile.id + ))); + } + if !matches!(profile.kind.as_str(), "api" | "cli") { + return Err(ConfigError::Invalid(format!( + "Provider 配置 {} 的类型必须是 api 或 cli", + profile.name + ))); + } + if profile.kind == "cli" && profile.command.trim().is_empty() { + return Err(ConfigError::Invalid(format!( + "CLI Provider 配置 {} 缺少命令", + profile.name + ))); + } + if !(10..=600).contains(&profile.timeout_secs) { + return Err(ConfigError::Invalid(format!( + "Provider 配置 {} 的超时必须在 10–600 秒之间", + profile.name + ))); } } - changed + if !ids.contains(self.distiller.active_profile_id.as_str()) { + return Err(ConfigError::Invalid( + "当前激活的 Provider 配置不存在".to_string(), + )); + } + Ok(()) } - /// 将配置写入指定路径(自动创建父目录) pub fn save(&self, path: &Path) -> ConfigResult<()> { + self.validate()?; if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - let content = toml::to_string_pretty(self)?; - fs::write(path, &content)?; - log::info!("配置已保存: {}", path.display()); + let contents = toml::to_string_pretty(self)?; + let mut options = fs::OpenOptions::new(); + options.create(true).write(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path)?; + file.write_all(contents.as_bytes())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(fs::Permissions::from_mode(0o600))?; + } + #[cfg(unix)] + Self::secure_permissions(path)?; + Ok(()) + } + + #[cfg(unix)] + fn secure_permissions(path: &Path) -> ConfigResult<()> { + use std::os::unix::fs::PermissionsExt; + + if let Some(parent) = path + .parent() + .filter(|p| p.file_name().and_then(|v| v.to_str()) == Some(".chattake")) + { + fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?; + } + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; Ok(()) } - /// 获取所有已启用的数据源 pub fn enabled_sources(&self) -> Vec<&SourceConfig> { self.collector .sources @@ -301,7 +378,6 @@ impl AppConfig { .collect() } - /// 根据数据源 ID 取展示名称(写入卡片冗余字段用) pub fn source_display_name(&self, source_id: &str) -> Option { self.collector .sources @@ -310,56 +386,57 @@ impl AppConfig { .map(|s| s.name.clone()) } - /// 构造 Sidecar `init` 方法的 JSON-RPC params(与 TS `handleInit` 字段一致) - /// - /// 根据 `distiller.mode` 自动选择 API 或 CLI 参数: - /// - "api" → 包含 provider / base_url / api_key / model - /// - "cli" → 包含 command / extra_args - pub fn sidecar_init_params(&self) -> Result { - match self.distiller.mode.as_str() { - "cli" => { - let cli = self.distiller.cli.as_ref().ok_or_else(|| { - "未配置 distiller.cli:请在设置中填写 CLI 命令名称(如 claude)".to_string() - })?; - // 与 sidecar 日志对照:若此处已是短名而用户填了绝对路径,说明配置未写入或未热更新 - log::info!("sidecar init 将使用 CLI command={}", cli.command); - Ok(serde_json::json!({ - "mode": "cli", - "command": cli.command, - "extra_args": cli.extra_args, - })) - } - _ => { - // 默认 API 模式 - let api = self.distiller.api.as_ref().ok_or_else(|| { - "未配置 distiller.api:请在设置中填写 LLM API(api_key、model 等)".to_string() - })?; - let base_url = api - .base_url - .clone() - .unwrap_or_else(|| "https://api.openai.com/v1".to_string()); - Ok(serde_json::json!({ - "mode": "api", - "provider": api.provider, - "base_url": base_url, - "api_key": api.api_key, - "model": api.model, - "timeout_secs": api.timeout_secs, - })) + pub fn provider_profile(&self, profile_id: Option<&str>) -> Result<&ProviderProfile, String> { + let id = profile_id.unwrap_or(&self.distiller.active_profile_id); + self.distiller + .profiles + .iter() + .find(|p| p.id == id) + .ok_or_else(|| format!("未找到 Provider 配置:{}", id)) + } + + pub fn sidecar_init_params(&self, profile_id: Option<&str>) -> Result { + let profile = self.provider_profile(profile_id)?; + if profile.kind == "cli" { + if profile.command.trim().is_empty() { + return Err(format!("CLI Provider「{}」尚未填写命令", profile.name)); } + return Ok(serde_json::json!({ + "kind": "cli", + "provider": profile.provider, + "command": profile.command, + "model": profile.model, + "timeout_secs": profile.timeout_secs, + })); + } + if profile.api_key.trim().is_empty() { + return Err(format!("API Provider「{}」尚未填写 API Key", profile.name)); } + if profile.base_url.trim().is_empty() || profile.model.trim().is_empty() { + return Err(format!( + "API Provider「{}」缺少 Base URL 或模型", + profile.name + )); + } + Ok(serde_json::json!({ + "kind": "api", + "provider": profile.provider, + "base_url": profile.base_url, + "api_key": profile.api_key, + "model": profile.model, + "timeout_secs": profile.timeout_secs, + })) } } impl SourceConfig { - /// 将 scan_dirs 中的 ~ 展开为用户主目录,返回绝对路径列表 pub fn resolved_scan_dirs(&self) -> Vec { let home = dirs::home_dir().expect("无法获取用户主目录"); self.scan_dirs .iter() .map(|dir| { - if dir.starts_with("~/") { - home.join(&dir[2..]) + if let Some(rest) = dir.strip_prefix("~/") { + home.join(rest) } else if dir == "~" { home.clone() } else { @@ -373,150 +450,129 @@ impl SourceConfig { #[cfg(test)] mod tests { use super::*; - use std::io::Write; - use tempfile::NamedTempFile; #[test] - fn test_default_config_has_three_sources() { + fn defaults_have_six_sources_and_one_profile() { let config = AppConfig::default(); - 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); + let ids: Vec<&str> = config + .collector + .sources + .iter() + .map(|s| s.id.as_str()) + .collect(); + assert_eq!( + ids, + vec!["claude-code", "cursor", "codex", "codebuddy", "omp", "pi"] + ); + assert_eq!(config.distiller.profiles.len(), 1); + assert_eq!(config.distiller.active_profile_id, "default"); + assert!(config.sync.scan_on_startup); } #[test] - fn test_default_config_roundtrip() { - let config = AppConfig::default(); - let toml_str = toml::to_string_pretty(&config).unwrap(); - let parsed: AppConfig = toml::from_str(&toml_str).unwrap(); - assert_eq!(parsed.collector.sources.len(), config.collector.sources.len()); - assert_eq!(parsed.sync.mode, "manual"); + fn profile_roundtrip_and_selection() { + let mut config = AppConfig::default(); + config.distiller.profiles.push(ProviderProfile { + id: "siliconflow-main".to_string(), + name: "硅基流动".to_string(), + kind: "api".to_string(), + provider: "siliconflow".to_string(), + base_url: "https://api.siliconflow.cn/v1".to_string(), + api_key: "sk-test".to_string(), + model: "deepseek-ai/DeepSeek-V3".to_string(), + command: String::new(), + timeout_secs: 90, + }); + config.distiller.active_profile_id = "siliconflow-main".to_string(); + let text = toml::to_string_pretty(&config).unwrap(); + let parsed: AppConfig = toml::from_str(&text).unwrap(); + let selected = parsed.provider_profile(None).unwrap(); + assert_eq!(selected.provider, "siliconflow"); + assert_eq!(selected.timeout_secs, 90); } + #[cfg(unix)] #[test] - fn test_load_creates_default_when_missing() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - assert!(!path.exists()); + fn saves_private_config_permissions() { + use std::os::unix::fs::PermissionsExt; - let config = AppConfig::load(Some(&path)).unwrap(); - assert!(path.exists()); - assert_eq!(config.collector.sources.len(), 3); + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().join(".chattake"); + let path = config_dir.join("config.toml"); + AppConfig::default().save(&path).unwrap(); + assert_eq!( + fs::metadata(&config_dir).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + + fs::set_permissions(&config_dir, fs::Permissions::from_mode(0o755)).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + AppConfig::load(Some(&path)).unwrap(); + assert_eq!( + fs::metadata(&config_dir).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); } #[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")); + fn rejects_duplicate_profile_ids() { + let mut config = AppConfig::default(); + config + .distiller + .profiles + .push(config.distiller.profiles[0].clone()); + assert!(config.validate().is_err()); } #[test] - fn test_load_existing_config() { - let mut file = NamedTempFile::new().unwrap(); - write!( - file, - r#" + fn migrates_v01_single_api() { + let text = r#" [collector] -[[collector.sources]] -id = "test-source" -name = "Test" -enabled = true -scan_dirs = ["~/test"] - +sources = [] [distiller] - +mode = "api" +[distiller.api] +provider = "deepseek" +base_url = "https://api.deepseek.com/v1" +api_key = "sk-old" +model = "deepseek-chat" +timeout_secs = 60 [sync] mode = "manual" -interval_secs = 600 -"# - ) - .unwrap(); - - let config = AppConfig::load(Some(file.path())).unwrap(); - // 升级合并:自动追加 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); - } - - #[test] - fn test_enabled_sources_filter() { - let mut config = AppConfig::default(); - config.collector.sources[1].enabled = false; - let enabled = config.enabled_sources(); - assert_eq!(enabled.len(), 1); - assert_eq!(enabled[0].id, "claude-code"); +interval_secs = 300 +"#; + let mut config: AppConfig = toml::from_str(text).unwrap(); + assert!(config.normalize()); + assert_eq!(config.distiller.active_profile_id, "migrated-api"); + assert_eq!( + config.provider_profile(None).unwrap().model, + "deepseek-chat" + ); + assert!(!toml::to_string(&config) + .unwrap() + .contains("[distiller.api]")); } #[test] - fn test_resolve_scan_dirs_expands_tilde() { + fn expands_home_directory() { let source = SourceConfig { id: "test".to_string(), name: "Test".to_string(), enabled: true, - scan_dirs: vec!["~/.claude".to_string(), "/absolute/path".to_string()], + scan_dirs: vec!["~/.codex/sessions".to_string(), "/tmp/x".to_string()], }; - let dirs = source.resolved_scan_dirs(); - let home = dirs::home_dir().unwrap(); - assert_eq!(dirs[0], home.join(".claude")); - assert_eq!(dirs[1], PathBuf::from("/absolute/path")); - } - - #[test] - fn test_config_with_api() { - let toml_str = r#" -[collector] -[[collector.sources]] -id = "claude-code" -name = "Claude Code" -enabled = true -scan_dirs = ["~/.claude"] - -[distiller] -[distiller.api] -provider = "openai-compatible" -base_url = "https://api.deepseek.com/v1" -api_key = "sk-test-key" -model = "deepseek-chat" -timeout_secs = 60 - -[sync] -mode = "manual" -"#; - let config: AppConfig = toml::from_str(toml_str).unwrap(); - let api = config.distiller.api.unwrap(); - assert_eq!(api.provider, "openai-compatible"); - assert_eq!(api.model, "deepseek-chat"); - assert_eq!(api.timeout_secs, 60); + assert_eq!( + source.resolved_scan_dirs()[0], + dirs::home_dir().unwrap().join(".codex/sessions") + ); + assert_eq!(source.resolved_scan_dirs()[1], PathBuf::from("/tmp/x")); } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 845dfe8..a7851fd 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,12 +1,13 @@ -mod commands; mod collector; +mod commands; +pub mod config; /// Cursor workspace 路径百分号解码(采集与 DB 迁移共用) mod path_local; -pub mod config; mod sidecar; mod storage; use std::sync::{Arc, RwLock}; +use tauri::Manager; use config::AppConfig; use sidecar::SidecarManager; @@ -23,6 +24,7 @@ pub struct AppState { pub db: Arc, pub config: Arc>, pub sidecar: Option>, + pub job_lock: Arc>, } impl AppState { @@ -34,11 +36,11 @@ impl AppState { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - // 开发构建(`bun run tauri dev` / cargo dev):默认开启提炼载荷全文日志(与 sidecar 的 XUNJI_LOG_DISTILL_PAYLOAD=1 一致)。 - // 发布构建不设置;若需在本机关闭,启动前可 export XUNJI_LOG_DISTILL_PAYLOAD=0。 + // 开发构建(`bun run tauri dev` / cargo dev):默认开启提炼载荷全文日志(与 sidecar 的 CHATTAKE_LOG_DISTILL_PAYLOAD=1 一致)。 + // 发布构建不设置;若需在本机关闭,启动前可 export CHATTAKE_LOG_DISTILL_PAYLOAD=0。 #[cfg(debug_assertions)] - if std::env::var_os("XUNJI_LOG_DISTILL_PAYLOAD").is_none() { - std::env::set_var("XUNJI_LOG_DISTILL_PAYLOAD", "1"); + if std::env::var_os("CHATTAKE_LOG_DISTILL_PAYLOAD").is_none() { + std::env::set_var("CHATTAKE_LOG_DISTILL_PAYLOAD", "1"); } // 默认 info 级别,可通过 RUST_LOG 环境变量覆盖(如 RUST_LOG=debug) @@ -46,10 +48,10 @@ pub fn run() { let config = AppConfig::load(None).expect("配置加载失败"); log::info!( - "已加载 {} 个数据源,{} 个已启用,提炼模式: {}", + "已加载 {} 个数据源,{} 个已启用,API 配置: {} 套", config.collector.sources.len(), config.enabled_sources().len(), - config.distiller.mode, + config.distiller.profiles.len(), ); let db = Database::open_default().expect("数据库初始化失败"); @@ -58,21 +60,24 @@ pub fn run() { if let Err(e) = db.reset_stale_analyzing() { log::error!("清理残留 analyzing 状态失败: {}", e); } + if let Err(e) = db.interrupt_running_jobs() { + log::error!("恢复中断任务状态失败: {}", e); + } let context = tauri::generate_context!(); - let sidecar = SidecarManager::find_binary(context.package_info()).map(|path| { - Arc::new(SidecarManager::new(path)) - }); + let sidecar = SidecarManager::find_binary(context.package_info()) + .map(|path| Arc::new(SidecarManager::new(path))); if sidecar.is_none() { - log::warn!("Sidecar 未就绪:提炼功能将不可用,直至构建或安装 xunji-sidecar"); + log::warn!("Sidecar 未就绪:提炼功能将不可用,直至构建或安装 chattake-sidecar"); } let state = AppState { db: Arc::new(db), config: Arc::new(RwLock::new(config)), sidecar, + job_lock: Arc::new(tokio::sync::Mutex::new(())), }; tauri::Builder::default() @@ -80,17 +85,41 @@ pub fn run() { .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .manage(state) + .setup(|app| { + let state = app.state::(); + if state.config_snapshot().sync.scan_on_startup { + if let Err(error) = commands::jobs::start_sync_internal( + app.handle().clone(), + state.db.clone(), + state.config_snapshot(), + state.sidecar.clone(), + state.job_lock.clone(), + None, + ) { + log::error!("启动扫描任务创建失败: {}", error); + } + } + Ok(()) + }) .invoke_handler(tauri::generate_handler![ - commands::sync::sync_all, + commands::jobs::start_sync, + commands::jobs::start_analysis, + commands::jobs::list_jobs, + commands::jobs::get_job, + commands::jobs::cancel_job, + commands::jobs::retry_job_item, commands::sessions::list_sessions, commands::sessions::count_sessions_by_filter_groups, commands::sessions::delete_sessions_by_filter_groups, commands::sessions::get_session, commands::sessions::get_session_messages, - commands::sessions::distill_session, commands::cards::search_cards, commands::cards::list_cards, commands::cards::get_card, + commands::cards::update_card, + commands::cards::publish_card, + commands::cards::list_tag_records, + commands::cards::merge_tags, commands::export::export_card_markdown, commands::export::export_cards_markdown_dir, commands::export::export_all_cards_markdown_dir, @@ -100,8 +129,11 @@ pub fn run() { commands::sidebar::list_tech_stack_counts, commands::sidebar::list_card_types, commands::config::get_config, + commands::config::get_database_backup_path, commands::config::save_config, - commands::cli_probe::probe_cli_tools, + commands::config::list_provider_models, + commands::config::test_provider, + commands::mcp::get_mcp_info, ]) .run(context) .expect("Tauri 应用启动失败"); diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index b15797a..7ec81a9 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,5 +1,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - xunji_desktop_lib::run() + chattake_desktop_lib::run() } diff --git a/apps/desktop/src-tauri/src/path_local.rs b/apps/desktop/src-tauri/src/path_local.rs index 28a8084..d6db146 100644 --- a/apps/desktop/src-tauri/src/path_local.rs +++ b/apps/desktop/src-tauri/src/path_local.rs @@ -11,8 +11,6 @@ //! "/Users/x/向善数据" //! ``` -use std::path::Path; - use url::Url; /// 将 Cursor `workspace.json` 的 `folder` 字段或库内已存的本地路径字符串解码为绝对路径。 @@ -56,28 +54,6 @@ pub fn decode_cursor_folder_to_local_path(folder: &str) -> String { raw.to_string() } -/// 对可能含 `%` 的本地路径做解码;若从路径可得到末级目录名,则一并返回用于 `project_name`。 -/// -/// 用于迁移:同时修正 `project_path` 与由路径推导的 `project_name`。 -pub fn decode_session_paths( - project_path: Option, - project_name: Option, -) -> (Option, Option) { - let new_path = project_path.map(|s| decode_cursor_folder_to_local_path(&s)); - - let new_name = if let Some(ref p) = new_path { - Path::new(p) - .file_name() - .and_then(|n| n.to_str()) - .map(String::from) - .or_else(|| project_name.map(|s| decode_cursor_folder_to_local_path(&s))) - } else { - project_name.map(|s| decode_cursor_folder_to_local_path(&s)) - }; - - (new_path, new_name) -} - #[cfg(test)] mod tests { use super::*; @@ -85,18 +61,27 @@ mod tests { #[test] fn decodes_file_url_with_percent_encoded_segment() { let s = "file:///Users/steve/%E5%90%91%E5%96%84%E6%95%B0%E6%8D%AE"; - assert_eq!(decode_cursor_folder_to_local_path(s), "/Users/steve/向善数据"); + assert_eq!( + decode_cursor_folder_to_local_path(s), + "/Users/steve/向善数据" + ); } #[test] fn decodes_plain_path_with_percent_encoded_segment() { let s = "/Users/steve/%E5%90%91%E5%96%84%E6%95%B0%E6%8D%AE"; - assert_eq!(decode_cursor_folder_to_local_path(s), "/Users/steve/向善数据"); + assert_eq!( + decode_cursor_folder_to_local_path(s), + "/Users/steve/向善数据" + ); } #[test] fn passthrough_ascii_path_without_percent() { let s = "/Users/steve/myproject"; - assert_eq!(decode_cursor_folder_to_local_path(s), "/Users/steve/myproject"); + assert_eq!( + decode_cursor_folder_to_local_path(s), + "/Users/steve/myproject" + ); } } diff --git a/apps/desktop/src-tauri/src/sidecar/mod.rs b/apps/desktop/src-tauri/src/sidecar/mod.rs index eb08e05..a605d3f 100644 --- a/apps/desktop/src-tauri/src/sidecar/mod.rs +++ b/apps/desktop/src-tauri/src/sidecar/mod.rs @@ -2,15 +2,15 @@ //! //! ```text //! Rust (SidecarManager) -//! ├── spawn() → 启动 xunji-sidecar 进程 +//! ├── spawn() → 启动 chattake-sidecar 进程 //! ├── call() → 通过 RpcClient 发送 JSON-RPC 请求 //! └── stop() → 终止进程 //! ``` //! //! 解析顺序(后者为兜底): -//! 1. 开发:仓库 `packages/sidecar/dist/xunji-sidecar`(tauri dev / cargo run) -//! 2. 安装包:`resource_dir/xunji-sidecar`(与 `tauri.conf.json` 的 `bundle.resources` 一致) -//! 3. 用户全局:`~/.xunji/bin/xunji-sidecar` +//! 1. 开发:仓库 `packages/sidecar/dist/chattake-sidecar`(tauri dev / cargo run) +//! 2. 安装包:`resource_dir/chattake-sidecar`(与 `tauri.conf.json` 的 `bundle.resources` 一致) +//! 3. 用户全局:`~/.chattake/bin/chattake-sidecar` pub mod rpc; @@ -19,10 +19,10 @@ use std::process::{Child, Command, Stdio}; use std::sync::Mutex; use rpc::{RpcClient, RpcError}; -use tauri::utils::platform::resource_dir; -use tauri::utils::{Env, PackageInfo}; use serde::de::DeserializeOwned; use serde_json::Value; +use tauri::utils::platform::resource_dir; +use tauri::utils::{Env, PackageInfo}; /// Sidecar 进程管理器 pub struct SidecarManager { @@ -47,63 +47,81 @@ impl SidecarManager { /// /// `package_info` 来自 [`tauri::generate_context!`],用于与 Tauri CLI 相同的 `resource_dir` 解析(跨平台)。 pub fn find_binary(package_info: &PackageInfo) -> Option { - let env = Env::default(); - - // 1) 开发模式:从 packages/sidecar/dist/ 加载 - // CARGO_MANIFEST_DIR = .../apps/desktop/src-tauri - // parent×3: src-tauri → desktop → apps → xunji root - // Bun `--compile` 在 Windows 上产出 `xunji-sidecar.exe`,与 Unix 无后缀名不同 - let dev_sidecar_name = if cfg!(target_os = "windows") { - "xunji-sidecar.exe" - } else { - "xunji-sidecar" - }; - let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(|p| p.parent()) - .and_then(|p| p.parent()) - .map(|p| p.join("packages/sidecar/dist").join(dev_sidecar_name)); - - if let Some(ref path) = dev_path { - if path.exists() { - log::info!("使用开发模式 sidecar: {}", path.display()); - return Some(path.clone()); - } - } + find_companion_binary(package_info, "chattake-sidecar", "sidecar") + } - // 2) 安装包内:macOS/Linux 为 `xunji-sidecar`,Windows 为 `xunji-sidecar.exe`(见 tauri.windows.conf.json) - if let Ok(dir) = resource_dir(package_info, &env) { - let bundled = if cfg!(target_os = "windows") { - dir.join("xunji-sidecar.exe") - } else { - dir.join("xunji-sidecar") - }; - if bundled.exists() { - log::info!("使用安装包内 sidecar: {}", bundled.display()); - return Some(bundled); - } + /// 查找开发目录、安装包资源或用户目录中的伴随二进制。 + pub fn find_companion_binary( + package_info: &PackageInfo, + binary_name: &str, + package_dir: &str, + ) -> Option { + find_companion_binary(package_info, binary_name, package_dir) + } +} + +fn find_companion_binary( + package_info: &PackageInfo, + binary_name: &str, + package_dir: &str, +) -> Option { + let env = Env::default(); + + // 1) 开发模式:从 packages/sidecar/dist/ 加载 + // CARGO_MANIFEST_DIR = .../apps/desktop/src-tauri + // parent×3: src-tauri → desktop → apps → chattake root + // Bun `--compile` 在 Windows 上产出 `chattake-sidecar.exe`,与 Unix 无后缀名不同 + let file_name = if cfg!(target_os = "windows") { + format!("{binary_name}.exe") + } else { + binary_name.to_string() + }; + let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + .map(|p| { + p.join("packages") + .join(package_dir) + .join("dist") + .join(&file_name) + }); + + if let Some(ref path) = dev_path { + if path.exists() { + log::info!("使用开发模式伴随程序: {}", path.display()); + return Some(path.clone()); } + } - // 3) 全局安装位置(可选覆盖) - if let Some(home) = dirs::home_dir() { - let global_path = if cfg!(target_os = "windows") { - home.join(".xunji/bin/xunji-sidecar.exe") - } else { - home.join(".xunji/bin/xunji-sidecar") - }; - if global_path.exists() { - log::info!("使用全局 sidecar: {}", global_path.display()); - return Some(global_path); - } + // 2) 安装包内:macOS/Linux 为 `chattake-sidecar`,Windows 为 `chattake-sidecar.exe`(见 tauri.windows.conf.json) + if let Ok(dir) = resource_dir(package_info, &env) { + let bundled = dir.join(&file_name); + if bundled.exists() { + log::info!("使用安装包内 sidecar: {}", bundled.display()); + return Some(bundled); } + } - log::warn!("未找到 sidecar 二进制文件"); - None + // 3) 全局安装位置(可选覆盖) + if let Some(home) = dirs::home_dir() { + let global_path = home.join(".chattake/bin").join(&file_name); + if global_path.exists() { + log::info!("使用全局 sidecar: {}", global_path.display()); + return Some(global_path); + } } + log::warn!("未找到伴随程序: {binary_name}"); + None +} + +impl SidecarManager { /// 启动 sidecar 进程 pub fn start(&self) -> Result<(), RpcError> { - let mut proc_guard = self.process.lock() + let mut proc_guard = self + .process + .lock() .map_err(|_| RpcError::Internal("process lock poisoned".into()))?; if proc_guard.is_some() { @@ -119,16 +137,22 @@ impl SidecarManager { .spawn() .map_err(|e| RpcError::Io(format!("启动 sidecar 失败: {}", e)))?; - let stdin = child.stdin.take() + let stdin = child + .stdin + .take() .ok_or_else(|| RpcError::Internal("无法获取 sidecar stdin".into()))?; - let stdout = child.stdout.take() + let stdout = child + .stdout + .take() .ok_or_else(|| RpcError::Internal("无法获取 sidecar stdout".into()))?; let rpc_client = RpcClient::new(stdin, stdout); // 先存进程再存 client *proc_guard = Some(child); - let mut client_guard = self.client.lock() + let mut client_guard = self + .client + .lock() .map_err(|_| RpcError::Internal("client lock poisoned".into()))?; *client_guard = Some(rpc_client); @@ -138,16 +162,43 @@ impl SidecarManager { /// 停止 sidecar 进程 pub fn stop(&self) -> Result<(), RpcError> { - let mut proc_guard = self.process.lock() + let mut proc_guard = self + .process + .lock() .map_err(|_| RpcError::Internal("process lock poisoned".into()))?; if let Some(mut child) = proc_guard.take() { log::info!("停止 sidecar 进程..."); - let _ = child.kill(); + #[cfg(unix)] + let terminated = Command::new("kill") + .arg("-TERM") + .arg(child.id().to_string()) + .status() + .map(|status| status.success()) + .unwrap_or(false); + #[cfg(not(unix))] + let terminated = false; + if !terminated { + let _ = child.kill(); + } + if terminated { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + while std::time::Instant::now() < deadline { + if child.try_wait().ok().flatten().is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } + if child.try_wait().ok().flatten().is_none() { + let _ = child.kill(); + } + } let _ = child.wait(); } - let mut client_guard = self.client.lock() + let mut client_guard = self + .client + .lock() .map_err(|_| RpcError::Internal("client lock poisoned".into()))?; *client_guard = None; @@ -195,10 +246,13 @@ impl SidecarManager { self.start()?; } - let client_guard = self.client.lock() + let client_guard = self + .client + .lock() .map_err(|_| RpcError::Internal("client lock poisoned".into()))?; - let client = client_guard.as_ref() + let client = client_guard + .as_ref() .ok_or_else(|| RpcError::Internal("RPC 客户端未就绪".into()))?; client.call(method, params) @@ -215,13 +269,20 @@ impl SidecarManager { self.start()?; } - let client_guard = self.client.lock() - .map_err(|_| RpcError::Internal("client lock poisoned".into()))?; - - let client = client_guard.as_ref() - .ok_or_else(|| RpcError::Internal("RPC 客户端未就绪".into()))?; - - client.call_with_timeout(method, params, timeout) + let result = { + let client_guard = self + .client + .lock() + .map_err(|_| RpcError::Internal("client lock poisoned".into()))?; + let client = client_guard + .as_ref() + .ok_or_else(|| RpcError::Internal("RPC 客户端未就绪".into()))?; + client.call_with_timeout(method, params, timeout) + }; + if matches!(result, Err(RpcError::Timeout(_))) { + let _ = self.stop(); + } + result } } diff --git a/apps/desktop/src-tauri/src/sidecar/rpc.rs b/apps/desktop/src-tauri/src/sidecar/rpc.rs index ae1fb12..4ce3bb4 100644 --- a/apps/desktop/src-tauri/src/sidecar/rpc.rs +++ b/apps/desktop/src-tauri/src/sidecar/rpc.rs @@ -8,8 +8,8 @@ use std::io::{BufRead, BufReader, Write}; use std::process::{ChildStdin, ChildStdout}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Mutex; -use std::time::Duration; +use std::sync::{mpsc, Mutex}; +use std::time::{Duration, Instant}; use serde::de::DeserializeOwned; use serde_json::Value; @@ -19,8 +19,9 @@ const DEFAULT_TIMEOUT_SECS: u64 = 120; /// JSON-RPC 2.0 客户端,持有 sidecar 进程的 stdin/stdout pub struct RpcClient { + calls: Mutex<()>, stdin: Mutex, - stdout: Mutex>, + responses: Mutex>>, request_id: AtomicU64, /// RPC 调用默认超时时间 timeout: Duration, @@ -28,9 +29,32 @@ pub struct RpcClient { impl RpcClient { pub fn new(stdin: ChildStdin, stdout: ChildStdout) -> Self { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => { + let _ = tx.send(Err("Sidecar stdout 已关闭".into())); + break; + } + Ok(_) => { + if tx.send(Ok(line)).is_err() { + break; + } + } + Err(error) => { + let _ = tx.send(Err(error.to_string())); + break; + } + } + } + }); Self { + calls: Mutex::new(()), stdin: Mutex::new(stdin), - stdout: Mutex::new(BufReader::new(stdout)), + responses: Mutex::new(rx), request_id: AtomicU64::new(1), timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS), } @@ -56,6 +80,11 @@ impl RpcClient { params: Value, timeout: Duration, ) -> Result { + // Sidecar 允许并发处理请求,但桌面端当前不需要并发;整体串行可避免响应串线。 + let _call_guard = self + .calls + .lock() + .map_err(|_| RpcError::Internal("call lock poisoned".into()))?; let id = self.request_id.fetch_add(1, Ordering::SeqCst); let request = serde_json::json!({ @@ -65,35 +94,52 @@ impl RpcClient { "id": id, }); - let request_str = serde_json::to_string(&request) - .map_err(|e| RpcError::Serialize(e.to_string()))?; + let request_str = + serde_json::to_string(&request).map_err(|e| RpcError::Serialize(e.to_string()))?; - log::debug!("RPC 请求: method={}, id={}, timeout={}s", method, id, timeout.as_secs()); + log::debug!( + "RPC 请求: method={}, id={}, timeout={}s", + method, + id, + timeout.as_secs() + ); // 写 stdin { - let mut stdin = self.stdin.lock() + let mut stdin = self + .stdin + .lock() .map_err(|_| RpcError::Internal("stdin lock poisoned".into()))?; - writeln!(stdin, "{}", request_str) - .map_err(|e| RpcError::Io(e.to_string()))?; - stdin.flush() - .map_err(|e| RpcError::Io(e.to_string()))?; + writeln!(stdin, "{}", request_str).map_err(|e| RpcError::Io(e.to_string()))?; + stdin.flush().map_err(|e| RpcError::Io(e.to_string()))?; } - // 读 stdout,通过独立线程 + channel 实现超时控制 - let response_str = self.read_response_with_timeout(timeout)?; - - if response_str.trim().is_empty() { - return Err(RpcError::Io("Sidecar 返回空响应(进程可能已退出)".into())); - } - - // 解析响应 - let response: Value = serde_json::from_str(response_str.trim()) - .map_err(|e| RpcError::Deserialize(format!( - "响应解析失败: {} - {}", - e, - &response_str[..response_str.len().min(200)] - )))?; + // 超时请求的迟到响应可能仍在队列中;按 id 丢弃,直到拿到本次响应。 + let deadline = Instant::now() + timeout; + let response = loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(RpcError::Timeout(timeout.as_secs())); + } + let response_str = self.read_response_with_timeout(remaining)?; + if response_str.trim().is_empty() { + return Err(RpcError::Io("Sidecar 返回空响应(进程可能已退出)".into())); + } + let response: Value = serde_json::from_str(response_str.trim()).map_err(|e| { + RpcError::Deserialize(format!( + "响应解析失败: {} - {}", + e, + &response_str[..response_str.len().min(200)] + )) + })?; + if response.get("id").and_then(Value::as_u64) == Some(id) { + break response; + } + log::warn!( + "丢弃非当前 RPC 响应: expected_id={id}, actual_id={:?}", + response.get("id") + ); + }; // 检查 error 字段 if let Some(err) = response.get("error") { @@ -106,7 +152,8 @@ impl RpcClient { } // 提取 result 字段并反序列化为目标类型 - let result = response.get("result") + let result = response + .get("result") .ok_or_else(|| RpcError::Deserialize("响应缺少 result 字段".into()))?; serde_json::from_value(result.clone()) @@ -115,38 +162,20 @@ impl RpcClient { /// 带超时的 stdout 读取。 /// - /// 实现方式:scoped thread 内阻塞读取 + channel recv_timeout。 - /// `std::thread::scope` 保证子线程在作用域内结束,无需 unsafe。 + /// stdout 由常驻读取线程持有;当前线程只在 channel 上等待,因此超时会立即返回。 fn read_response_with_timeout(&self, timeout: Duration) -> Result { - let mut stdout = self.stdout.lock() - .map_err(|_| RpcError::Internal("stdout lock poisoned".into()))?; - - let (tx, rx) = std::sync::mpsc::channel::>(); - - // scoped thread 可以安全借用栈上的 MutexGuard - std::thread::scope(|s| { - let reader = &mut *stdout; - s.spawn(move || { - let mut line = String::new(); - match reader.read_line(&mut line) { - Ok(_) => { let _ = tx.send(Ok(line)); } - Err(e) => { let _ = tx.send(Err(e.to_string())); } - } - }); - - // 在 scope 内等待结果(带超时) - // scope 结束时会自动 join 子线程 - match rx.recv_timeout(timeout) { - Ok(Ok(line)) => Ok(line), - Ok(Err(e)) => Err(RpcError::Io(e)), - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - Err(RpcError::Timeout(timeout.as_secs())) - } - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - Err(RpcError::Io("读取线程异常退出".into())) - } + let responses = self + .responses + .lock() + .map_err(|_| RpcError::Internal("response lock poisoned".into()))?; + match responses.recv_timeout(timeout) { + Ok(Ok(line)) => Ok(line), + Ok(Err(error)) => Err(RpcError::Io(error)), + Err(mpsc::RecvTimeoutError::Timeout) => Err(RpcError::Timeout(timeout.as_secs())), + Err(mpsc::RecvTimeoutError::Disconnected) => { + Err(RpcError::Io("读取线程异常退出".into())) } - }) + } } } diff --git a/apps/desktop/src-tauri/src/storage/analysis.rs b/apps/desktop/src-tauri/src/storage/analysis.rs new file mode 100644 index 0000000..5e2a59a --- /dev/null +++ b/apps/desktop/src-tauri/src/storage/analysis.rs @@ -0,0 +1,56 @@ +use chrono::Utc; +use rusqlite::params; +use uuid::Uuid; + +use super::db::{Database, DbResult}; + +impl Database { + pub fn create_analysis_run( + &self, + job_id: &str, + session_id: &str, + profile_id: &str, + provider: &str, + base_url_host: &str, + model: &str, + content_hash: &str, + prompt_version: &str, + ) -> DbResult { + let id = Uuid::new_v4().to_string(); + self.conn().execute( + "INSERT INTO analysis_runs(id,job_id,session_id,provider_profile_id,provider,base_url_host,model,content_hash,prompt_version,created_at) + VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)", + params![id, job_id, session_id, profile_id, provider, base_url_host, model, content_hash, prompt_version, Utc::now().to_rfc3339()], + )?; + Ok(id) + } + + #[allow(clippy::too_many_arguments)] + pub fn finish_analysis_run( + &self, + id: &str, + value: Option<&str>, + reason: Option<&str>, + judge_tokens: (i64, i64), + extract_tokens: (i64, i64), + duration_ms: i64, + error_kind: Option<&str>, + error: Option<&str>, + ) -> DbResult<()> { + self.conn().execute( + "UPDATE analysis_runs SET value=?1,reason=?2,prompt_tokens_judge=?3,completion_tokens_judge=?4, + prompt_tokens_extract=?5,completion_tokens_extract=?6,duration_ms=?7,error_kind=?8,error=?9,finished_at=?10 WHERE id=?11", + params![value, reason, judge_tokens.0, judge_tokens.1, extract_tokens.0, extract_tokens.1, + duration_ms, error_kind, error, Utc::now().to_rfc3339(), id], + )?; + Ok(()) + } + + pub fn session_has_cards(&self, session_id: &str) -> DbResult { + Ok(self.read_conn()?.query_row( + "SELECT EXISTS(SELECT 1 FROM cards WHERE session_id=?1)", + params![session_id], + |row| row.get::<_, i64>(0), + )? != 0) + } +} diff --git a/apps/desktop/src-tauri/src/storage/cards.rs b/apps/desktop/src-tauri/src/storage/cards.rs index 019dc70..f383122 100644 --- a/apps/desktop/src-tauri/src/storage/cards.rs +++ b/apps/desktop/src-tauri/src/storage/cards.rs @@ -1,7 +1,5 @@ -use std::collections::HashMap; - use chrono::Utc; -use rusqlite::{params, OptionalExtension, Row}; +use rusqlite::{params, OptionalExtension, Row, Transaction}; use uuid::Uuid; use super::db::{Database, DbError, DbResult}; @@ -11,8 +9,7 @@ use super::models::*; // // cards.rs 和 search.rs 共用这些函数,避免列顺序不一致导致的映射错误。 -pub(super) const CARD_SUMMARY_COLUMNS: &str = - r#"c.id, c.session_id, c.title, c."type", c.value, c.summary, c.category_id, c.source_name, c.project_name, c.created_at, c.updated_at"#; +pub(super) const CARD_SUMMARY_COLUMNS: &str = r#"c.id, c.session_id, c.title, c."type", c.value, c.summary, c.publication_status, c.source_name, c.project_name, c.created_at, c.updated_at"#; /// 从查询行映射 CardSummary(列顺序需与 CARD_SUMMARY_COLUMNS 一致) pub(super) fn card_summary_from_row(row: &Row<'_>) -> rusqlite::Result { @@ -23,35 +20,29 @@ pub(super) fn card_summary_from_row(row: &Row<'_>) -> rusqlite::Result) -> rusqlite::Result { - // tech_stack 以逗号分隔字符串存储,读出后拆分还原为 Vec - let tech_stack_str: Option = row.get(18)?; - let tech_stack = tech_stack_str - .unwrap_or_default() - .split(',') - .filter(|s| !s.is_empty()) - .map(String::from) - .collect(); - Ok(Card { id: row.get(0)?, session_id: row.get(1)?, - title: row.get(2)?, - card_type: row.get(3)?, - value: row.get(4)?, - summary: row.get(5)?, - note: row.get(6)?, - category_id: row.get(7)?, - memory: row.get(8)?, - skill: row.get(9)?, + analysis_run_id: row.get(2)?, + title: row.get(3)?, + card_type: row.get(4)?, + value: row.get(5)?, + summary: row.get(6)?, + note: row.get(7)?, + publication_status: row.get(8)?, + is_user_edited: row.get::<_, i64>(9)? != 0, source_name: row.get(10)?, project_name: row.get(11)?, prompt_tokens: row.get(12)?, @@ -61,13 +52,69 @@ fn card_from_row(row: &Row<'_>) -> rusqlite::Result { created_at: row.get(16)?, updated_at: row.get(17)?, tags: Vec::new(), - tech_stack, - // 19–20:与 `get_card` 中 JOIN sessions 列顺序一致 - source_session_external_id: row.get(19)?, - source_session_path: row.get(20)?, + tech_stack: Vec::new(), + source_session_external_id: row.get(18)?, + source_session_path: row.get(19)?, }) } +fn rebuild_card_fts(tx: &Transaction<'_>, card_id: &str) -> rusqlite::Result<()> { + let row = tx.query_row( + "SELECT rowid,title,summary,note FROM cards WHERE id=?1", + params![card_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + )?; + let labels = |kind: &str| -> rusqlite::Result { + tx.query_row( + "SELECT COALESCE(GROUP_CONCAT(t.name, ','),'') FROM tags t JOIN card_tags ct ON ct.tag_id=t.id WHERE ct.card_id=?1 AND t.kind=?2", + params![card_id, kind], + |row| row.get(0), + ) + }; + tx.execute("DELETE FROM cards_fts WHERE rowid=?1", params![row.0])?; + tx.execute( + "INSERT INTO cards_fts(rowid,title,summary,note,tags,technologies) VALUES(?1,?2,?3,?4,?5,?6)", + params![row.0, row.1, row.2, row.3, labels("topic")?, labels("technology")?], + )?; + Ok(()) +} + +fn attach_labels( + tx: &Transaction<'_>, + card_id: &str, + kind: &str, + names: &[String], +) -> rusqlite::Result<()> { + for name in names { + let display = name.trim(); + let normalized = display.to_lowercase(); + if normalized.is_empty() { + continue; + } + tx.execute( + "INSERT OR IGNORE INTO tags(id,name,normalized_name,kind) VALUES(?1,?2,?3,?4)", + params![Uuid::new_v4().to_string(), display, normalized, kind], + )?; + let tag_id: String = tx.query_row( + "SELECT id FROM tags WHERE kind=?1 AND normalized_name=?2", + params![kind, normalized], + |row| row.get(0), + )?; + tx.execute( + "INSERT OR IGNORE INTO card_tags(card_id,tag_id) VALUES(?1,?2)", + params![card_id, tag_id], + )?; + } + Ok(()) +} + // ─────────────────────────── 动态筛选条件构建 ───────────────────────── // // 被 list_cards 和 search_cards 共用。 @@ -78,8 +125,15 @@ fn card_from_row(row: &Row<'_>) -> rusqlite::Result { /// 根据 CardFilters 构建 WHERE 子句和参数列表 pub(super) fn build_card_where(filters: &CardFilters) -> (String, Vec) { - let mut conds: Vec = Vec::new(); + let mut conds: Vec = vec!["c.publication_status = ?".to_string()]; let mut params: Vec = Vec::new(); + params.push( + filters + .publication_status + .as_deref() + .unwrap_or("published") + .to_string(), + ); if let Some(ref t) = filters.card_type { conds.push(r#"c."type" = ?"#.to_string()); @@ -101,7 +155,7 @@ pub(super) fn build_card_where(filters: &CardFilters) -> (String, Vec) { "c.id IN (\ SELECT ct.card_id FROM card_tags ct \ INNER JOIN tags t ON ct.tag_id = t.id \ - WHERE t.name IN ({placeholders}) \ + WHERE t.kind = 'topic' AND t.name IN ({placeholders}) \ GROUP BY ct.card_id HAVING COUNT(DISTINCT t.name) = {n}\ )" )); @@ -109,7 +163,6 @@ pub(super) fn build_card_where(filters: &CardFilters) -> (String, Vec) { } } - // 技术栈存为逗号分隔字符串;用边界匹配避免子串误命中(如 go / golang) if let Some(ref stacks) = filters.tech_stack { let unique: Vec<&String> = { let mut seen = std::collections::HashSet::new(); @@ -119,12 +172,15 @@ pub(super) fn build_card_where(filters: &CardFilters) -> (String, Vec) { .filter(|s| seen.insert(*s)) .collect() }; - for s in unique { - conds.push( - "instr(',' || lower(ifnull(c.tech_stack, '')) || ',', ',' || lower(?) || ',') > 0" - .to_string(), - ); - params.push(s.clone()); + if !unique.is_empty() { + let n = unique.len(); + let placeholders = vec!["?"; n].join(","); + conds.push(format!( + "c.id IN (SELECT ct.card_id FROM card_tags ct INNER JOIN tags t ON ct.tag_id=t.id \ + WHERE t.kind='technology' AND t.name IN ({placeholders}) \ + GROUP BY ct.card_id HAVING COUNT(DISTINCT t.name)={n})" + )); + params.extend(unique.into_iter().cloned()); } } @@ -147,96 +203,62 @@ impl Database { let id = Uuid::new_v4().to_string(); let now = Utc::now().to_rfc3339(); let tags_joined = card.tags.join(","); - // tech_stack 同样逗号拼接存储 - let tech_stack_joined = card.tech_stack.join(","); - + let technologies_joined = card.tech_stack.join(","); let mut conn = self.conn(); let tx = conn.transaction()?; - // 19 列 = 15 个 ? 占位符 + 4 个 NULL(category_id, memory, skill, feedback) - // - // id session_id title type value summary note - // ? ? ? ? ? ? ? - // - // category_id memory skill source_name project_name - // NULL NULL NULL ? ? - // - // prompt_tokens completion_tokens cost_yuan feedback tech_stack created_at updated_at - // ? ? ? NULL ? ? ? tx.execute( "INSERT INTO cards ( - id, session_id, title, type, value, summary, note, - category_id, memory, skill, source_name, project_name, - prompt_tokens, completion_tokens, cost_yuan, feedback, - tech_stack, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?, ?, ?, NULL, ?, ?, ?)", - params![ - &id, card.session_id, card.title, card.card_type, card.value, - card.summary, card.note, card.source_name, card.project_name, - card.prompt_tokens, card.completion_tokens, card.cost_yuan, - &tech_stack_joined, &now, &now, - ], - )?; - - // 标签处理: INSERT OR IGNORE 保证幂等 → 查回 id → 关联 - for tag_name in card.tags { - let tag_id = Uuid::new_v4().to_string(); - tx.execute( - "INSERT OR IGNORE INTO tags (id, name, type) VALUES (?, ?, 'auto')", - params![&tag_id, tag_name.as_str()], - )?; - let resolved_id: String = tx.query_row( - "SELECT id FROM tags WHERE name = ?", - params![tag_name.as_str()], - |row| row.get(0), - )?; - tx.execute( - "INSERT OR IGNORE INTO card_tags (card_id, tag_id) VALUES (?, ?)", - params![&id, &resolved_id], - )?; - } - - // 同步 FTS5 全文索引(独立表,手动写入) - // 取出刚插入行的 rowid,使 FTS 行与 cards 行对应 - let rowid: i64 = tx.query_row( - "SELECT rowid FROM cards WHERE id = ?", - params![&id], - |row| row.get(0), - )?; - tx.execute( - "INSERT INTO cards_fts(rowid, title, summary, note, tags) VALUES (?, ?, ?, ?, ?)", + id, session_id, analysis_run_id, title, type, value, summary, note, + publication_status, source_name, project_name, + prompt_tokens, completion_tokens, cost_yuan, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", params![ - rowid, + &id, + card.session_id, + card.analysis_run_id, card.title, - card.summary.unwrap_or_default(), + card.card_type, + card.value, + card.summary, card.note, - &tags_joined, + card.publication_status, + card.source_name, + card.project_name, + card.prompt_tokens, + card.completion_tokens, + card.cost_yuan, + &now, + &now, ], )?; + attach_labels(&tx, &id, "topic", card.tags)?; + attach_labels(&tx, &id, "technology", card.tech_stack)?; + rebuild_card_fts(&tx, &id)?; + tx.commit()?; log::info!( - "创建卡片: id={}, title={:?}, value={:?}, tags=[{}], tech_stack列='{}' ({}段)", + "创建卡片: id={}, title={:?}, value={:?}, topics=[{}], technologies=[{}]", id, card.title, card.value, tags_joined, - tech_stack_joined, - card.tech_stack.len() + technologies_joined, ); Ok(id) } /// 获取卡片完整信息(含关联标签) pub fn get_card(&self, id: &str) -> DbResult { - let conn = self.conn(); + let conn = self.read_conn()?; let mut card = conn .query_row( - "SELECT c.id, c.session_id, c.title, c.type, c.value, c.summary, c.note, \ - c.category_id, c.memory, c.skill, c.source_name, c.project_name, \ + "SELECT c.id, c.session_id, c.analysis_run_id, c.title, c.type, c.value, c.summary, c.note, \ + c.publication_status, c.is_user_edited, c.source_name, c.project_name, \ c.prompt_tokens, c.completion_tokens, c.cost_yuan, c.feedback, \ - c.created_at, c.updated_at, c.tech_stack, \ - sess.session_id, COALESCE(sess.raw_path, sess.project_path) \ + c.created_at, c.updated_at, \ + sess.external_session_id, COALESCE(sess.raw_path, sess.project_path) \ FROM cards c \ INNER JOIN sessions sess ON c.session_id = sess.id \ WHERE c.id = ?", @@ -248,16 +270,165 @@ impl Database { // 关联查询标签名 let mut stmt = conn.prepare( - "SELECT t.name FROM tags t \ + "SELECT t.name, t.kind FROM tags t \ INNER JOIN card_tags ct ON t.id = ct.tag_id \ - WHERE ct.card_id = ? ORDER BY t.name", + WHERE ct.card_id = ? ORDER BY t.kind, t.name", )?; - card.tags = stmt - .query_map(params![id], |row| row.get::<_, String>(0))? - .collect::, _>>()?; + for row in stmt.query_map(params![id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? { + let (name, kind) = row?; + if kind == "technology" { + card.tech_stack.push(name); + } else { + card.tags.push(name); + } + } Ok(card) } + pub fn update_card(&self, id: &str, update: &CardUpdate<'_>) -> DbResult<()> { + let mut conn = self.conn(); + let tx = conn.transaction()?; + let changed = tx.execute( + "UPDATE cards SET title=?1,type=?2,summary=?3,note=?4,is_user_edited=1,updated_at=?5 WHERE id=?6", + params![update.title, update.card_type, update.summary, update.note, Utc::now().to_rfc3339(), id], + )?; + if changed == 0 { + return Err(DbError::NotFound(format!("card {id}"))); + } + tx.execute("DELETE FROM card_tags WHERE card_id=?1", params![id])?; + attach_labels(&tx, id, "topic", update.tags)?; + attach_labels(&tx, id, "technology", update.technologies)?; + rebuild_card_fts(&tx, id)?; + tx.commit()?; + Ok(()) + } + + pub fn publish_card(&self, id: &str, replace_existing: bool) -> DbResult<()> { + let mut conn = self.conn(); + let tx = conn.transaction()?; + let session_id: String = tx + .query_row( + "SELECT session_id FROM cards WHERE id=?1", + params![id], + |row| row.get(0), + ) + .optional()? + .ok_or_else(|| DbError::NotFound(format!("card {id}")))?; + if replace_existing { + let mut stmt = tx.prepare( + "SELECT id,rowid FROM cards WHERE session_id=?1 AND id<>?2 AND publication_status='published' AND is_user_edited=0", + )?; + let old = stmt + .query_map(params![session_id, id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })? + .collect::, _>>()?; + drop(stmt); + for (old_id, rowid) in old { + tx.execute("DELETE FROM cards_fts WHERE rowid=?1", params![rowid])?; + tx.execute("DELETE FROM cards WHERE id=?1", params![old_id])?; + } + } + tx.execute( + "UPDATE cards SET publication_status='published',updated_at=?1 WHERE id=?2", + params![Utc::now().to_rfc3339(), id], + )?; + tx.commit()?; + Ok(()) + } + + pub fn list_tag_records(&self) -> DbResult> { + let conn = self.read_conn()?; + let mut stmt = + conn.prepare("SELECT id,name,normalized_name,kind FROM tags ORDER BY kind,name")?; + let tags = stmt + .query_map([], |row| { + Ok(Tag { + id: row.get(0)?, + name: row.get(1)?, + normalized_name: row.get(2)?, + kind: row.get(3)?, + }) + })? + .collect::, _>>() + .map_err(DbError::from)?; + Ok(tags) + } + + pub fn merge_tags(&self, kind: &str, sources: &[String], target: &str) -> DbResult<()> { + if !matches!(kind, "topic" | "technology") || target.trim().is_empty() || sources.is_empty() + { + return Err(DbError::Invalid("标签类型、来源或目标无效".into())); + } + let mut conn = self.conn(); + let tx = conn.transaction()?; + let target_name = target.trim(); + let target_normalized = target_name.to_lowercase(); + tx.execute( + "INSERT OR IGNORE INTO tags(id,name,normalized_name,kind) VALUES(?1,?2,?3,?4)", + params![ + Uuid::new_v4().to_string(), + target_name, + target_normalized, + kind + ], + )?; + let target_id: String = tx.query_row( + "SELECT id FROM tags WHERE kind=?1 AND normalized_name=?2", + params![kind, target_normalized], + |row| row.get(0), + )?; + let normalized = sources + .iter() + .map(|name| name.trim().to_lowercase()) + .collect::>(); + let placeholders = vec!["?"; normalized.len()].join(","); + let sql = format!( + "SELECT id FROM tags WHERE kind=? AND normalized_name IN ({placeholders}) AND id<>?" + ); + let mut values = vec![kind.to_string()]; + values.extend(normalized); + values.push(target_id.clone()); + let mut stmt = tx.prepare(&sql)?; + let source_ids = stmt + .query_map(rusqlite::params_from_iter(values.iter()), |row| { + row.get::<_, String>(0) + })? + .collect::, _>>()?; + drop(stmt); + if source_ids.is_empty() { + return Ok(()); + } + let source_placeholders = vec!["?"; source_ids.len()].join(","); + let affected_sql = format!( + "SELECT DISTINCT card_id FROM card_tags WHERE tag_id IN ({source_placeholders})" + ); + let mut affected_stmt = tx.prepare(&affected_sql)?; + let affected = affected_stmt + .query_map(rusqlite::params_from_iter(source_ids.iter()), |row| { + row.get::<_, String>(0) + })? + .collect::, _>>()?; + drop(affected_stmt); + for card_id in &affected { + tx.execute( + "INSERT OR IGNORE INTO card_tags(card_id,tag_id) VALUES(?1,?2)", + params![card_id, target_id], + )?; + } + let delete_links = format!("DELETE FROM card_tags WHERE tag_id IN ({source_placeholders})"); + tx.execute(&delete_links, rusqlite::params_from_iter(source_ids.iter()))?; + let delete_tags = format!("DELETE FROM tags WHERE id IN ({source_placeholders})"); + tx.execute(&delete_tags, rusqlite::params_from_iter(source_ids.iter()))?; + for card_id in &affected { + rebuild_card_fts(&tx, card_id)?; + } + tx.commit()?; + Ok(()) + } + /// 分页查询知识卡片列表,支持按类型/价值/标签筛选 pub fn list_cards( &self, @@ -270,7 +441,7 @@ impl Database { let limit = page_size as i64; let offset = (page - 1) as i64 * limit; - let conn = self.conn(); + let conn = self.read_conn()?; let count_sql = format!("SELECT COUNT(*) FROM cards c{}", where_sql); let total: i64 = conn.query_row( @@ -300,16 +471,20 @@ impl Database { /// 库内卡片总数(不受筛选;供「导出全部」前提示条数) pub fn count_all_cards(&self) -> DbResult { - let n: i64 = self - .conn() - .query_row("SELECT COUNT(*) FROM cards", [], |r| r.get(0))?; + let n: i64 = self.read_conn()?.query_row( + "SELECT COUNT(*) FROM cards WHERE publication_status='published'", + [], + |r| r.get(0), + )?; Ok(n.max(0) as u64) } /// 全部卡片 id(按创建时间升序,批量导出顺序稳定) pub fn list_all_card_ids(&self) -> DbResult> { - let conn = self.conn(); - let mut stmt = conn.prepare("SELECT id FROM cards ORDER BY created_at ASC")?; + let conn = self.read_conn()?; + let mut stmt = conn.prepare( + "SELECT id FROM cards WHERE publication_status='published' ORDER BY created_at ASC", + )?; let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; rows.collect::, _>>().map_err(DbError::from) } @@ -338,11 +513,7 @@ impl Database { } tx.commit()?; if !ids.is_empty() { - log::info!( - "已删除会话 {} 下的 {} 张旧卡片", - session_db_id, - ids.len() - ); + log::info!("已删除会话 {} 下的 {} 张旧卡片", session_db_id, ids.len()); } Ok(ids.len() as u64) } @@ -380,11 +551,13 @@ impl Database { /// 查询所有标签及其关联的卡片数量(按数量降序),用于知识库侧栏标签筛选。 pub fn list_all_tags(&self) -> DbResult> { - let conn = self.conn(); + let conn = self.read_conn()?; let mut stmt = conn.prepare( "SELECT t.name, COUNT(ct.card_id) as cnt FROM tags t LEFT JOIN card_tags ct ON t.id = ct.tag_id + LEFT JOIN cards c ON c.id = ct.card_id + WHERE t.kind = 'topic' AND c.publication_status = 'published' GROUP BY t.id, t.name HAVING cnt > 0 ORDER BY cnt DESC, t.name ASC", @@ -398,48 +571,34 @@ impl Database { rows.collect::, _>>().map_err(DbError::from) } - /// 从 `cards.tech_stack` 逗号分隔列聚合技术栈及卡片命中次数(知识库侧栏「技术栈」区块)。 - /// 展示名取**首次出现**的写法;统计时按小写合并(Rust/rust 视为同一项)。 + /// 聚合 tags.kind=technology 的已发布卡片数量。 pub fn list_all_tech_stack_counts(&self) -> DbResult> { - let conn = self.conn(); + let conn = self.read_conn()?; let mut stmt = conn.prepare( - "SELECT tech_stack FROM cards WHERE tech_stack IS NOT NULL AND TRIM(tech_stack) != ''", + "SELECT t.name, COUNT(ct.card_id) AS cnt + FROM tags t + JOIN card_tags ct ON ct.tag_id = t.id + JOIN cards c ON c.id = ct.card_id + WHERE t.kind = 'technology' AND c.publication_status = 'published' + GROUP BY t.id, t.name + ORDER BY cnt DESC, t.name ASC", )?; - let mut counts: HashMap = HashMap::new(); - let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; - for row in rows { - let s = row?; - for part in s.split(',') { - let t = part.trim(); - if t.is_empty() { - continue; - } - let key = t.to_lowercase(); - let e = counts - .entry(key) - .or_insert_with(|| (t.to_string(), 0)); - e.1 += 1; - } - } - let mut v: Vec = counts - .into_values() - .map(|(name, count)| TagCount { name, count }) - .collect(); - v.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name))); - log::debug!( - "list_all_tech_stack_counts: {} 个不同技术栈条目", - v.len() - ); - Ok(v) + let rows = stmt.query_map([], |row| { + Ok(TagCount { + name: row.get(0)?, + count: row.get(1)?, + }) + })?; + rows.collect::, _>>().map_err(DbError::from) } /// 按知识类型统计卡片数量(按数量降序),用于知识库侧栏类型筛选。 pub fn list_card_type_counts(&self) -> DbResult> { - let conn = self.conn(); + let conn = self.read_conn()?; let mut stmt = conn.prepare( r#"SELECT "type", COUNT(*) as cnt FROM cards - WHERE "type" IS NOT NULL AND "type" != '' + WHERE publication_status = 'published' GROUP BY "type" ORDER BY cnt DESC, "type" ASC"#, )?; @@ -452,3 +611,160 @@ impl Database { rows.collect::, _>>().map_err(DbError::from) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stores_topic_and_technology_tags_and_hides_drafts_from_library() { + let dir = tempfile::tempdir().unwrap(); + let db = Database::open(&dir.path().join("cards.db")).unwrap(); + let session_id = db + .insert_session( + "codex", + "external", + "local", + None, + None, + 0, + Some("hash"), + "/tmp/source", + "2026-08-30T00:00:00Z", + "2026-08-30T00:00:00Z", + None, + ) + .unwrap(); + let topics = vec!["全文检索".to_string()]; + let technologies = vec!["SQLite".to_string(), "Rust".to_string()]; + let job = db + .create_job( + "analysis", + "queued", + Some(( + "profile", + "siliconflow", + "https://api.siliconflow.cn/v1", + "model", + )), + &[NewJobItem { + session_id: Some(&session_id), + source_id: None, + raw_path: None, + }], + ) + .unwrap(); + let run_id = db + .create_analysis_run( + &job.id, + &session_id, + "profile", + "siliconflow", + "api.siliconflow.cn", + "model", + "hash", + "test", + ) + .unwrap(); + let high_id = db + .insert_card(&NewCard { + session_id: &session_id, + analysis_run_id: &run_id, + title: "SQLite FTS5 索引", + card_type: "implementation", + value: "high", + summary: "建立 trigram 索引", + note: "SQLite FTS5 trigram", + publication_status: "published", + source_name: Some("Codex"), + project_name: None, + prompt_tokens: 1, + completion_tokens: 1, + cost_yuan: 0.0, + tags: &topics, + tech_stack: &technologies, + }) + .unwrap(); + let draft_id = db + .insert_card(&NewCard { + session_id: &session_id, + analysis_run_id: &run_id, + title: "草稿", + card_type: "explanation", + value: "medium", + summary: "待确认", + note: "draft body", + publication_status: "draft", + source_name: Some("Codex"), + project_name: None, + prompt_tokens: 1, + completion_tokens: 1, + cost_yuan: 0.0, + tags: &topics, + tech_stack: &technologies, + }) + .unwrap(); + + assert_eq!( + db.get_card(&high_id).unwrap().publication_status, + "published" + ); + assert_eq!(db.get_card(&draft_id).unwrap().publication_status, "draft"); + assert_eq!( + db.list_cards(&CardFilters::default(), 1, 20).unwrap().total, + 1 + ); + assert_eq!(db.list_all_tags().unwrap()[0].name, "全文检索"); + assert_eq!(db.list_all_tech_stack_counts().unwrap().len(), 2); + assert_eq!( + db.search_cards("SQLite", &CardFilters::default()) + .unwrap() + .len(), + 1 + ); + + db.update_card( + &draft_id, + &CardUpdate { + title: "人工整理草稿", + card_type: "explanation", + summary: "已校订", + note: "manual SQLite note", + tags: &["数据库".into()], + technologies: &["SQLite".into()], + }, + ) + .unwrap(); + assert!(db.get_card(&draft_id).unwrap().is_user_edited); + db.publish_card(&draft_id, false).unwrap(); + db.merge_tags("topic", &["数据库".into(), "全文检索".into()], "数据检索") + .unwrap(); + assert_eq!(db.get_card(&draft_id).unwrap().tags, vec!["数据检索"]); + + let replacement = db + .insert_card(&NewCard { + session_id: &session_id, + analysis_run_id: &run_id, + title: "替换草稿", + card_type: "decision", + value: "high", + summary: "替换旧自动知识", + note: "replacement", + publication_status: "draft", + source_name: Some("Codex"), + project_name: None, + prompt_tokens: 1, + completion_tokens: 1, + cost_yuan: 0.0, + tags: &[], + tech_stack: &[], + }) + .unwrap(); + db.publish_card(&replacement, true).unwrap(); + assert!(db.get_card(&high_id).is_err()); + assert!( + db.get_card(&draft_id).is_ok(), + "人工编辑的旧知识不得自动删除" + ); + } +} diff --git a/apps/desktop/src-tauri/src/storage/db.rs b/apps/desktop/src-tauri/src/storage/db.rs index 09b1609..d4c1969 100644 --- a/apps/desktop/src-tauri/src/storage/db.rs +++ b/apps/desktop/src-tauri/src/storage/db.rs @@ -2,7 +2,9 @@ use std::fs; use std::path::{Path, PathBuf}; use std::sync::Mutex; -use rusqlite::Connection; +use chrono::Utc; +use rusqlite::{params, Connection, OpenFlags}; +use uuid::Uuid; use super::migrations; @@ -14,6 +16,8 @@ pub enum DbError { Io(#[from] std::io::Error), #[error("Not found: {0}")] NotFound(String), + #[error("Invalid data: {0}")] + Invalid(String), } pub type DbResult = Result; @@ -21,10 +25,11 @@ pub type DbResult = Result; /// 应用数据库,封装 SQLite 连接。 /// /// 使用 Mutex 保证多线程安全(Tauri 的 invoke 在异步线程中调用)。 -/// 默认路径: ~/.xunji/db/xunji.db +/// 默认路径: ~/.chattake/db/chattake.db pub struct Database { conn: Mutex, path: PathBuf, + last_backup_path: Option, } impl Database { @@ -36,6 +41,12 @@ impl Database { log::info!("打开数据库: {}", path.display()); let conn = Connection::open(path)?; + let version: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; + let last_backup_path = if version > 0 && version < migrations::SCHEMA_VERSION { + Some(backup_before_rebuild(&conn, path, version)?) + } else { + None + }; // WAL 模式允许 MCP Server 只读并发访问;busy_timeout 避免写锁竞争时立即失败 conn.execute_batch( "PRAGMA journal_mode=WAL; @@ -46,16 +57,17 @@ impl Database { let db = Self { conn: Mutex::new(conn), path: path.to_path_buf(), + last_backup_path, }; db.migrate()?; log::info!("数据库就绪"); Ok(db) } - /// 使用默认路径 ~/.xunji/db/xunji.db 打开数据库 + /// 使用默认路径 ~/.chattake/db/chattake.db 打开数据库 pub fn open_default() -> DbResult { let base = dirs::home_dir().expect("无法获取用户主目录"); - let path = base.join(".xunji").join("db").join("xunji.db"); + let path = base.join(".chattake").join("db").join("chattake.db"); Self::open(&path) } @@ -64,11 +76,79 @@ impl Database { self.conn.lock().expect("数据库 Mutex 已中毒") } + /// 列表、搜索和详情使用独立短连接,避免长查询占用串行写锁。 + pub fn read_conn(&self) -> DbResult { + let conn = Connection::open_with_flags( + &self.path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + conn.execute_batch("PRAGMA query_only=ON; PRAGMA busy_timeout=5000;")?; + Ok(conn) + } + pub fn path(&self) -> &Path { &self.path } + pub fn last_backup_path(&self) -> Option<&Path> { + self.last_backup_path.as_deref() + } + fn migrate(&self) -> DbResult<()> { migrations::run(&self.conn()) } } + +fn backup_before_rebuild(conn: &Connection, db_path: &Path, version: u32) -> DbResult { + let parent = db_path + .parent() + .ok_or_else(|| DbError::Invalid("数据库路径缺少父目录".into()))?; + let backup_dir = parent.join("backups"); + fs::create_dir_all(&backup_dir)?; + let stamp = Utc::now().format("%Y%m%d-%H%M%S"); + let backup_path = backup_dir.join(format!( + "chattake-v{version}-{stamp}-{}.db", + &Uuid::new_v4().to_string()[..8] + )); + conn.execute( + "VACUUM INTO ?1", + params![backup_path.to_string_lossy().as_ref()], + )?; + log::warn!("旧数据库已备份,可恢复路径: {}", backup_path.display()); + Ok(backup_path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backs_up_before_rebuilding_old_database() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chattake.db"); + let old = Connection::open(&path).unwrap(); + old.execute_batch( + "CREATE TABLE legacy(id INTEGER); INSERT INTO legacy VALUES(1); PRAGMA user_version=6;", + ) + .unwrap(); + drop(old); + + let db = Database::open(&path).unwrap(); + let backup = db.last_backup_path().expect("backup path"); + assert!(backup.exists()); + let backup_conn = Connection::open(backup).unwrap(); + assert_eq!( + backup_conn + .query_row("SELECT COUNT(*) FROM legacy", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!( + db.conn() + .pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) + .unwrap(), + migrations::SCHEMA_VERSION + ); + } +} diff --git a/apps/desktop/src-tauri/src/storage/jobs.rs b/apps/desktop/src-tauri/src/storage/jobs.rs new file mode 100644 index 0000000..feaa15c --- /dev/null +++ b/apps/desktop/src-tauri/src/storage/jobs.rs @@ -0,0 +1,366 @@ +use chrono::Utc; +use rusqlite::{params, OptionalExtension, Row}; +use uuid::Uuid; + +use super::db::{Database, DbError, DbResult}; +use super::models::{Job, JobItem, NewJobItem}; + +fn item_from_row(row: &Row<'_>) -> rusqlite::Result { + Ok(JobItem { + id: row.get(0)?, + job_id: row.get(1)?, + session_id: row.get(2)?, + source_id: row.get(3)?, + raw_path: row.get(4)?, + status: row.get(5)?, + phase: row.get(6)?, + duration_ms: row.get(7)?, + error: row.get(8)?, + created_at: row.get(9)?, + started_at: row.get(10)?, + finished_at: row.get(11)?, + }) +} + +fn job_from_row(row: &Row<'_>) -> rusqlite::Result { + Ok(Job { + id: row.get(0)?, + kind: row.get(1)?, + status: row.get(2)?, + phase: row.get(3)?, + done: row.get(4)?, + total: row.get(5)?, + cancel_requested: row.get::<_, i64>(6)? != 0, + error: row.get(7)?, + provider_profile_id: row.get(8)?, + provider: row.get(9)?, + base_url: row.get(10)?, + model: row.get(11)?, + created_at: row.get(12)?, + started_at: row.get(13)?, + finished_at: row.get(14)?, + items: Vec::new(), + }) +} + +const JOB_COLUMNS: &str = "id,kind,status,phase,done,total,cancel_requested,error,provider_profile_id,provider,base_url,model,created_at,started_at,finished_at"; +const ITEM_COLUMNS: &str = "id,job_id,session_id,source_id,raw_path,status,phase,duration_ms,error,created_at,started_at,finished_at"; + +impl Database { + pub fn create_job( + &self, + kind: &str, + phase: &str, + profile: Option<(&str, &str, &str, &str)>, + items: &[NewJobItem<'_>], + ) -> DbResult { + if items.is_empty() { + return Err(DbError::Invalid("任务至少需要一个条目".into())); + } + let id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + let mut conn = self.conn(); + let tx = conn.transaction()?; + let (profile_id, provider, base_url, model) = profile + .map(|value| (Some(value.0), Some(value.1), Some(value.2), Some(value.3))) + .unwrap_or((None, None, None, None)); + tx.execute( + "INSERT INTO jobs(id,kind,status,phase,total,provider_profile_id,provider,base_url,model,created_at) + VALUES(?1,?2,'queued',?3,?4,?5,?6,?7,?8,?9)", + params![id, kind, phase, items.len() as i64, profile_id, provider, base_url, model, now], + )?; + for item in items { + tx.execute( + "INSERT INTO job_items(id,job_id,session_id,source_id,raw_path,status,phase,created_at) + VALUES(?1,?2,?3,?4,?5,'queued','queued',?6)", + params![Uuid::new_v4().to_string(), id, item.session_id, item.source_id, item.raw_path, now], + )?; + } + tx.commit()?; + self.get_job(&id) + } + + pub fn get_job(&self, id: &str) -> DbResult { + let conn = self.read_conn()?; + let mut job = conn + .query_row( + &format!("SELECT {JOB_COLUMNS} FROM jobs WHERE id=?1"), + params![id], + job_from_row, + ) + .optional()? + .ok_or_else(|| DbError::NotFound(format!("job {id}")))?; + let mut stmt = conn.prepare(&format!( + "SELECT {ITEM_COLUMNS} FROM job_items WHERE job_id=?1 ORDER BY created_at,id" + ))?; + job.items = stmt + .query_map(params![id], item_from_row)? + .collect::, _>>()?; + Ok(job) + } + + /// 进度事件只携带最近条目,避免每处理一个文件都序列化完整历史。 + pub fn get_job_snapshot(&self, id: &str) -> DbResult { + let conn = self.read_conn()?; + let mut job = conn + .query_row( + &format!("SELECT {JOB_COLUMNS} FROM jobs WHERE id=?1"), + params![id], + job_from_row, + ) + .optional()? + .ok_or_else(|| DbError::NotFound(format!("job {id}")))?; + let item_sql = if job.kind == "sync" { + format!("SELECT {ITEM_COLUMNS} FROM job_items WHERE job_id=?1 AND (status='running' OR id IN (SELECT id FROM job_items WHERE job_id=?1 ORDER BY created_at DESC,id DESC LIMIT 50)) ORDER BY created_at DESC,id DESC") + } else { + format!("SELECT {ITEM_COLUMNS} FROM job_items WHERE job_id=?1 ORDER BY created_at DESC,id DESC") + }; + let mut stmt = conn.prepare(&item_sql)?; + job.items = stmt + .query_map(params![id], item_from_row)? + .collect::, _>>()?; + job.items.reverse(); + Ok(job) + } + + pub fn list_jobs(&self, active_only: bool) -> DbResult> { + let conn = self.read_conn()?; + let sql = if active_only { + format!("SELECT {JOB_COLUMNS} FROM jobs WHERE status IN ('queued','running','interrupted') ORDER BY created_at DESC") + } else { + format!("SELECT {JOB_COLUMNS} FROM jobs ORDER BY created_at DESC LIMIT 50") + }; + let mut stmt = conn.prepare(&sql)?; + let mut jobs = stmt + .query_map([], job_from_row)? + .collect::, _>>()?; + for job in &mut jobs { + let mut items_stmt = conn.prepare(&format!( + "SELECT {ITEM_COLUMNS} FROM job_items WHERE job_id=?1 ORDER BY created_at,id" + ))?; + job.items = items_stmt + .query_map(params![job.id], item_from_row)? + .collect::, _>>()?; + } + Ok(jobs) + } + + pub fn mark_job_running(&self, id: &str, phase: &str) -> DbResult<()> { + self.conn().execute( + "UPDATE jobs SET status='running',phase=?1,started_at=COALESCE(started_at,?2) WHERE id=?3 AND status IN ('queued','interrupted')", + params![phase, Utc::now().to_rfc3339(), id], + )?; + Ok(()) + } + + pub fn append_job_item(&self, job_id: &str, item: &NewJobItem<'_>) -> DbResult { + let id = Uuid::new_v4().to_string(); + let mut conn = self.conn(); + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO job_items(id,job_id,session_id,source_id,raw_path,status,phase,created_at) VALUES(?1,?2,?3,?4,?5,'running','scanning',?6)", + params![id, job_id, item.session_id, item.source_id, item.raw_path, Utc::now().to_rfc3339()], + )?; + tx.execute("UPDATE jobs SET total=total+1 WHERE id=?1", params![job_id])?; + tx.commit()?; + Ok(id) + } + + pub fn mark_item_running(&self, id: &str, phase: &str) -> DbResult<()> { + self.conn().execute( + "UPDATE job_items SET status='running',phase=?1,started_at=?2 WHERE id=?3", + params![phase, Utc::now().to_rfc3339(), id], + )?; + Ok(()) + } + + pub fn update_job_item_phase(&self, job_id: &str, item_id: &str, phase: &str) -> DbResult<()> { + let mut conn = self.conn(); + let tx = conn.transaction()?; + tx.execute( + "UPDATE job_items SET phase=?1 WHERE id=?2", + params![phase, item_id], + )?; + tx.execute( + "UPDATE jobs SET phase=?1 WHERE id=?2", + params![phase, job_id], + )?; + tx.commit()?; + Ok(()) + } + + pub fn finish_item( + &self, + job_id: &str, + item_id: &str, + status: &str, + phase: &str, + duration_ms: i64, + error: Option<&str>, + ) -> DbResult<()> { + let mut conn = self.conn(); + let tx = conn.transaction()?; + tx.execute( + "UPDATE job_items SET status=?1,phase=?2,duration_ms=?3,error=?4,finished_at=?5 WHERE id=?6", + params![status, phase, duration_ms, error, Utc::now().to_rfc3339(), item_id], + )?; + tx.execute( + "UPDATE jobs SET done=done+1,phase=?1 WHERE id=?2", + params![phase, job_id], + )?; + tx.commit()?; + Ok(()) + } + + pub fn finish_job(&self, id: &str, status: &str, error: Option<&str>) -> DbResult<()> { + self.conn().execute( + "UPDATE jobs SET status=?1,phase=?1,error=?2,finished_at=?3 WHERE id=?4", + params![status, error, Utc::now().to_rfc3339(), id], + )?; + Ok(()) + } + + pub fn request_job_cancel(&self, id: &str) -> DbResult<()> { + let changed = self.conn().execute( + "UPDATE jobs SET cancel_requested=1 WHERE id=?1 AND status IN ('queued','running')", + params![id], + )?; + if changed == 0 { + return Err(DbError::Invalid("任务已结束,不能取消".into())); + } + Ok(()) + } + + pub fn job_cancel_requested(&self, id: &str) -> DbResult { + self.read_conn()? + .query_row( + "SELECT cancel_requested FROM jobs WHERE id=?1", + params![id], + |row| row.get::<_, i64>(0).map(|v| v != 0), + ) + .map_err(DbError::from) + } + + pub fn cancel_queued_items(&self, job_id: &str) -> DbResult { + let mut conn = self.conn(); + let tx = conn.transaction()?; + let count = tx.execute( + "UPDATE job_items SET status='cancelled',phase='cancelled',finished_at=?1 WHERE job_id=?2 AND status='queued'", + params![Utc::now().to_rfc3339(), job_id], + )?; + tx.execute( + "UPDATE jobs SET done=done+?1 WHERE id=?2", + params![count as i64, job_id], + )?; + tx.commit()?; + Ok(count) + } + + pub fn interrupt_running_jobs(&self) -> DbResult { + let mut conn = self.conn(); + let tx = conn.transaction()?; + let now = Utc::now().to_rfc3339(); + let items = tx.execute( + "UPDATE job_items SET status='interrupted',phase='interrupted',finished_at=?1 WHERE status='running'", params![now], + )?; + tx.execute( + "UPDATE jobs SET status='interrupted',phase='interrupted',finished_at=?1 WHERE status='running'", params![now], + )?; + tx.execute( + "UPDATE jobs SET done=(SELECT COUNT(*) FROM job_items WHERE job_id=jobs.id AND status IN ('succeeded','failed','cancelled','interrupted')) WHERE status='interrupted'", [], + )?; + tx.commit()?; + Ok(items) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn persists_job_and_items_without_api_key_column() { + let dir = tempfile::tempdir().unwrap(); + let db = Database::open(&dir.path().join("jobs.db")).unwrap(); + // FK 约束要求真实会话。 + let session = db + .insert_session( + "codex", + "e1", + "local", + None, + None, + 0, + None, + "/tmp/x", + "2026-01-01T00:00:00Z", + "2026-01-01T00:00:00Z", + None, + ) + .unwrap(); + let items = [NewJobItem { + session_id: Some(&session), + source_id: None, + raw_path: None, + }]; + let job = db + .create_job( + "analysis", + "queued", + Some(( + "p1", + "siliconflow", + "https://api.siliconflow.cn/v1", + "model", + )), + &items, + ) + .unwrap(); + assert_eq!(job.total, 1); + let columns: Vec = db + .read_conn() + .unwrap() + .prepare("PRAGMA table_info(jobs)") + .unwrap() + .query_map([], |row| row.get(1)) + .unwrap() + .collect::>() + .unwrap(); + assert!(!columns.iter().any(|column| column == "api_key")); + } + + #[test] + fn progress_snapshot_caps_file_items() { + let dir = tempfile::tempdir().unwrap(); + let db = Database::open(&dir.path().join("jobs.db")).unwrap(); + let job = db + .create_job( + "sync", + "queued", + None, + &[NewJobItem { + session_id: None, + source_id: Some("codex"), + raw_path: None, + }], + ) + .unwrap(); + db.mark_item_running(&job.items[0].id, "scanning").unwrap(); + for index in 0..60 { + let item_id = db + .append_job_item( + &job.id, + &NewJobItem { + session_id: None, + source_id: Some("codex"), + raw_path: Some(&format!("/tmp/{index}.jsonl")), + }, + ) + .unwrap(); + db.finish_item(&job.id, &item_id, "succeeded", "imported", 0, None) + .unwrap(); + } + assert_eq!(db.get_job(&job.id).unwrap().items.len(), 61); + assert_eq!(db.get_job_snapshot(&job.id).unwrap().items.len(), 51); + } +} diff --git a/apps/desktop/src-tauri/src/storage/migrations.rs b/apps/desktop/src-tauri/src/storage/migrations.rs index 882d636..70782a5 100644 --- a/apps/desktop/src-tauri/src/storage/migrations.rs +++ b/apps/desktop/src-tauri/src/storage/migrations.rs @@ -1,322 +1,192 @@ -use rusqlite::params; use rusqlite::Connection; use super::db::DbResult; -/// 数据库 Schema 迁移入口。 -/// -/// 通过 PRAGMA user_version 追踪当前版本号, -/// 按版本递增依次执行对应的迁移函数。 -/// -/// user_version=0 → 执行 migrate_v1 → user_version=1 -/// user_version=1 → 执行 migrate_v2 → user_version=2 (未来) -/// +pub const SCHEMA_VERSION: u32 = 7; + pub fn run(conn: &Connection) -> DbResult<()> { let version: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - log::debug!("当前数据库版本: v{}", version); - - if version < 1 { - migrate_v1(conn)?; - } - if version < 2 { - migrate_v2(conn)?; - } - if version < 3 { - migrate_v3(conn)?; + if version == SCHEMA_VERSION { + return Ok(()); } - if version < 4 { - migrate_v4(conn)?; + if version > SCHEMA_VERSION { + return Err(super::db::DbError::Invalid(format!( + "数据库版本 v{version} 高于当前应用支持的 v{SCHEMA_VERSION}" + ))); } - if version < 5 { - migrate_v5(conn)?; + if version > 0 { + log::warn!("将已备份的 v{} 数据库重建为 v{}", version, SCHEMA_VERSION); } - if version < 6 { - migrate_v6(conn)?; - } - - Ok(()) + rebuild_v7(conn) } -fn migrate_v1(conn: &Connection) -> DbResult<()> { - log::info!("执行数据库迁移 v1..."); - +fn rebuild_v7(conn: &Connection) -> DbResult<()> { conn.execute_batch( - " - CREATE TABLE IF NOT EXISTS sources ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - enabled INTEGER DEFAULT 1, - scan_paths TEXT, - last_sync TEXT, - config TEXT - ); - - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - source_id TEXT NOT NULL, - session_id TEXT NOT NULL, - source_host TEXT DEFAULT 'local', - project_path TEXT, - project_name TEXT, - message_count INTEGER DEFAULT 0, - content_hash TEXT, - raw_path TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - status TEXT DEFAULT 'pending', - value TEXT, - has_updates INTEGER DEFAULT 0, - analyzed_at TEXT, - error_message TEXT, - UNIQUE(session_id, source_host) + r#" + PRAGMA foreign_keys=OFF; + DROP TABLE IF EXISTS cards_fts; + DROP TABLE IF EXISTS card_tags; + DROP TABLE IF EXISTS tags; + DROP TABLE IF EXISTS analysis_runs; + DROP TABLE IF EXISTS job_items; + DROP TABLE IF EXISTS jobs; + DROP TABLE IF EXISTS token_usage; + DROP TABLE IF EXISTS sync_log; + DROP TABLE IF EXISTS categories; + DROP TABLE IF EXISTS cards; + DROP TABLE IF EXISTS messages; + DROP TABLE IF EXISTS sessions; + DROP TABLE IF EXISTS sources; + + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source_id TEXT NOT NULL, + external_session_id TEXT NOT NULL, + source_host TEXT NOT NULL DEFAULT 'local', + project_path TEXT, + project_name TEXT, + message_count INTEGER NOT NULL DEFAULT 0, + content_hash TEXT, + raw_path TEXT, + raw_mtime_ms INTEGER, + raw_size_bytes INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + value TEXT, + has_updates INTEGER NOT NULL DEFAULT 0, + analyzed_at TEXT, + error_message TEXT, + analysis_title TEXT, + analysis_type TEXT, + analysis_note TEXT, + UNIQUE(source_id, external_session_id, source_host) ); - CREATE TABLE IF NOT EXISTS messages ( + CREATE TABLE messages ( id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, role TEXT NOT NULL, content TEXT NOT NULL, timestamp TEXT, - tokens_in INTEGER DEFAULT 0, - tokens_out INTEGER DEFAULT 0, + tokens_in INTEGER NOT NULL DEFAULT 0, + tokens_out INTEGER NOT NULL DEFAULT 0, seq_order INTEGER NOT NULL, - FOREIGN KEY (session_id) REFERENCES sessions(id) - ); - - CREATE TABLE IF NOT EXISTS cards ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - title TEXT NOT NULL, - type TEXT, - value TEXT, - summary TEXT, - note TEXT NOT NULL, - category_id TEXT, - memory TEXT, - skill TEXT, - source_name TEXT, - project_name TEXT, - prompt_tokens INTEGER DEFAULT 0, - completion_tokens INTEGER DEFAULT 0, - cost_yuan REAL DEFAULT 0, - feedback TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (session_id) REFERENCES sessions(id) - ); - - CREATE TABLE IF NOT EXISTS tags ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - type TEXT DEFAULT 'auto', - count INTEGER DEFAULT 0 + UNIQUE(session_id, seq_order) ); - CREATE TABLE IF NOT EXISTS card_tags ( - card_id TEXT NOT NULL, - tag_id TEXT NOT NULL, - PRIMARY KEY (card_id, tag_id), - FOREIGN KEY (card_id) REFERENCES cards(id), - FOREIGN KEY (tag_id) REFERENCES tags(id) + CREATE TABLE jobs ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('queued','running','succeeded','failed','cancelled','interrupted')), + phase TEXT NOT NULL, + done INTEGER NOT NULL DEFAULT 0, + total INTEGER NOT NULL DEFAULT 0, + cancel_requested INTEGER NOT NULL DEFAULT 0, + error TEXT, + provider_profile_id TEXT, + provider TEXT, + base_url TEXT, + model TEXT, + created_at TEXT NOT NULL, + started_at TEXT, + finished_at TEXT ); - CREATE TABLE IF NOT EXISTS categories ( + CREATE TABLE job_items ( id TEXT PRIMARY KEY, - name TEXT NOT NULL, - parent_id TEXT, - sort_order INTEGER DEFAULT 0, - FOREIGN KEY (parent_id) REFERENCES categories(id) + job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + source_id TEXT, + raw_path TEXT, + status TEXT NOT NULL CHECK(status IN ('queued','running','succeeded','failed','cancelled','interrupted')), + phase TEXT NOT NULL, + duration_ms INTEGER, + error TEXT, + created_at TEXT NOT NULL, + started_at TEXT, + finished_at TEXT ); - CREATE TABLE IF NOT EXISTS sync_log ( - id TEXT PRIMARY KEY, - source_id TEXT NOT NULL, - started_at TEXT NOT NULL, - finished_at TEXT, - sessions_found INTEGER DEFAULT 0, - sessions_new INTEGER DEFAULT 0, - sessions_updated INTEGER DEFAULT 0, - status TEXT DEFAULT 'running' + CREATE TABLE analysis_runs ( + id TEXT PRIMARY KEY, + job_id TEXT REFERENCES jobs(id) ON DELETE SET NULL, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + provider_profile_id TEXT NOT NULL, + provider TEXT NOT NULL, + base_url_host TEXT NOT NULL, + model TEXT NOT NULL, + content_hash TEXT NOT NULL, + prompt_version TEXT NOT NULL, + value TEXT, + reason TEXT, + prompt_tokens_judge INTEGER NOT NULL DEFAULT 0, + completion_tokens_judge INTEGER NOT NULL DEFAULT 0, + prompt_tokens_extract INTEGER NOT NULL DEFAULT 0, + completion_tokens_extract INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER, + error_kind TEXT, + error TEXT, + created_at TEXT NOT NULL, + finished_at TEXT ); - CREATE TABLE IF NOT EXISTS token_usage ( - id TEXT PRIMARY KEY, - card_id TEXT, - provider TEXT NOT NULL, - model TEXT NOT NULL, - prompt_tokens INTEGER NOT NULL, - completion_tokens INTEGER NOT NULL, - cost_yuan REAL NOT NULL, - created_at TEXT NOT NULL, - FOREIGN KEY (card_id) REFERENCES cards(id) + CREATE TABLE cards ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + analysis_run_id TEXT REFERENCES analysis_runs(id) ON DELETE SET NULL, + title TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('decision','troubleshooting','implementation','explanation','snippet')), + value TEXT NOT NULL CHECK(value IN ('high','medium')), + summary TEXT NOT NULL, + note TEXT NOT NULL, + publication_status TEXT NOT NULL CHECK(publication_status IN ('draft','published')), + is_user_edited INTEGER NOT NULL DEFAULT 0, + source_name TEXT, + project_name TEXT, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + cost_yuan REAL NOT NULL DEFAULT 0, + feedback TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL ); - -- FTS5 全文搜索索引(独立表,手动管理写入/删除) - -- 注意:不使用 content='cards',因为 cards 表没有 tags 列 - CREATE VIRTUAL TABLE IF NOT EXISTS cards_fts USING fts5( - title, summary, note, tags, - tokenize='unicode61' + CREATE TABLE tags ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + normalized_name TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('topic','technology')), + UNIQUE(kind, normalized_name) ); - -- Indexes - CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source_id); - CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_name); - CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status); - CREATE INDEX IF NOT EXISTS idx_sessions_created ON sessions(created_at); - CREATE INDEX IF NOT EXISTS idx_cards_session ON cards(session_id); - CREATE INDEX IF NOT EXISTS idx_cards_type ON cards(type); - CREATE INDEX IF NOT EXISTS idx_cards_value ON cards(value); - CREATE INDEX IF NOT EXISTS idx_cards_created ON cards(created_at); - CREATE INDEX IF NOT EXISTS idx_card_tags_tag ON card_tags(tag_id); - CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id); - - PRAGMA user_version = 1; - ", - )?; - - log::info!("数据库迁移 v1 完成"); - Ok(()) -} - -/// v2 迁移:修复 cards_fts FTS5 表。 -/// -/// v1 的 `cards_fts` 使用了 `content='cards'`,导致 SQLite 在读取时 -/// 反查 cards 表并报 "no such column: T.tags"(cards 表没有 tags 列)。 -/// -/// 修复方案: -/// 1. 删除旧的 content-backed FTS5 表 -/// 2. 重建为独立 FTS5 表(不绑定 content 表) -/// 3. 从现有卡片 + card_tags 重建索引 -fn migrate_v2(conn: &Connection) -> DbResult<()> { - log::info!("执行数据库迁移 v2:修复 cards_fts 表..."); - - conn.execute_batch( - " - -- 删除旧的 content-backed FTS5 表(及其影子表) - DROP TABLE IF EXISTS cards_fts; + CREATE TABLE card_tags ( + card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE, + tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY(card_id, tag_id) + ); - -- 重建为独立 FTS5 表,不使用 content='cards' CREATE VIRTUAL TABLE cards_fts USING fts5( - title, summary, note, tags, - tokenize='unicode61' + title, summary, note, tags, technologies, + tokenize='trigram' ); - -- 从现有卡片数据重建全文索引 - -- tags 字段通过 card_tags + tags 关联表拼接为逗号分隔字符串 - INSERT INTO cards_fts(rowid, title, summary, note, tags) - SELECT - c.rowid, - c.title, - COALESCE(c.summary, ''), - c.note, - COALESCE( - (SELECT GROUP_CONCAT(t.name, ',') - FROM card_tags ct - JOIN tags t ON ct.tag_id = t.id - WHERE ct.card_id = c.id), - '' - ) - FROM cards c; - - PRAGMA user_version = 2; - ", + CREATE INDEX idx_sessions_source_updated ON sessions(source_id, updated_at DESC); + CREATE INDEX idx_sessions_project ON sessions(project_name); + CREATE INDEX idx_sessions_status_updated ON sessions(status, updated_at DESC); + CREATE INDEX idx_messages_session_order ON messages(session_id, seq_order); + CREATE INDEX idx_cards_session_status ON cards(session_id, publication_status, created_at DESC); + CREATE INDEX idx_cards_type_status ON cards(type, publication_status); + CREATE INDEX idx_cards_value_status ON cards(value, publication_status); + CREATE INDEX idx_card_tags_tag ON card_tags(tag_id, card_id); + CREATE INDEX idx_jobs_status_created ON jobs(status, created_at DESC); + CREATE INDEX idx_job_items_job_status ON job_items(job_id, status); + CREATE INDEX idx_analysis_runs_session_created ON analysis_runs(session_id, created_at DESC); + + PRAGMA user_version=7; + PRAGMA foreign_keys=ON; + "#, )?; - - log::info!("数据库迁移 v2 完成"); - Ok(()) -} - -/// v3 迁移:为 sessions 表新增 `analysis_note` 列。 -/// -/// 用于持久化低/无价值会话的判断原因,使页面刷新后仍可在会话列表展示摘要, -/// 而不依赖前端内存中的临时 patchItem。 -fn migrate_v3(conn: &Connection) -> DbResult<()> { - log::info!("执行数据库迁移 v3:为 sessions 表新增 analysis_note 列..."); - - conn.execute_batch( - " - ALTER TABLE sessions ADD COLUMN analysis_note TEXT; - PRAGMA user_version = 3; - ", - )?; - - log::info!("数据库迁移 v3 完成"); - Ok(()) -} - -/// v5 迁移:为 cards 表新增 `tech_stack` 列。 -/// -/// LLM 在 distill_full 时会返回涉及的技术栈列表(如 ["Rust", "SQLite", "Tauri"]), -/// 此前该字段被丢弃(#[allow(dead_code)]),v5 开始持久化为逗号分隔字符串。 -fn migrate_v5(conn: &Connection) -> DbResult<()> { - log::info!("执行数据库迁移 v5:为 cards 表新增 tech_stack 列..."); - - conn.execute_batch( - " - ALTER TABLE cards ADD COLUMN tech_stack TEXT; - PRAGMA user_version = 5; - ", - )?; - - log::info!("数据库迁移 v5 完成"); - Ok(()) -} - -/// v6 迁移:将 sessions 中仍含百分号编码的 `project_path` / `project_name` 解码为 UTF-8 展示。 -/// -/// 与采集端 [`crate::path_local::decode_cursor_folder_to_local_path`] 对齐,修复升级前已入库的数据, -/// 避免侧栏树与列表继续显示 `%E5%90%91...`。 -fn migrate_v6(conn: &Connection) -> DbResult<()> { - log::info!("执行数据库迁移 v6:解码 sessions 中百分号编码的项目路径..."); - - let mut stmt = conn.prepare( - "SELECT id, project_path, project_name FROM sessions - WHERE (project_path IS NOT NULL AND instr(project_path, '%') > 0) - OR (project_name IS NOT NULL AND instr(project_name, '%') > 0)", - )?; - - let rows: Vec<(String, Option, Option)> = stmt - .query_map([], |row| { - Ok((row.get(0)?, row.get(1)?, row.get(2)?)) - })? - .collect::, _>>()?; - - let mut upd = conn.prepare( - "UPDATE sessions SET project_path = ?1, project_name = ?2 WHERE id = ?3", - )?; - - let mut fixed = 0usize; - for (id, pp, pn) in rows { - let (new_pp, new_pn) = crate::path_local::decode_session_paths(pp.clone(), pn.clone()); - if new_pp != pp || new_pn != pn { - upd.execute(params![new_pp, new_pn, id])?; - fixed += 1; - } - } - - if fixed > 0 { - log::info!("数据库迁移 v6:已修正 {} 条会话的项目路径/名称", fixed); - } - - conn.execute_batch("PRAGMA user_version = 6;")?; - log::info!("数据库迁移 v6 完成"); - Ok(()) -} -/// -/// 低/无价值会话无 Card 产出,但 judge_value 仍返回对话类型(type)和原因(reason)。 -/// 这两列持久化该信息,使列表在刷新后仍能展示类型徽章和由原因构造的标题, -/// 而无需依赖前端内存中的临时 patchItem。 -fn migrate_v4(conn: &Connection) -> DbResult<()> { - log::info!("执行数据库迁移 v4:为 sessions 表新增 analysis_title / analysis_type 列..."); - - conn.execute_batch( - " - ALTER TABLE sessions ADD COLUMN analysis_title TEXT; - ALTER TABLE sessions ADD COLUMN analysis_type TEXT; - PRAGMA user_version = 4; - ", - )?; - - log::info!("数据库迁移 v4 完成"); + log::info!("数据库 v7 Schema 已创建"); Ok(()) } diff --git a/apps/desktop/src-tauri/src/storage/mod.rs b/apps/desktop/src-tauri/src/storage/mod.rs index 527594e..a498c3d 100644 --- a/apps/desktop/src-tauri/src/storage/mod.rs +++ b/apps/desktop/src-tauri/src/storage/mod.rs @@ -10,10 +10,12 @@ //! search.rs FTS5 全文搜索 //! ``` -pub mod db; -pub mod models; +mod analysis; mod cards; +pub mod db; +mod jobs; mod migrations; +pub mod models; mod search; mod sessions; diff --git a/apps/desktop/src-tauri/src/storage/models.rs b/apps/desktop/src-tauri/src/storage/models.rs index ee71c33..ee20a09 100644 --- a/apps/desktop/src-tauri/src/storage/models.rs +++ b/apps/desktop/src-tauri/src/storage/models.rs @@ -1,28 +1,9 @@ use serde::{Deserialize, Serialize}; -// ─────────────────────────────── 数据源 ─────────────────────────────── - -/// 数据源配置(如 Claude Code、Cursor 等 AI IDE) -#[derive(Debug, Clone, Serialize, Deserialize)] -#[allow(dead_code)] // v0.2 设置页面使用 -pub struct Source { - pub id: String, - /// 数据源显示名称,如 "Claude Code" - pub name: String, - /// 是否启用该数据源的采集 - pub enabled: bool, - /// 扫描目录列表,JSON 数组格式存储 - pub scan_paths: Option, - /// 最近一次同步时间 (RFC 3339) - pub last_sync: Option, - /// 数据源专属配置,JSON 格式 - pub config: Option, -} - // ─────────────────────────────── 会话 ───────────────────────────────── // // 一条 Session 对应一次 AI 对话(如 Claude Code 的一个 JSONL 文件)。 -// 去重键: (session_id, source_host) +// 去重键: (source_id, session_id, source_host) /// AI 对话会话完整信息 #[derive(Debug, Clone, Serialize, Deserialize)] @@ -150,6 +131,7 @@ pub struct Card { pub id: String, /// 来源会话的数据库主键 pub session_id: String, + pub analysis_run_id: Option, /// 卡片标题(LLM 生成) pub title: String, /// 知识类型: debug | architecture | performance | best-practice | concept | tool-usage | refactor | other @@ -161,12 +143,9 @@ pub struct Card { pub summary: Option, /// Markdown 格式的完整技术笔记 pub note: String, - /// 所属分类 ID(v0.1 暂未使用) - pub category_id: Option, - /// LLM 生成的 Memory 规则(v0.2 实现) - pub memory: Option, - /// LLM 生成的 Skill 定义(v0.2 实现) - pub skill: Option, + /// draft | published + pub publication_status: String, + pub is_user_edited: bool, /// 来源数据源名称(冗余存储,方便展示) pub source_name: Option, /// 来源项目名称(冗余存储,方便展示) @@ -184,7 +163,7 @@ pub struct Card { /// 通过 card_tags + tags 表 JOIN 查询填充,不直接存储在 cards 表 #[serde(default)] pub tags: Vec, - /// LLM 提炼时识别到的技术栈(如 Rust、SQLite、Tauri 等),逗号分隔存储 + /// LLM 提炼时识别到的技术项(通过 tags.kind=technology 关联) #[serde(default)] pub tech_stack: Vec, /// 来源会话在数据源侧的标识(`sessions.session_id`,非 DB 主键 `sessions.id`) @@ -195,7 +174,7 @@ pub struct Card { pub source_session_path: Option, } -/// 卡片列表轻量版(不含 note、memory、skill 等大字段) +/// 卡片列表轻量版(不含 note 等大字段) #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CardSummary { @@ -206,11 +185,17 @@ pub struct CardSummary { pub card_type: Option, pub value: Option, pub summary: Option, - pub category_id: Option, + pub publication_status: String, pub source_name: Option, pub project_name: Option, pub created_at: String, pub updated_at: String, + #[serde(default)] + pub match_snippet: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub technologies: Vec, } // ─────────────────────────────── 标签 ───────────────────────────────── @@ -220,54 +205,9 @@ pub struct CardSummary { #[allow(dead_code)] // v0.2 标签管理页使用 pub struct Tag { pub id: String, - /// 标签名称(唯一约束) pub name: String, - /// 标签来源: auto(LLM 生成)| manual(用户创建) - #[serde(rename = "type")] - pub tag_type: Option, - /// 使用该标签的卡片数量(非实时计算) - pub count: i64, -} - -// ─────────────────────────────── 同步日志 ────────────────────────────── - -/// 数据源同步操作日志 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[allow(dead_code)] // v0.2 同步历史页使用 -pub struct SyncLog { - pub id: String, - /// 触发同步的数据源 ID - pub source_id: String, - pub started_at: String, - pub finished_at: Option, - /// 本次扫描发现的会话总数 - pub sessions_found: i64, - /// 本次新导入的会话数 - pub sessions_new: i64, - /// 本次检测到更新的会话数 - pub sessions_updated: i64, - /// 同步状态: running | completed | failed - pub status: String, -} - -// ─────────────────────────────── Token 用量 ─────────────────────────── - -/// LLM 调用的 Token 用量与费用记录 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[allow(dead_code)] // v0.2 费用统计页使用 -pub struct TokenUsage { - pub id: String, - /// 关联的知识卡片 ID - pub card_id: Option, - /// LLM 提供商名称(如 openai、deepseek) - pub provider: String, - /// 使用的模型名称 - pub model: String, - pub prompt_tokens: i64, - pub completion_tokens: i64, - /// 费用(元) - pub cost_yuan: f64, - pub created_at: String, + pub normalized_name: String, + pub kind: String, } // ─────────────────────────────── 写入用结构体 ───────────────────────── @@ -277,14 +217,16 @@ pub struct TokenUsage { /// 创建知识卡片的入参 pub struct NewCard<'a> { pub session_id: &'a str, + pub analysis_run_id: &'a str, pub title: &'a str, - /// 知识类型: debug | architecture | performance | best-practice | ... - pub card_type: Option<&'a str>, - /// 价值等级: high | medium | low | none - pub value: Option<&'a str>, - pub summary: Option<&'a str>, + /// 知识类型: decision | troubleshooting | implementation | explanation | snippet + pub card_type: &'a str, + /// 产出卡片时仅为 high | medium + pub value: &'a str, + pub summary: &'a str, /// Markdown 格式技术笔记 pub note: &'a str, + pub publication_status: &'a str, pub source_name: Option<&'a str>, pub project_name: Option<&'a str>, pub prompt_tokens: i32, @@ -293,7 +235,7 @@ pub struct NewCard<'a> { pub cost_yuan: f64, /// LLM 生成的标签列表 pub tags: &'a [String], - /// LLM 识别的技术栈列表(如 ["Rust", "SQLite", "Tauri"]) + /// LLM 识别的技术项(写入 tags.kind=technology) pub tech_stack: &'a [String], } @@ -325,6 +267,13 @@ pub struct PaginatedResult { pub page_size: u32, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CursorPage { + pub items: Vec, + pub next_cursor: Option, +} + /// 会话列表筛选条件 #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct SessionFilters { @@ -372,9 +321,56 @@ pub struct TypeCount { pub count: i64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JobItem { + pub id: String, + pub job_id: String, + pub session_id: Option, + pub source_id: Option, + pub raw_path: Option, + pub status: String, + pub phase: String, + pub duration_ms: Option, + pub error: Option, + pub created_at: String, + pub started_at: Option, + pub finished_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Job { + pub id: String, + pub kind: String, + pub status: String, + pub phase: String, + pub done: i64, + pub total: i64, + pub cancel_requested: bool, + pub error: Option, + pub provider_profile_id: Option, + pub provider: Option, + pub base_url: Option, + pub model: Option, + pub created_at: String, + pub started_at: Option, + pub finished_at: Option, + #[serde(default)] + pub items: Vec, +} + +pub struct NewJobItem<'a> { + pub session_id: Option<&'a str>, + pub source_id: Option<&'a str>, + pub raw_path: Option<&'a str>, +} + /// 知识卡片筛选条件 #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CardFilters { + /// 默认只看 published;草稿中心显式传 draft。 + pub publication_status: Option, /// 按标签名称筛选(AND 语义,需同时匹配所有标签) pub tags: Option>, /// 按知识类型筛选 @@ -383,6 +379,15 @@ pub struct CardFilters { pub value: Option, /// FTS5 全文搜索关键词 pub search: Option, - /// 按技术栈名称筛选(AND 语义;与 `cards.tech_stack` 逗号分隔字段逐 token 匹配,大小写不敏感) + /// 按 tags.kind=technology 筛选(AND 语义) pub tech_stack: Option>, } + +pub struct CardUpdate<'a> { + pub title: &'a str, + pub card_type: &'a str, + pub summary: &'a str, + pub note: &'a str, + pub tags: &'a [String], + pub technologies: &'a [String], +} diff --git a/apps/desktop/src-tauri/src/storage/search.rs b/apps/desktop/src-tauri/src/storage/search.rs index ae6e92f..82acf3d 100644 --- a/apps/desktop/src-tauri/src/storage/search.rs +++ b/apps/desktop/src-tauri/src/storage/search.rs @@ -1,51 +1,111 @@ +use rusqlite::Row; + use super::cards::{build_card_where, card_summary_from_row, CARD_SUMMARY_COLUMNS}; use super::db::{Database, DbResult}; use super::models::*; +fn labels(value: Option) -> Vec { + value + .unwrap_or_default() + .split(',') + .filter(|item| !item.is_empty()) + .map(str::to_owned) + .collect() +} + +fn search_row(row: &Row<'_>, query: &str, fts: bool) -> rusqlite::Result { + let mut card = card_summary_from_row(row)?; + let note: String = row.get(11)?; + card.tags = labels(row.get(12)?); + card.technologies = labels(row.get(13)?); + card.match_snippet = if fts { + row.get(14)? + } else { + Some(text_snippet( + &format!( + "{}\n{}\n{}", + card.title, + card.summary.as_deref().unwrap_or_default(), + note + ), + query, + )) + }; + Ok(card) +} + +fn text_snippet(text: &str, query: &str) -> String { + let index = text + .find(query) + .or_else(|| text.to_lowercase().find(&query.to_lowercase())) + .unwrap_or(0); + let mut start = index.saturating_sub(80); + while start > 0 && !text.is_char_boundary(start) { + start -= 1; + } + let mut end = (index + query.len() + 120).min(text.len()); + while end < text.len() && !text.is_char_boundary(end) { + end += 1; + } + format!( + "{}{}{}", + if start > 0 { "…" } else { "" }, + &text[start..end], + if end < text.len() { "…" } else { "" } + ) +} + impl Database { - /// FTS5 全文搜索知识卡片,支持叠加类型/价值/标签筛选。 - /// - /// 使用 BM25 算法排序,最多返回 50 条结果。 - /// 空查询直接返回空列表。 pub fn search_cards(&self, query: &str, filters: &CardFilters) -> DbResult> { - let q = query.trim(); - if q.is_empty() { + let query = query.trim(); + if query.is_empty() { return Ok(Vec::new()); } - - // 将 FTS MATCH 条件与 card_filter 的常规条件合并。 - // 注意:FTS5 要求 MATCH 左侧必须是虚拟表名(如 cards_fts),不能用 JOIN 别名 `fts`, - // 否则任意关键词都会触发 “no such column: fts”(解析器把别名误当成列)。 let (where_sql, filter_params) = build_card_where(filters); - let where_clause = if where_sql.is_empty() { - " WHERE cards_fts MATCH ?".to_string() + let filters_sql = where_sql.strip_prefix(" WHERE ").unwrap_or(&where_sql); + let tag_sql = "(SELECT GROUP_CONCAT(t.name, ',') FROM tags t JOIN card_tags ct ON ct.tag_id=t.id WHERE ct.card_id=c.id AND t.kind='topic')"; + let tech_sql = "(SELECT GROUP_CONCAT(t.name, ',') FROM tags t JOIN card_tags ct ON ct.tag_id=t.id WHERE ct.card_id=c.id AND t.kind='technology')"; + let use_fts = query.chars().count() >= 3; + let sql = if use_fts { + format!("SELECT {CARD_SUMMARY_COLUMNS},c.note,{tag_sql},{tech_sql},snippet(cards_fts,-1,'','','…',24) FROM cards c JOIN cards_fts ON c.rowid=cards_fts.rowid WHERE cards_fts MATCH ? AND ({filters_sql}) ORDER BY bm25(cards_fts) LIMIT 50") } else { - let rest = where_sql.strip_prefix(" WHERE ").unwrap_or(&where_sql); - format!(" WHERE cards_fts MATCH ? AND ({})", rest) + format!("SELECT {CARD_SUMMARY_COLUMNS},c.note,{tag_sql},{tech_sql},NULL FROM cards c WHERE lower(c.title || char(10) || c.summary || char(10) || c.note || char(10) || COALESCE({tag_sql},'') || char(10) || COALESCE({tech_sql},'')) LIKE lower(?) ESCAPE '\\' AND ({filters_sql}) ORDER BY c.updated_at DESC LIMIT 50") }; - - let sql = format!( - "SELECT {cols} FROM cards c \ - JOIN cards_fts fts ON c.rowid = fts.rowid \ - {where_clause} \ - ORDER BY bm25(cards_fts) LIMIT 50", - cols = CARD_SUMMARY_COLUMNS, - ); - - // FTS MATCH 参数在最前面,后面跟 filter 参数 - let mut params_vec: Vec = Vec::with_capacity(1 + filter_params.len()); - params_vec.push(q.to_string()); - params_vec.extend(filter_params); - - let conn = self.conn(); + let first = if use_fts { + format!("\"{}\"", query.replace('"', "\"\"")) + } else { + format!( + "%{}%", + query + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") + ) + }; + let mut values = vec![first]; + values.extend(filter_params); + let conn = self.read_conn()?; let mut stmt = conn.prepare(&sql)?; - let results: Vec = stmt - .query_map(rusqlite::params_from_iter(params_vec.iter()), |r| { - card_summary_from_row(r) + let cards = stmt + .query_map(rusqlite::params_from_iter(values.iter()), |row| { + search_row(row, query, use_fts) })? .collect::, _>>()?; + Ok(cards) + } +} - log::debug!("全文搜索: query={:?}, 结果数={}", q, results.len()); - Ok(results) +#[cfg(test)] +mod tests { + use super::text_snippet; + + #[test] + fn short_query_snippet_is_utf8_safe() { + let snippet = text_snippet( + &format!("{}知识提取{}", "前".repeat(100), "后".repeat(100)), + "知识", + ); + assert!(snippet.contains("知识提取")); + assert!(snippet.starts_with('…')); } } diff --git a/apps/desktop/src-tauri/src/storage/sessions.rs b/apps/desktop/src-tauri/src/storage/sessions.rs index 71b10fb..ce1189d 100644 --- a/apps/desktop/src-tauri/src/storage/sessions.rs +++ b/apps/desktop/src-tauri/src/storage/sessions.rs @@ -1,4 +1,5 @@ use std::collections::HashSet; +use std::path::Path; use chrono::Utc; use rusqlite::params; @@ -22,25 +23,25 @@ fn session_filters_where_clause( let mut param_values: Vec> = Vec::new(); if let Some(ref source) = filters.source { - conditions.push("source_id = ?"); + conditions.push("s.source_id = ?"); param_values.push(Box::new(source.clone())); } if let Some(ref host) = filters.host { - conditions.push("source_host = ?"); + conditions.push("s.source_host = ?"); param_values.push(Box::new(host.clone())); } if let Some(ref project) = filters.project { if project == UNLINKED_PROJECT_LABEL { // 未关联项目:库内为 NULL,不能与字面量「(未关联项目)」等值匹配 - conditions.push("project_name IS NULL"); + conditions.push("s.project_name IS NULL"); } else { - conditions.push("(project_name = ? OR project_path = ?)"); + conditions.push("(s.project_name = ? OR s.project_path = ?)"); param_values.push(Box::new(project.clone())); param_values.push(Box::new(project.clone())); } } if let Some(ref status) = filters.status { - conditions.push("status = ?"); + conditions.push("s.status = ?"); param_values.push(Box::new(status.clone())); } @@ -125,35 +126,35 @@ fn session_summary_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result DbResult { + let path = raw_path.to_string_lossy(); + self.read_conn()?.query_row( + "SELECT EXISTS(SELECT 1 FROM sessions WHERE source_id=?1 AND raw_path=?2 AND raw_mtime_ms=?3 AND raw_size_bytes=?4)", + params![source_id, path.as_ref(), mtime_ms, size_bytes], + |row| row.get(0), + ).map_err(DbError::from) + } + + /// 导入会话。使用 INSERT OR IGNORE 实现去重(唯一键: source + external id + host)。 /// /// 返回数据库主键 ID(新插入时为新 UUID,冲突时返回已有记录的 ID)。 pub fn insert_session( @@ -174,20 +175,30 @@ impl Database { let conn = self.conn(); let rows = conn.execute( "INSERT OR IGNORE INTO sessions ( - id, source_id, session_id, source_host, project_path, project_name, + id, source_id, external_session_id, source_host, project_path, project_name, 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, analysis_title, + &id, + source_id, + session_id, + source_host, + project_path, + project_name, + message_count, + content_hash, + raw_path, + created_at, + updated_at, + analysis_title, ], )?; if rows == 0 { // 唯一约束冲突 → 查询已有记录的 ID 返回 let existing: String = conn.query_row( - "SELECT id FROM sessions WHERE session_id = ?1 AND source_host = ?2", - params![session_id, source_host], + "SELECT id FROM sessions WHERE source_id = ?1 AND external_session_id = ?2 AND source_host = ?3", + params![source_id, session_id, source_host], |row| row.get(0), )?; log::debug!("会话已存在: session_id={}, db_id={}", session_id, existing); @@ -195,18 +206,16 @@ impl Database { } else { log::info!( "导入会话: source={}, project={:?}, messages={}", - source_id, project_name, message_count + source_id, + project_name, + message_count ); Ok(id) } } /// 批量写入消息(事务内执行,保证原子性) - pub fn insert_messages( - &self, - session_db_id: &str, - messages: &[NewMessage], - ) -> DbResult<()> { + pub fn insert_messages(&self, session_db_id: &str, messages: &[NewMessage]) -> DbResult<()> { let conn = self.conn(); let tx = conn.unchecked_transaction()?; for (seq_order, msg) in messages.iter().enumerate() { @@ -216,8 +225,14 @@ impl Database { id, session_id, role, content, timestamp, tokens_in, tokens_out, seq_order ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ - id, session_db_id, msg.role, msg.content, - msg.timestamp, msg.tokens_in, msg.tokens_out, seq_order as i32 + id, + session_db_id, + msg.role, + msg.content, + msg.timestamp, + msg.tokens_in, + msg.tokens_out, + seq_order as i32 ], )?; } @@ -227,9 +242,9 @@ impl Database { } pub fn get_session(&self, id: &str) -> DbResult { - let conn = self.conn(); + let conn = self.read_conn()?; conn.query_row( - "SELECT id, source_id, session_id, source_host, project_path, project_name, + "SELECT id, source_id, external_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, analysis_title FROM sessions WHERE id = ?1", @@ -263,7 +278,7 @@ impl Database { } pub fn get_session_messages(&self, session_id: &str) -> DbResult> { - let conn = self.conn(); + let conn = self.read_conn()?; let mut stmt = conn.prepare( "SELECT id, session_id, role, content, timestamp, tokens_in, tokens_out, seq_order FROM messages WHERE session_id = ?1 ORDER BY seq_order ASC", @@ -283,6 +298,44 @@ impl Database { rows.collect::, _>>().map_err(DbError::from) } + pub fn get_session_messages_page( + &self, + session_id: &str, + cursor: Option, + limit: u32, + ) -> DbResult> { + let limit = limit.clamp(1, 100) as i64; + let conn = self.read_conn()?; + let mut stmt = conn.prepare( + "SELECT id,session_id,role,content,timestamp,tokens_in,tokens_out,seq_order + FROM messages WHERE session_id=?1 AND seq_order>?2 ORDER BY seq_order LIMIT ?3", + )?; + let mut items = stmt + .query_map( + params![session_id, cursor.unwrap_or(-1), limit + 1], + |row| { + Ok(Message { + id: row.get(0)?, + session_id: row.get(1)?, + role: row.get(2)?, + content: row.get(3)?, + timestamp: row.get(4)?, + tokens_in: row.get(5)?, + tokens_out: row.get(6)?, + seq_order: row.get(7)?, + }) + }, + )? + .collect::, _>>()?; + let next_cursor = if items.len() as i64 > limit { + items.truncate(limit as usize); + items.last().map(|item| item.seq_order) + } else { + None + }; + Ok(CursorPage { items, next_cursor }) + } + /// 分页查询会话列表,支持按数据源/项目/状态动态筛选。 /// /// 分页参数: page 从 1 开始,page=0 等同于 page=1。 @@ -294,20 +347,24 @@ impl Database { ) -> DbResult> { let (where_clause, param_values) = session_filters_where_clause(filters); - let conn = self.conn(); + let conn = self.read_conn()?; // 先查总数 - let count_sql = format!("SELECT COUNT(*) FROM sessions{}", where_clause); + let count_sql = format!("SELECT COUNT(*) FROM sessions s{}", where_clause); let param_refs: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|b| b.as_ref()).collect(); - let total: i64 = conn - .query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?; + let total: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?; // 再查当页数据 let offset = page.saturating_sub(1) as i64 * page_size as i64; let list_sql = format!( - "SELECT {} FROM sessions s{} ORDER BY s.created_at DESC LIMIT ? OFFSET ?", - SESSION_SUMMARY_COLUMNS, where_clause + "{} SELECT {} FROM sessions s \ + LEFT JOIN latest_card lc ON lc.session_id=s.id \ + LEFT JOIN message_stats ms ON ms.session_id=s.id \ + LEFT JOIN first_user fu ON fu.session_id=s.id \ + LEFT JOIN latest_tags lt ON lt.card_id=lc.id \ + {} ORDER BY s.created_at DESC LIMIT ? OFFSET ?", + SESSION_SUMMARY_CTES, SESSION_SUMMARY_COLUMNS, where_clause ); let mut data_params = param_values; @@ -338,8 +395,8 @@ impl Database { let mut set: HashSet = HashSet::new(); for g in groups { let (where_clause, param_values) = session_filters_where_clause(g); - let list_sql = format!("SELECT id FROM sessions{}", where_clause); - let conn = self.conn(); + let list_sql = format!("SELECT s.id FROM sessions s{}", where_clause); + let conn = self.read_conn()?; let param_refs: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|b| b.as_ref()).collect(); let mut stmt = conn.prepare(&list_sql)?; @@ -384,17 +441,18 @@ impl Database { Ok(ids.len() as u64) } - /// 检查是否已存在相同的会话(去重键: session_id + source_host) + /// 检查是否已存在相同的会话(去重键: source_id + external_session_id + source_host) pub fn check_duplicate( &self, + source_id: &str, session_id: &str, source_host: &str, ) -> DbResult> { - let conn = self.conn(); + let conn = self.read_conn()?; let mut stmt = conn.prepare( - "SELECT id FROM sessions WHERE session_id = ?1 AND source_host = ?2 LIMIT 1", + "SELECT id FROM sessions WHERE source_id = ?1 AND external_session_id = ?2 AND source_host = ?3 LIMIT 1", )?; - let mut rows = stmt.query(params![session_id, source_host])?; + let mut rows = stmt.query(params![source_id, session_id, source_host])?; match rows.next()? { Some(row) => Ok(Some(row.get(0)?)), None => Ok(None), @@ -432,7 +490,12 @@ impl Database { if n == 0 { return Err(DbError::NotFound(id.to_string())); } - log::info!("会话状态变更: id={}, status={}, value={:?}", id, status, value); + log::info!( + "会话状态变更: id={}, status={}, value={:?}", + id, + status, + value + ); Ok(()) } @@ -452,7 +515,12 @@ impl Database { if n == 0 { return Err(DbError::NotFound(id.to_string())); } - log::debug!("写入 analysis_meta: id={}, type={}, title={}", id, card_type, title); + log::debug!( + "写入 analysis_meta: id={}, type={}, title={}", + id, + card_type, + title + ); Ok(()) } @@ -511,7 +579,10 @@ impl Database { [], )?; if n > 0 { - log::info!("启动清理:已将 {} 个残留 analyzing 状态的会话重置为 pending", n); + log::info!( + "启动清理:已将 {} 个残留 analyzing 状态的会话重置为 pending", + n + ); } Ok(n) } @@ -531,7 +602,7 @@ impl Database { /// /// 返回扁平的分组列表,前端负责组装成树结构。 pub fn get_session_groups(&self) -> DbResult> { - let conn = self.conn(); + let conn = self.read_conn()?; let mut stmt = conn.prepare( "SELECT source_id, source_host, project_name, COUNT(*) as cnt FROM sessions @@ -553,6 +624,44 @@ impl Database { #[cfg(test)] mod tests { use super::Database; + use crate::storage::models::SessionFilters; + + #[test] + fn list_sessions_qualifies_project_filter_after_card_join() { + let dir = tempfile::tempdir().expect("tempdir"); + let db = Database::open(&dir.path().join("project-filter.db")).expect("open db"); + + db.insert_session( + "codex", + "sess-project", + "local", + Some("/tmp/demo"), + Some("demo"), + 1, + None, + "/tmp/demo.jsonl", + "2025-01-01T00:00:00Z", + "2025-01-01T00:00:00Z", + None, + ) + .expect("insert"); + + let result = db + .list_sessions( + &SessionFilters { + source: Some("codex".into()), + host: Some("local".into()), + project: Some("demo".into()), + ..Default::default() + }, + 1, + 20, + ) + .expect("project-filtered list"); + + assert_eq!(result.total, 1); + assert_eq!(result.items[0].project_name.as_deref(), Some("demo")); + } /// 回归:Claude/Cursor 重同步时采集端 `analysis_title` 为 None,不得抹掉 /// `update_session_analysis_meta` 已写入的展示标题(见 docs/踩坑 同名文档)。 @@ -601,7 +710,7 @@ mod tests { let id = db .insert_session( - "codebuddy-cli", + "codebuddy", "sess-2", "local", None, diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 7982c57..5a13f46 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,8 +1,8 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json", - "productName": "XunJi", - "version": "0.1.6", - "identifier": "com.xunji.app", + "productName": "ChatTake", + "version": "0.2.0", + "identifier": "com.chattake.desktop", "build": { "frontendDist": "../dist", "devUrl": "http://localhost:1420", @@ -12,7 +12,7 @@ "app": { "windows": [ { - "title": "寻迹 - AI 编程知识平台", + "title": "有得 · AI 对话知识库", "width": 1280, "height": 800, "minWidth": 960, @@ -30,20 +30,24 @@ "icon": [ "icons/32x32.png", "icons/128x128.png", - "icons/128x128@2x.png" + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" ], "resources": { - "../../../packages/sidecar/dist/xunji-sidecar": "xunji-sidecar" + "../../../packages/sidecar/dist/chattake-sidecar": "chattake-sidecar", + "../../../packages/mcp-server/dist/chattake-mcp": "chattake-mcp" }, "macOS": { - "bundleName": "寻迹" + "bundleName": "有得", + "signingIdentity": "-" } }, "plugins": { "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlENzEzM0RBODkxQ0Y3QTEKUldTaDl4eUoyak54blV5a2huR0h2dUxML3ozYXE1enhtTHlMbzc1NEVaakJld010N1NmUXFjdXgK", "endpoints": [ - "https://github.com/CodeHourra/xunji/releases/latest/download/latest.json" + "https://github.com/CodeHourra/chattake/releases/latest/download/latest.json" ], "windows": { "installMode": "passive" diff --git a/apps/desktop/src-tauri/tauri.windows.conf.json b/apps/desktop/src-tauri/tauri.windows.conf.json index b77ae75..5b70ade 100644 --- a/apps/desktop/src-tauri/tauri.windows.conf.json +++ b/apps/desktop/src-tauri/tauri.windows.conf.json @@ -2,7 +2,8 @@ "$schema": "https://schema.tauri.app/config/2", "bundle": { "resources": { - "../../../packages/sidecar/dist/xunji-sidecar.exe": "xunji-sidecar.exe" + "../../../packages/sidecar/dist/chattake-sidecar.exe": "chattake-sidecar.exe", + "../../../packages/mcp-server/dist/chattake-mcp.exe": "chattake-mcp.exe" } } } diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 5d14ed8..4bec8ba 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -1,4 +1,5 @@ diff --git a/apps/desktop/src/components/SourceInfo.vue b/apps/desktop/src/components/SourceInfo.vue index edf74ab..6a2f0d7 100644 --- a/apps/desktop/src/components/SourceInfo.vue +++ b/apps/desktop/src/components/SourceInfo.vue @@ -33,8 +33,7 @@ defineProps<{ class="text-neutral-600 dark:text-neutral-300 truncate min-w-0" :title="projectName || undefined" > - - + {{ projectName ?? '—' }} diff --git a/apps/desktop/src/components/TopBar.vue b/apps/desktop/src/components/TopBar.vue index 4d8a89d..bbb3502 100644 --- a/apps/desktop/src/components/TopBar.vue +++ b/apps/desktop/src/components/TopBar.vue @@ -1,5 +1,5 @@ + + diff --git a/apps/desktop/src/composables/useAppUpdater.ts b/apps/desktop/src/composables/useAppUpdater.ts index 8179bac..8c65182 100644 --- a/apps/desktop/src/composables/useAppUpdater.ts +++ b/apps/desktop/src/composables/useAppUpdater.ts @@ -65,7 +65,7 @@ export function useAppUpdater() { const msg = e instanceof Error ? e.message : String(e) errorText.value = msg.includes('Tauri') || msg.includes('not allowed') - ? '请在寻迹桌面客户端内使用(浏览器预览不支持检查更新)。' + ? '请在有得桌面客户端内使用(浏览器预览不支持检查更新)。' : `检查更新失败:${msg}` phase.value = 'error' console.error('[updater] check failed', e) diff --git a/apps/desktop/src/data/app-changelog.md b/apps/desktop/src/data/app-changelog.md index 180f4d0..2d32538 100644 --- a/apps/desktop/src/data/app-changelog.md +++ b/apps/desktop/src/data/app-changelog.md @@ -1,7 +1,43 @@ -# 寻迹(XunJi)更新日志 +# 有得(ChatTake)更新日志 > 面向使用者的更新说明。实现细节见 `docs/CHANGELOG.md`。 +## 0.2.0(2026-08-30) + +### 新增 + +- **全新品牌**:产品正式更名为「有得 · ChatTake」,应用、数据目录、MCP、安装包和仓库标识同步更新,不保留旧名称兼容层。 +- **六来源采集**:正式支持 Claude Code、Cursor、Codex、CodeBuddy、OMP、Pi,启动时只扫描变化,不会自动消耗模型 Token。 +- **API / CLI Provider**:可同时保存 OpenAI-compatible API 与 Claude Code、Codex、Cursor、OMP、Pi、CodeBuddy CLI 配置;任务固定使用启动时选择的配置。 +- **知识草稿**:高价值知识自动发布,中价值进入草稿;重新分析先生成草稿,不覆盖已有知识。 +- **持久任务中心**:显示真实阶段、文件/会话计数、供应商、模型、耗时和失败项,支持取消与切换配置重试。 +- **只读 MCP**:提供搜索、卡片详情、聚合项和来源对话四个工具,只暴露已发布知识。 + +### 重构与性能 + +- 建立 Light/Dark 双主题与克制的液态玻璃视觉语言,以线性档案索引替代满屏卡片,补齐来源标识和窄窗口布局。 +- 知识固定为决策、排障、实现、解释、片段五类,主题与技术项改为可治理标签。 +- 使用文件指纹与内容哈希增量同步;会话列表改为单次聚合查询,对话按 100 条游标分页。 +- 搜索改为 FTS5 trigram,中英文子串与 2 字短词均可检索;生产 Chunk 最大值低于 500 KB。 + +### 不兼容调整 + +- 删除所有 `*-internal` 路径和旧分类/Memory/Skill 数据模型。 +- 旧数据库会在可恢复备份成功后重建,备份位于 `~/.chattake/db/backups/`。 + +### 修复 + +- **macOS 发布**:改用 Ad Hoc 签名、未公证分发,生成 DMG 不再依赖 Apple Developer 证书;首次启动由用户在「隐私与安全性」确认 +- 修复会话列表按项目筛选时,多表联查可能触发 SQLite `ambiguous column name: project_name` 的问题。 + +## 0.1.7(2026-03-26) + +### 开发体验 + +- **本地 macOS 打包**:`make macos` 在未设置环境变量时,会尝试从 `apps/desktop/chattake.updater.key` 注入 `TAURI_SIGNING_PRIVATE_KEY`(与 CI 一致;私钥文件勿提交)。详见 `docs/macOS 打包说明.md`。 + +--- + ## 0.1.6(2026-03-25) ### 体验改进 @@ -14,7 +50,7 @@ ### 体验改进 -- **桌面端品牌与安装包**:安装包、GitHub Release 资源文件名使用 **XunJi** 前缀(避免部分环境下非 ASCII 文件名异常);macOS 程序坞与启动台等仍显示 **「寻迹」**。 +- **桌面端品牌与安装包**:安装包、GitHub Release 资源文件名使用 **ChatTake** 前缀(避免部分环境下非 ASCII 文件名异常);macOS 程序坞与启动台等仍显示 **「有得」**。 --- diff --git a/apps/desktop/src/lib/distillHints.ts b/apps/desktop/src/lib/distillHints.ts index 57902a9..09d34d7 100644 --- a/apps/desktop/src/lib/distillHints.ts +++ b/apps/desktop/src/lib/distillHints.ts @@ -4,7 +4,7 @@ /** 是否在文案中追加 Sidecar / 配置相关提示 */ export function shouldAppendSidecarHint(message: string): boolean { - return /sidecar|xunji-sidecar|未找到.*可执行|distill_session join/i.test(message) + return /sidecar|chattake-sidecar|未找到.*可执行|distill_session join/i.test(message) } export function shouldAppendApiHint(message: string): boolean { @@ -19,7 +19,7 @@ export function appendDistillHint(message: string): string { if (!m) return m if (m.includes('请确认已构建') || m.includes('请检查设置中的 API')) return m if (shouldAppendSidecarHint(m)) { - return `${m} — 请确认已构建 packages/sidecar,或在「设置 → 提炼引擎」检查 API/CLI 模式与密钥。` + return `${m} — 请确认已构建 packages/sidecar,或在「设置 → AI 配置」检查当前配置与密钥。` } if (shouldAppendApiHint(m)) { return `${m} — 请检查设置中的 API Key、模型、超时与网络。` diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index f0ea912..d23a36e 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -7,12 +7,12 @@ import { invoke } from '@tauri-apps/api/core' import type { AppConfigDto, + ProviderProfileDto, Card, CardSummary, - CliProbeResult, - DistillSessionResult, ListCardsParams, Message, + Job, PaginatedResult, SearchCardsParams, Session, @@ -20,14 +20,23 @@ import type { SessionListParams, SessionFilterPayload, SessionSummary, - SyncResult, TagCount, + TagRecord, + CardUpdatePayload, + CursorPage, + McpInfo, TypeCount, } from '../types' export const api = { - /** 全量采集 */ - syncAll: () => invoke('sync_all'), + startSync: (scope?: string | null) => invoke('start_sync', { scope: scope ?? null }), + startAnalysis: (sessionIds: string[], providerProfileId?: string | null) => + invoke('start_analysis', { sessionIds, providerProfileId: providerProfileId ?? null }), + listJobs: (activeOnly = false) => invoke('list_jobs', { activeOnly }), + getJob: (jobId: string) => invoke('get_job', { jobId }), + cancelJob: (jobId: string) => invoke('cancel_job', { jobId }), + retryJobItem: (jobId: string, itemId: string, providerProfileId?: string | null) => + invoke('retry_job_item', { jobId, itemId, providerProfileId: providerProfileId ?? null }), /** 分页查询会话列表 */ listSessions: (params: SessionListParams) => @@ -72,12 +81,8 @@ export const api = { getSession: (id: string) => invoke('get_session', { id }), /** 获取会话所有消息(对话回放用) */ - getSessionMessages: (sessionId: string) => - invoke('get_session_messages', { sessionId }), - - /** 提炼会话 → 价值判断 + 可选笔记生成 */ - distillSession: (sessionId: string) => - invoke('distill_session', { sessionId }), + getSessionMessages: (sessionId: string, cursor?: number | null) => + invoke>('get_session_messages', { sessionId, cursor: cursor ?? null, limit: 100 }), /** FTS5 全文搜索卡片 */ searchCards: (params: SearchCardsParams) => @@ -95,12 +100,20 @@ export const api = { cardType: params.cardType ?? null, value: params.value ?? null, techStack: params.techStack ?? null, + publicationStatus: params.publicationStatus ?? null, page: params.page ?? null, pageSize: params.pageSize ?? null, }), /** 获取单张卡片(含 tags) */ getCard: (id: string) => invoke('get_card', { id }), + updateCard: (id: string, payload: CardUpdatePayload) => + invoke('update_card', { id, ...payload }), + publishCard: (id: string, replaceExisting: boolean) => + invoke('publish_card', { id, replaceExisting }), + listTagRecords: () => invoke('list_tag_records'), + mergeTags: (kind: TagRecord['kind'], sources: string[], target: string) => + invoke('merge_tags', { kind, sources, target }), /** 获取会话分组统计(侧栏目录树) */ getSessionGroups: () => invoke('get_session_groups'), @@ -117,14 +130,22 @@ export const api = { /** 读取当前应用配置 */ getConfig: () => invoke('get_config'), + /** 本次启动若重建过旧库,返回可恢复备份路径。 */ + getDatabaseBackupPath: () => invoke('get_database_backup_path'), + /** 保存应用配置(写磁盘 + 热更新内存) */ saveConfig: (config: AppConfigDto) => invoke('save_config', { config }), - /** - * 在登录 shell 环境下探测常见 AI CLI 的绝对路径(与终端 `command -v` 一致)。 - * 用于设置页「自动检测」,避免用户手填路径。 - */ - probeCliTools: () => invoke('probe_cli_tools'), + /** 临时加载供应商模型列表;结果不持久化。 */ + listProviderModels: (profile: ProviderProfileDto) => + invoke('list_provider_models', { profile }), + + /** 优先测试模型列表;不支持时发送最小 Chat Completions 请求。 */ + testProvider: (profile: ProviderProfileDto) => + invoke('test_provider', { profile }), + + /** 获取只读 MCP 的可用状态、路径与手动配置片段。 */ + getMcpInfo: () => invoke('get_mcp_info'), /** 单条笔记导出为 Markdown(路径由系统「另存为」决定) */ exportCardMarkdown: (cardId: string, filePath: string) => diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 13464ac..580b6ba 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -14,7 +14,7 @@ const ui = useUiStore() router.afterEach((to) => { if (to.name === 'library') { ui.activeTab = 'library' - } else { + } else if (to.name === 'sessions') { ui.activeTab = 'sessions' } }) diff --git a/apps/desktop/src/stores/analysisQueue.ts b/apps/desktop/src/stores/analysisQueue.ts index 39b349c..1174e4c 100644 --- a/apps/desktop/src/stores/analysisQueue.ts +++ b/apps/desktop/src/stores/analysisQueue.ts @@ -1,248 +1,104 @@ -/** - * 全局分析队列 —— 列表单条、批量、详情页「提炼」共享同一串行队列。 - * - * ```text - * enqueue → tasks[] → _processNext(串行 api.distillSession) - * ↓ - * sessions.patchItem(列表状态) - * ↓ - * callbacks(详情页本地状态 / 单条成功后跳转) - * ``` - */ import { computed, ref } from 'vue' import { defineStore } from 'pinia' +import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { api } from '../lib/tauri' -import type { DistillSessionResult } from '../types' +import type { Job } from '../types' import { useSessionsStore } from './sessions' -/** 队列任务状态 */ -export type QueueTaskStatus = 'pending' | 'running' | 'done' | 'error' - -/** 单条队列任务(model 字段见 types) */ export interface QueueTask { + jobId: string + itemId: string sessionId: string - /** 面板展示的标题(项目名或卡片标题) */ displayTitle: string - status: QueueTaskStatus - /** 成功产出笔记 / 低价值无卡片 */ - outcome?: 'success' | 'low_value' - /** distill 失败时与 invoke / DB error_message 对齐的可读原因 */ + status: 'pending' | 'running' | 'done' | 'error' errorMessage?: string - /** 当前条已耗时(秒),仅 running 时递增 */ elapsedSec: number - callbacks?: { - onSuccess?: (r: DistillSessionResult) => void - onLowValue?: (r: DistillSessionResult) => void - onError?: (msg: string) => void - } } -export interface EnqueueOptions { - onSuccess?: (r: DistillSessionResult) => void - onLowValue?: (r: DistillSessionResult) => void - onError?: (msg: string) => void -} +const terminal = new Set(['succeeded', 'failed', 'cancelled', 'interrupted']) export const useAnalysisQueueStore = defineStore('analysisQueue', () => { - const tasks = ref([]) - - /** 是否正在执行 _processNext 内的一次 distill(防止重入) */ - let processing = false - - let elapsedTimer: ReturnType | null = null - - function clearElapsedTimer() { - if (elapsedTimer) { - clearInterval(elapsedTimer) - elapsedTimer = null - } + const jobs = ref([]) + const clock = ref(Date.now()) + let initialized = false + let unlisten: UnlistenFn | null = null + let timer: ReturnType | null = null + + const tasks = computed(() => jobs.value + .filter((job) => job.kind === 'analysis') + .flatMap((job) => job.items.map((item) => ({ + jobId: job.id, + itemId: item.id, + sessionId: item.sessionId ?? '', + displayTitle: item.sessionId ?? '未知会话', + status: item.status === 'queued' ? 'pending' + : item.status === 'running' ? 'running' + : item.status === 'succeeded' ? 'done' : 'error', + errorMessage: item.error ?? undefined, + elapsedSec: item.startedAt && item.status === 'running' + ? Math.max(0, Math.floor((clock.value - Date.parse(item.startedAt)) / 1000)) : 0, + })))) + + const currentTask = computed(() => tasks.value.find((task) => task.status === 'running') ?? null) + const pendingCount = computed(() => tasks.value.filter((task) => task.status === 'pending').length) + const totalCount = computed(() => jobs.value.reduce((sum, job) => sum + job.total, 0)) + const doneCount = computed(() => jobs.value.reduce((sum, job) => sum + job.done, 0)) + const hasAny = computed(() => jobs.value.length > 0) + const isIdle = computed(() => hasAny.value && jobs.value.every((job) => terminal.has(job.status))) + const progressPercent = computed(() => totalCount.value ? Math.round(doneCount.value / totalCount.value * 100) : 0) + + function upsert(job: Job) { + const index = jobs.value.findIndex((item) => item.id === job.id) + if (index < 0) jobs.value.unshift(job) + else jobs.value[index] = job + if (terminal.has(job.status)) void useSessionsStore().loadPage() } - /** 当前正在跑的任务(至多一条) */ - const currentTask = computed(() => tasks.value.find((t) => t.status === 'running') ?? null) - - const pendingCount = computed(() => tasks.value.filter((t) => t.status === 'pending').length) - - const totalCount = computed(() => tasks.value.length) - - const doneCount = computed( - () => tasks.value.filter((t) => t.status === 'done' || t.status === 'error').length, - ) - - /** 队列中是否还有任务(含已结束待用户点关闭) */ - const hasAny = computed(() => tasks.value.length > 0) - - /** - * 是否全部结束(无 pending / running),可显示「关闭」清空面板 - */ - const isIdle = computed( - () => - tasks.value.length > 0 && - !tasks.value.some((t) => t.status === 'pending' || t.status === 'running'), - ) - - /** 进度百分比:已完成(含失败/取消) / 总数 */ - const progressPercent = computed(() => { - if (totalCount.value === 0) return 0 - return Math.round((doneCount.value / totalCount.value) * 100) - }) - - /** 最近完成的最多 3 条(从新到旧) */ - const recentCompleted = computed(() => { - const out: QueueTask[] = [] - for (let i = tasks.value.length - 1; i >= 0 && out.length < 3; i--) { - const t = tasks.value[i] - if (t.status === 'done' || t.status === 'error') out.push(t) - } - return out - }) - - /** - * 入队:若同 session 已在 pending/running 则忽略(防重复点击) - * @returns 是否实际入队(用于批量统计) - */ - function enqueue(sessionId: string, displayTitle: string, options: EnqueueOptions = {}): boolean { - const dup = tasks.value.some( - (t) => - t.sessionId === sessionId && (t.status === 'pending' || t.status === 'running'), - ) - if (dup) { - console.warn('[analysisQueue] 会话已在队列中,忽略重复入队:', sessionId) - return false - } - - const sessions = useSessionsStore() - sessions.patchItem(sessionId, { status: 'analyzing' }) - - tasks.value.push({ - sessionId, - displayTitle: displayTitle || sessionId, - status: 'pending', - elapsedSec: 0, - callbacks: { ...options }, - }) - - void processNext() - return true + async function initialize() { + if (initialized) return + initialized = true + jobs.value = await api.listJobs(true).catch(() => []) + unlisten = await listen('job://updated', (event) => upsert(event.payload)) + timer = setInterval(() => { clock.value = Date.now() }, 1000) } - /** - * 停止:未开始的 pending 全部标记为 error,会话状态恢复为 pending - * 当前 running 的一条会继续跑完(无法中断后端 distill) - */ - function cancel() { + async function startAnalysis(sessionIds: string[], providerProfileId?: string) { const sessions = useSessionsStore() - for (const t of tasks.value) { - if (t.status === 'pending') { - t.status = 'error' - t.errorMessage = '已取消排队' - sessions.patchItem(t.sessionId, { status: 'pending' }) - // 让调用方回调(如批量统计)能收到「本条已结束」 - t.callbacks?.onError?.('已取消排队') - } - } + sessionIds.forEach((id) => sessions.patchItem(id, { status: 'analyzing' })) + const job = await api.startAnalysis(sessionIds, providerProfileId) + upsert(job) + return job } - /** 清空已结束任务,收起面板 */ - function clear() { - tasks.value = tasks.value.filter( - (t) => t.status !== 'done' && t.status !== 'error', - ) + async function startSync(scope?: string) { + const job = await api.startSync(scope) + upsert(job) + return job } - function applyResultToSessions(sessionId: string, result: DistillSessionResult) { - const sessions = useSessionsStore() - if (result.isLowValue) { - sessions.patchItem(sessionId, { - status: 'analyzed', - value: result.value, - cardId: null, - cardTitle: result.cardTitle ?? null, - cardSummary: result.reason ?? null, - cardType: result.cardType ?? null, - cardTags: null, - }) - } else { - const card = result.card! - sessions.patchItem(sessionId, { - status: 'analyzed', - value: card.value ?? null, - cardId: card.id, - cardTitle: card.title, - cardSummary: card.summary ?? null, - cardType: card.type ?? null, - cardTags: card.tags?.join(',') ?? null, - }) - } + async function cancel(jobId?: string) { + const targets = jobs.value.filter((job) => !terminal.has(job.status) && (!jobId || job.id === jobId)) + await Promise.all(targets.map(async (job) => upsert(await api.cancelJob(job.id)))) } - async function processNext() { - // 防止 await 间隙被重复触发导致双开 distill - if (processing) return - if (tasks.value.some((t) => t.status === 'running')) return - - const next = tasks.value.find((t) => t.status === 'pending') - if (!next) return - - processing = true - next.status = 'running' - next.elapsedSec = 0 - - clearElapsedTimer() - elapsedTimer = setInterval(() => { - const running = tasks.value.find((t) => t.sessionId === next.sessionId && t.status === 'running') - if (running) running.elapsedSec++ - }, 1000) - - try { - const result = await api.distillSession(next.sessionId) - clearElapsedTimer() + async function retry(jobId: string, itemId: string, providerProfileId?: string) { + const job = await api.retryJobItem(jobId, itemId, providerProfileId) + upsert(job) + return job + } - if (import.meta.env.DEV) { - console.info( - `[distill] traceId=${result.traceId} sessionId=${next.sessionId}(与终端 Rust/sidecar 日志 grep 同一 id)`, - ) - } + function clear() { jobs.value = jobs.value.filter((job) => !terminal.has(job.status)) } - next.status = 'done' - if (result.isLowValue) { - next.outcome = 'low_value' - applyResultToSessions(next.sessionId, result) - next.callbacks?.onLowValue?.(result) - } else { - next.outcome = 'success' - applyResultToSessions(next.sessionId, result) - next.callbacks?.onSuccess?.(result) - } - } catch (e) { - clearElapsedTimer() - const msg = e instanceof Error ? e.message : String(e) - next.status = 'error' - next.errorMessage = msg - const sessions = useSessionsStore() - // 与后端 update_session_error 一致,列表 SessionCard 可立即展示原因而无需整页刷新 - sessions.patchItem(next.sessionId, { status: 'error', errorMessage: msg }) - next.callbacks?.onError?.(msg) - } finally { - processing = false - setTimeout(() => { - void processNext() - }, 500) - } + function dispose() { + unlisten?.() + unlisten = null + if (timer) clearInterval(timer) + timer = null + initialized = false } return { - tasks, - currentTask, - pendingCount, - totalCount, - doneCount, - hasAny, - isIdle, - progressPercent, - recentCompleted, - enqueue, - cancel, - clear, + jobs, clock, tasks, currentTask, pendingCount, totalCount, doneCount, hasAny, isIdle, + progressPercent, initialize, startAnalysis, startSync, cancel, retry, clear, dispose, } }) diff --git a/apps/desktop/src/stores/sessions.ts b/apps/desktop/src/stores/sessions.ts index 864c400..c8aecae 100644 --- a/apps/desktop/src/stores/sessions.ts +++ b/apps/desktop/src/stores/sessions.ts @@ -13,7 +13,6 @@ export const useSessionsStore = defineStore('sessions', () => { const page = ref(1) const pageSize = ref(20) const loading = ref(false) - const syncing = ref(false) const error = ref(null) async function loadPage() { @@ -40,22 +39,6 @@ export const useSessionsStore = defineStore('sessions', () => { } } - async function syncAll() { - syncing.value = true - error.value = null - try { - const r = await api.syncAll() - console.info('[sessions] sync', r) - await loadPage() - return r - } catch (e) { - error.value = e instanceof Error ? e.message : String(e) - throw e - } finally { - syncing.value = false - } - } - function setPage(p: number) { page.value = Math.max(1, p) void loadPage() @@ -84,10 +67,8 @@ export const useSessionsStore = defineStore('sessions', () => { page, pageSize, loading, - syncing, error, loadPage, - syncAll, setPage, setPageSize, patchItem, diff --git a/apps/desktop/src/stores/ui.ts b/apps/desktop/src/stores/ui.ts index e3c043d..c86a207 100644 --- a/apps/desktop/src/stores/ui.ts +++ b/apps/desktop/src/stores/ui.ts @@ -10,7 +10,8 @@ export type MainTab = 'sessions' | 'library' export const useUiStore = defineStore('ui', () => { const activeTab = ref('sessions') const sidebarWidth = ref(280) - const darkMode = ref(false) + const darkMode = ref(localStorage.getItem('chattake:theme') === 'dark' + || (!localStorage.getItem('chattake:theme') && matchMedia('(prefers-color-scheme: dark)').matches)) function toggleTheme() { darkMode.value = !darkMode.value @@ -25,6 +26,7 @@ export const useUiStore = defineStore('ui', () => { } else { root.classList.remove('dark') } + localStorage.setItem('chattake:theme', v ? 'dark' : 'light') }, { immediate: true }, ) diff --git a/apps/desktop/src/types/index.ts b/apps/desktop/src/types/index.ts index 98dc975..3a9836f 100644 --- a/apps/desktop/src/types/index.ts +++ b/apps/desktop/src/types/index.ts @@ -2,31 +2,40 @@ * 与 Rust Tauri 返回值对应的 TypeScript 类型(字段名使用 camelCase,与 serde 配置一致) */ -export interface SyncResult { - found: number - new: number - updated: number - skipped: number +export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'interrupted' + +export interface JobItem { + id: string + jobId: string + sessionId: string | null + sourceId: string | null + rawPath: string | null + status: JobStatus + phase: string + durationMs: number | null + error: string | null + createdAt: string + startedAt: string | null + finishedAt: string | null } -/** - * distill_session 返回结果。 - * - * - isLowValue = true:价值为 low / none,已更新 DB,无卡片产出 - * - isLowValue = false:已生成笔记卡片,card 有值 - */ -export interface DistillSessionResult { - /** 本次分析 trace,与终端 sidecar / Rust 日志一致,grep 此 id 可串联全流程 */ - traceId: string - value: string - isLowValue: boolean - /** 低/无价值时:由 reason 截取的简短标题 */ - cardTitle: string | null - /** 低/无价值时:judge_value 返回的对话类型(英文枚举,展示用 @xunji/shared getCardTypeLabel) */ - cardType: string | null - /** 低/无价值时的原因说明(作为摘要展示) */ - reason: string | null - card: Card | null +export interface Job { + id: string + kind: 'sync' | 'analysis' + status: JobStatus + phase: string + done: number + total: number + cancelRequested: boolean + error: string | null + providerProfileId: string | null + provider: string | null + baseUrl: string | null + model: string | null + createdAt: string + startedAt: string | null + finishedAt: string | null + items: JobItem[] } export interface PaginatedResult { @@ -36,6 +45,11 @@ export interface PaginatedResult { pageSize: number } +export interface CursorPage { + items: T[] + nextCursor: number | null +} + /** 列表行:会话摘要 */ export interface SessionSummary { id: string @@ -104,14 +118,14 @@ export interface Message { export interface Card { id: string sessionId: string + analysisRunId: string | null title: string type: string | null value: string | null summary: string | null note: string - categoryId: string | null - memory: string | null - skill: string | null + publicationStatus: 'draft' | 'published' + isUserEdited: boolean sourceName: string | null projectName: string | null promptTokens: number @@ -136,11 +150,14 @@ export interface CardSummary { type: string | null value: string | null summary: string | null - categoryId: string | null + publicationStatus: 'draft' | 'published' sourceName: string | null projectName: string | null createdAt: string updatedAt: string + matchSnippet: string | null + tags: string[] + technologies: string[] } export interface SessionListParams { @@ -192,10 +209,27 @@ export interface ListCardsParams { value?: string | null /** 技术栈名称列表,AND 语义(卡片 tech_stack 字段需同时包含所选项) */ techStack?: string[] | null + publicationStatus?: 'draft' | 'published' page?: number pageSize?: number } +export interface TagRecord { + id: string + name: string + normalizedName: string + kind: 'topic' | 'technology' +} + +export interface CardUpdatePayload { + title: string + cardType: string + summary: string + note: string + tags: string[] + technologies: string[] +} + // ─── 配置相关类型(与 Rust AppConfigDto 对应) ─────────────────────────────── export interface AppConfigDto { @@ -205,36 +239,20 @@ export interface AppConfigDto { } export interface DistillerConfigDto { - /** 提炼模式: "api" | "cli" */ - mode: string - api: ApiConfigDto | null - cli: CliConfigDto | null + activeProfileId: string + profiles: ProviderProfileDto[] } -export interface ApiConfigDto { - /** 提供商标识(如 "openai"、"deepseek"、"openai-compatible") */ +export interface ProviderProfileDto { + id: string + name: string + kind: 'api' | 'cli' provider: string - /** API Base URL(可选) */ - baseUrl: string | null - /** API 密钥 */ + baseUrl: string apiKey: string - /** 模型名称 */ model: string - /** 请求超时(秒) */ - timeoutSecs: number -} - -export interface CliConfigDto { - /** CLI 命令名或可执行文件绝对路径 */ command: string - /** 附加参数 */ - extraArgs: string[] -} - -/** `probe_cli_tools` 返回:候选名与登录 shell 下解析到的绝对路径 */ -export interface CliProbeResult { - name: string - resolvedPath: string | null + timeoutSecs: number } export interface CollectorConfigDto { @@ -249,6 +267,12 @@ export interface SourceConfigDto { } export interface SyncConfigDto { - mode: string - intervalSecs: number + scanOnStartup: boolean +} + +export interface McpInfo { + available: boolean + binaryPath: string | null + databasePath: string + configSnippet: string | null } diff --git a/apps/desktop/src/views/KnowledgeView.vue b/apps/desktop/src/views/KnowledgeView.vue index 7746428..211d7ac 100644 --- a/apps/desktop/src/views/KnowledgeView.vue +++ b/apps/desktop/src/views/KnowledgeView.vue @@ -1,34 +1,41 @@ + + diff --git a/apps/desktop/src/views/SessionDetailView.vue b/apps/desktop/src/views/SessionDetailView.vue index f343096..c0f05a2 100644 --- a/apps/desktop/src/views/SessionDetailView.vue +++ b/apps/desktop/src/views/SessionDetailView.vue @@ -2,7 +2,7 @@ import { computed, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { storeToRefs } from 'pinia' -import { NButton, NResult, NSpin, NTag, NTooltip, useMessage } from 'naive-ui' +import { NButton, NInput, NModal, NResult, NSelect, NSpin, NTag, NTooltip, useDialog, useMessage } from 'naive-ui' import NoteCard from '../components/NoteCard.vue' import ChatReplay from '../components/ChatReplay.vue' import { useAnalysisQueueStore } from '../stores/analysisQueue' @@ -18,18 +18,29 @@ const props = defineProps<{ const route = useRoute() const router = useRouter() const message = useMessage() +const dialog = useDialog() const queue = useAnalysisQueueStore() const { currentTask, tasks } = storeToRefs(queue) const session = ref(null) const messages = ref([]) +const nextCursor = ref(null) +const loadingMore = ref(false) const card = ref(null) const loading = ref(true) const analyzeError = ref(null) const loadError = ref(null) const exportMdLoading = ref(false) const mode = ref<'note' | 'chat'>('note') +const editOpen = ref(false) +const editSaving = ref(false) +const edit = ref({ title: '', cardType: 'explanation', summary: '', note: '', tags: '', technologies: '' }) +const typeOptions = [ + { label: '决策', value: 'decision' }, { label: '排障', value: 'troubleshooting' }, + { label: '实现', value: 'implementation' }, { label: '解释', value: 'explanation' }, + { label: '片段', value: 'snippet' }, +] /** 当前会话是否正在执行 distill(队列头) */ const analyzing = computed( @@ -52,12 +63,13 @@ async function load() { loading.value = true loadError.value = null try { - const [sess, msgs] = await Promise.all([ + const [sess, page] = await Promise.all([ api.getSession(props.sessionId), api.getSessionMessages(props.sessionId), ]) session.value = sess - messages.value = msgs + messages.value = page.items + nextCursor.value = page.nextCursor const cardId = typeof route.query.cardId === 'string' ? route.query.cardId : null if (cardId) { @@ -73,6 +85,16 @@ async function load() { } } +async function loadMoreMessages() { + if (nextCursor.value == null) return + loadingMore.value = true + try { + const page = await api.getSessionMessages(props.sessionId, nextCursor.value) + messages.value.push(...page.items) + nextCursor.value = page.nextCursor + } finally { loadingMore.value = false } +} + onMounted(load) watch(() => [props.sessionId, route.query.cardId], () => { void load() @@ -81,6 +103,10 @@ watch(() => [props.sessionId, route.query.cardId], () => { // ────────────── 操作 ────────────── function close() { + if (router.options.history.state.back) { + router.back() + return + } void router.push({ name: 'sessions' }) } @@ -97,42 +123,64 @@ async function onExportMarkdown() { } } -function analyze() { +async function analyze() { analyzeError.value = null if (session.value) session.value.status = 'analyzing' - const title = - session.value?.analysisTitle - ?? session.value?.projectName - ?? session.value?.projectPath - ?? props.sessionId - queue.enqueue(props.sessionId, title, { - onLowValue: (result) => { - if (session.value) { - session.value.status = 'analyzed' - session.value.value = result.value - } - card.value = null - analyzeError.value = `已判断为${result.value === 'none' ? '无价值' : '低价值'}:${result.reason ?? ''}` - void router.replace({ - name: 'session-detail', - params: { sessionId: props.sessionId }, - }) - }, - onSuccess: (result) => { - card.value = result.card - mode.value = 'note' - if (session.value) { - session.value.status = 'analyzed' - session.value.value = result.value - } - void router.replace({ - name: 'session-detail', - params: { sessionId: props.sessionId }, - query: result.card ? { cardId: result.card.id } : {}, - }) - }, - onError: (msg) => { - analyzeError.value = appendDistillHint(msg) + try { + await queue.startAnalysis([props.sessionId]) + analyzeError.value = '分析任务已创建;完成后可从对话列表打开生成的知识项。' + } catch (error) { + analyzeError.value = appendDistillHint(error instanceof Error ? error.message : String(error)) + if (session.value) session.value.status = 'error' + } +} + +function openEdit() { + if (!card.value) return + edit.value = { + title: card.value.title, + cardType: card.value.type ?? 'explanation', + summary: card.value.summary ?? '', + note: card.value.note, + tags: card.value.tags.join(','), + technologies: card.value.techStack.join(','), + } + editOpen.value = true +} + +function splitLabels(value: string) { + return value.split(/[,,]/).map((item) => item.trim()).filter(Boolean) +} + +async function saveEdit() { + if (!card.value) return + editSaving.value = true + try { + card.value = await api.updateCard(card.value.id, { + title: edit.value.title, + cardType: edit.value.cardType, + summary: edit.value.summary, + note: edit.value.note, + tags: splitLabels(edit.value.tags), + technologies: splitLabels(edit.value.technologies), + }) + editOpen.value = false + message.success('草稿已保存') + } catch (error) { + message.error(error instanceof Error ? error.message : String(error)) + } finally { editSaving.value = false } +} + +function publish(replaceExisting: boolean) { + if (!card.value) return + const label = replaceExisting ? '替换同会话中未人工编辑的旧知识' : '与现有知识并存' + dialog.warning({ + title: '发布草稿', content: `将发布此草稿,并${label}。人工编辑过的旧知识不会被自动删除。`, + positiveText: '确认发布', negativeText: '取消', + onPositiveClick: async () => { + if (!card.value) return + card.value = await api.publishCard(card.value.id, replaceExisting) + message.success('知识已发布') }, }) } @@ -141,9 +189,14 @@ function analyze() { const sourceIcon = computed(() => { if (!session.value) return 'i-lucide-message-square' - if (session.value.sourceId === 'claude-code') return 'i-lucide-bot' - if (session.value.sourceId === 'cursor') return 'i-lucide-terminal-square' - return 'i-lucide-message-square' + return { + 'claude-code': 'i-lucide-terminal', + codex: 'i-lucide-code-2', + cursor: 'i-lucide-mouse-pointer-click', + omp: 'i-lucide-orbit', + pi: 'i-lucide-circle-dot', + codebuddy: 'i-lucide-code', + }[session.value.sourceId] ?? 'i-lucide-message-square' }) const statusTagType = computed<'default' | 'info' | 'success' | 'error'>(() => { @@ -175,15 +228,15 @@ const statusLabel = computed(() => { }) const valueColors: Record = { - high: '#10b981', - medium: '#f59e0b', - low: '#94a3b8', + high: '#31594f', + medium: '#9f4526', + low: '#5d615b', } + + diff --git a/apps/desktop/src/views/SessionsView.vue b/apps/desktop/src/views/SessionsView.vue index 0ef8de7..9311a38 100644 --- a/apps/desktop/src/views/SessionsView.vue +++ b/apps/desktop/src/views/SessionsView.vue @@ -10,43 +10,16 @@ import { useSearchStore } from '../stores/search' import { useAnalysisQueueStore } from '../stores/analysisQueue' import { api } from '../lib/tauri' import { appendDistillHint } from '../lib/distillHints' +import { getCardTypeLabel } from '@chattake/shared' const sessions = useSessionsStore() const router = useRouter() const search = useSearchStore() const queue = useAnalysisQueueStore() -// ── 批量分析完成统计(仅批量入口使用 callbacks 计数) ─────────────────────── - -const batchExpected = ref(0) -const batchFinished = ref(0) -const batchSuccess = ref(0) -const batchFail = ref(0) - /** 知识库卡片总数:用于区分「无笔记可搜」与「关键词无匹配」 */ const libraryCardTotal = ref(null) -function resetBatchStats() { - batchExpected.value = 0 - batchFinished.value = 0 - batchSuccess.value = 0 - batchFail.value = 0 -} - -function tryFinishBatchToast() { - if (batchExpected.value === 0) return - if (batchFinished.value < batchExpected.value) return - if (batchFail.value === 0) { - showToast(`批量分析完成,共处理 ${batchSuccess.value} 条`) - } else { - showToast( - `分析完成:成功 ${batchSuccess.value},失败 ${batchFail.value}`, - batchFail.value > 0 ? 'error' : 'success', - ) - } - resetBatchStats() -} - // ── 批量选择 ───────────────────────────────────────────────────────────────── /** 是否处于批量选择模式 */ @@ -66,6 +39,10 @@ function showToast(msg: string, type: 'success' | 'error' | 'warning' = 'success }, type === 'error' ? 9000 : 4000) } +function plainSnippet(value: string | null) { + return value?.replace(/<\/?mark>/g, '') ?? '' +} + // ── 生命周期 ───────────────────────────────────────────────────────────────── onMounted(() => { @@ -109,38 +86,23 @@ const indeterminate = computed( ) // 批量分析进行中:队列里仍有来自本次批量预期的任务未完成 -const batchRunning = computed( - () => batchExpected.value > 0 && batchFinished.value < batchExpected.value, -) +const activeAnalysisJob = computed(() => queue.jobs.find( + (job) => job.kind === 'analysis' && (job.status === 'queued' || job.status === 'running'), +)) +const batchRunning = computed(() => (activeAnalysisJob.value?.total ?? 0) > 1) +const batchFinished = computed(() => activeAnalysisJob.value?.done ?? 0) +const batchExpected = computed(() => activeAnalysisJob.value?.total ?? 0) // ── 方法 ───────────────────────────────────────────────────────────────────── /** 单条分析(从 SessionCard 触发) */ -function onAnalyze(sessionId: string) { - const s = sessions.items.find((x) => x.id === sessionId) - const title = s?.cardTitle || s?.projectName || sessionId - queue.enqueue(sessionId, title, { - onLowValue: (result) => { - showToast( - `已判断为${result.value === 'none' ? '无价值' : '低价值'}:${result.reason ?? ''}`, - 'warning', - ) - }, - onSuccess: (result) => { - const card = result.card - if (card) { - showToast(`笔记已生成:${card.title}`) - void router.push({ - name: 'session-detail', - params: { sessionId }, - query: { cardId: card.id }, - }) - } - }, - onError: (msg) => { - showToast(msg, 'error') - }, - }) +async function onAnalyze(sessionId: string) { + try { + await queue.startAnalysis([sessionId]) + showToast('分析任务已创建,可在进度中心查看阶段和进度') + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), 'error') + } } function toggleBatchMode() { @@ -171,37 +133,18 @@ function onSelectionChange(id: string, checked: boolean) { /** * 批量分析:全部入队,由全局队列串行执行;通过 callbacks 统计完成后 Toast */ -function startBatchAnalyze() { +async function startBatchAnalyze() { const ids = [...selectedIds.value] if (!ids.length) return - resetBatchStats() - - ids.forEach((id) => { - const s = sessions.items.find((x) => x.id === id) - const title = s?.cardTitle || s?.projectName || id - const enqueued = queue.enqueue(id, title, { - onLowValue: () => { - batchSuccess.value++ - batchFinished.value++ - tryFinishBatchToast() - }, - onSuccess: () => { - batchSuccess.value++ - batchFinished.value++ - tryFinishBatchToast() - }, - onError: () => { - batchFail.value++ - batchFinished.value++ - tryFinishBatchToast() - }, - }) - if (enqueued) batchExpected.value++ - }) - - batchMode.value = false - selectedIds.value = new Set() + try { + await queue.startAnalysis(ids) + showToast(`已创建批量分析任务,共 ${ids.length} 条`) + batchMode.value = false + selectedIds.value = new Set() + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), 'error') + } } function openSearchHit(cardId: string, sessionId: string) { @@ -214,7 +157,14 @@ function openSearchHit(cardId: string, sessionId: string) {