diff --git a/.env.example b/.env.example
index f5d95656..12a7ba63 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,5 @@
# ===========================================
-# 课伴 (KeBan) MVP-2 Alpha 环境变量模板
+# 熵减 (Entropydecrease) 环境变量模板
# ===========================================
# 复制此文件为 .env 并填入实际值
@@ -54,10 +54,10 @@ AI_GATEWAY_PORT=8000
# --- 生产部署配置 ---
# CORS 允许的前端域名(多个用逗号分隔)
-# CORS_ORIGINS=https://keban.app,https://www.keban.app
+# CORS_ORIGINS=https://entropydecrease.com,https://www.entropydecrease.com
# 前端 API 基础 URL(生产环境设置为后端服务域名)
-# VITE_API_BASE_URL=https://api.keban.app
+# VITE_API_BASE_URL=https://api.entropydecrease.com
# 日志级别(DEBUG/INFO/WARNING/ERROR)
# LOG_LEVEL=INFO
diff --git a/.gitattributes b/.gitattributes
index 24a8e879..e86aa3a4 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1 @@
-*.png filter=lfs diff=lfs merge=lfs -text
+*.png filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/workflows/deploy-server.yml b/.github/workflows/deploy-server.yml
index 24c98881..69bafed0 100644
--- a/.github/workflows/deploy-server.yml
+++ b/.github/workflows/deploy-server.yml
@@ -2,7 +2,7 @@ name: Deploy AI Gateway
on:
push:
- branches: [master]
+ branches: [main]
paths: ['server/**']
jobs:
@@ -10,19 +10,48 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - name: Build and push Docker image
- run: |
- cd server
- docker build -t ai-gateway:${{ github.sha }} .
- - name: Deploy to server
+
+ # 上传 server/ 源码到服务器(服务器非 git 仓库,代码由 CI 同步)
+ # 注意:rm 必须为 false —— 服务器上的 .env.production 为真实生产密钥,
+ # 不在仓库内(被 gitignore),删除目标目录会导致密钥永久丢失。
+ - name: Upload server sources
+ uses: appleboy/scp-action@v0.1.7
+ with:
+ host: ${{ secrets.SERVER_HOST }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SSH_PRIVATE_KEY }}
+ source: server/*
+ target: /opt/Entropydecrease
+ rm: false
+
+ - name: Rebuild and restart services
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
- cd /opt/keban
- docker-compose pull
- docker-compose up -d --force-recreate
- sleep 10
- curl -f http://localhost:8000/health || (docker-compose rollback && exit 1)
+ set -e
+ cd /opt/Entropydecrease/server
+
+ # .env.production 含生产密钥,必须显式传入,否则 DB_PASSWORD 等会被解析为空串
+ COMPOSE="docker compose -f docker-compose.prod.yml --env-file .env.production"
+
+ # 拉取第三方镜像(postgres/redis/nginx),自建服务由 --build 重新构建
+ $COMPOSE pull --ignore-buildable || true
+ $COMPOSE up -d --build --force-recreate
+
+ # 健康检查:失败时输出日志便于定位(docker compose 无 rollback 子命令,
+ # 回滚由 revert 提交重新触发部署完成)
+ sleep 15
+ if ! curl -fsS http://127.0.0.1:8000/health; then
+ echo "::error::ai-gateway 健康检查失败,最近日志:"
+ $COMPOSE logs --tail=60 ai-gateway
+ exit 1
+ fi
+ if ! curl -fsS http://127.0.0.1:8080/health; then
+ echo "::error::sync-service 健康检查失败,最近日志:"
+ $COMPOSE logs --tail=60 sync-service
+ exit 1
+ fi
+ echo "部署成功:ai-gateway 与 sync-service 健康检查通过"
diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml
index a5b89c93..b1c7438f 100644
--- a/.github/workflows/deploy-website.yml
+++ b/.github/workflows/deploy-website.yml
@@ -2,7 +2,7 @@ name: Deploy Website
on:
push:
- branches: [master]
+ branches: [main]
paths: ['website/**']
jobs:
@@ -29,7 +29,7 @@ jobs:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
- script: mkdir -p /opt/keban/website
+ script: mkdir -p /opt/Entropydecrease/website
- name: Upload static files to server
uses: appleboy/scp-action@v0.1.7
@@ -38,7 +38,7 @@ jobs:
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: website/out/*
- target: /opt/keban/website
+ target: /opt/Entropydecrease/website
strip_components: 2
rm: true
@@ -52,7 +52,7 @@ jobs:
# scp 的 rm:true 会重建挂载目录导致 bind mount 失效,必须重启容器
docker restart entropy-decrease-nginx
sleep 5
- curl -sf https://entropydecrease.com/ | grep -q "课伴" || exit 1
+ curl -sf https://entropydecrease.com/ | grep -q "熵减" || exit 1
- name: Notify IndexNow
run: |
diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml
index 70a7be61..6c67ce45 100644
--- a/.github/workflows/pr-check.yml
+++ b/.github/workflows/pr-check.yml
@@ -2,7 +2,7 @@ name: PR Quality Check
on:
pull_request:
- branches: [master]
+ branches: [main, dev]
jobs:
# ===========================================================================
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 23d7b86d..ab436568 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,55 +1,55 @@
-name: Release Electron App
-
-# 由 semantic-release 创建的 v* tag 触发:构建 Electron 安装包并附加到对应 Release
-on:
- push:
- tags: ['v*']
-
-# 上传构建产物到 GitHub Release 需要写权限
-permissions:
- contents: write
-
-jobs:
- lint-test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: 'npm'
- cache-dependency-path: client/package-lock.json
- - run: cd client && npm ci
- - run: cd client && npm run lint
- - run: cd client && npm run test -- --run
-
- build:
- needs: lint-test
- strategy:
- matrix:
- os: [windows-latest, macos-latest]
- runs-on: ${{ matrix.os }}
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: 'npm'
- cache-dependency-path: client/package-lock.json
- - run: cd client && npm ci
- - run: cd client && npm run electron:build
- - uses: actions/upload-artifact@v4
- with:
- name: release-${{ matrix.os }}
- path: client/release/*.{exe,dmg,AppImage}
-
- release:
- needs: build
- runs-on: ubuntu-latest
- steps:
- - uses: actions/download-artifact@v4
- with:
- pattern: release-*
- - uses: softprops/action-gh-release@v2
- with:
- files: release-*/*
+name: Release Electron App
+
+# 由 semantic-release 创建的 v* tag 触发:构建 Electron 安装包并附加到对应 Release
+on:
+ push:
+ tags: ['v*']
+
+# 上传构建产物到 GitHub Release 需要写权限
+permissions:
+ contents: write
+
+jobs:
+ lint-test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: 'npm'
+ cache-dependency-path: client/package-lock.json
+ - run: cd client && npm ci
+ - run: cd client && npm run lint
+ - run: cd client && npm run test -- --run
+
+ build:
+ needs: lint-test
+ strategy:
+ matrix:
+ os: [windows-latest, macos-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: 'npm'
+ cache-dependency-path: client/package-lock.json
+ - run: cd client && npm ci
+ - run: cd client && npm run electron:build
+ - uses: actions/upload-artifact@v4
+ with:
+ name: release-${{ matrix.os }}
+ path: client/release/*.{exe,dmg,AppImage}
+
+ release:
+ needs: build
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ pattern: release-*
+ - uses: softprops/action-gh-release@v2
+ with:
+ files: release-*/*
diff --git a/.github/workflows/version-release.yml b/.github/workflows/version-release.yml
index f2f23b4a..03ab5ff9 100644
--- a/.github/workflows/version-release.yml
+++ b/.github/workflows/version-release.yml
@@ -6,7 +6,7 @@ name: Version & Release
# 自动更新 package.json/package-lock.json、生成 CHANGELOG、创建 Git tag 与 GitHub Release。
on:
push:
- branches: [master]
+ branches: [main]
# 同一分支串行执行,避免并发发布产生 tag/版本竞争
concurrency:
diff --git a/.gitignore b/.gitignore
index d1f613b0..553912a4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,9 +35,9 @@ Thumbs.db
.env.local
.env.*.local
.env.production
+.env.test
-# Documentation (local only, not version controlled)
-docs/
+# Brainstorm scratch (personal notes)
brainstorm*.md
ui_brainstorm.md
diff --git a/.husky/commit-msg b/.husky/commit-msg
new file mode 100644
index 00000000..da994831
--- /dev/null
+++ b/.husky/commit-msg
@@ -0,0 +1 @@
+npx --no -- commitlint --edit "$1"
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 00000000..2312dc58
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1 @@
+npx lint-staged
diff --git a/.releaserc.json b/.releaserc.json
index 95b47479..88fde5d8 100644
--- a/.releaserc.json
+++ b/.releaserc.json
@@ -1,6 +1,6 @@
{
"branches": [
- "master"
+ "main"
],
"tagFormat": "v${version}",
"plugins": [
diff --git a/AGENTS.md b/AGENTS.md
index c26ad8b0..22a53e7f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,80 +1,91 @@
-# AGENTS.md — 课伴 (KeBan) 仓库代理上下文
-
-## 项目概述
-
-课伴是面向学生与终身学习者的 AI 智能学习桌面应用,践行费曼学习法与间隔重复。
-核心理念:本地优先 + AI 增强可选。
-
-## 项目结构
-
-```
-├── client/ # Electron + React 桌面客户端(主产品)
-│ ├── electron/ # Electron 主进程(TypeScript)
-│ └── src/ # React 渲染进程(Vite + Tailwind)
-├── server/ # 后端服务
-│ ├── ai-gateway/ # AI 网关(Python / FastAPI / LangChain)
-│ ├── sync-service/# 数据同步服务(Go / Gin)
-│ └── nginx/ # 反向代理配置
-├── website/ # 官网(Next.js 静态站点)
-├── scripts/ # 仓库级工具脚本(版本、音效生成、会话检测)
-└── docs/ # 全生命周期开发工作流文档
-```
-
-## 核心模块边界
-
-| 模块 | 入口 | 职责 |
-|------|------|------|
-| Electron 主进程 | `client/electron/main.ts` | 窗口管理、IPC、系统托盘、自动更新 |
-| AI 集成层 | `client/electron/ai/` | 本地 Ollama 推理 + 云端 AI 网关调度 |
-| AI 网关配置 | `server/ai-gateway/config.py` | 多模型路由(Qwen/DeepSeek/GLM)、超时、降级 |
-| AI 网关入口 | `server/ai-gateway/main.py` | FastAPI 应用、中间件、路由注册 |
-| 前端渲染 | `client/src/App.tsx` | React 路由、全局布局、3D 场景 |
-| 数据层 | `client/electron/db/` | better-sqlite3 本地持久化 |
-
-## 变更需额外审查的文件
-
-以下文件变更影响面大,需要额外审查:
-
-- `client/electron/main.ts` — 应用生命周期,改动可能导致启动失败
-- `client/electron/preload.ts` — IPC 桥接安全边界
-- `client/electron/ai/` — AI 调度逻辑,涉及多 provider 降级链
-- `server/ai-gateway/config.py` — 模型路由与密钥配置,影响全部 AI 功能
-- `server/ai-gateway/middleware/auth.py` — JWT 认证,安全关键
-- `server/ai-gateway/middleware/rate_limit.py` — 频率限制,影响可用性
-- `server/docker-compose.prod.yml` — 生产编排,改动影响部署
-- `client/electron-builder.yml` — 打包配置,影响发布产物
-- `.github/workflows/` — CI/CD 流水线
-
-## 本地验证命令
-
-```bash
-# 客户端(在 client/ 目录下执行)
-npm run lint # Oxlint 代码检查
-npm run test # Vitest 单元测试
-npm run build # tsc -b && vite build(TypeScript 编译 + Vite 打包)
-
-# Electron 桌面端构建
-npm run electron:build
-
-# 服务端 AI 网关(在 server/ai-gateway/ 目录下)
-pip install -r requirements.txt
-python main.py # 启动 FastAPI 开发服务器
-
-# 仓库级
-npm run release:dry # semantic-release 干跑验证
-```
-
-## 技术栈速查
-
-- **客户端**: React 18, TypeScript, Vite, Electron, Tailwind CSS, Zustand, TanStack Query, Dexie.js, TipTap, Framer Motion, Vitest, Oxlint
-- **AI 网关**: Python, FastAPI, LangChain, 通义千问/DeepSeek/智谱 GLM
-- **同步服务**: Go, Gin, PostgreSQL, Redis
-- **官网**: Next.js (静态导出)
-- **基础设施**: Docker, docker-compose, Nginx, electron-updater
-
-## 约定
-
-- 提交信息遵循 Conventional Commits 规范
-- 版本号由 semantic-release 自动管理
-- AI 功能必须支持离线降级(本地优先原则)
-- 多模态模型 `max_tokens` 限制:Qwen 最大 4096,GLM-4V-Flash 最大 1024
+# AGENTS.md — 熵减 (Entropydecrease) 仓库代理上下文
+
+## 项目概述
+
+熵减是面向学生与终身学习者的 AI 智能学习桌面应用,践行费曼学习法与间隔重复。
+核心理念:本地优先 + AI 增强可选。
+
+## 项目结构
+
+```
+├── client/ # Electron + React 桌面客户端(主产品)
+│ ├── electron/ # Electron 主进程(TypeScript)
+│ └── src/ # React 渲染进程(Vite + Tailwind)
+├── server/ # 后端服务
+│ ├── ai-gateway/ # AI 网关(Python / FastAPI / LangChain)
+│ ├── sync-service/# 数据同步服务(Go / Gin)
+│ └── nginx/ # 反向代理配置
+├── website/ # 官网(Next.js 静态站点)
+├── scripts/ # 仓库级工具脚本(版本、音效生成、会话检测)
+└── docs/ # 全生命周期开发工作流文档
+```
+
+## 核心模块边界
+
+| 模块 | 入口 | 职责 |
+|------|------|------|
+| Electron 主进程 | `client/electron/main.ts` | 窗口管理、IPC、系统托盘、自动更新 |
+| AI 集成层 | `client/electron/ai/` | 本地 Ollama 推理 + 云端 AI 网关调度 |
+| AI 网关配置 | `server/ai-gateway/config/` | 多模型路由(Qwen/DeepSeek/GLM/Gemini)、超时、降级(包化:runtime/limits/providers/fallback/app) |
+| AI 网关入口 | `server/ai-gateway/main.py` | FastAPI 应用装配、中间件、路由注册 |
+| 前端渲染 | `client/src/App.tsx` | React 路由、全局布局、3D 场景 |
+| 数据层 | `client/electron/db/` | better-sqlite3 本地持久化 |
+| 同步服务入口 | `server/sync-service/main.go` | Gin 路由装配、健康探针、WebSocket 通道 |
+
+## 变更需额外审查的文件
+
+以下文件变更影响面大,需要额外审查:
+
+- `client/electron/main.ts` — 应用生命周期,改动可能导致启动失败
+- `client/electron/preload.ts` — IPC 桥接安全边界
+- `client/electron/ai/` — AI 调度逻辑,涉及多 provider 降级链
+- `server/ai-gateway/config/` — 模型路由与密钥配置,影响全部 AI 功能
+- `server/ai-gateway/middleware/auth.py` — JWT 认证,安全关键
+- `server/ai-gateway/middleware/rate_limit.py` — 频率限制,影响可用性
+- `server/sync-service/middleware/auth.go` — 同步服务 JWT 认证,安全关键
+- `server/docker-compose.prod.yml` — 生产编排,改动影响部署
+- `client/electron-builder.yml` — 打包配置,影响发布产物
+- `.github/workflows/` — CI/CD 流水线
+
+## 本地验证命令
+
+```bash
+# 客户端(在 client/ 目录下执行)
+npm run lint # Oxlint 代码检查
+npm run test # Vitest 单元测试
+npm run build # tsc -b && vite build(TypeScript 编译 + Vite 打包)
+
+# Electron 桌面端构建
+npm run electron:build
+
+# 服务端 AI 网关(在 server/ai-gateway/ 目录下)
+pip install -r requirements.txt
+python -m pytest tests/ -q # 单元测试(133 基线)
+python main.py # 启动 FastAPI 开发服务器
+
+# 同步服务(在 server/sync-service/ 目录下)
+go build ./... && go vet ./... && go test ./...
+
+# 官网(在 website/ 目录下)
+npm run build # next build(静态导出)
+
+# 仓库级
+npm run release:dry # semantic-release 干跑验证(需 git 仓库)
+```
+
+## 技术栈速查
+
+- **客户端**: React 18, TypeScript, Vite, Electron, Tailwind CSS, Zustand, TanStack Query, Dexie.js, TipTap, Framer Motion, Vitest, Oxlint
+- **AI 网关**: Python, FastAPI, LangChain, 通义千问/DeepSeek/智谱 GLM/Gemini
+- **同步服务**: Go, Gin, PostgreSQL, Redis
+- **官网**: Next.js (静态导出)
+- **基础设施**: Docker, docker-compose, Nginx, electron-updater
+
+## 约定
+
+- 提交信息遵循 Conventional Commits 规范
+- 版本号由 semantic-release 自动管理
+- AI 功能必须支持离线降级(本地优先原则)
+- 多模态模型 `max_tokens` 限制:Qwen 最大 4096,GLM-4V-Flash 最大 1024
+- 单文件 ≤300 行(AI 编程规范 §1);全部源码文件需含 `@ai-context` 中英双语注释(§3)
+- 用户数据标识(keban 库名 / keban_device_id / keban_crypto_salt 等)永久豁免品牌重命名,保证跨版本数据兼容
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ee1e37be..963513de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,70 +1,64 @@
-# 更新日志
-
-本项目所有值得关注的变更都会记录在此文件中。
-版本号遵循 [语义化版本 SemVer](https://semver.org/lang/zh-CN/),提交信息遵循 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/)。
-
-## [0.24.0](https://github.com/Aparencia/KeBan/compare/v0.23.0...v0.24.0) (2026-07-29)
-
-### ✨ 新功能
-
-* v0.22.0 — 音效系统重构、UI 组件增强与 AI 余额查询 ([b8c20e2](https://github.com/Aparencia/KeBan/commit/b8c20e2497cfed8c0e690998bf27a078746ab401))
-
-## [0.23.0](https://github.com/Aparencia/KeBan/compare/v0.22.0...v0.23.0) (2026-07-28)
-
-### ✨ 新功能
-
-* v0.21.0 — 许可证变更为 BUSL 1.1 与快捷方式设置优化 ([601d2a8](https://github.com/Aparencia/KeBan/commit/601d2a80fe62078d66b068f82139aad8302a71de))
-
-## [0.22.0](https://github.com/Aparencia/KeBan/compare/v0.21.0...v0.22.0) (2026-07-28)
-
-### ✨ 新功能
-
-* v0.20.1 — 课堂笔记插入、3D 安全合成器与捕获系统优化 ([0267a27](https://github.com/Aparencia/KeBan/commit/0267a2799fcbb15fc3aaf93e1e33aa45f42ba1ec))
-
-## [0.21.0](https://github.com/Aparencia/KeBan/compare/v0.20.1...v0.21.0) (2026-07-28)
-
-### ✨ 新功能
-
-* **website:** add Baidu site verification meta tag ([858666f](https://github.com/Aparencia/KeBan/commit/858666f1a4afb5221e4b73883d8ffa48b467e899))
-
-## [0.20.1](https://github.com/Aparencia/KeBan/compare/v0.20.0...v0.20.1) (2026-07-27)
-
-### 🐛 缺陷修复
-
-* **ci:** 部署后重启Nginx容器修复bind mount失效导致的403 ([babb5e8](https://github.com/Aparencia/KeBan/commit/babb5e8b6b7a2934bf2da8f79564ea1cbd3499b6))
-
-## [0.20.0](https://github.com/Aparencia/KeBan/compare/v0.19.0...v0.20.0) (2026-07-27)
-
-### ✨ 新功能
-
-* v0.18.0 — 官网 SEO 优化、支持页面与赞助二维码 ([0a332f1](https://github.com/Aparencia/KeBan/commit/0a332f1e5a51a2a721849209d18bb642c509a59a))
-
-## [0.19.0](https://github.com/Aparencia/KeBan/compare/v0.18.0...v0.19.0) (2026-07-27)
-
-### ✨ 新功能
-
-* v0.17.1 — CRDT 协同引擎、FSRS 调度器、AI 流式传输与全模块增强 ([231ab19](https://github.com/Aparencia/KeBan/commit/231ab19c27c46cfd597c2febdc627b4ea425cf8e))
-
-## [0.18.0](https://github.com/Aparencia/KeBan/compare/v0.17.1...v0.18.0) (2026-07-27)
-
-### ✨ 新功能
-
-* v0.17.0 — 课堂模块组件化、课程智能检测与多模态分析增强 ([9f2cf56](https://github.com/Aparencia/KeBan/commit/9f2cf5678da78185335edfed629cf6b06967ea25))
-
-## [0.17.1](https://github.com/Aparencia/KeBan/compare/v0.17.0...v0.17.1) (2026-07-26)
-
-### 🐛 缺陷修复
-
-* **classroom:** 智能采集语音识别流式化+merge降级+去重复传输 ([cd26df0](https://github.com/Aparencia/KeBan/commit/cd26df0b6cce555908108fb9faa5204396aa2ec1))
-
-## [0.17.0](https://github.com/Aparencia/KeBan/compare/v0.16.0...v0.17.0) (2026-07-25)
-
-### ✨ 新功能
-
-* **classroom:** 课堂助手模式UI优化、窗口智能识别与笔记生成加速 ([aad3c6c](https://github.com/Aparencia/KeBan/commit/aad3c6c9680501c52384f6fc3d656d5f0d39645d))
-
-## [0.16.0](https://github.com/Aparencia/KeBan/compare/v0.15.0...v0.16.0) (2026-07-25)
-
-### ✨ 新功能
-
-* v0.15.1 — Ollama 本地模型接入、AI 处理器重构与认证体系增强 ([8bcad71](https://github.com/Aparencia/KeBan/commit/8bcad7141c270f5ec585f3ea3587d18aa4f01b57))
+# 更新日志
+
+本项目所有值得关注的变更都会记录在此文件中。
+版本号遵循 [语义化版本 SemVer](https://semver.org/lang/zh-CN/),提交信息遵循 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/)。
+
+## [0.23.0](https://github.com/Aparencia/KeBan/compare/v0.22.0...v0.23.0) (2026-07-28)
+
+### ✨ 新功能
+
+* v0.21.0 — 许可证变更为 BUSL 1.1 与快捷方式设置优化 ([601d2a8](https://github.com/Aparencia/KeBan/commit/601d2a80fe62078d66b068f82139aad8302a71de))
+
+## [0.22.0](https://github.com/Aparencia/KeBan/compare/v0.21.0...v0.22.0) (2026-07-28)
+
+### ✨ 新功能
+
+* v0.20.1 — 课堂笔记插入、3D 安全合成器与捕获系统优化 ([0267a27](https://github.com/Aparencia/KeBan/commit/0267a2799fcbb15fc3aaf93e1e33aa45f42ba1ec))
+
+## [0.21.0](https://github.com/Aparencia/KeBan/compare/v0.20.1...v0.21.0) (2026-07-28)
+
+### ✨ 新功能
+
+* **website:** add Baidu site verification meta tag ([858666f](https://github.com/Aparencia/KeBan/commit/858666f1a4afb5221e4b73883d8ffa48b467e899))
+
+## [0.20.1](https://github.com/Aparencia/KeBan/compare/v0.20.0...v0.20.1) (2026-07-27)
+
+### 🐛 缺陷修复
+
+* **ci:** 部署后重启Nginx容器修复bind mount失效导致的403 ([babb5e8](https://github.com/Aparencia/KeBan/commit/babb5e8b6b7a2934bf2da8f79564ea1cbd3499b6))
+
+## [0.20.0](https://github.com/Aparencia/KeBan/compare/v0.19.0...v0.20.0) (2026-07-27)
+
+### ✨ 新功能
+
+* v0.18.0 — 官网 SEO 优化、支持页面与赞助二维码 ([0a332f1](https://github.com/Aparencia/KeBan/commit/0a332f1e5a51a2a721849209d18bb642c509a59a))
+
+## [0.19.0](https://github.com/Aparencia/KeBan/compare/v0.18.0...v0.19.0) (2026-07-27)
+
+### ✨ 新功能
+
+* v0.17.1 — CRDT 协同引擎、FSRS 调度器、AI 流式传输与全模块增强 ([231ab19](https://github.com/Aparencia/KeBan/commit/231ab19c27c46cfd597c2febdc627b4ea425cf8e))
+
+## [0.18.0](https://github.com/Aparencia/KeBan/compare/v0.17.1...v0.18.0) (2026-07-27)
+
+### ✨ 新功能
+
+* v0.17.0 — 课堂模块组件化、课程智能检测与多模态分析增强 ([9f2cf56](https://github.com/Aparencia/KeBan/commit/9f2cf5678da78185335edfed629cf6b06967ea25))
+
+## [0.17.1](https://github.com/Aparencia/KeBan/compare/v0.17.0...v0.17.1) (2026-07-26)
+
+### 🐛 缺陷修复
+
+* **classroom:** 智能采集语音识别流式化+merge降级+去重复传输 ([cd26df0](https://github.com/Aparencia/KeBan/commit/cd26df0b6cce555908108fb9faa5204396aa2ec1))
+
+## [0.17.0](https://github.com/Aparencia/KeBan/compare/v0.16.0...v0.17.0) (2026-07-25)
+
+### ✨ 新功能
+
+* **classroom:** 课堂助手模式UI优化、窗口智能识别与笔记生成加速 ([aad3c6c](https://github.com/Aparencia/KeBan/commit/aad3c6c9680501c52384f6fc3d656d5f0d39645d))
+
+## [0.16.0](https://github.com/Aparencia/KeBan/compare/v0.15.0...v0.16.0) (2026-07-25)
+
+### ✨ 新功能
+
+* v0.15.1 — Ollama 本地模型接入、AI 处理器重构与认证体系增强 ([8bcad71](https://github.com/Aparencia/KeBan/commit/8bcad7141c270f5ec585f3ea3587d18aa4f01b57))
diff --git a/LICENSE b/LICENSE
index 7cd150aa..f248ca0f 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,100 +1,100 @@
-Business Source License 1.1
-
-Parameters
-
-Licensor: KeBan (课伴) Copyright Holder
-Licensed Work: KeBan (课伴) - AI-Powered Learning Companion
- The Licensed Work is (c) 2025-2026 KeBan Contributors.
-Additional Use Grant: You may make use of the Licensed Work, provided that
- you do not use the Licensed Work for a Production
- Purpose that competes with the Licensed Work.
- "Production Purpose" means any use that is intended
- for or directed to commercial advantage or monetary
- compensation. Non-commercial personal use, educational
- use by students, and internal evaluation are permitted.
-Change Date: Four years from the date the Licensed Work is
- published.
-Change License: Apache License, Version 2.0
-
-For information about alternative licensing arrangements for the Software,
-please contact the Licensor.
-
-Notice
-
-The Business Source License (this document, or the "License") is not an Open
-Source license. However, the Licensed Work will eventually be made available
-under an Open Source License, as stated in this License.
-
-License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
-"Business Source License" is a trademark of MariaDB Corporation Ab.
-
------------------------------------------------------------------------------
-
-Business Source License 1.1
-
-Terms
-
-The Licensor hereby grants you the right to copy, modify, create derivative
-works, redistribute, and make non-production use of the Licensed Work. The
-Licensor may make an Additional Use Grant, above, permitting limited
-production use.
-
-Effective on the Change Date, or the fourth anniversary of the first publicly
-available distribution of a specific version of the Licensed Work under this
-License, whichever comes first, the Licensor hereby grants you rights under
-the terms of the Change License, and the rights granted in the paragraph
-above terminate.
-
-If your use of the Licensed Work does not comply with the requirements
-currently in effect as described in this License, you must purchase a
-commercial license from the Licensor, its affiliated entities, or authorized
-resellers, or you must refrain from using the Licensed Work.
-
-All copies of the original and modified Licensed Work, and derivative works
-of the Licensed Work, are subject to this License. This License applies
-separately for each version of the Licensed Work and the Change Date may
-vary for each version of the Licensed Work released by Licensor.
-
-You must conspicuously display this License on each original or modified copy
-of the Licensed Work. If you receive the Licensed Work in original or
-modified form from a third party, the terms and conditions set forth in this
-License apply to your use of that work.
-
-Any use of the Licensed Work in violation of this License will automatically
-terminate your rights under this License for the current and all other
-versions of the Licensed Work.
-
-This License does not grant you any right in any trademark or logo of
-Licensor or its affiliates (provided that you may use a trademark or logo of
-Licensor as expressly required by this License).
-
-TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
-AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
-EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
-TITLE.
-
-MariaDB hereby grants you permission to use this License's text to license
-your works, and to refer to it using the trademark "Business Source License",
-as long as you comply with the Covenants of Licensor below.
-
-Covenants of Licensor
-
-In consideration of the right to use this License's text and the "Business
-Source License" name and trademark, Licensor covenants to MariaDB, and to all
-other recipients of the licensed work to be provided by Licensor:
-
-1. To specify as the Change License the GPL Version 2.0 or any later version,
- or a license that is compatible with GPL Version 2.0 or a later version,
- where "compatible" means that software provided under the Change License
- can be included in a program with software provided under GPL Version 2.0
- or a later version. Licensor may specify additional Change Licenses without
- limitation.
-
-2. To either: (a) specify an additional grant of rights to use that does not
- impose any additional restriction on the right granted in this License, as
- the Additional Use Grant; or (b) insert the text "None".
-
-3. To specify a Change Date.
-
-4. Not to modify this License in any other way.
+Business Source License 1.1
+
+Parameters
+
+Licensor: KeBan (课伴) Copyright Holder
+Licensed Work: KeBan (课伴) - AI-Powered Learning Companion
+ The Licensed Work is (c) 2025-2026 KeBan Contributors.
+Additional Use Grant: You may make use of the Licensed Work, provided that
+ you do not use the Licensed Work for a Production
+ Purpose that competes with the Licensed Work.
+ "Production Purpose" means any use that is intended
+ for or directed to commercial advantage or monetary
+ compensation. Non-commercial personal use, educational
+ use by students, and internal evaluation are permitted.
+Change Date: Four years from the date the Licensed Work is
+ published.
+Change License: Apache License, Version 2.0
+
+For information about alternative licensing arrangements for the Software,
+please contact the Licensor.
+
+Notice
+
+The Business Source License (this document, or the "License") is not an Open
+Source license. However, the Licensed Work will eventually be made available
+under an Open Source License, as stated in this License.
+
+License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
+"Business Source License" is a trademark of MariaDB Corporation Ab.
+
+-----------------------------------------------------------------------------
+
+Business Source License 1.1
+
+Terms
+
+The Licensor hereby grants you the right to copy, modify, create derivative
+works, redistribute, and make non-production use of the Licensed Work. The
+Licensor may make an Additional Use Grant, above, permitting limited
+production use.
+
+Effective on the Change Date, or the fourth anniversary of the first publicly
+available distribution of a specific version of the Licensed Work under this
+License, whichever comes first, the Licensor hereby grants you rights under
+the terms of the Change License, and the rights granted in the paragraph
+above terminate.
+
+If your use of the Licensed Work does not comply with the requirements
+currently in effect as described in this License, you must purchase a
+commercial license from the Licensor, its affiliated entities, or authorized
+resellers, or you must refrain from using the Licensed Work.
+
+All copies of the original and modified Licensed Work, and derivative works
+of the Licensed Work, are subject to this License. This License applies
+separately for each version of the Licensed Work and the Change Date may
+vary for each version of the Licensed Work released by Licensor.
+
+You must conspicuously display this License on each original or modified copy
+of the Licensed Work. If you receive the Licensed Work in original or
+modified form from a third party, the terms and conditions set forth in this
+License apply to your use of that work.
+
+Any use of the Licensed Work in violation of this License will automatically
+terminate your rights under this License for the current and all other
+versions of the Licensed Work.
+
+This License does not grant you any right in any trademark or logo of
+Licensor or its affiliates (provided that you may use a trademark or logo of
+Licensor as expressly required by this License).
+
+TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
+AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
+EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
+TITLE.
+
+MariaDB hereby grants you permission to use this License's text to license
+your works, and to refer to it using the trademark "Business Source License",
+as long as you comply with the Covenants of Licensor below.
+
+Covenants of Licensor
+
+In consideration of the right to use this License's text and the "Business
+Source License" name and trademark, Licensor covenants to MariaDB, and to all
+other recipients of the licensed work to be provided by Licensor:
+
+1. To specify as the Change License the GPL Version 2.0 or any later version,
+ or a license that is compatible with GPL Version 2.0 or a later version,
+ where "compatible" means that software provided under the Change License
+ can be included in a program with software provided under GPL Version 2.0
+ or a later version. Licensor may specify additional Change Licenses without
+ limitation.
+
+2. To either: (a) specify an additional grant of rights to use that does not
+ impose any additional restriction on the right granted in this License, as
+ the Additional Use Grant; or (b) insert the text "None".
+
+3. To specify a Change Date.
+
+4. Not to modify this License in any other way.
diff --git a/README.md b/README.md
index e87b663f..4e89e076 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
📚 课伴 KeBan
+ 📚 熵减 Entropydecrease
你的 AI 智能学习伙伴 — 让每一分钟学习都高效有价值
@@ -10,13 +10,13 @@
---
-## 📝 关于课伴
+## 📝 关于熵减
-**课伴(KeBan)** 是一款面向学生和终身学习者的高效学习桌面应用。它围绕全场景学习需求,基于费曼学习法、间隔重复等科学方法论,将时间管理、笔记记录、记忆巩固和深度理解整合在一个工具中,帮助你建立科学的学习闭环。
+**熵减(Entropydecrease)** 是一款面向学生和终身学习者的高效学习桌面应用。它围绕全场景学习需求,基于费曼学习法、间隔重复等科学方法论,将时间管理、笔记记录、记忆巩固和深度理解整合在一个工具中,帮助你建立科学的学习闭环。
**解决的核心痛点:**
-| 痛点 | 课伴方案 |
+| 痛点 | 熵减方案 |
|------|----------|
| 学习时容易走神、效率低 | 沉浸式番茄钟 + 滑动退出 + 后台不中断计时 |
| 网课内容记不下来 | 网课笔记提取:自动截屏 + AI 视觉识别 + ASR 语音转写 |
@@ -33,7 +33,7 @@
## ✨ 核心功能
-课伴围绕 **"学 → 记 → 练 → 悟 → 思"** 学习闭环,提供六大模块:
+熵减围绕 **"学 → 记 → 练 → 悟 → 思"** 学习闭环,提供六大模块:
### 🍅 番茄钟 — 时间管理
@@ -153,8 +153,8 @@
```bash
# 克隆仓库
-git clone https://github.com/Aparencia/KeBan.git
-cd KeBan/client
+git clone https://github.com/Aparencia/Entropydecrease.git
+cd Entropydecrease/client
# 安装依赖并启动开发服务器
npm install
@@ -172,14 +172,14 @@ npm run electron:build
构建产物位于 `client/release/` 目录,格式为 Windows NSIS(`.exe`),支持当前用户安装,无需管理员权限。应用内置自动更新功能。
-也可以从 [GitHub Releases](https://github.com/Aparencia/KeBan/releases) 页面直接下载最新安装包。
+也可以从 [GitHub Releases](https://github.com/Aparencia/Entropydecrease/releases) 页面直接下载最新安装包。
---
## 📁 项目结构
```
-KeBan/
+Entropydecrease/
├── client/ # 前端客户端(React + Electron 桌面应用)
│ ├── src/features/ # 业务功能模块(番茄钟、笔记、闪卡、费曼、灵感、仪表盘)
│ ├── src/lib/ # 核心库(AI、存储、同步、加密、网络、捕获、音效等)
diff --git a/client/.env.example b/client/.env.example
index c428dc0d..418cf68e 100644
--- a/client/.env.example
+++ b/client/.env.example
@@ -1,30 +1,30 @@
-# ============================================
-# 课伴 KeBan - 环境变量配置模板
-# 复制此文件为 .env 并填入实际值
-# ============================================
-
-# ---- Supabase 配置 ----
-# Supabase 项目 URL(从 Supabase Dashboard 获取)
-VITE_SUPABASE_URL=
-# Supabase 匿名公钥(从 Supabase Dashboard 获取)
-VITE_SUPABASE_ANON_KEY=
-
-# ---- API 服务地址 ----
-# 同步服务(sync-service)基础地址
-# 开发环境: http://127.0.0.1:8080
-# 生产环境: https://entropydecrease.com
-VITE_API_BASE_URL=https://entropydecrease.com
-
-# 同步服务健康检查端点
-VITE_API_HEALTH_URL=https://entropydecrease.com/health
-
-# ---- AI 网关 ----
-# AI Gateway 服务地址
-# 开发环境: http://127.0.0.1:8000
-# 生产环境: https://entropydecrease.com
-VITE_AI_GATEWAY_URL=https://entropydecrease.com
-
-# ---- Ollama 本地推理(可选) ----
-# Ollama 服务地址,默认自动检测 localhost:11434
-# 仅在自定义端口或远程 Ollama 时需要配置
-# VITE_OLLAMA_BASE_URL=http://localhost:11434
+# ============================================
+# 熵减 Entropydecrease - 环境变量配置模板
+# 复制此文件为 .env 并填入实际值
+# ============================================
+
+# ---- Supabase 配置 ----
+# Supabase 项目 URL(从 Supabase Dashboard 获取)
+VITE_SUPABASE_URL=
+# Supabase 匿名公钥(从 Supabase Dashboard 获取)
+VITE_SUPABASE_ANON_KEY=
+
+# ---- API 服务地址 ----
+# 同步服务(sync-service)基础地址
+# 开发环境: http://127.0.0.1:8080
+# 生产环境: https://entropydecrease.com
+VITE_API_BASE_URL=https://entropydecrease.com
+
+# 同步服务健康检查端点
+VITE_API_HEALTH_URL=https://entropydecrease.com/health
+
+# ---- AI 网关 ----
+# AI Gateway 服务地址
+# 开发环境: http://127.0.0.1:8000
+# 生产环境: https://entropydecrease.com
+VITE_AI_GATEWAY_URL=https://entropydecrease.com
+
+# ---- Ollama 本地推理(可选) ----
+# Ollama 服务地址,默认自动检测 localhost:11434
+# 仅在自定义端口或远程 Ollama 时需要配置
+# VITE_OLLAMA_BASE_URL=http://localhost:11434
diff --git a/client/.gitignore b/client/.gitignore
index 6618bdad..aebfb8d2 100644
--- a/client/.gitignore
+++ b/client/.gitignore
@@ -1,30 +1,30 @@
-# Environment variables
-.env
-.env.local
-.env.test
-.env.*.local
-
-# Logs
-logs
-*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-pnpm-debug.log*
-lerna-debug.log*
-
-node_modules
-dist
-dist-ssr
-*.local
-
-# Editor directories and files
-.vscode/*
-!.vscode/extensions.json
-.idea
-.DS_Store
-*.suo
-*.ntvs*
-*.njsproj
-*.sln
-*.sw?
+# Environment variables
+.env
+.env.local
+.env.test
+.env.*.local
+
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/client/.oxlintrc.json b/client/.oxlintrc.json
index 6fa991da..d2ffbe25 100644
--- a/client/.oxlintrc.json
+++ b/client/.oxlintrc.json
@@ -1,8 +1,8 @@
-{
- "$schema": "./node_modules/oxlint/configuration_schema.json",
- "plugins": ["react", "typescript", "oxc"],
- "rules": {
- "react/rules-of-hooks": "error",
- "react/only-export-components": ["warn", { "allowConstantExport": true }]
- }
-}
+{
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
+ "plugins": ["react", "typescript", "oxc"],
+ "rules": {
+ "react/rules-of-hooks": "error",
+ "react/only-export-components": ["warn", { "allowConstantExport": true }]
+ }
+}
diff --git a/client/components.json b/client/components.json
index 0eb78712..153487b6 100644
--- a/client/components.json
+++ b/client/components.json
@@ -1,19 +1,19 @@
-{
- "$schema": "https://ui.shadcn.com/schema.json",
- "style": "default",
- "rsc": false,
- "tsx": true,
- "tailwind": {
- "config": "tailwind.config.js",
- "css": "src/styles/tokens.css",
- "baseColor": "neutral",
- "cssVariables": true
- },
- "aliases": {
- "components": "@/components",
- "utils": "@/lib/utils",
- "ui": "@/components/ui",
- "lib": "@/lib",
- "hooks": "@/hooks"
- }
-}
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.js",
+ "css": "src/styles/tokens.css",
+ "baseColor": "neutral",
+ "cssVariables": true
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ }
+}
diff --git a/client/electron-builder.yml b/client/electron-builder.yml
index b3023594..3d6b8365 100644
--- a/client/electron-builder.yml
+++ b/client/electron-builder.yml
@@ -3,8 +3,8 @@ appId: com.entropydecrease.app
productName: "Entropy decrease"
publish:
provider: github
- owner: YourGitHubUsername
- repo: KeBan
+ owner: Aparencia
+ repo: Entropydecrease
directories:
output: release
buildResources: build
diff --git a/client/electron/ai/gatewayConfig.ts b/client/electron/ai/gatewayConfig.ts
new file mode 100644
index 00000000..fcf4a3bf
--- /dev/null
+++ b/client/electron/ai/gatewayConfig.ts
@@ -0,0 +1,117 @@
+/**
+ * AI 网关地址管理(主进程侧)
+ *
+ * @ai-context: 从 ai/utils.ts 拆出。地址解析优先级按模式分流:
+ * 开发=env > IPC运行时 > 默认;生产=IPC/持久化 > env > 默认。
+ * 开发模式不写持久化文件(防调试数据污染生产配置)。
+ * 持久化文件为 userData/ai-gateway-config.json。
+ * @ai-context: DEFAULT_GATEWAY_URL 与 cspPolicy.ts、渲染进程 config.ts
+ * 三处需保持一致;修改默认域名需三处同步。
+ */
+import { app } from 'electron';
+import * as path from 'path';
+import { readFile, writeFile } from 'fs/promises';
+import { logger } from '../logger.js';
+
+const DEFAULT_GATEWAY_URL = 'https://entropydecrease.com';
+const GATEWAY_CONFIG_FILE = 'ai-gateway-config.json';
+
+// ── 运行时网关地址(渲染进程通过 IPC 同步) ──
+let _runtimeGatewayUrl: string | null = null;
+
+/** 记录 gatewayUrl() 是否已打印过首次解析日志,避免重复输出 */
+let _gatewayFirstResolveLogged = false;
+
+/**
+ * 判定当前是否为开发模式
+ * 可靠依据:electron:dev 脚本设置 NODE_ENV=development,安装包运行时 app.isPackaged=true
+ */
+export function isDevMode(): boolean {
+ return process.env.NODE_ENV === 'development' || !app.isPackaged;
+}
+
+/**
+ * 获取 AI 网关地址
+ *
+ * 按模式分流优先级:
+ * - 开发模式:环境变量 > 运行时 IPC > 默认值
+ * - 生产模式:运行时 IPC > 持久化文件(已存入_runtimeGatewayUrl) > 环境变量 > 默认值
+ */
+export function gatewayUrl(): string {
+ const url = _resolveGatewayUrl();
+ if (!_gatewayFirstResolveLogged) {
+ _gatewayFirstResolveLogged = true;
+ const dev = isDevMode();
+ const source = dev
+ ? (process.env.VITE_AI_GATEWAY_URL
+ ? `env (VITE_AI_GATEWAY_URL=${process.env.VITE_AI_GATEWAY_URL})`
+ : _runtimeGatewayUrl
+ ? 'runtime (IPC)'
+ : 'DEFAULT (hardcoded fallback)')
+ : (_runtimeGatewayUrl
+ ? 'runtime (IPC/persisted)'
+ : process.env.VITE_AI_GATEWAY_URL
+ ? `env (VITE_AI_GATEWAY_URL=${process.env.VITE_AI_GATEWAY_URL})`
+ : 'DEFAULT (hardcoded fallback)');
+ logger.info(`[AI] Gateway URL resolved: ${url} [source: ${source}, mode: ${dev ? 'dev' : 'prod'}]`);
+ if (!process.env.VITE_AI_GATEWAY_URL && !_runtimeGatewayUrl) {
+ logger.warn('[AI] Gateway URL fell back to DEFAULT. Set VITE_AI_GATEWAY_URL in .env or configure via AI settings.');
+ }
+ }
+ return url;
+}
+
+/** 内部解析逻辑,按模式分流优先级 */
+function _resolveGatewayUrl(): string {
+ if (isDevMode()) {
+ // 开发模式:环境变量优先,持久化不覆盖开发配置
+ return process.env.VITE_AI_GATEWAY_URL || _runtimeGatewayUrl || DEFAULT_GATEWAY_URL;
+ }
+ // 生产模式:运行时/持久化 > 环境变量 > 默认值
+ return _runtimeGatewayUrl || process.env.VITE_AI_GATEWAY_URL || DEFAULT_GATEWAY_URL;
+}
+
+/**
+ * 设置运行时网关地址(由渲染进程通过 IPC 调用)
+ * 同时持久化到 userData 目录,确保主进程重启后仍可用
+ */
+export async function setRuntimeGatewayUrl(url: string): Promise {
+ _runtimeGatewayUrl = url;
+ // 重置首次解析日志标记,使下次 gatewayUrl() 重新打印来源
+ _gatewayFirstResolveLogged = false;
+ logger.info(`[AI] Runtime gateway URL set via IPC: ${url}`);
+ // 开发模式不写入持久化文件,防止调试数据污染生产配置
+ if (isDevMode()) {
+ logger.info('[AI] Dev mode: skip persisting gateway URL to file');
+ return;
+ }
+ // 持久化到文件
+ try {
+ const configPath = path.join(app.getPath('userData'), GATEWAY_CONFIG_FILE);
+ await writeFile(configPath, JSON.stringify({ gatewayUrl: url }), 'utf-8');
+ } catch (err) {
+ logger.error('[AI-Gateway] Failed to persist gateway URL', err);
+ }
+}
+
+/**
+ * 应用启动时从持久化文件加载网关地址
+ * 在 registerAIHandlers 之前调用
+ */
+export async function loadPersistedGatewayUrl(): Promise {
+ if (isDevMode()) {
+ logger.info('[AI] Dev mode: skip loading persisted gateway URL (using .env config)');
+ return;
+ }
+ try {
+ const configPath = path.join(app.getPath('userData'), GATEWAY_CONFIG_FILE);
+ const raw = await readFile(configPath, 'utf-8');
+ const config = JSON.parse(raw);
+ if (config.gatewayUrl) {
+ _runtimeGatewayUrl = config.gatewayUrl;
+ logger.info(`[AI] Loaded persisted gateway URL from file: ${config.gatewayUrl}`);
+ }
+ } catch {
+ // 文件不存在或解析失败,静默忽略
+ }
+}
diff --git a/client/electron/ai/gatewayHttp.ts b/client/electron/ai/gatewayHttp.ts
new file mode 100644
index 00000000..ebc6bd56
--- /dev/null
+++ b/client/electron/ai/gatewayHttp.ts
@@ -0,0 +1,186 @@
+/**
+ * AI 网关 HTTP 请求层(postJson / postMultipart / 降级链)
+ *
+ * @ai-context: 从 ai/utils.ts 拆出。executePost 统一 JSON 与 multipart
+ * 两种请求的公共骨架(超时/日志/req-id/错误诊断/响应解析),消除原
+ * 两函数 80 行重复;错误诊断按 ECONNREFUSED/ENOTFOUND/ETIMEDOUT 给出
+ * 运维提示。X-Request-ID 贯穿网关日志链路。
+ * @ai-context: callWithLocalFallback 是"本地优先、云端降级"的核心:
+ * Ollama 启用且运行 → localHandler,失败静默降级 postJson;
+ * source 字段('local'|'remote')供渲染层展示推理来源,勿删。
+ */
+import { randomUUID } from 'crypto';
+import { logger } from '../logger.js';
+import { gatewayUrl } from './gatewayConfig.js';
+import { isLocalInferenceEnabled } from './ollama/config.js';
+import { isOllamaAvailable } from './ollama/OllamaService.js';
+
+/** 构建公共请求头(JSON 模式含 Content-Type,multipart 由 fetch 自动生成) */
+function buildHeaders(clientRequestId: string, json: boolean, authToken?: string, userApiKey?: string): Record {
+ const headers: Record = { 'X-Request-ID': clientRequestId };
+ if (json) headers['Content-Type'] = 'application/json';
+ if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
+ if (userApiKey) headers['X-User-API-Key'] = userApiKey;
+ return headers;
+}
+
+/** 网络错误诊断日志(常见错误码给出运维提示) */
+function logNetworkHint(errDetail: string): void {
+ if (/ECONNREFUSED/i.test(errDetail)) {
+ logger.error('[AI] Hint: Connection refused — check if AI Gateway service is running and the URL is correct');
+ } else if (/ENOTFOUND/i.test(errDetail)) {
+ logger.error('[AI] Hint: DNS resolution failed — check the gateway URL hostname');
+ } else if (/ETIMEDOUT/i.test(errDetail)) {
+ logger.error('[AI] Hint: Connection timed out — check network connectivity and firewall rules');
+ }
+}
+
+/**
+ * POST 公共骨架:超时控制 + 日志 + req-id + 错误诊断 + JSON 响应解析
+ */
+async function executePost(
+ apiPath: string,
+ requestBody: string | FormData,
+ isJson: boolean,
+ bodyDesc: string,
+ authToken?: string,
+ userApiKey?: string,
+ timeoutMs: number = 60000,
+): Promise<{ data: TRes; requestId: string | undefined }> {
+ const base = gatewayUrl();
+ if (!base) {
+ throw new Error('[AI] Gateway URL not configured. Set VITE_AI_GATEWAY_URL in .env or configure via AI settings');
+ }
+ const url = `${base}${apiPath}`;
+ const startTime = Date.now();
+ const clientRequestId = randomUUID();
+
+ // ── 请求前日志 ──
+ logger.info(`[AI] → POST ${url} [req-id: ${clientRequestId}]`);
+ logger.debug(`[AI] Request config: timeout=${timeoutMs}ms, hasAuth=${!!authToken}, hasUserKey=${!!userApiKey}, ${bodyDesc}`);
+
+ const headers = buildHeaders(clientRequestId, isJson, authToken, userApiKey);
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+
+ let resp: Response;
+ try {
+ resp = await fetch(url, {
+ method: 'POST',
+ headers,
+ body: requestBody,
+ signal: controller.signal,
+ });
+ } catch (networkError: unknown) {
+ const elapsed = Date.now() - startTime;
+ const err = networkError as { name?: string; message?: string; cause?: unknown };
+ if (err.name === 'AbortError') {
+ logger.error(`[AI] ✖ TIMEOUT ${url} after ${elapsed}ms`);
+ throw new Error(`Request timeout after ${timeoutMs}ms`);
+ }
+ // 详细网络错误诊断
+ const cause = err.cause ? String(err.cause) : '';
+ const errDetail = err.message || String(networkError);
+ logger.error(`[AI] ✖ NETWORK_ERROR ${url} after ${elapsed}ms: ${errDetail}${cause ? ` (cause: ${cause})` : ''}`);
+ logNetworkHint(errDetail);
+ throw new Error(`Network error: ${errDetail}`);
+ } finally {
+ clearTimeout(timeoutId);
+ }
+
+ const elapsed = Date.now() - startTime;
+ const requestId = resp.headers.get('ai-gateway-request-id') ?? undefined;
+
+ if (!resp.ok) {
+ const detail = await resp.text().catch(() => 'unknown error');
+ // 截取响应体前 500 字符防止日志爆炸
+ const detailPreview = detail.length > 500 ? `${detail.slice(0, 500)}...(+${detail.length - 500} chars)` : detail;
+ logger.error(`[AI] ✖ HTTP ${resp.status} ${url} (${elapsed}ms) [req-id: ${requestId ?? clientRequestId}]: ${detailPreview}`);
+ throw new Error(`HTTP ${resp.status}: ${detail}`);
+ }
+
+ logger.info(`[AI] ← ${resp.status} ${url} (${elapsed}ms)${requestId ? ` [req-id: ${requestId}]` : ''}`);
+
+ try {
+ const data = (await resp.json()) as TRes;
+ return { data, requestId };
+ } catch (e) {
+ logger.error(`[AI] Response JSON parse error for ${url}: ${e}`);
+ throw new Error(`Response parse error: ${e}`);
+ }
+}
+
+/**
+ * 通用 POST 请求辅助函数:
+ * 1. 将请求体序列化为 JSON
+ * 2. 如有 authToken,添加 Authorization header
+ * 3. HTTP 失败时抛出包含状态码和详情的错误字符串
+ * 4. 返回解析后的 JSON 响应
+ */
+export async function postJson(
+ apiPath: string,
+ body: TReq,
+ authToken?: string,
+ userApiKey?: string,
+ timeoutMs: number = 60000,
+): Promise<{ data: TRes; requestId: string | undefined }> {
+ const bodyDesc = `bodyKeys=${Object.keys(body as Record).join(',')}`;
+ return executePost(apiPath, JSON.stringify(body), true, bodyDesc, authToken, userApiKey, timeoutMs);
+}
+
+/**
+ * Multipart POST 请求辅助函数:
+ * 1. 不设置 Content-Type header(Node.js fetch 自动设置 multipart/form-data; boundary=...)
+ * 2. body 直接传 FormData(不做 JSON.stringify)
+ * 3. 默认超时 300000ms(5 分钟,视频文件较大)
+ */
+export async function postMultipart(
+ apiPath: string,
+ formData: FormData,
+ authToken?: string,
+ userApiKey?: string,
+ timeoutMs: number = 300000,
+): Promise<{ data: TRes; requestId: string | undefined }> {
+ return executePost(apiPath, formData, false, 'body=FormData', authToken, userApiKey, timeoutMs);
+}
+
+/**
+ * 带本地 Ollama 降级的调用函数
+ *
+ * 逻辑:
+ * 1. 检查 OllamaConfig.enabled && OllamaService.isRunning()
+ * 2. 是 → 调用 localHandler(),成功则返回 { source: 'local' }
+ * 3. 本地失败/未启用 → 调用现有 postJson()(远程 AI Gateway)
+ */
+export async function callWithLocalFallback(
+ apiPath: string,
+ body: TReq,
+ localHandler: () => Promise,
+ authToken?: string,
+ userApiKey?: string,
+ timeoutMs: number = 60000,
+): Promise<{ data: TRes; source: 'local' | 'remote'; requestId?: string }> {
+ // 检查本地 Ollama 是否可用
+ if (isLocalInferenceEnabled() && isOllamaAvailable()) {
+ try {
+ const localResult = await localHandler();
+ logger.info(`[AI] ← Local Ollama success for ${apiPath}`);
+ return { data: localResult, source: 'local' };
+ } catch (localErr) {
+ const errMsg = localErr instanceof Error ? localErr.message : String(localErr);
+ logger.warn(`[AI] Local Ollama failed for ${apiPath}, falling back to remote: ${errMsg}`);
+ // 本地失败,降级到远程
+ }
+ }
+
+ // 远程 AI Gateway 调用
+ const { data, requestId } = await postJson(
+ apiPath,
+ body,
+ authToken,
+ userApiKey,
+ timeoutMs,
+ );
+ return { data, source: 'remote', requestId };
+}
diff --git a/client/electron/ai/gatewayStream.ts b/client/electron/ai/gatewayStream.ts
new file mode 100644
index 00000000..340b6c1c
--- /dev/null
+++ b/client/electron/ai/gatewayStream.ts
@@ -0,0 +1,122 @@
+/**
+ * AI 网关流式请求层(SSE 解析)
+ *
+ * @ai-context: 从 ai/utils.ts 拆出。按 \n\n 分割 SSE 事件、data: 行
+ * 逐 chunk yield;[DONE] 结束、parsed.error 抛错、JSON 解析失败时
+ * 回退为纯文本 yield(网关某些端点直接输出文本片段)。
+ * 消费方为 streamHandler.ts(转发到渲染进程 ai:stream:* 事件)。
+ */
+import { randomUUID } from 'crypto';
+import { logger } from '../logger.js';
+import { gatewayUrl } from './gatewayConfig.js';
+
+/**
+ * 流式 POST 请求:解析 SSE data: 行,逐 chunk yield 文本
+ */
+export async function* postJsonStream(
+ apiPath: string,
+ body: TReq,
+ authToken?: string,
+ userApiKey?: string,
+ timeoutMs: number = 300000,
+): AsyncGenerator {
+ const base = gatewayUrl();
+ if (!base) {
+ throw new Error('[AI] Gateway URL not configured');
+ }
+ const url = `${base}${apiPath}`;
+ const clientRequestId = randomUUID();
+
+ logger.info(`[AI] → POST (stream) ${url} [req-id: ${clientRequestId}]`);
+
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ 'X-Request-ID': clientRequestId,
+ };
+ if (authToken) {
+ headers['Authorization'] = `Bearer ${authToken}`;
+ }
+ if (userApiKey) {
+ headers['X-User-API-Key'] = userApiKey;
+ }
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+
+ let resp: Response;
+ try {
+ resp = await fetch(url, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(body),
+ signal: controller.signal,
+ });
+ } catch (networkError: unknown) {
+ const err = networkError as { name?: string; message?: string };
+ if (err.name === 'AbortError') {
+ throw new Error(`Stream request timeout after ${timeoutMs}ms`);
+ }
+ throw new Error(`Stream network error: ${err.message || String(networkError)}`);
+ } finally {
+ clearTimeout(timeoutId);
+ }
+
+ const requestId = resp.headers.get('ai-gateway-request-id') ?? undefined;
+
+ if (!resp.ok) {
+ const detail = await resp.text().catch(() => 'unknown error');
+ logger.error(`[AI] ✖ Stream HTTP ${resp.status} ${url} [req-id: ${requestId ?? clientRequestId}]: ${detail.slice(0, 200)}`);
+ throw new Error(`Stream HTTP ${resp.status}: ${detail}`);
+ }
+
+ logger.info(`[AI] ← Stream started ${url}${requestId ? ` [req-id: ${requestId}]` : ''}`);
+
+ if (!resp.body) {
+ throw new Error('Stream response body is null');
+ }
+
+ const reader = resp.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+
+ // 按 \n\n 分割 SSE 事件
+ const events = buffer.split('\n\n');
+ buffer = events.pop() || '';
+
+ for (const event of events) {
+ const lines = event.split('\n');
+ for (const line of lines) {
+ if (line.startsWith('data: ')) {
+ const data = line.slice(6).trim();
+ if (data === '[DONE]') {
+ return;
+ }
+ try {
+ const parsed = JSON.parse(data);
+ if (parsed.error) {
+ throw new Error(`Stream error: ${parsed.error}`);
+ }
+ if (parsed.chunk) {
+ yield parsed.chunk;
+ }
+ } catch (e) {
+ // JSON 解析失败,尝试作为纯文本
+ if (data && data !== '[DONE]') {
+ yield data;
+ }
+ }
+ }
+ }
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ }
+}
diff --git a/client/electron/ai/handlers/anchorPointHandler.ts b/client/electron/ai/handlers/anchorPointHandler.ts
index 70b4f4db..dad35cbe 100644
--- a/client/electron/ai/handlers/anchorPointHandler.ts
+++ b/client/electron/ai/handlers/anchorPointHandler.ts
@@ -1,109 +1,111 @@
-/**
- * AI 记忆锚点生成功能 Handler
- *
- * 处理 ai_anchor_point IPC 请求,调用 AI 网关从笔记内容生成记忆锚点。
- */
-
-import { safeHandle } from '../../ipcUtils.js';
-import { logger } from '../../logger.js';
-import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
-import { generateText } from '../ollama/OllamaProvider.js';
-
-// ================================================================
-// IPC Handler
-// ================================================================
-
-/**
- * ai_anchor_point — POST /api/v1/ai/anchor-point
- */
-function register(): void {
- safeHandle(
- 'ai_anchor_point',
- async (
- _event,
- args: {
- content: string;
- title?: string;
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- const startMs = Date.now();
- logger.info(`[AI] [anchor-point] IPC received: content_length=${args.content.length}, title=${args.title ?? '(empty)'}, hasAuth=${!!args.authToken}`);
- logger.debug(`[AI] [anchor-point] Content preview: ${args.content.slice(0, 80)}...`);
-
- const reqBody = {
- content: args.content,
- title: args.title ?? '',
- };
-
- logger.info(`[AI] [anchor-point] Target: ${gatewayUrl()}/api/v1/ai/anchor-point`);
-
- interface AnchorPointResp {
- concept: string;
- association: string;
- memory_technique: string;
- importance: number;
- }
- interface AnchorPointGenResp {
- anchor_points: AnchorPointResp[];
- status: string;
- model: string;
- tokens_used: number;
- latency_ms: number;
- }
-
- try {
- const localHandler = async (): Promise => {
- const prompt = `从以下笔记中生成记忆锚点,返回JSON: {"anchor_points": [{"concept": "...", "association": "...", "memory_technique": "...", "importance": 0.8}], "status": "ok"}\n\n笔记:\n${args.content}`;
- const result = await generateText(prompt, '你是一个记忆锚点生成助手。请仅返回JSON。', { temperature: 0.6, maxTokens: 1024 });
- const parsed = JSON.parse(result.content);
- return { anchor_points: parsed.anchor_points ?? [], status: 'ok', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
- };
-
- const { data: resp, source, requestId } = await callWithLocalFallback(
- '/api/v1/ai/anchor-point',
- reqBody,
- localHandler,
- args.authToken,
- args.userApiKey,
- 60000,
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [anchor-point] ✔ Success (${source}): anchor_points=${resp.anchor_points.length}, status=${resp.status}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- anchorPoints: resp.anchor_points.map((ap: { concept: string; association: string; memory_technique: string; importance: number }) => ({
- concept: ap.concept,
- association: ap.association,
- memoryTechnique: ap.memory_technique,
- importance: ap.importance,
- })),
- status: resp.status,
- model: resp.model,
- tokensUsed: resp.tokens_used,
- latencyMs: resp.latency_ms,
- requestId,
- source,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [anchor-point] ✖ Failed after ${elapsed}ms: ${error.message}`);
- if (error.cause) logger.error(`[AI] [anchor-point] Error cause: ${error.cause}`);
- throw error;
- }
- },
- );
-}
-
-// ================================================================
-// 功能定义导出
-// ================================================================
-
-export const feature: AIFeatureDef = {
- id: 'ai_anchor_point',
- name: 'AI 记忆锚点',
- version: '1.0.0',
- register,
-};
+/**
+ * AI 记忆锚点生成功能 Handler
+ *
+ * 处理 ai_anchor_point IPC 请求,调用 AI 网关从笔记内容生成记忆锚点。
+ *
+ * @ai-context: 记忆锚点生成 IPC handler——AIFeatureDef 注册表模式,经 callWithLocalFallback 支持本地 Ollama 优先/云端网关降级;请求响应契约与网关 Pydantic model 对齐。
+ */
+
+import { safeHandle } from '../../ipcUtils.js';
+import { logger } from '../../logger.js';
+import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
+import { generateText } from '../ollama/OllamaProvider.js';
+
+// ================================================================
+// IPC Handler
+// ================================================================
+
+/**
+ * ai_anchor_point — POST /api/v1/ai/anchor-point
+ */
+function register(): void {
+ safeHandle(
+ 'ai_anchor_point',
+ async (
+ _event,
+ args: {
+ content: string;
+ title?: string;
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ const startMs = Date.now();
+ logger.info(`[AI] [anchor-point] IPC received: content_length=${args.content.length}, title=${args.title ?? '(empty)'}, hasAuth=${!!args.authToken}`);
+ logger.debug(`[AI] [anchor-point] Content preview: ${args.content.slice(0, 80)}...`);
+
+ const reqBody = {
+ content: args.content,
+ title: args.title ?? '',
+ };
+
+ logger.info(`[AI] [anchor-point] Target: ${gatewayUrl()}/api/v1/ai/anchor-point`);
+
+ interface AnchorPointResp {
+ concept: string;
+ association: string;
+ memory_technique: string;
+ importance: number;
+ }
+ interface AnchorPointGenResp {
+ anchor_points: AnchorPointResp[];
+ status: string;
+ model: string;
+ tokens_used: number;
+ latency_ms: number;
+ }
+
+ try {
+ const localHandler = async (): Promise => {
+ const prompt = `从以下笔记中生成记忆锚点,返回JSON: {"anchor_points": [{"concept": "...", "association": "...", "memory_technique": "...", "importance": 0.8}], "status": "ok"}\n\n笔记:\n${args.content}`;
+ const result = await generateText(prompt, '你是一个记忆锚点生成助手。请仅返回JSON。', { temperature: 0.6, maxTokens: 1024 });
+ const parsed = JSON.parse(result.content);
+ return { anchor_points: parsed.anchor_points ?? [], status: 'ok', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
+ };
+
+ const { data: resp, source, requestId } = await callWithLocalFallback(
+ '/api/v1/ai/anchor-point',
+ reqBody,
+ localHandler,
+ args.authToken,
+ args.userApiKey,
+ 60000,
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [anchor-point] ✔ Success (${source}): anchor_points=${resp.anchor_points.length}, status=${resp.status}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ anchorPoints: resp.anchor_points.map((ap: { concept: string; association: string; memory_technique: string; importance: number }) => ({
+ concept: ap.concept,
+ association: ap.association,
+ memoryTechnique: ap.memory_technique,
+ importance: ap.importance,
+ })),
+ status: resp.status,
+ model: resp.model,
+ tokensUsed: resp.tokens_used,
+ latencyMs: resp.latency_ms,
+ requestId,
+ source,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [anchor-point] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ if (error.cause) logger.error(`[AI] [anchor-point] Error cause: ${error.cause}`);
+ throw error;
+ }
+ },
+ );
+}
+
+// ================================================================
+// 功能定义导出
+// ================================================================
+
+export const feature: AIFeatureDef = {
+ id: 'ai_anchor_point',
+ name: 'AI 记忆锚点',
+ version: '1.0.0',
+ register,
+};
diff --git a/client/electron/ai/handlers/evaluateHandler.ts b/client/electron/ai/handlers/evaluateHandler.ts
index 47364a63..cd71312f 100644
--- a/client/electron/ai/handlers/evaluateHandler.ts
+++ b/client/electron/ai/handlers/evaluateHandler.ts
@@ -1,126 +1,128 @@
-/**
- * AI 评估功能 Handler
- *
- * 处理 ai_evaluate IPC 请求,调用 AI 网关评估用户对概念的解释。
- */
-
-import { safeHandle } from '../../ipcUtils.js';
-import { logger } from '../../logger.js';
-import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
-import { generateText } from '../ollama/OllamaProvider.js';
-
-// ================================================================
-// IPC Handler
-// ================================================================
-
-/**
- * ai_evaluate — POST /api/v1/ai/evaluate-explanation
- */
-function register(): void {
- safeHandle(
- 'ai_evaluate',
- async (
- _event,
- args: {
- concept: string;
- explanation: string;
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- const startMs = Date.now();
- logger.info(`[AI] [evaluate] IPC received: concept_length=${args.concept.length}, explanation_length=${args.explanation.length}, hasAuth=${!!args.authToken}`);
- logger.debug(`[AI] [evaluate] Concept: ${args.concept.slice(0, 60)}, Explanation preview: ${args.explanation.slice(0, 80)}...`);
-
- const reqBody = {
- concept: args.concept,
- explanation: args.explanation,
- };
-
- logger.info(`[AI] [evaluate] Target: ${gatewayUrl()}/api/v1/ai/evaluate-explanation`);
-
- interface DimensionResp {
- dimension: string;
- score: number;
- feedback: string;
- }
- interface EvaluateResp {
- overall_score: number;
- dimensions: DimensionResp[];
- strengths: string[];
- improvements: string[];
- encouragement: string;
- model: string;
- tokens_used: number;
- latency_ms: number;
- }
-
- try {
- const localHandler = async (): Promise => {
- const prompt = `请评估以下对概念“${args.concept}”的解释:\n\n${args.explanation}\n\n请以JSON格式返回评估结果,格式为: {"overall_score": 75, "strengths": ["..."], "improvements": ["..."], "encouragement": "..."}`;
- const result = await generateText(prompt, '你是一个费曼学习法评估助手,擅长评估学生对概念的理解程度。请仅返回JSON。', { temperature: 0.4, maxTokens: 1024 });
- try {
- const parsed = JSON.parse(result.content);
- return {
- overall_score: parsed.overall_score ?? 60,
- dimensions: [],
- strengths: parsed.strengths ?? [],
- improvements: parsed.improvements ?? [],
- encouragement: parsed.encouragement ?? '继续加油!',
- model: result.model,
- tokens_used: result.tokens_used,
- latency_ms: result.latency_ms,
- };
- } catch {
- return { overall_score: 60, dimensions: [], strengths: [], improvements: [], encouragement: result.content.slice(0, 200), model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
- }
- };
-
- const { data: resp, source, requestId } = await callWithLocalFallback(
- '/api/v1/ai/evaluate-explanation',
- reqBody,
- localHandler,
- args.authToken,
- args.userApiKey,
- 40000,
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [evaluate] ✔ Success (${source}): overall_score=${resp.overall_score}, dimensions=${resp.dimensions.length}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- overallScore: resp.overall_score,
- dimensions: resp.dimensions.map((d) => ({
- name: d.dimension,
- score: d.score,
- feedback: d.feedback,
- })),
- strengths: resp.strengths,
- improvements: resp.improvements,
- encouragement: resp.encouragement,
- model: resp.model,
- tokensUsed: resp.tokens_used,
- latencyMs: resp.latency_ms,
- requestId,
- source,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [evaluate] ✖ Failed after ${elapsed}ms: ${error.message}`);
- if (error.cause) logger.error(`[AI] [evaluate] Error cause: ${error.cause}`);
- throw error;
- }
- },
- );
-}
-
-// ================================================================
-// 功能定义导出
-// ================================================================
-
-export const feature: AIFeatureDef = {
- id: 'ai_evaluate',
- name: 'AI 解释评估',
- version: '1.0.0',
- register,
-};
+/**
+ * AI 评估功能 Handler
+ *
+ * 处理 ai_evaluate IPC 请求,调用 AI 网关评估用户对概念的解释。
+ *
+ * @ai-context: 费曼解释评估 IPC handler——AIFeatureDef 注册表模式,经 callWithLocalFallback 支持本地 Ollama 优先/云端网关降级;请求响应契约与网关 Pydantic model 对齐。
+ */
+
+import { safeHandle } from '../../ipcUtils.js';
+import { logger } from '../../logger.js';
+import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
+import { generateText } from '../ollama/OllamaProvider.js';
+
+// ================================================================
+// IPC Handler
+// ================================================================
+
+/**
+ * ai_evaluate — POST /api/v1/ai/evaluate-explanation
+ */
+function register(): void {
+ safeHandle(
+ 'ai_evaluate',
+ async (
+ _event,
+ args: {
+ concept: string;
+ explanation: string;
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ const startMs = Date.now();
+ logger.info(`[AI] [evaluate] IPC received: concept_length=${args.concept.length}, explanation_length=${args.explanation.length}, hasAuth=${!!args.authToken}`);
+ logger.debug(`[AI] [evaluate] Concept: ${args.concept.slice(0, 60)}, Explanation preview: ${args.explanation.slice(0, 80)}...`);
+
+ const reqBody = {
+ concept: args.concept,
+ explanation: args.explanation,
+ };
+
+ logger.info(`[AI] [evaluate] Target: ${gatewayUrl()}/api/v1/ai/evaluate-explanation`);
+
+ interface DimensionResp {
+ dimension: string;
+ score: number;
+ feedback: string;
+ }
+ interface EvaluateResp {
+ overall_score: number;
+ dimensions: DimensionResp[];
+ strengths: string[];
+ improvements: string[];
+ encouragement: string;
+ model: string;
+ tokens_used: number;
+ latency_ms: number;
+ }
+
+ try {
+ const localHandler = async (): Promise => {
+ const prompt = `请评估以下对概念“${args.concept}”的解释:\n\n${args.explanation}\n\n请以JSON格式返回评估结果,格式为: {"overall_score": 75, "strengths": ["..."], "improvements": ["..."], "encouragement": "..."}`;
+ const result = await generateText(prompt, '你是一个费曼学习法评估助手,擅长评估学生对概念的理解程度。请仅返回JSON。', { temperature: 0.4, maxTokens: 1024 });
+ try {
+ const parsed = JSON.parse(result.content);
+ return {
+ overall_score: parsed.overall_score ?? 60,
+ dimensions: [],
+ strengths: parsed.strengths ?? [],
+ improvements: parsed.improvements ?? [],
+ encouragement: parsed.encouragement ?? '继续加油!',
+ model: result.model,
+ tokens_used: result.tokens_used,
+ latency_ms: result.latency_ms,
+ };
+ } catch {
+ return { overall_score: 60, dimensions: [], strengths: [], improvements: [], encouragement: result.content.slice(0, 200), model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
+ }
+ };
+
+ const { data: resp, source, requestId } = await callWithLocalFallback(
+ '/api/v1/ai/evaluate-explanation',
+ reqBody,
+ localHandler,
+ args.authToken,
+ args.userApiKey,
+ 40000,
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [evaluate] ✔ Success (${source}): overall_score=${resp.overall_score}, dimensions=${resp.dimensions.length}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ overallScore: resp.overall_score,
+ dimensions: resp.dimensions.map((d) => ({
+ name: d.dimension,
+ score: d.score,
+ feedback: d.feedback,
+ })),
+ strengths: resp.strengths,
+ improvements: resp.improvements,
+ encouragement: resp.encouragement,
+ model: resp.model,
+ tokensUsed: resp.tokens_used,
+ latencyMs: resp.latency_ms,
+ requestId,
+ source,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [evaluate] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ if (error.cause) logger.error(`[AI] [evaluate] Error cause: ${error.cause}`);
+ throw error;
+ }
+ },
+ );
+}
+
+// ================================================================
+// 功能定义导出
+// ================================================================
+
+export const feature: AIFeatureDef = {
+ id: 'ai_evaluate',
+ name: 'AI 解释评估',
+ version: '1.0.0',
+ register,
+};
diff --git a/client/electron/ai/handlers/feynmanHandler.ts b/client/electron/ai/handlers/feynmanHandler.ts
index f0eaaee6..b100e925 100644
--- a/client/electron/ai/handlers/feynmanHandler.ts
+++ b/client/electron/ai/handlers/feynmanHandler.ts
@@ -1,180 +1,182 @@
-/**
- * 费曼学习法功能 Handler
- *
- * 处理 ai_feynman_question 和 ai_feynman_evaluate_answers IPC 请求,
- * 调用 AI 网关生成追问并评估用户回答。
- */
-
-import { safeHandle } from '../../ipcUtils.js';
-import { logger } from '../../logger.js';
-import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
-import { generateText } from '../ollama/OllamaProvider.js';
-
-// ================================================================
-// IPC Handler
-// ================================================================
-
-/**
- * 注册费曼学习法相关的所有 IPC handler
- */
-function register(): void {
- /**
- * ai_feynman_question — POST /api/v1/ai/feynman-question
- */
- safeHandle(
- 'ai_feynman_question',
- async (
- _event,
- args: {
- concept: string;
- explanation: string;
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- const startMs = Date.now();
- logger.info(`[AI] [feynman-q] IPC received: concept_length=${args.concept.length}, explanation_length=${args.explanation.length}, hasAuth=${!!args.authToken}`);
- logger.debug(`[AI] [feynman-q] Concept: ${args.concept.slice(0, 60)}`);
-
- const reqBody = {
- concept: args.concept,
- explanation: args.explanation,
- };
-
- logger.info(`[AI] [feynman-q] Target: ${gatewayUrl()}/api/v1/ai/feynman-question`);
-
- interface FeynmanQuestionResp {
- questions: Array<{ question: string; focus: string }>;
- model: string;
- tokens_used: number;
- latency_ms: number;
- }
-
- try {
- const localHandler = async (): Promise => {
- const prompt = `针对概念“${args.concept}”和解释“${args.explanation}”,生成苏格拉底式追问,返回JSON: {"questions": [{"question": "...", "focus": "..."}]}`;
- const result = await generateText(prompt, '你是一个费曼学习法追问助手。请仅返回JSON。', { temperature: 0.7, maxTokens: 1024 });
- const parsed = JSON.parse(result.content);
- return { questions: parsed.questions ?? [], model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
- };
-
- const { data: resp, source, requestId } = await callWithLocalFallback(
- '/api/v1/ai/feynman-question',
- reqBody,
- localHandler,
- args.authToken,
- args.userApiKey,
- 40000,
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [feynman-q] ✔ Success (${source}): questions=${resp.questions.length}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- questions: resp.questions.map((q: { question: string; focus: string }) => ({
- question: q.question,
- focus: q.focus,
- })),
- model: resp.model,
- tokensUsed: resp.tokens_used,
- latencyMs: resp.latency_ms,
- requestId,
- source,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [feynman-q] ✖ Failed after ${elapsed}ms: ${error.message}`);
- if (error.cause) logger.error(`[AI] [feynman-q] Error cause: ${error.cause}`);
- throw error;
- }
- },
- );
-
- /**
- * ai_feynman_evaluate_answers — POST /api/v1/ai/feynman-evaluate-answers
- */
- safeHandle(
- 'ai_feynman_evaluate_answers',
- async (
- _event,
- args: {
- concept: string;
- questions: string[];
- answers: string[];
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- const startMs = Date.now();
- logger.info(`[AI] [feynman-eval] IPC received: concept_length=${args.concept.length}, questions=${args.questions.length}, answers=${args.answers.length}, hasAuth=${!!args.authToken}`);
- logger.debug(`[AI] [feynman-eval] Concept: ${args.concept.slice(0, 60)}, Q count=${args.questions.length}`);
-
- const reqBody = {
- concept: args.concept,
- questions: args.questions,
- answers: args.answers,
- };
-
- logger.info(`[AI] [feynman-eval] Target: ${gatewayUrl()}/api/v1/ai/feynman-evaluate-answers`);
-
- interface FeynmanAnswerEvalResp {
- understanding_score: number;
- feedback: string;
- strong_points: string[];
- weak_points: string[];
- model: string;
- tokens_used: number;
- latency_ms: number;
- }
-
- try {
- const localHandler = async (): Promise => {
- const prompt = `评估用户对概念“${args.concept}”的回答,返回JSON: {"understanding_score": 70, "feedback": "...", "strong_points": ["..."], "weak_points": ["..."]}`;
- const result = await generateText(prompt, '你是一个费曼学习法评估助手。请仅返回JSON。', { temperature: 0.4, maxTokens: 1024 });
- const parsed = JSON.parse(result.content);
- return { understanding_score: parsed.understanding_score ?? 60, feedback: parsed.feedback ?? '', strong_points: parsed.strong_points ?? [], weak_points: parsed.weak_points ?? [], model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
- };
-
- const { data: resp, source, requestId } = await callWithLocalFallback(
- '/api/v1/ai/feynman-evaluate-answers',
- reqBody,
- localHandler,
- args.authToken,
- args.userApiKey,
- 40000,
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [feynman-eval] ✔ Success (${source}): score=${resp.understanding_score}, strong=${resp.strong_points.length}, weak=${resp.weak_points.length}, model=${resp.model}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- understandingScore: resp.understanding_score,
- feedback: resp.feedback,
- strongPoints: resp.strong_points,
- weakPoints: resp.weak_points,
- model: resp.model,
- tokensUsed: resp.tokens_used,
- latencyMs: resp.latency_ms,
- requestId,
- source,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [feynman-eval] ✖ Failed after ${elapsed}ms: ${error.message}`);
- if (error.cause) logger.error(`[AI] [feynman-eval] Error cause: ${error.cause}`);
- throw error;
- }
- },
- );
-}
-
-// ================================================================
-// 功能定义导出
-// ================================================================
-
-export const feature: AIFeatureDef = {
- id: 'ai_feynman',
- name: '浮出水面',
- version: '1.0.0',
- register,
-};
+/**
+ * 费曼学习法功能 Handler
+ *
+ * 处理 ai_feynman_question 和 ai_feynman_evaluate_answers IPC 请求,
+ * 调用 AI 网关生成追问并评估用户回答。
+ *
+ * @ai-context: 费曼提问与答案评估 IPC handler——AIFeatureDef 注册表模式,经 callWithLocalFallback 支持本地 Ollama 优先/云端网关降级;请求响应契约与网关 Pydantic model 对齐。
+ */
+
+import { safeHandle } from '../../ipcUtils.js';
+import { logger } from '../../logger.js';
+import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
+import { generateText } from '../ollama/OllamaProvider.js';
+
+// ================================================================
+// IPC Handler
+// ================================================================
+
+/**
+ * 注册费曼学习法相关的所有 IPC handler
+ */
+function register(): void {
+ /**
+ * ai_feynman_question — POST /api/v1/ai/feynman-question
+ */
+ safeHandle(
+ 'ai_feynman_question',
+ async (
+ _event,
+ args: {
+ concept: string;
+ explanation: string;
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ const startMs = Date.now();
+ logger.info(`[AI] [feynman-q] IPC received: concept_length=${args.concept.length}, explanation_length=${args.explanation.length}, hasAuth=${!!args.authToken}`);
+ logger.debug(`[AI] [feynman-q] Concept: ${args.concept.slice(0, 60)}`);
+
+ const reqBody = {
+ concept: args.concept,
+ explanation: args.explanation,
+ };
+
+ logger.info(`[AI] [feynman-q] Target: ${gatewayUrl()}/api/v1/ai/feynman-question`);
+
+ interface FeynmanQuestionResp {
+ questions: Array<{ question: string; focus: string }>;
+ model: string;
+ tokens_used: number;
+ latency_ms: number;
+ }
+
+ try {
+ const localHandler = async (): Promise => {
+ const prompt = `针对概念“${args.concept}”和解释“${args.explanation}”,生成苏格拉底式追问,返回JSON: {"questions": [{"question": "...", "focus": "..."}]}`;
+ const result = await generateText(prompt, '你是一个费曼学习法追问助手。请仅返回JSON。', { temperature: 0.7, maxTokens: 1024 });
+ const parsed = JSON.parse(result.content);
+ return { questions: parsed.questions ?? [], model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
+ };
+
+ const { data: resp, source, requestId } = await callWithLocalFallback(
+ '/api/v1/ai/feynman-question',
+ reqBody,
+ localHandler,
+ args.authToken,
+ args.userApiKey,
+ 40000,
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [feynman-q] ✔ Success (${source}): questions=${resp.questions.length}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ questions: resp.questions.map((q: { question: string; focus: string }) => ({
+ question: q.question,
+ focus: q.focus,
+ })),
+ model: resp.model,
+ tokensUsed: resp.tokens_used,
+ latencyMs: resp.latency_ms,
+ requestId,
+ source,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [feynman-q] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ if (error.cause) logger.error(`[AI] [feynman-q] Error cause: ${error.cause}`);
+ throw error;
+ }
+ },
+ );
+
+ /**
+ * ai_feynman_evaluate_answers — POST /api/v1/ai/feynman-evaluate-answers
+ */
+ safeHandle(
+ 'ai_feynman_evaluate_answers',
+ async (
+ _event,
+ args: {
+ concept: string;
+ questions: string[];
+ answers: string[];
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ const startMs = Date.now();
+ logger.info(`[AI] [feynman-eval] IPC received: concept_length=${args.concept.length}, questions=${args.questions.length}, answers=${args.answers.length}, hasAuth=${!!args.authToken}`);
+ logger.debug(`[AI] [feynman-eval] Concept: ${args.concept.slice(0, 60)}, Q count=${args.questions.length}`);
+
+ const reqBody = {
+ concept: args.concept,
+ questions: args.questions,
+ answers: args.answers,
+ };
+
+ logger.info(`[AI] [feynman-eval] Target: ${gatewayUrl()}/api/v1/ai/feynman-evaluate-answers`);
+
+ interface FeynmanAnswerEvalResp {
+ understanding_score: number;
+ feedback: string;
+ strong_points: string[];
+ weak_points: string[];
+ model: string;
+ tokens_used: number;
+ latency_ms: number;
+ }
+
+ try {
+ const localHandler = async (): Promise => {
+ const prompt = `评估用户对概念“${args.concept}”的回答,返回JSON: {"understanding_score": 70, "feedback": "...", "strong_points": ["..."], "weak_points": ["..."]}`;
+ const result = await generateText(prompt, '你是一个费曼学习法评估助手。请仅返回JSON。', { temperature: 0.4, maxTokens: 1024 });
+ const parsed = JSON.parse(result.content);
+ return { understanding_score: parsed.understanding_score ?? 60, feedback: parsed.feedback ?? '', strong_points: parsed.strong_points ?? [], weak_points: parsed.weak_points ?? [], model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
+ };
+
+ const { data: resp, source, requestId } = await callWithLocalFallback(
+ '/api/v1/ai/feynman-evaluate-answers',
+ reqBody,
+ localHandler,
+ args.authToken,
+ args.userApiKey,
+ 40000,
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [feynman-eval] ✔ Success (${source}): score=${resp.understanding_score}, strong=${resp.strong_points.length}, weak=${resp.weak_points.length}, model=${resp.model}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ understandingScore: resp.understanding_score,
+ feedback: resp.feedback,
+ strongPoints: resp.strong_points,
+ weakPoints: resp.weak_points,
+ model: resp.model,
+ tokensUsed: resp.tokens_used,
+ latencyMs: resp.latency_ms,
+ requestId,
+ source,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [feynman-eval] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ if (error.cause) logger.error(`[AI] [feynman-eval] Error cause: ${error.cause}`);
+ throw error;
+ }
+ },
+ );
+}
+
+// ================================================================
+// 功能定义导出
+// ================================================================
+
+export const feature: AIFeatureDef = {
+ id: 'ai_feynman',
+ name: '浮出水面',
+ version: '1.0.0',
+ register,
+};
diff --git a/client/electron/ai/handlers/flashcardHandler.ts b/client/electron/ai/handlers/flashcardHandler.ts
index 82eeff27..70711c29 100644
--- a/client/electron/ai/handlers/flashcardHandler.ts
+++ b/client/electron/ai/handlers/flashcardHandler.ts
@@ -1,119 +1,121 @@
-/**
- * AI 闪卡生成功能 Handler
- *
- * 处理 ai_generate_cards IPC 请求,调用 AI 网关从笔记生成闪卡。
- */
-
-import { safeHandle } from '../../ipcUtils.js';
-import { logger } from '../../logger.js';
-import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
-import { generateText } from '../ollama/OllamaProvider.js';
-
-// ================================================================
-// IPC Handler
-// ================================================================
-
-/**
- * ai_generate_cards — POST /api/v1/ai/generate-cards
- */
-function register(): void {
- safeHandle(
- 'ai_generate_cards',
- async (
- _event,
- args: {
- note: string;
- maxCards?: number;
- difficulty?: string;
- cardType?: string;
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- const startMs = Date.now();
- logger.info(`[AI] [flashcard] IPC received: note_length=${args.note.length}, maxCards=${args.maxCards ?? 'default'}, difficulty=${args.difficulty ?? 'default'}, cardType=${args.cardType ?? 'default'}, hasAuth=${!!args.authToken}`);
- logger.debug(`[AI] [flashcard] Note preview: ${args.note.slice(0, 80)}...`);
-
- const reqBody = {
- note: args.note,
- options: {
- ...(args.maxCards != null && { max_cards: args.maxCards }),
- ...(args.difficulty != null && { difficulty: args.difficulty }),
- ...(args.cardType != null && { card_type: args.cardType }),
- },
- };
-
- logger.info(`[AI] [flashcard] Target: ${gatewayUrl()}/api/v1/ai/generate-cards`);
-
- interface CardResp {
- front: string;
- back: string;
- type: string;
- confidence: number;
- }
- interface CardGenResp {
- cards: CardResp[];
- total_extracted: number;
- model: string;
- tokens_used: number;
- }
-
- try {
- const localHandler = async (): Promise => {
- const maxHint = args.maxCards ? `,最多生成${args.maxCards}张` : '';
- const diffHint = args.difficulty ? `,难度:${args.difficulty}` : '';
- const prompt = `请从以下笔记内容生成闪卡(问答对)${maxHint}${diffHint}。\n请以JSON格式返回,格式为: {"cards": [{"front": "问题", "back": "答案", "type": "basic", "confidence": 0.9}]}\n\n笔记内容:\n${args.note}`;
- const result = await generateText(prompt, '你是一个专业的闪卡生成助手,擅长从笔记中提取核心知识点并生成问答对。请仅返回JSON。', { temperature: 0.5, maxTokens: 2048 });
- try {
- const parsed = JSON.parse(result.content);
- return { cards: parsed.cards || [], total_extracted: (parsed.cards || []).length, model: result.model, tokens_used: result.tokens_used };
- } catch {
- return { cards: [], total_extracted: 0, model: result.model, tokens_used: result.tokens_used };
- }
- };
-
- const { data: resp, source, requestId } = await callWithLocalFallback(
- '/api/v1/ai/generate-cards',
- reqBody,
- localHandler,
- args.authToken,
- args.userApiKey,
- 90000,
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [flashcard] ✔ Success (${source}): cards=${resp.cards.length}, total_extracted=${resp.total_extracted}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- cards: resp.cards.map((c) => ({
- front: c.front,
- back: c.back,
- type: c.type,
- confidence: c.confidence,
- })),
- totalExtracted: resp.total_extracted,
- model: resp.model,
- tokensUsed: resp.tokens_used,
- requestId,
- source,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [flashcard] ✖ Failed after ${elapsed}ms: ${error.message}`);
- if (error.cause) logger.error(`[AI] [flashcard] Error cause: ${error.cause}`);
- throw error;
- }
- },
- );
-}
-
-// ================================================================
-// 功能定义导出
-// ================================================================
-
-export const feature: AIFeatureDef = {
- id: 'ai_generate_cards',
- name: 'AI 反衰减呼吸生成',
- version: '1.0.0',
- register,
-};
+/**
+ * AI 闪卡生成功能 Handler
+ *
+ * 处理 ai_generate_cards IPC 请求,调用 AI 网关从笔记生成闪卡。
+ *
+ * @ai-context: 闪卡生成 IPC handler——AIFeatureDef 注册表模式,经 callWithLocalFallback 支持本地 Ollama 优先/云端网关降级;请求响应契约与网关 Pydantic model 对齐。
+ */
+
+import { safeHandle } from '../../ipcUtils.js';
+import { logger } from '../../logger.js';
+import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
+import { generateText } from '../ollama/OllamaProvider.js';
+
+// ================================================================
+// IPC Handler
+// ================================================================
+
+/**
+ * ai_generate_cards — POST /api/v1/ai/generate-cards
+ */
+function register(): void {
+ safeHandle(
+ 'ai_generate_cards',
+ async (
+ _event,
+ args: {
+ note: string;
+ maxCards?: number;
+ difficulty?: string;
+ cardType?: string;
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ const startMs = Date.now();
+ logger.info(`[AI] [flashcard] IPC received: note_length=${args.note.length}, maxCards=${args.maxCards ?? 'default'}, difficulty=${args.difficulty ?? 'default'}, cardType=${args.cardType ?? 'default'}, hasAuth=${!!args.authToken}`);
+ logger.debug(`[AI] [flashcard] Note preview: ${args.note.slice(0, 80)}...`);
+
+ const reqBody = {
+ note: args.note,
+ options: {
+ ...(args.maxCards != null && { max_cards: args.maxCards }),
+ ...(args.difficulty != null && { difficulty: args.difficulty }),
+ ...(args.cardType != null && { card_type: args.cardType }),
+ },
+ };
+
+ logger.info(`[AI] [flashcard] Target: ${gatewayUrl()}/api/v1/ai/generate-cards`);
+
+ interface CardResp {
+ front: string;
+ back: string;
+ type: string;
+ confidence: number;
+ }
+ interface CardGenResp {
+ cards: CardResp[];
+ total_extracted: number;
+ model: string;
+ tokens_used: number;
+ }
+
+ try {
+ const localHandler = async (): Promise => {
+ const maxHint = args.maxCards ? `,最多生成${args.maxCards}张` : '';
+ const diffHint = args.difficulty ? `,难度:${args.difficulty}` : '';
+ const prompt = `请从以下笔记内容生成闪卡(问答对)${maxHint}${diffHint}。\n请以JSON格式返回,格式为: {"cards": [{"front": "问题", "back": "答案", "type": "basic", "confidence": 0.9}]}\n\n笔记内容:\n${args.note}`;
+ const result = await generateText(prompt, '你是一个专业的闪卡生成助手,擅长从笔记中提取核心知识点并生成问答对。请仅返回JSON。', { temperature: 0.5, maxTokens: 2048 });
+ try {
+ const parsed = JSON.parse(result.content);
+ return { cards: parsed.cards || [], total_extracted: (parsed.cards || []).length, model: result.model, tokens_used: result.tokens_used };
+ } catch {
+ return { cards: [], total_extracted: 0, model: result.model, tokens_used: result.tokens_used };
+ }
+ };
+
+ const { data: resp, source, requestId } = await callWithLocalFallback(
+ '/api/v1/ai/generate-cards',
+ reqBody,
+ localHandler,
+ args.authToken,
+ args.userApiKey,
+ 90000,
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [flashcard] ✔ Success (${source}): cards=${resp.cards.length}, total_extracted=${resp.total_extracted}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ cards: resp.cards.map((c) => ({
+ front: c.front,
+ back: c.back,
+ type: c.type,
+ confidence: c.confidence,
+ })),
+ totalExtracted: resp.total_extracted,
+ model: resp.model,
+ tokensUsed: resp.tokens_used,
+ requestId,
+ source,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [flashcard] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ if (error.cause) logger.error(`[AI] [flashcard] Error cause: ${error.cause}`);
+ throw error;
+ }
+ },
+ );
+}
+
+// ================================================================
+// 功能定义导出
+// ================================================================
+
+export const feature: AIFeatureDef = {
+ id: 'ai_generate_cards',
+ name: 'AI 反衰减呼吸生成',
+ version: '1.0.0',
+ register,
+};
diff --git a/client/electron/ai/handlers/mergeNotesHandler.ts b/client/electron/ai/handlers/mergeNotesHandler.ts
index 2881b812..5abc4186 100644
--- a/client/electron/ai/handlers/mergeNotesHandler.ts
+++ b/client/electron/ai/handlers/mergeNotesHandler.ts
@@ -1,137 +1,139 @@
-/**
- * AI 片段笔记合并 Handler
- *
- * 处理 ai_merge_notes IPC 请求,将增量分析产生的多个片段笔记
- * 合并为一份完整结构化笔记。纯文本操作,无需多模态模型。
- * 对应端点:POST /api/v1/multimodal/merge-notes
- */
-
-import { safeHandle } from '../../ipcUtils.js';
-import { logger } from '../../logger.js';
-import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
-import { generateText } from '../ollama/OllamaProvider.js';
-
-// ================================================================
-// IPC Handler
-// ================================================================
-
-/**
- * ai_merge_notes — POST /api/v1/multimodal/merge-notes
- */
-function register(): void {
- safeHandle(
- 'ai_merge_notes',
- async (
- _event,
- args: {
- partials: string[];
- duration?: number; // 秒
- language?: string;
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- const startMs = Date.now();
- const partialCount = args.partials?.length ?? 0;
- logger.info(`[AI] [merge-notes] IPC received: partials=${partialCount}, duration=${args.duration ?? 0}s`);
-
- if (!args.partials || args.partials.length === 0) {
- throw new Error('partials 不能为空');
- }
-
- // 只有一个片段时直接返回
- if (args.partials.length === 1) {
- return {
- content: args.partials[0].trim(),
- modelUsed: 'none (single partial)',
- source: 'local' as const,
- };
- }
-
- const reqBody = {
- partials: args.partials,
- duration: args.duration ?? 0,
- language: args.language ?? 'zh-CN',
- };
-
- logger.info(`[AI] [merge-notes] Target: ${gatewayUrl()}/api/v1/multimodal/merge-notes`);
-
- interface MergeNotesResp {
- content: string;
- model_used: string;
- }
-
- try {
- // 本地 Ollama 降级:纯文本合并
- const localHandler = async (): Promise => {
- const partsContent = args.partials
- .map((p, i) => `### 片段 ${i + 1}\n\n${p.trim()}`)
- .join('\n\n---\n\n');
-
- const prompt = [
- `以下是一门课程(总时长约 ${args.duration ?? 0} 秒)的 ${args.partials.length} 个片段笔记。`,
- '请将它们合并为一份完整的结构化课堂笔记,要求:',
- '1. 去除重复内容',
- '2. 使用 Markdown 二级标题按知识模块组织',
- '3. 补充片段间的衔接语句',
- '4. 保留所有公式、定义、代码',
- '5. 末尾添加「核心知识点摘要」',
- '',
- '---',
- '',
- partsContent,
- '',
- '---',
- '',
- '请直接输出合并后的 Markdown 笔记。',
- ].join('\n');
-
- const result = await generateText(
- prompt,
- '你是一个专业的课堂笔记整理助手,擅长将多个片段笔记合并为完整、连贯的结构化笔记。',
- { temperature: 0.3, maxTokens: 4096 },
- );
-
- return {
- content: result.content,
- model_used: result.model,
- };
- };
-
- const { data: resp, source, requestId } = await callWithLocalFallback(
- '/api/v1/multimodal/merge-notes',
- reqBody,
- localHandler,
- args.authToken,
- args.userApiKey,
- 30000, // 纯文本合并,30s 超时足够
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [merge-notes] ✔ Success (${source}): content_length=${resp.content?.length ?? 0}, model=${resp.model_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- content: resp.content,
- modelUsed: resp.model_used,
- source,
- requestId,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [merge-notes] ✖ Failed after ${elapsed}ms: ${error.message}`);
- throw error;
- }
- },
- );
-}
-
-// ================================================================
-// 功能定义导出
-// ================================================================
-
-export const feature: AIFeatureDef = {
- id: 'ai_merge_notes',
- name: 'AI 片段笔记合并',
- version: '1.0.0',
- register,
-};
+/**
+ * AI 片段笔记合并 Handler
+ *
+ * 处理 ai_merge_notes IPC 请求,将增量分析产生的多个片段笔记
+ * 合并为一份完整结构化笔记。纯文本操作,无需多模态模型。
+ * 对应端点:POST /api/v1/multimodal/merge-notes
+ *
+ * @ai-context: 笔记合并 IPC handler——AIFeatureDef 注册表模式,经 callWithLocalFallback 支持本地 Ollama 优先/云端网关降级;请求响应契约与网关 Pydantic model 对齐。
+ */
+
+import { safeHandle } from '../../ipcUtils.js';
+import { logger } from '../../logger.js';
+import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
+import { generateText } from '../ollama/OllamaProvider.js';
+
+// ================================================================
+// IPC Handler
+// ================================================================
+
+/**
+ * ai_merge_notes — POST /api/v1/multimodal/merge-notes
+ */
+function register(): void {
+ safeHandle(
+ 'ai_merge_notes',
+ async (
+ _event,
+ args: {
+ partials: string[];
+ duration?: number; // 秒
+ language?: string;
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ const startMs = Date.now();
+ const partialCount = args.partials?.length ?? 0;
+ logger.info(`[AI] [merge-notes] IPC received: partials=${partialCount}, duration=${args.duration ?? 0}s`);
+
+ if (!args.partials || args.partials.length === 0) {
+ throw new Error('partials 不能为空');
+ }
+
+ // 只有一个片段时直接返回
+ if (args.partials.length === 1) {
+ return {
+ content: args.partials[0].trim(),
+ modelUsed: 'none (single partial)',
+ source: 'local' as const,
+ };
+ }
+
+ const reqBody = {
+ partials: args.partials,
+ duration: args.duration ?? 0,
+ language: args.language ?? 'zh-CN',
+ };
+
+ logger.info(`[AI] [merge-notes] Target: ${gatewayUrl()}/api/v1/multimodal/merge-notes`);
+
+ interface MergeNotesResp {
+ content: string;
+ model_used: string;
+ }
+
+ try {
+ // 本地 Ollama 降级:纯文本合并
+ const localHandler = async (): Promise => {
+ const partsContent = args.partials
+ .map((p, i) => `### 片段 ${i + 1}\n\n${p.trim()}`)
+ .join('\n\n---\n\n');
+
+ const prompt = [
+ `以下是一门课程(总时长约 ${args.duration ?? 0} 秒)的 ${args.partials.length} 个片段笔记。`,
+ '请将它们合并为一份完整的结构化课堂笔记,要求:',
+ '1. 去除重复内容',
+ '2. 使用 Markdown 二级标题按知识模块组织',
+ '3. 补充片段间的衔接语句',
+ '4. 保留所有公式、定义、代码',
+ '5. 末尾添加「核心知识点摘要」',
+ '',
+ '---',
+ '',
+ partsContent,
+ '',
+ '---',
+ '',
+ '请直接输出合并后的 Markdown 笔记。',
+ ].join('\n');
+
+ const result = await generateText(
+ prompt,
+ '你是一个专业的课堂笔记整理助手,擅长将多个片段笔记合并为完整、连贯的结构化笔记。',
+ { temperature: 0.3, maxTokens: 4096 },
+ );
+
+ return {
+ content: result.content,
+ model_used: result.model,
+ };
+ };
+
+ const { data: resp, source, requestId } = await callWithLocalFallback(
+ '/api/v1/multimodal/merge-notes',
+ reqBody,
+ localHandler,
+ args.authToken,
+ args.userApiKey,
+ 30000, // 纯文本合并,30s 超时足够
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [merge-notes] ✔ Success (${source}): content_length=${resp.content?.length ?? 0}, model=${resp.model_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ content: resp.content,
+ modelUsed: resp.model_used,
+ source,
+ requestId,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [merge-notes] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ throw error;
+ }
+ },
+ );
+}
+
+// ================================================================
+// 功能定义导出
+// ================================================================
+
+export const feature: AIFeatureDef = {
+ id: 'ai_merge_notes',
+ name: 'AI 片段笔记合并',
+ version: '1.0.0',
+ register,
+};
diff --git a/client/electron/ai/handlers/optimizeCardHandler.ts b/client/electron/ai/handlers/optimizeCardHandler.ts
index 5bfd2add..9e79895a 100644
--- a/client/electron/ai/handlers/optimizeCardHandler.ts
+++ b/client/electron/ai/handlers/optimizeCardHandler.ts
@@ -1,102 +1,104 @@
-/**
- * AI 闪卡优化功能 Handler
- *
- * 处理 ai_optimize_card IPC 请求,调用 AI 网关优化已有闪卡的正反面内容。
- */
-
-import { safeHandle } from '../../ipcUtils.js';
-import { logger } from '../../logger.js';
-import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
-import { generateText } from '../ollama/OllamaProvider.js';
-
-// ================================================================
-// IPC Handler
-// ================================================================
-
-/**
- * ai_optimize_card — POST /api/v1/ai/optimize-card
- */
-function register(): void {
- safeHandle(
- 'ai_optimize_card',
- async (
- _event,
- args: {
- front: string;
- back: string;
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- const startMs = Date.now();
- logger.info(`[AI] [optimize-card] IPC received: front_length=${args.front.length}, back_length=${args.back.length}, hasAuth=${!!args.authToken}`);
- logger.debug(`[AI] [optimize-card] Front preview: ${args.front.slice(0, 60)}, Back preview: ${args.back.slice(0, 60)}`);
-
- const reqBody = {
- front: args.front,
- back: args.back,
- };
-
- logger.info(`[AI] [optimize-card] Target: ${gatewayUrl()}/api/v1/ai/optimize-card`);
-
- interface OptimizeCardResp {
- suggested_front: string;
- suggested_back: string;
- improvements: string[];
- model: string;
- tokens_used: number;
- latency_ms: number;
- }
-
- try {
- const localHandler = async (): Promise => {
- const prompt = `请优化以下闪卡,返回JSON: {"suggested_front": "...", "suggested_back": "...", "improvements": ["..."]}
-正面:${args.front}
-反面:${args.back}`;
- const result = await generateText(prompt, '你是一个闪卡优化助手,擅长改进问答对的表述。请仅返回JSON。', { temperature: 0.5, maxTokens: 1024 });
- const parsed = JSON.parse(result.content);
- return { suggested_front: parsed.suggested_front ?? args.front, suggested_back: parsed.suggested_back ?? args.back, improvements: parsed.improvements ?? [], model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
- };
-
- const { data: resp, source, requestId } = await callWithLocalFallback(
- '/api/v1/ai/optimize-card',
- reqBody,
- localHandler,
- args.authToken,
- args.userApiKey,
- 60000,
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [optimize-card] ✔ Success (${source}): improvements=${resp.improvements.length}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- suggestedFront: resp.suggested_front,
- suggestedBack: resp.suggested_back,
- improvements: resp.improvements,
- model: resp.model,
- tokensUsed: resp.tokens_used,
- latencyMs: resp.latency_ms,
- requestId,
- source,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [optimize-card] ✖ Failed after ${elapsed}ms: ${error.message}`);
- if (error.cause) logger.error(`[AI] [optimize-card] Error cause: ${error.cause}`);
- throw error;
- }
- },
- );
-}
-
-// ================================================================
-// 功能定义导出
-// ================================================================
-
-export const feature: AIFeatureDef = {
- id: 'ai_optimize_card',
- name: 'AI 闪卡优化',
- version: '1.0.0',
- register,
-};
+/**
+ * AI 闪卡优化功能 Handler
+ *
+ * 处理 ai_optimize_card IPC 请求,调用 AI 网关优化已有闪卡的正反面内容。
+ *
+ * @ai-context: 卡片优化 IPC handler——AIFeatureDef 注册表模式,经 callWithLocalFallback 支持本地 Ollama 优先/云端网关降级;请求响应契约与网关 Pydantic model 对齐。
+ */
+
+import { safeHandle } from '../../ipcUtils.js';
+import { logger } from '../../logger.js';
+import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
+import { generateText } from '../ollama/OllamaProvider.js';
+
+// ================================================================
+// IPC Handler
+// ================================================================
+
+/**
+ * ai_optimize_card — POST /api/v1/ai/optimize-card
+ */
+function register(): void {
+ safeHandle(
+ 'ai_optimize_card',
+ async (
+ _event,
+ args: {
+ front: string;
+ back: string;
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ const startMs = Date.now();
+ logger.info(`[AI] [optimize-card] IPC received: front_length=${args.front.length}, back_length=${args.back.length}, hasAuth=${!!args.authToken}`);
+ logger.debug(`[AI] [optimize-card] Front preview: ${args.front.slice(0, 60)}, Back preview: ${args.back.slice(0, 60)}`);
+
+ const reqBody = {
+ front: args.front,
+ back: args.back,
+ };
+
+ logger.info(`[AI] [optimize-card] Target: ${gatewayUrl()}/api/v1/ai/optimize-card`);
+
+ interface OptimizeCardResp {
+ suggested_front: string;
+ suggested_back: string;
+ improvements: string[];
+ model: string;
+ tokens_used: number;
+ latency_ms: number;
+ }
+
+ try {
+ const localHandler = async (): Promise => {
+ const prompt = `请优化以下闪卡,返回JSON: {"suggested_front": "...", "suggested_back": "...", "improvements": ["..."]}
+正面:${args.front}
+反面:${args.back}`;
+ const result = await generateText(prompt, '你是一个闪卡优化助手,擅长改进问答对的表述。请仅返回JSON。', { temperature: 0.5, maxTokens: 1024 });
+ const parsed = JSON.parse(result.content);
+ return { suggested_front: parsed.suggested_front ?? args.front, suggested_back: parsed.suggested_back ?? args.back, improvements: parsed.improvements ?? [], model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
+ };
+
+ const { data: resp, source, requestId } = await callWithLocalFallback(
+ '/api/v1/ai/optimize-card',
+ reqBody,
+ localHandler,
+ args.authToken,
+ args.userApiKey,
+ 60000,
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [optimize-card] ✔ Success (${source}): improvements=${resp.improvements.length}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ suggestedFront: resp.suggested_front,
+ suggestedBack: resp.suggested_back,
+ improvements: resp.improvements,
+ model: resp.model,
+ tokensUsed: resp.tokens_used,
+ latencyMs: resp.latency_ms,
+ requestId,
+ source,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [optimize-card] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ if (error.cause) logger.error(`[AI] [optimize-card] Error cause: ${error.cause}`);
+ throw error;
+ }
+ },
+ );
+}
+
+// ================================================================
+// 功能定义导出
+// ================================================================
+
+export const feature: AIFeatureDef = {
+ id: 'ai_optimize_card',
+ name: 'AI 闪卡优化',
+ version: '1.0.0',
+ register,
+};
diff --git a/client/electron/ai/handlers/predictHandler.ts b/client/electron/ai/handlers/predictHandler.ts
index d1f2b9f8..5f245d53 100644
--- a/client/electron/ai/handlers/predictHandler.ts
+++ b/client/electron/ai/handlers/predictHandler.ts
@@ -1,105 +1,107 @@
-/**
- * AI 学习预测功能 Handler
- *
- * 处理 ai_predict IPC 请求,调用 AI 网关基于笔记内容生成预测性问题。
- */
-
-import { safeHandle } from '../../ipcUtils.js';
-import { logger } from '../../logger.js';
-import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
-import { generateText } from '../ollama/OllamaProvider.js';
-
-// ================================================================
-// IPC Handler
-// ================================================================
-
-/**
- * ai_predict — POST /api/v1/ai/predict
- */
-function register(): void {
- safeHandle(
- 'ai_predict',
- async (
- _event,
- args: {
- content: string;
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- const startMs = Date.now();
- logger.info(`[AI] [predict] IPC received: content_length=${args.content.length}, hasAuth=${!!args.authToken}`);
- logger.debug(`[AI] [predict] Content preview: ${args.content.slice(0, 80)}...`);
-
- const reqBody = { content: args.content };
-
- logger.info(`[AI] [predict] Target: ${gatewayUrl()}/api/v1/ai/predict`);
-
- interface PredictionResp {
- question: string;
- type: string;
- reason: string;
- curiosity_score: number;
- }
- interface PredictGenResp {
- predictions: PredictionResp[];
- status: string;
- model: string;
- tokens_used: number;
- latency_ms: number;
- }
-
- try {
- const localHandler = async (): Promise => {
- const prompt = `基于以下笔记内容,生成预测性问题,返回JSON: {"predictions": [{"question": "...", "type": "...", "reason": "...", "curiosity_score": 0.8}], "status": "ok"}\n\n笔记:\n${args.content}`;
- const result = await generateText(prompt, '你是一个预测驱动学习助手,擅长从笔记中生成引导性问题。请仅返回JSON。', { temperature: 0.6, maxTokens: 1024 });
- const parsed = JSON.parse(result.content);
- return { predictions: parsed.predictions ?? [], status: 'ok', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
- };
-
- const { data: resp, source, requestId } = await callWithLocalFallback(
- '/api/v1/ai/predict',
- reqBody,
- localHandler,
- args.authToken,
- args.userApiKey,
- 60000,
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [predict] ✔ Success (${source}): predictions=${resp.predictions.length}, status=${resp.status}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- predictions: resp.predictions.map((p: { question: string; type: string; reason: string; curiosity_score: number }) => ({
- question: p.question,
- type: p.type,
- reason: p.reason,
- curiosityScore: p.curiosity_score,
- })),
- status: resp.status,
- model: resp.model,
- tokensUsed: resp.tokens_used,
- latencyMs: resp.latency_ms,
- requestId,
- source,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [predict] ✖ Failed after ${elapsed}ms: ${error.message}`);
- if (error.cause) logger.error(`[AI] [predict] Error cause: ${error.cause}`);
- throw error;
- }
- },
- );
-}
-
-// ================================================================
-// 功能定义导出
-// ================================================================
-
-export const feature: AIFeatureDef = {
- id: 'ai_predict',
- name: 'AI 学习预测',
- version: '1.0.0',
- register,
-};
+/**
+ * AI 学习预测功能 Handler
+ *
+ * 处理 ai_predict IPC 请求,调用 AI 网关基于笔记内容生成预测性问题。
+ *
+ * @ai-context: 预测提问 IPC handler——AIFeatureDef 注册表模式,经 callWithLocalFallback 支持本地 Ollama 优先/云端网关降级;请求响应契约与网关 Pydantic model 对齐。
+ */
+
+import { safeHandle } from '../../ipcUtils.js';
+import { logger } from '../../logger.js';
+import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
+import { generateText } from '../ollama/OllamaProvider.js';
+
+// ================================================================
+// IPC Handler
+// ================================================================
+
+/**
+ * ai_predict — POST /api/v1/ai/predict
+ */
+function register(): void {
+ safeHandle(
+ 'ai_predict',
+ async (
+ _event,
+ args: {
+ content: string;
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ const startMs = Date.now();
+ logger.info(`[AI] [predict] IPC received: content_length=${args.content.length}, hasAuth=${!!args.authToken}`);
+ logger.debug(`[AI] [predict] Content preview: ${args.content.slice(0, 80)}...`);
+
+ const reqBody = { content: args.content };
+
+ logger.info(`[AI] [predict] Target: ${gatewayUrl()}/api/v1/ai/predict`);
+
+ interface PredictionResp {
+ question: string;
+ type: string;
+ reason: string;
+ curiosity_score: number;
+ }
+ interface PredictGenResp {
+ predictions: PredictionResp[];
+ status: string;
+ model: string;
+ tokens_used: number;
+ latency_ms: number;
+ }
+
+ try {
+ const localHandler = async (): Promise => {
+ const prompt = `基于以下笔记内容,生成预测性问题,返回JSON: {"predictions": [{"question": "...", "type": "...", "reason": "...", "curiosity_score": 0.8}], "status": "ok"}\n\n笔记:\n${args.content}`;
+ const result = await generateText(prompt, '你是一个预测驱动学习助手,擅长从笔记中生成引导性问题。请仅返回JSON。', { temperature: 0.6, maxTokens: 1024 });
+ const parsed = JSON.parse(result.content);
+ return { predictions: parsed.predictions ?? [], status: 'ok', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
+ };
+
+ const { data: resp, source, requestId } = await callWithLocalFallback(
+ '/api/v1/ai/predict',
+ reqBody,
+ localHandler,
+ args.authToken,
+ args.userApiKey,
+ 60000,
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [predict] ✔ Success (${source}): predictions=${resp.predictions.length}, status=${resp.status}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ predictions: resp.predictions.map((p: { question: string; type: string; reason: string; curiosity_score: number }) => ({
+ question: p.question,
+ type: p.type,
+ reason: p.reason,
+ curiosityScore: p.curiosity_score,
+ })),
+ status: resp.status,
+ model: resp.model,
+ tokensUsed: resp.tokens_used,
+ latencyMs: resp.latency_ms,
+ requestId,
+ source,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [predict] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ if (error.cause) logger.error(`[AI] [predict] Error cause: ${error.cause}`);
+ throw error;
+ }
+ },
+ );
+}
+
+// ================================================================
+// 功能定义导出
+// ================================================================
+
+export const feature: AIFeatureDef = {
+ id: 'ai_predict',
+ name: 'AI 学习预测',
+ version: '1.0.0',
+ register,
+};
diff --git a/client/electron/ai/handlers/recommendHandler.ts b/client/electron/ai/handlers/recommendHandler.ts
index e92b9e35..65e48353 100644
--- a/client/electron/ai/handlers/recommendHandler.ts
+++ b/client/electron/ai/handlers/recommendHandler.ts
@@ -1,112 +1,114 @@
-/**
- * AI 推荐时长功能 Handler
- *
- * 处理 ai_recommend_duration IPC 请求,
- * 调用 AI 网关根据学习历史推荐最佳学习时长。
- */
-
-import { safeHandle } from '../../ipcUtils.js';
-import { logger } from '../../logger.js';
-import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
-import { generateText } from '../ollama/OllamaProvider.js';
-
-// ================================================================
-// IPC Handler
-// ================================================================
-
-/**
- * ai_recommend_duration — POST /api/v1/ai/recommend-duration
- */
-function register(): void {
- safeHandle(
- 'ai_recommend_duration',
- async (
- _event,
- args: {
- history: Array<{
- durationMinutes: number;
- completed: boolean;
- subject: string;
- timestamp: string;
- }>;
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- // 前端 camelCase → 后端 snake_case
- const startMs = Date.now();
- logger.info(`[AI] [recommend] IPC received: sessions_count=${args.history.length}, hasAuth=${!!args.authToken}`);
- logger.debug(`[AI] [recommend] History preview: ${args.history.length} sessions, first=${args.history[0]?.timestamp ?? 'N/A'}, last=${args.history[args.history.length - 1]?.timestamp ?? 'N/A'}`);
-
- const reqBody = {
- history: args.history.map((h) => ({
- duration_minutes: h.durationMinutes,
- completed: h.completed,
- subject: h.subject,
- timestamp: h.timestamp,
- })),
- };
-
- logger.info(`[AI] [recommend] Target: ${gatewayUrl()}/api/v1/ai/recommend-duration`);
-
- interface RecommendResp {
- recommended_minutes: number;
- break_minutes: number;
- reason: string;
- source: string;
- model: string;
- tokens_used: number;
- latency_ms: number;
- }
-
- try {
- const localHandler = async (): Promise => {
- const prompt = `根据学习历史推荐最佳学习时长,返回JSON: {"recommended_minutes": 25, "break_minutes": 5, "reason": "...", "source": "local_ollama"}\n学习历史:${JSON.stringify(reqBody)}`;
- const result = await generateText(prompt, '你是一个学习时间管理助手。请仅返回JSON。', { temperature: 0.4, maxTokens: 512 });
- const parsed = JSON.parse(result.content);
- return { recommended_minutes: parsed.recommended_minutes ?? 25, break_minutes: parsed.break_minutes ?? 5, reason: parsed.reason ?? '', source: 'local_ollama', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
- };
-
- const { data: resp, source, requestId } = await callWithLocalFallback(
- '/api/v1/ai/recommend-duration',
- reqBody,
- localHandler,
- args.authToken,
- args.userApiKey,
- 40000,
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [recommend] ✔ Success (${source}): recommended=${resp.recommended_minutes}min, break=${resp.break_minutes}min, source=${resp.source}, model=${resp.model}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- recommendedMinutes: resp.recommended_minutes,
- breakMinutes: resp.break_minutes,
- reason: resp.reason,
- source: resp.source,
- isLocalFallback: resp.source === 'local_rule' || source === 'local',
- model: resp.model,
- tokensUsed: resp.tokens_used,
- latencyMs: resp.latency_ms,
- requestId,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [recommend] ✖ Failed after ${elapsed}ms: ${error.message}`);
- if (error.cause) logger.error(`[AI] [recommend] Error cause: ${error.cause}`);
- throw error;
- }
- },
- );
-}
-
-// ================================================================
-// 功能定义导出
-// ================================================================
-
-export const feature: AIFeatureDef = {
- id: 'ai_recommend_duration',
- name: 'AI 推荐学习时长',
- version: '1.0.0',
- register,
-};
+/**
+ * AI 推荐时长功能 Handler
+ *
+ * 处理 ai_recommend_duration IPC 请求,
+ * 调用 AI 网关根据学习历史推荐最佳学习时长。
+ *
+ * @ai-context: 时长推荐 IPC handler——AIFeatureDef 注册表模式,经 callWithLocalFallback 支持本地 Ollama 优先/云端网关降级;请求响应契约与网关 Pydantic model 对齐。
+ */
+
+import { safeHandle } from '../../ipcUtils.js';
+import { logger } from '../../logger.js';
+import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
+import { generateText } from '../ollama/OllamaProvider.js';
+
+// ================================================================
+// IPC Handler
+// ================================================================
+
+/**
+ * ai_recommend_duration — POST /api/v1/ai/recommend-duration
+ */
+function register(): void {
+ safeHandle(
+ 'ai_recommend_duration',
+ async (
+ _event,
+ args: {
+ history: Array<{
+ durationMinutes: number;
+ completed: boolean;
+ subject: string;
+ timestamp: string;
+ }>;
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ // 前端 camelCase → 后端 snake_case
+ const startMs = Date.now();
+ logger.info(`[AI] [recommend] IPC received: sessions_count=${args.history.length}, hasAuth=${!!args.authToken}`);
+ logger.debug(`[AI] [recommend] History preview: ${args.history.length} sessions, first=${args.history[0]?.timestamp ?? 'N/A'}, last=${args.history[args.history.length - 1]?.timestamp ?? 'N/A'}`);
+
+ const reqBody = {
+ history: args.history.map((h) => ({
+ duration_minutes: h.durationMinutes,
+ completed: h.completed,
+ subject: h.subject,
+ timestamp: h.timestamp,
+ })),
+ };
+
+ logger.info(`[AI] [recommend] Target: ${gatewayUrl()}/api/v1/ai/recommend-duration`);
+
+ interface RecommendResp {
+ recommended_minutes: number;
+ break_minutes: number;
+ reason: string;
+ source: string;
+ model: string;
+ tokens_used: number;
+ latency_ms: number;
+ }
+
+ try {
+ const localHandler = async (): Promise => {
+ const prompt = `根据学习历史推荐最佳学习时长,返回JSON: {"recommended_minutes": 25, "break_minutes": 5, "reason": "...", "source": "local_ollama"}\n学习历史:${JSON.stringify(reqBody)}`;
+ const result = await generateText(prompt, '你是一个学习时间管理助手。请仅返回JSON。', { temperature: 0.4, maxTokens: 512 });
+ const parsed = JSON.parse(result.content);
+ return { recommended_minutes: parsed.recommended_minutes ?? 25, break_minutes: parsed.break_minutes ?? 5, reason: parsed.reason ?? '', source: 'local_ollama', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
+ };
+
+ const { data: resp, source, requestId } = await callWithLocalFallback(
+ '/api/v1/ai/recommend-duration',
+ reqBody,
+ localHandler,
+ args.authToken,
+ args.userApiKey,
+ 40000,
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [recommend] ✔ Success (${source}): recommended=${resp.recommended_minutes}min, break=${resp.break_minutes}min, source=${resp.source}, model=${resp.model}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ recommendedMinutes: resp.recommended_minutes,
+ breakMinutes: resp.break_minutes,
+ reason: resp.reason,
+ source: resp.source,
+ isLocalFallback: resp.source === 'local_rule' || source === 'local',
+ model: resp.model,
+ tokensUsed: resp.tokens_used,
+ latencyMs: resp.latency_ms,
+ requestId,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [recommend] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ if (error.cause) logger.error(`[AI] [recommend] Error cause: ${error.cause}`);
+ throw error;
+ }
+ },
+ );
+}
+
+// ================================================================
+// 功能定义导出
+// ================================================================
+
+export const feature: AIFeatureDef = {
+ id: 'ai_recommend_duration',
+ name: 'AI 推荐学习时长',
+ version: '1.0.0',
+ register,
+};
diff --git a/client/electron/ai/handlers/rescueHandler.ts b/client/electron/ai/handlers/rescueHandler.ts
index 01e237a6..eba86b90 100644
--- a/client/electron/ai/handlers/rescueHandler.ts
+++ b/client/electron/ai/handlers/rescueHandler.ts
@@ -1,114 +1,116 @@
-/**
- * AI 学习救援功能 Handler
- *
- * 处理 ai_rescue IPC 请求,调用 AI 网关为卡住的学习者提供分层救援提示。
- */
-
-import { safeHandle } from '../../ipcUtils.js';
-import { logger } from '../../logger.js';
-import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
-import { generateText } from '../ollama/OllamaProvider.js';
-
-// ================================================================
-// IPC Handler
-// ================================================================
-
-/**
- * ai_rescue — POST /api/v1/ai/rescue
- */
-function register(): void {
- safeHandle(
- 'ai_rescue',
- async (
- _event,
- args: {
- content: string;
- stuckDescription: string;
- attemptedMethods?: string;
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- const startMs = Date.now();
- logger.info(`[AI] [rescue] IPC received: content_length=${args.content.length}, stuck_length=${args.stuckDescription.length}, methods=${args.attemptedMethods ?? 'none'}, hasAuth=${!!args.authToken}`);
- logger.debug(`[AI] [rescue] Stuck description: ${args.stuckDescription.slice(0, 80)}`);
-
- // 前端 camelCase → 后端 snake_case
- const reqBody = {
- content: args.content,
- stuck_description: args.stuckDescription,
- attempted_methods: args.attemptedMethods ?? '',
- };
-
- logger.info(`[AI] [rescue] Target: ${gatewayUrl()}/api/v1/ai/rescue`);
-
- interface RescueLevelResp {
- level: number;
- label: string;
- suggestion: string;
- hint_question: string;
- }
- interface RescueGenResp {
- rescue_levels: RescueLevelResp[];
- encouragement: string;
- status: string;
- model: string;
- tokens_used: number;
- latency_ms: number;
- }
-
- try {
- const localHandler = async (): Promise => {
- const prompt = `学习者卡住了,请提供分层救援提示,返回JSON: {"rescue_levels": [{"level": 1, "label": "...", "suggestion": "...", "hint_question": "..."}], "encouragement": "...", "status": "ok"}\n\n卡住情境:${JSON.stringify(reqBody)}`;
- const result = await generateText(prompt, '你是一个学习救援助手,擅长为卡住的学习者提供分层提示。请仅返回JSON。', { temperature: 0.6, maxTokens: 1024 });
- const parsed = JSON.parse(result.content);
- return { rescue_levels: parsed.rescue_levels ?? [], encouragement: parsed.encouragement ?? '加油!', status: 'ok', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
- };
-
- const { data: resp, source, requestId } = await callWithLocalFallback(
- '/api/v1/ai/rescue',
- reqBody,
- localHandler,
- args.authToken,
- args.userApiKey,
- 60000,
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [rescue] ✔ Success (${source}): levels=${resp.rescue_levels.length}, status=${resp.status}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- rescueLevels: resp.rescue_levels.map((lv: { level: number; label: string; suggestion: string; hint_question: string }) => ({
- level: lv.level,
- label: lv.label,
- suggestion: lv.suggestion,
- hintQuestion: lv.hint_question,
- })),
- encouragement: resp.encouragement,
- status: resp.status,
- model: resp.model,
- tokensUsed: resp.tokens_used,
- latencyMs: resp.latency_ms,
- requestId,
- source,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [rescue] ✖ Failed after ${elapsed}ms: ${error.message}`);
- if (error.cause) logger.error(`[AI] [rescue] Error cause: ${error.cause}`);
- throw error;
- }
- },
- );
-}
-
-// ================================================================
-// 功能定义导出
-// ================================================================
-
-export const feature: AIFeatureDef = {
- id: 'ai_rescue',
- name: 'AI 学习救援',
- version: '1.0.0',
- register,
-};
+/**
+ * AI 学习救援功能 Handler
+ *
+ * 处理 ai_rescue IPC 请求,调用 AI 网关为卡住的学习者提供分层救援提示。
+ *
+ * @ai-context: 卡壳救援 IPC handler——AIFeatureDef 注册表模式,经 callWithLocalFallback 支持本地 Ollama 优先/云端网关降级;请求响应契约与网关 Pydantic model 对齐。
+ */
+
+import { safeHandle } from '../../ipcUtils.js';
+import { logger } from '../../logger.js';
+import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
+import { generateText } from '../ollama/OllamaProvider.js';
+
+// ================================================================
+// IPC Handler
+// ================================================================
+
+/**
+ * ai_rescue — POST /api/v1/ai/rescue
+ */
+function register(): void {
+ safeHandle(
+ 'ai_rescue',
+ async (
+ _event,
+ args: {
+ content: string;
+ stuckDescription: string;
+ attemptedMethods?: string;
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ const startMs = Date.now();
+ logger.info(`[AI] [rescue] IPC received: content_length=${args.content.length}, stuck_length=${args.stuckDescription.length}, methods=${args.attemptedMethods ?? 'none'}, hasAuth=${!!args.authToken}`);
+ logger.debug(`[AI] [rescue] Stuck description: ${args.stuckDescription.slice(0, 80)}`);
+
+ // 前端 camelCase → 后端 snake_case
+ const reqBody = {
+ content: args.content,
+ stuck_description: args.stuckDescription,
+ attempted_methods: args.attemptedMethods ?? '',
+ };
+
+ logger.info(`[AI] [rescue] Target: ${gatewayUrl()}/api/v1/ai/rescue`);
+
+ interface RescueLevelResp {
+ level: number;
+ label: string;
+ suggestion: string;
+ hint_question: string;
+ }
+ interface RescueGenResp {
+ rescue_levels: RescueLevelResp[];
+ encouragement: string;
+ status: string;
+ model: string;
+ tokens_used: number;
+ latency_ms: number;
+ }
+
+ try {
+ const localHandler = async (): Promise => {
+ const prompt = `学习者卡住了,请提供分层救援提示,返回JSON: {"rescue_levels": [{"level": 1, "label": "...", "suggestion": "...", "hint_question": "..."}], "encouragement": "...", "status": "ok"}\n\n卡住情境:${JSON.stringify(reqBody)}`;
+ const result = await generateText(prompt, '你是一个学习救援助手,擅长为卡住的学习者提供分层提示。请仅返回JSON。', { temperature: 0.6, maxTokens: 1024 });
+ const parsed = JSON.parse(result.content);
+ return { rescue_levels: parsed.rescue_levels ?? [], encouragement: parsed.encouragement ?? '加油!', status: 'ok', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
+ };
+
+ const { data: resp, source, requestId } = await callWithLocalFallback(
+ '/api/v1/ai/rescue',
+ reqBody,
+ localHandler,
+ args.authToken,
+ args.userApiKey,
+ 60000,
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [rescue] ✔ Success (${source}): levels=${resp.rescue_levels.length}, status=${resp.status}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ rescueLevels: resp.rescue_levels.map((lv: { level: number; label: string; suggestion: string; hint_question: string }) => ({
+ level: lv.level,
+ label: lv.label,
+ suggestion: lv.suggestion,
+ hintQuestion: lv.hint_question,
+ })),
+ encouragement: resp.encouragement,
+ status: resp.status,
+ model: resp.model,
+ tokensUsed: resp.tokens_used,
+ latencyMs: resp.latency_ms,
+ requestId,
+ source,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [rescue] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ if (error.cause) logger.error(`[AI] [rescue] Error cause: ${error.cause}`);
+ throw error;
+ }
+ },
+ );
+}
+
+// ================================================================
+// 功能定义导出
+// ================================================================
+
+export const feature: AIFeatureDef = {
+ id: 'ai_rescue',
+ name: 'AI 学习救援',
+ version: '1.0.0',
+ register,
+};
diff --git a/client/electron/ai/handlers/sessionAnalyzeHandler.ts b/client/electron/ai/handlers/sessionAnalyzeHandler.ts
index 1f505712..21265d68 100644
--- a/client/electron/ai/handlers/sessionAnalyzeHandler.ts
+++ b/client/electron/ai/handlers/sessionAnalyzeHandler.ts
@@ -1,155 +1,157 @@
-/**
- * AI 课堂多模态分析 Handler
- *
- * 处理 ai_session_analyze IPC 请求,调用 AI 网关对课堂关键帧与音频段进行多模态分析。
- * 支持本地 Ollama 降级:优先调用本地多模态模型,失败后降级到远程网关。
- * 对应端点:POST /api/v1/multimodal/analyze-session
- */
-
-import { safeHandle } from '../../ipcUtils.js';
-import { logger } from '../../logger.js';
-import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
-import { generateVisionMulti } from '../ollama/OllamaProvider.js';
-
-// ================================================================
-// IPC Handler
-// ================================================================
-
-/**
- * ai_session_analyze — POST /api/v1/multimodal/analyze-session
- */
-function register(): void {
- safeHandle(
- 'ai_session_analyze',
- async (
- _event,
- args: {
- keyframes: Array<{
- timestamp: number; // 已转为秒
- imageBase64: string;
- changeType: string;
- }>;
- audioSegments: Array<{
- timestampStart: number; // 已转为秒
- timestampEnd: number;
- audioText: string | null;
- }>;
- duration: number; // 已转为秒
- language?: string;
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- const startMs = Date.now();
- const kfCount = args.keyframes?.length ?? 0;
- const segCount = args.audioSegments?.length ?? 0;
- logger.info(`[AI] [session-analyze] IPC received: keyframes=${kfCount}, audioSegments=${segCount}, duration=${args.duration}s, language=${args.language ?? 'zh'}, hasAuth=${!!args.authToken}`);
-
- const reqBody = {
- keyframes: args.keyframes.map(kf => ({
- timestamp: kf.timestamp,
- image_base64: kf.imageBase64,
- change_type: kf.changeType,
- })),
- audio_segments: args.audioSegments.map(seg => ({
- timestamp_start: seg.timestampStart,
- timestamp_end: seg.timestampEnd,
- audio_text: seg.audioText,
- })),
- duration: args.duration,
- language: args.language ?? 'zh',
- };
-
- logger.info(`[AI] [session-analyze] Target: ${gatewayUrl()}/api/v1/multimodal/analyze-session`);
-
- interface AnalyzeSessionResp {
- content: string;
- keyframes_analyzed: number;
- model_used: string;
- }
-
- try {
- // 本地 Ollama 降级链
- const localHandler = async (): Promise => {
- // 从 keyframes 中提取 imageBase64 数组
- const imagesBase64 = args.keyframes.map(kf => kf.imageBase64);
-
- // 构造包含时间轴信息和音频段文本的 prompt
- const timelineParts: string[] = [];
- for (const kf of args.keyframes) {
- timelineParts.push(`[${kf.timestamp}s] 画面变化类型:${kf.changeType}`);
- }
-
- const audioParts: string[] = [];
- for (const seg of args.audioSegments) {
- if (seg.audioText) {
- audioParts.push(`[${seg.timestampStart}s - ${seg.timestampEnd}s] 音频内容:${seg.audioText}`);
- }
- }
-
- const prompt = [
- `请对以下课堂内容进行多模态分析。课程总时长:${args.duration}秒。`,
- '',
- '=== 关键帧时间轴 ===',
- ...timelineParts,
- '',
- ...(audioParts.length > 0
- ? ['=== 音频段内容 ===', ...audioParts, '']
- : []),
- '请综合分析课堂内容,包括知识点提取、教学结构分析、重点难点标注等。',
- `语言:${args.language ?? 'zh'}`,
- ].join('\n');
-
- const result = await generateVisionMulti(
- imagesBase64,
- prompt,
- '你是一个专业的课堂内容分析助手,擅长从视觉和音频信息中提取教学结构化知识。',
- { temperature: 0.3, maxTokens: 4096 },
- );
-
- return {
- content: result.content,
- keyframes_analyzed: imagesBase64.length,
- model_used: result.model,
- };
- };
-
- const { data: resp, source, requestId } = await callWithLocalFallback(
- '/api/v1/multimodal/analyze-session',
- reqBody,
- localHandler,
- args.authToken,
- args.userApiKey,
- 120000,
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [session-analyze] ✔ Success (${source}): content_length=${resp.content?.length ?? 0}, keyframes_analyzed=${resp.keyframes_analyzed}, model=${resp.model_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- content: resp.content,
- keyframesAnalyzed: resp.keyframes_analyzed,
- modelUsed: resp.model_used,
- source,
- requestId,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [session-analyze] ✖ Failed after ${elapsed}ms: ${error.message}`);
- if (error.cause) logger.error(`[AI] [session-analyze] Error cause: ${error.cause}`);
- throw error;
- }
- },
- );
-}
-
-// ================================================================
-// 功能定义导出
-// ================================================================
-
-export const feature: AIFeatureDef = {
- id: 'ai_session_analyze',
- name: 'AI 课堂多模态分析',
- version: '1.0.0',
- register,
-};
+/**
+ * AI 课堂多模态分析 Handler
+ *
+ * 处理 ai_session_analyze IPC 请求,调用 AI 网关对课堂关键帧与音频段进行多模态分析。
+ * 支持本地 Ollama 降级:优先调用本地多模态模型,失败后降级到远程网关。
+ * 对应端点:POST /api/v1/multimodal/analyze-session
+ *
+ * @ai-context: 课堂会话分析 IPC handler——AIFeatureDef 注册表模式,经 callWithLocalFallback 支持本地 Ollama 优先/云端网关降级;请求响应契约与网关 Pydantic model 对齐。
+ */
+
+import { safeHandle } from '../../ipcUtils.js';
+import { logger } from '../../logger.js';
+import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
+import { generateVisionMulti } from '../ollama/OllamaProvider.js';
+
+// ================================================================
+// IPC Handler
+// ================================================================
+
+/**
+ * ai_session_analyze — POST /api/v1/multimodal/analyze-session
+ */
+function register(): void {
+ safeHandle(
+ 'ai_session_analyze',
+ async (
+ _event,
+ args: {
+ keyframes: Array<{
+ timestamp: number; // 已转为秒
+ imageBase64: string;
+ changeType: string;
+ }>;
+ audioSegments: Array<{
+ timestampStart: number; // 已转为秒
+ timestampEnd: number;
+ audioText: string | null;
+ }>;
+ duration: number; // 已转为秒
+ language?: string;
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ const startMs = Date.now();
+ const kfCount = args.keyframes?.length ?? 0;
+ const segCount = args.audioSegments?.length ?? 0;
+ logger.info(`[AI] [session-analyze] IPC received: keyframes=${kfCount}, audioSegments=${segCount}, duration=${args.duration}s, language=${args.language ?? 'zh'}, hasAuth=${!!args.authToken}`);
+
+ const reqBody = {
+ keyframes: args.keyframes.map(kf => ({
+ timestamp: kf.timestamp,
+ image_base64: kf.imageBase64,
+ change_type: kf.changeType,
+ })),
+ audio_segments: args.audioSegments.map(seg => ({
+ timestamp_start: seg.timestampStart,
+ timestamp_end: seg.timestampEnd,
+ audio_text: seg.audioText,
+ })),
+ duration: args.duration,
+ language: args.language ?? 'zh',
+ };
+
+ logger.info(`[AI] [session-analyze] Target: ${gatewayUrl()}/api/v1/multimodal/analyze-session`);
+
+ interface AnalyzeSessionResp {
+ content: string;
+ keyframes_analyzed: number;
+ model_used: string;
+ }
+
+ try {
+ // 本地 Ollama 降级链
+ const localHandler = async (): Promise => {
+ // 从 keyframes 中提取 imageBase64 数组
+ const imagesBase64 = args.keyframes.map(kf => kf.imageBase64);
+
+ // 构造包含时间轴信息和音频段文本的 prompt
+ const timelineParts: string[] = [];
+ for (const kf of args.keyframes) {
+ timelineParts.push(`[${kf.timestamp}s] 画面变化类型:${kf.changeType}`);
+ }
+
+ const audioParts: string[] = [];
+ for (const seg of args.audioSegments) {
+ if (seg.audioText) {
+ audioParts.push(`[${seg.timestampStart}s - ${seg.timestampEnd}s] 音频内容:${seg.audioText}`);
+ }
+ }
+
+ const prompt = [
+ `请对以下课堂内容进行多模态分析。课程总时长:${args.duration}秒。`,
+ '',
+ '=== 关键帧时间轴 ===',
+ ...timelineParts,
+ '',
+ ...(audioParts.length > 0
+ ? ['=== 音频段内容 ===', ...audioParts, '']
+ : []),
+ '请综合分析课堂内容,包括知识点提取、教学结构分析、重点难点标注等。',
+ `语言:${args.language ?? 'zh'}`,
+ ].join('\n');
+
+ const result = await generateVisionMulti(
+ imagesBase64,
+ prompt,
+ '你是一个专业的课堂内容分析助手,擅长从视觉和音频信息中提取教学结构化知识。',
+ { temperature: 0.3, maxTokens: 4096 },
+ );
+
+ return {
+ content: result.content,
+ keyframes_analyzed: imagesBase64.length,
+ model_used: result.model,
+ };
+ };
+
+ const { data: resp, source, requestId } = await callWithLocalFallback(
+ '/api/v1/multimodal/analyze-session',
+ reqBody,
+ localHandler,
+ args.authToken,
+ args.userApiKey,
+ 120000,
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [session-analyze] ✔ Success (${source}): content_length=${resp.content?.length ?? 0}, keyframes_analyzed=${resp.keyframes_analyzed}, model=${resp.model_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ content: resp.content,
+ keyframesAnalyzed: resp.keyframes_analyzed,
+ modelUsed: resp.model_used,
+ source,
+ requestId,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [session-analyze] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ if (error.cause) logger.error(`[AI] [session-analyze] Error cause: ${error.cause}`);
+ throw error;
+ }
+ },
+ );
+}
+
+// ================================================================
+// 功能定义导出
+// ================================================================
+
+export const feature: AIFeatureDef = {
+ id: 'ai_session_analyze',
+ name: 'AI 课堂多模态分析',
+ version: '1.0.0',
+ register,
+};
diff --git a/client/electron/ai/handlers/socraticHandler.ts b/client/electron/ai/handlers/socraticHandler.ts
index b946af46..00a6b311 100644
--- a/client/electron/ai/handlers/socraticHandler.ts
+++ b/client/electron/ai/handlers/socraticHandler.ts
@@ -1,281 +1,283 @@
-/**
- * 苏格拉底追问功能 Handler
- *
- * 处理 ai_socratic / ai_socratic_evaluate / ai_socratic_deepening IPC 请求,
- * 调用 AI 网关实现苏格拉底式追问、评估与深化。
- */
-
-import { safeHandle } from '../../ipcUtils.js';
-import { logger } from '../../logger.js';
-import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
-import { generateText } from '../ollama/OllamaProvider.js';
-
-// ================================================================
-// 公共类型
-// ================================================================
-
-interface BackendHistoryItem {
- role: 'tutor' | 'learner';
- content: string;
-}
-
-// ================================================================
-// IPC Handler
-// ================================================================
-
-/**
- * 注册苏格拉底相关的全部 IPC handler
- */
-function register(): void {
- /**
- * ai_socratic — POST /api/v1/ai/socratic
- */
- safeHandle(
- 'ai_socratic',
- async (
- _event,
- args: {
- topic: string;
- history?: BackendHistoryItem[] | null;
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- const startMs = Date.now();
- logger.info(`[AI] [socratic] IPC received: topic_length=${args.topic.length}, history=${args.history?.length ?? 0}, hasAuth=${!!args.authToken}`);
- logger.debug(`[AI] [socratic] Topic preview: ${args.topic.slice(0, 80)}`);
-
- const reqBody = {
- topic: args.topic,
- history: args.history ?? null,
- };
-
- logger.info(`[AI] [socratic] Target: ${gatewayUrl()}/api/v1/ai/socratic`);
-
- interface SocraticResp {
- question: string;
- hint: string;
- thinking_direction: string;
- depth_level: number;
- turn_count: number;
- status: string;
- model: string;
- tokens_used: number;
- latency_ms: number;
- }
-
- try {
- const localHandler = async (): Promise => {
- const prompt = `作为苏格拉底式导师,针对主题“${args.topic}”生成一个引导性问题,返回JSON: {"question": "...", "hint": "...", "thinking_direction": "...", "depth_level": 1, "turn_count": 1, "status": "ok"}`;
- const result = await generateText(prompt, '你是一个苏格拉底式学习导师。请仅返回JSON。', { temperature: 0.7, maxTokens: 512 });
- const parsed = JSON.parse(result.content);
- return { question: parsed.question ?? '', hint: parsed.hint ?? '', thinking_direction: parsed.thinking_direction ?? '', depth_level: parsed.depth_level ?? 1, turn_count: parsed.turn_count ?? 1, status: 'ok', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
- };
-
- const { data: resp, source, requestId } = await callWithLocalFallback(
- '/api/v1/ai/socratic',
- reqBody,
- localHandler,
- args.authToken,
- args.userApiKey,
- 60000,
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [socratic] ✔ Success (${source}): depth=${resp.depth_level}, turn=${resp.turn_count}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- question: resp.question,
- hint: resp.hint,
- thinkingDirection: resp.thinking_direction,
- depthLevel: resp.depth_level,
- turnCount: resp.turn_count,
- status: resp.status,
- model: resp.model,
- tokensUsed: resp.tokens_used,
- latencyMs: resp.latency_ms,
- requestId,
- source,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [socratic] ✖ Failed after ${elapsed}ms: ${error.message}`);
- if (error.cause) logger.error(`[AI] [socratic] Error cause: ${error.cause}`);
- throw error;
- }
- },
- );
-
- /**
- * ai_socratic_evaluate — POST /api/v1/ai/socratic/evaluate
- */
- safeHandle(
- 'ai_socratic_evaluate',
- async (
- _event,
- args: {
- topic: string;
- question: string;
- answer: string;
- history?: BackendHistoryItem[];
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- const startMs = Date.now();
- logger.info(`[AI] [socratic-eval] IPC received: topic_length=${args.topic.length}, question_length=${args.question.length}, answer_length=${args.answer.length}, history=${args.history?.length ?? 0}, hasAuth=${!!args.authToken}`);
- logger.debug(`[AI] [socratic-eval] Topic: ${args.topic.slice(0, 60)}, Q: ${args.question.slice(0, 60)}`);
-
- const reqBody = {
- topic: args.topic,
- question: args.question,
- answer: args.answer,
- history: args.history ?? [],
- };
-
- logger.info(`[AI] [socratic-eval] Target: ${gatewayUrl()}/api/v1/ai/socratic/evaluate`);
-
- interface SocraticEvaluateResp {
- dimensions: { accuracy: number; completeness: number; logic: number; expression: number };
- feedback: string;
- encouragement: string;
- status: string;
- model: string;
- tokens_used: number;
- latency_ms: number;
- }
-
- try {
- const localHandler = async (): Promise => {
- const prompt = `评估学生对问题的回答,返回JSON: {"dimensions": {"accuracy": 70, "completeness": 65, "logic": 75, "expression": 80}, "feedback": "...", "encouragement": "...", "status": "ok"}`;
- const result = await generateText(prompt, '你是一个苏格拉底式评估助手。请仅返回JSON。', { temperature: 0.4, maxTokens: 512 });
- const parsed = JSON.parse(result.content);
- return { dimensions: parsed.dimensions ?? { accuracy: 60, completeness: 60, logic: 60, expression: 60 }, feedback: parsed.feedback ?? '', encouragement: parsed.encouragement ?? '继续加油!', status: 'ok', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
- };
-
- const { data: resp, source, requestId } = await callWithLocalFallback(
- '/api/v1/ai/socratic/evaluate',
- reqBody,
- localHandler,
- args.authToken,
- args.userApiKey,
- 60000,
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [socratic-eval] ✔ Success (${source}): accuracy=${resp.dimensions.accuracy}, completeness=${resp.dimensions.completeness}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- dimensions: resp.dimensions,
- feedback: resp.feedback,
- encouragement: resp.encouragement,
- status: resp.status,
- model: resp.model,
- tokensUsed: resp.tokens_used,
- latencyMs: resp.latency_ms,
- requestId,
- source,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [socratic-eval] ✖ Failed after ${elapsed}ms: ${error.message}`);
- if (error.cause) logger.error(`[AI] [socratic-eval] Error cause: ${error.cause}`);
- throw error;
- }
- },
- );
-
- /**
- * ai_socratic_deepening — POST /api/v1/ai/socratic/deepening
- */
- safeHandle(
- 'ai_socratic_deepening',
- async (
- _event,
- args: {
- topic: string;
- dialogueSummary: string;
- history?: BackendHistoryItem[];
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- const startMs = Date.now();
- logger.info(`[AI] [socratic-deep] IPC received: topic_length=${args.topic.length}, summary_length=${args.dialogueSummary.length}, history=${args.history?.length ?? 0}, hasAuth=${!!args.authToken}`);
- logger.debug(`[AI] [socratic-deep] Topic: ${args.topic.slice(0, 60)}, Summary preview: ${args.dialogueSummary.slice(0, 80)}`);
-
- // 前端 camelCase dialogueSummary → 后端 snake_case dialogue_summary
- const reqBody = {
- topic: args.topic,
- dialogue_summary: args.dialogueSummary,
- history: args.history ?? [],
- };
-
- logger.info(`[AI] [socratic-deep] Target: ${gatewayUrl()}/api/v1/ai/socratic/deepening`);
-
- interface AngleResp {
- key: string;
- label: string;
- question: string;
- }
- interface SocraticDeepeningResp {
- angles: AngleResp[];
- status: string;
- model: string;
- tokens_used: number;
- latency_ms: number;
- }
-
- try {
- const localHandler = async (): Promise => {
- const prompt = `针对主题“${args.topic}”的对话摘要,生成深化探索角度,返回JSON: {"angles": [{"key": "...", "label": "...", "question": "..."}], "status": "ok"}`;
- const result = await generateText(prompt, '你是一个苏格拉底式深化探索助手。请仅返回JSON。', { temperature: 0.7, maxTokens: 1024 });
- const parsed = JSON.parse(result.content);
- return { angles: parsed.angles ?? [], status: 'ok', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
- };
-
- const { data: resp, source, requestId } = await callWithLocalFallback(
- '/api/v1/ai/socratic/deepening',
- reqBody,
- localHandler,
- args.authToken,
- args.userApiKey,
- 60000,
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [socratic-deep] ✔ Success (${source}): angles=${resp.angles.length}, status=${resp.status}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- angles: resp.angles.map((a: { key: string; label: string; question: string }) => ({
- key: a.key,
- label: a.label,
- question: a.question,
- })),
- status: resp.status,
- model: resp.model,
- tokensUsed: resp.tokens_used,
- latencyMs: resp.latency_ms,
- requestId,
- source,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [socratic-deep] ✖ Failed after ${elapsed}ms: ${error.message}`);
- if (error.cause) logger.error(`[AI] [socratic-deep] Error cause: ${error.cause}`);
- throw error;
- }
- },
- );
-}
-
-// ================================================================
-// 功能定义导出
-// ================================================================
-
-export const feature: AIFeatureDef = {
- id: 'ai_socratic',
- name: 'AI 苏格拉底追问',
- version: '1.0.0',
- register,
-};
+/**
+ * 苏格拉底追问功能 Handler
+ *
+ * 处理 ai_socratic / ai_socratic_evaluate / ai_socratic_deepening IPC 请求,
+ * 调用 AI 网关实现苏格拉底式追问、评估与深化。
+ *
+ * @ai-context: 苏格拉底追问(含评估/深化) IPC handler——AIFeatureDef 注册表模式,经 callWithLocalFallback 支持本地 Ollama 优先/云端网关降级;请求响应契约与网关 Pydantic model 对齐。
+ */
+
+import { safeHandle } from '../../ipcUtils.js';
+import { logger } from '../../logger.js';
+import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
+import { generateText } from '../ollama/OllamaProvider.js';
+
+// ================================================================
+// 公共类型
+// ================================================================
+
+interface BackendHistoryItem {
+ role: 'tutor' | 'learner';
+ content: string;
+}
+
+// ================================================================
+// IPC Handler
+// ================================================================
+
+/**
+ * 注册苏格拉底相关的全部 IPC handler
+ */
+function register(): void {
+ /**
+ * ai_socratic — POST /api/v1/ai/socratic
+ */
+ safeHandle(
+ 'ai_socratic',
+ async (
+ _event,
+ args: {
+ topic: string;
+ history?: BackendHistoryItem[] | null;
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ const startMs = Date.now();
+ logger.info(`[AI] [socratic] IPC received: topic_length=${args.topic.length}, history=${args.history?.length ?? 0}, hasAuth=${!!args.authToken}`);
+ logger.debug(`[AI] [socratic] Topic preview: ${args.topic.slice(0, 80)}`);
+
+ const reqBody = {
+ topic: args.topic,
+ history: args.history ?? null,
+ };
+
+ logger.info(`[AI] [socratic] Target: ${gatewayUrl()}/api/v1/ai/socratic`);
+
+ interface SocraticResp {
+ question: string;
+ hint: string;
+ thinking_direction: string;
+ depth_level: number;
+ turn_count: number;
+ status: string;
+ model: string;
+ tokens_used: number;
+ latency_ms: number;
+ }
+
+ try {
+ const localHandler = async (): Promise => {
+ const prompt = `作为苏格拉底式导师,针对主题“${args.topic}”生成一个引导性问题,返回JSON: {"question": "...", "hint": "...", "thinking_direction": "...", "depth_level": 1, "turn_count": 1, "status": "ok"}`;
+ const result = await generateText(prompt, '你是一个苏格拉底式学习导师。请仅返回JSON。', { temperature: 0.7, maxTokens: 512 });
+ const parsed = JSON.parse(result.content);
+ return { question: parsed.question ?? '', hint: parsed.hint ?? '', thinking_direction: parsed.thinking_direction ?? '', depth_level: parsed.depth_level ?? 1, turn_count: parsed.turn_count ?? 1, status: 'ok', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
+ };
+
+ const { data: resp, source, requestId } = await callWithLocalFallback(
+ '/api/v1/ai/socratic',
+ reqBody,
+ localHandler,
+ args.authToken,
+ args.userApiKey,
+ 60000,
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [socratic] ✔ Success (${source}): depth=${resp.depth_level}, turn=${resp.turn_count}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ question: resp.question,
+ hint: resp.hint,
+ thinkingDirection: resp.thinking_direction,
+ depthLevel: resp.depth_level,
+ turnCount: resp.turn_count,
+ status: resp.status,
+ model: resp.model,
+ tokensUsed: resp.tokens_used,
+ latencyMs: resp.latency_ms,
+ requestId,
+ source,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [socratic] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ if (error.cause) logger.error(`[AI] [socratic] Error cause: ${error.cause}`);
+ throw error;
+ }
+ },
+ );
+
+ /**
+ * ai_socratic_evaluate — POST /api/v1/ai/socratic/evaluate
+ */
+ safeHandle(
+ 'ai_socratic_evaluate',
+ async (
+ _event,
+ args: {
+ topic: string;
+ question: string;
+ answer: string;
+ history?: BackendHistoryItem[];
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ const startMs = Date.now();
+ logger.info(`[AI] [socratic-eval] IPC received: topic_length=${args.topic.length}, question_length=${args.question.length}, answer_length=${args.answer.length}, history=${args.history?.length ?? 0}, hasAuth=${!!args.authToken}`);
+ logger.debug(`[AI] [socratic-eval] Topic: ${args.topic.slice(0, 60)}, Q: ${args.question.slice(0, 60)}`);
+
+ const reqBody = {
+ topic: args.topic,
+ question: args.question,
+ answer: args.answer,
+ history: args.history ?? [],
+ };
+
+ logger.info(`[AI] [socratic-eval] Target: ${gatewayUrl()}/api/v1/ai/socratic/evaluate`);
+
+ interface SocraticEvaluateResp {
+ dimensions: { accuracy: number; completeness: number; logic: number; expression: number };
+ feedback: string;
+ encouragement: string;
+ status: string;
+ model: string;
+ tokens_used: number;
+ latency_ms: number;
+ }
+
+ try {
+ const localHandler = async (): Promise => {
+ const prompt = `评估学生对问题的回答,返回JSON: {"dimensions": {"accuracy": 70, "completeness": 65, "logic": 75, "expression": 80}, "feedback": "...", "encouragement": "...", "status": "ok"}`;
+ const result = await generateText(prompt, '你是一个苏格拉底式评估助手。请仅返回JSON。', { temperature: 0.4, maxTokens: 512 });
+ const parsed = JSON.parse(result.content);
+ return { dimensions: parsed.dimensions ?? { accuracy: 60, completeness: 60, logic: 60, expression: 60 }, feedback: parsed.feedback ?? '', encouragement: parsed.encouragement ?? '继续加油!', status: 'ok', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
+ };
+
+ const { data: resp, source, requestId } = await callWithLocalFallback(
+ '/api/v1/ai/socratic/evaluate',
+ reqBody,
+ localHandler,
+ args.authToken,
+ args.userApiKey,
+ 60000,
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [socratic-eval] ✔ Success (${source}): accuracy=${resp.dimensions.accuracy}, completeness=${resp.dimensions.completeness}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ dimensions: resp.dimensions,
+ feedback: resp.feedback,
+ encouragement: resp.encouragement,
+ status: resp.status,
+ model: resp.model,
+ tokensUsed: resp.tokens_used,
+ latencyMs: resp.latency_ms,
+ requestId,
+ source,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [socratic-eval] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ if (error.cause) logger.error(`[AI] [socratic-eval] Error cause: ${error.cause}`);
+ throw error;
+ }
+ },
+ );
+
+ /**
+ * ai_socratic_deepening — POST /api/v1/ai/socratic/deepening
+ */
+ safeHandle(
+ 'ai_socratic_deepening',
+ async (
+ _event,
+ args: {
+ topic: string;
+ dialogueSummary: string;
+ history?: BackendHistoryItem[];
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ const startMs = Date.now();
+ logger.info(`[AI] [socratic-deep] IPC received: topic_length=${args.topic.length}, summary_length=${args.dialogueSummary.length}, history=${args.history?.length ?? 0}, hasAuth=${!!args.authToken}`);
+ logger.debug(`[AI] [socratic-deep] Topic: ${args.topic.slice(0, 60)}, Summary preview: ${args.dialogueSummary.slice(0, 80)}`);
+
+ // 前端 camelCase dialogueSummary → 后端 snake_case dialogue_summary
+ const reqBody = {
+ topic: args.topic,
+ dialogue_summary: args.dialogueSummary,
+ history: args.history ?? [],
+ };
+
+ logger.info(`[AI] [socratic-deep] Target: ${gatewayUrl()}/api/v1/ai/socratic/deepening`);
+
+ interface AngleResp {
+ key: string;
+ label: string;
+ question: string;
+ }
+ interface SocraticDeepeningResp {
+ angles: AngleResp[];
+ status: string;
+ model: string;
+ tokens_used: number;
+ latency_ms: number;
+ }
+
+ try {
+ const localHandler = async (): Promise => {
+ const prompt = `针对主题“${args.topic}”的对话摘要,生成深化探索角度,返回JSON: {"angles": [{"key": "...", "label": "...", "question": "..."}], "status": "ok"}`;
+ const result = await generateText(prompt, '你是一个苏格拉底式深化探索助手。请仅返回JSON。', { temperature: 0.7, maxTokens: 1024 });
+ const parsed = JSON.parse(result.content);
+ return { angles: parsed.angles ?? [], status: 'ok', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
+ };
+
+ const { data: resp, source, requestId } = await callWithLocalFallback(
+ '/api/v1/ai/socratic/deepening',
+ reqBody,
+ localHandler,
+ args.authToken,
+ args.userApiKey,
+ 60000,
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [socratic-deep] ✔ Success (${source}): angles=${resp.angles.length}, status=${resp.status}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ angles: resp.angles.map((a: { key: string; label: string; question: string }) => ({
+ key: a.key,
+ label: a.label,
+ question: a.question,
+ })),
+ status: resp.status,
+ model: resp.model,
+ tokensUsed: resp.tokens_used,
+ latencyMs: resp.latency_ms,
+ requestId,
+ source,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [socratic-deep] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ if (error.cause) logger.error(`[AI] [socratic-deep] Error cause: ${error.cause}`);
+ throw error;
+ }
+ },
+ );
+}
+
+// ================================================================
+// 功能定义导出
+// ================================================================
+
+export const feature: AIFeatureDef = {
+ id: 'ai_socratic',
+ name: 'AI 苏格拉底追问',
+ version: '1.0.0',
+ register,
+};
diff --git a/client/electron/ai/handlers/summarizeHandler.ts b/client/electron/ai/handlers/summarizeHandler.ts
index b3e3a3ef..81244efa 100644
--- a/client/electron/ai/handlers/summarizeHandler.ts
+++ b/client/electron/ai/handlers/summarizeHandler.ts
@@ -1,113 +1,115 @@
-/**
- * AI 摘要功能 Handler
- *
- * 处理 ai_summarize IPC 请求,调用 AI 网关生成文本摘要。
- * 支持本地 Ollama 降级:优先调用本地模型,失败后降级到远程网关。
- */
-
-import { safeHandle } from '../../ipcUtils.js';
-import { logger } from '../../logger.js';
-import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
-import { generateText } from '../ollama/OllamaProvider.js';
-
-// ================================================================
-// IPC Handler
-// ================================================================
-
-/**
- * ai_summarize — POST /api/v1/ai/summarize
- * 接收前端 camelCase 参数,转为后端 snake_case 请求体,
- * 再将后端 snake_case 响应转回 camelCase。
- */
-function register(): void {
- safeHandle(
- 'ai_summarize',
- async (
- _event,
- args: {
- text: string;
- maxLength?: number;
- style?: string;
- language?: string;
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- const startMs = Date.now();
- logger.info(`[AI] [summarize] IPC received: text_length=${args.text.length}, style=${args.style ?? 'default'}, language=${args.language ?? 'auto'}, maxLength=${args.maxLength ?? 'none'}, hasAuth=${!!args.authToken}`);
- logger.debug(`[AI] [summarize] Text preview: ${args.text.slice(0, 80)}...`);
-
- const reqBody = {
- text: args.text,
- options: {
- ...(args.maxLength != null && { max_length: args.maxLength }),
- ...(args.style != null && { style: args.style }),
- ...(args.language != null && { language: args.language }),
- },
- };
-
- logger.info(`[AI] [summarize] Target: ${gatewayUrl()}/api/v1/ai/summarize`);
-
- interface SummarizeResp {
- summary: string;
- model: string;
- tokens_used: number;
- latency_ms: number;
- }
-
- try {
- // 本地 Ollama 降级链
- const localHandler = async (): Promise => {
- const styleHint = args.style ? `,风格:${args.style}` : '';
- const langHint = args.language ? `,语言:${args.language}` : '';
- const lenHint = args.maxLength ? `,字数控制在${args.maxLength}字以内` : '';
- const prompt = `请对以下内容进行摘要${styleHint}${langHint}${lenHint}:\n\n${args.text}`;
- const result = await generateText(prompt, '你是一个专业的学习笔记摘要助手,擅长提炼核心要点。', { temperature: 0.5, maxTokens: 2048 });
- return {
- summary: result.content,
- model: result.model,
- tokens_used: result.tokens_used,
- latency_ms: result.latency_ms,
- };
- };
-
- const { data: resp, source, requestId } = await callWithLocalFallback(
- '/api/v1/ai/summarize',
- reqBody,
- localHandler,
- args.authToken,
- args.userApiKey,
- 90000,
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [summarize] ✔ Success (${source}): model=${resp.model}, tokens=${resp.tokens_used}, backend_latency=${resp.latency_ms}ms, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- summary: resp.summary,
- model: resp.model,
- tokensUsed: resp.tokens_used,
- latencyMs: resp.latency_ms,
- requestId,
- source,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [summarize] ✖ Failed after ${elapsed}ms: ${error.message}`);
- if (error.cause) logger.error(`[AI] [summarize] Error cause: ${error.cause}`);
- throw error;
- }
- },
- );
-}
-
-// ================================================================
-// 功能定义导出
-// ================================================================
-
-export const feature: AIFeatureDef = {
- id: 'ai_summarize',
- name: 'AI 文本摘要',
- version: '1.0.0',
- register,
-};
+/**
+ * AI 摘要功能 Handler
+ *
+ * 处理 ai_summarize IPC 请求,调用 AI 网关生成文本摘要。
+ * 支持本地 Ollama 降级:优先调用本地模型,失败后降级到远程网关。
+ *
+ * @ai-context: 笔记摘要 IPC handler——AIFeatureDef 注册表模式,经 callWithLocalFallback 支持本地 Ollama 优先/云端网关降级;请求响应契约与网关 Pydantic model 对齐。
+ */
+
+import { safeHandle } from '../../ipcUtils.js';
+import { logger } from '../../logger.js';
+import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
+import { generateText } from '../ollama/OllamaProvider.js';
+
+// ================================================================
+// IPC Handler
+// ================================================================
+
+/**
+ * ai_summarize — POST /api/v1/ai/summarize
+ * 接收前端 camelCase 参数,转为后端 snake_case 请求体,
+ * 再将后端 snake_case 响应转回 camelCase。
+ */
+function register(): void {
+ safeHandle(
+ 'ai_summarize',
+ async (
+ _event,
+ args: {
+ text: string;
+ maxLength?: number;
+ style?: string;
+ language?: string;
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ const startMs = Date.now();
+ logger.info(`[AI] [summarize] IPC received: text_length=${args.text.length}, style=${args.style ?? 'default'}, language=${args.language ?? 'auto'}, maxLength=${args.maxLength ?? 'none'}, hasAuth=${!!args.authToken}`);
+ logger.debug(`[AI] [summarize] Text preview: ${args.text.slice(0, 80)}...`);
+
+ const reqBody = {
+ text: args.text,
+ options: {
+ ...(args.maxLength != null && { max_length: args.maxLength }),
+ ...(args.style != null && { style: args.style }),
+ ...(args.language != null && { language: args.language }),
+ },
+ };
+
+ logger.info(`[AI] [summarize] Target: ${gatewayUrl()}/api/v1/ai/summarize`);
+
+ interface SummarizeResp {
+ summary: string;
+ model: string;
+ tokens_used: number;
+ latency_ms: number;
+ }
+
+ try {
+ // 本地 Ollama 降级链
+ const localHandler = async (): Promise => {
+ const styleHint = args.style ? `,风格:${args.style}` : '';
+ const langHint = args.language ? `,语言:${args.language}` : '';
+ const lenHint = args.maxLength ? `,字数控制在${args.maxLength}字以内` : '';
+ const prompt = `请对以下内容进行摘要${styleHint}${langHint}${lenHint}:\n\n${args.text}`;
+ const result = await generateText(prompt, '你是一个专业的学习笔记摘要助手,擅长提炼核心要点。', { temperature: 0.5, maxTokens: 2048 });
+ return {
+ summary: result.content,
+ model: result.model,
+ tokens_used: result.tokens_used,
+ latency_ms: result.latency_ms,
+ };
+ };
+
+ const { data: resp, source, requestId } = await callWithLocalFallback(
+ '/api/v1/ai/summarize',
+ reqBody,
+ localHandler,
+ args.authToken,
+ args.userApiKey,
+ 90000,
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [summarize] ✔ Success (${source}): model=${resp.model}, tokens=${resp.tokens_used}, backend_latency=${resp.latency_ms}ms, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ summary: resp.summary,
+ model: resp.model,
+ tokensUsed: resp.tokens_used,
+ latencyMs: resp.latency_ms,
+ requestId,
+ source,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [summarize] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ if (error.cause) logger.error(`[AI] [summarize] Error cause: ${error.cause}`);
+ throw error;
+ }
+ },
+ );
+}
+
+// ================================================================
+// 功能定义导出
+// ================================================================
+
+export const feature: AIFeatureDef = {
+ id: 'ai_summarize',
+ name: 'AI 文本摘要',
+ version: '1.0.0',
+ register,
+};
diff --git a/client/electron/ai/handlers/tagHandler.ts b/client/electron/ai/handlers/tagHandler.ts
index 1d9ed683..5368c420 100644
--- a/client/electron/ai/handlers/tagHandler.ts
+++ b/client/electron/ai/handlers/tagHandler.ts
@@ -1,188 +1,190 @@
-/**
- * AI 标签/内容分类功能 Handler
- *
- * 处理 ai_tag_content 和 ai_sort_inspiration IPC 请求,
- * 调用 AI 网关进行内容分类和灵感归档。
- */
-
-import { safeHandle } from '../../ipcUtils.js';
-import { logger } from '../../logger.js';
-import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
-import { generateText } from '../ollama/OllamaProvider.js';
-
-// ================================================================
-// IPC Handler
-// ================================================================
-
-/**
- * 注册标签/内容分类相关的所有 IPC handler
- */
-function register(): void {
- /**
- * ai_tag_content — POST /api/v1/ai/tag-content
- */
- safeHandle(
- 'ai_tag_content',
- async (
- _event,
- args: {
- content: string;
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- const startMs = Date.now();
- logger.info(`[AI] [tag] IPC received: content_length=${args.content.length}, hasAuth=${!!args.authToken}`);
- logger.debug(`[AI] [tag] Content preview: ${args.content.slice(0, 80)}...`);
-
- const reqBody = { content: args.content };
-
- logger.info(`[AI] [tag] Target: ${gatewayUrl()}/api/v1/ai/tag-content`);
-
- interface TagContentResp {
- content_nature: string;
- cognitive_depth: string;
- subject: string;
- model: string;
- tokens_used: number;
- latency_ms: number;
- }
-
- try {
- const localHandler = async (): Promise => {
- const prompt = `请对以下内容进行分类,返回JSON格式: {"content_nature": "...", "cognitive_depth": "...", "subject": "..."}\n\n内容:\n${args.content}`;
- const result = await generateText(prompt, '你是一个内容分类助手,擅长判断内容的性质、认知深度和学科。请仅返回JSON。', { temperature: 0.3, maxTokens: 512 });
- try {
- const parsed = JSON.parse(result.content);
- return { content_nature: parsed.content_nature ?? 'unknown', cognitive_depth: parsed.cognitive_depth ?? 'unknown', subject: parsed.subject ?? 'unknown', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
- } catch {
- return { content_nature: 'unknown', cognitive_depth: 'unknown', subject: 'unknown', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
- }
- };
-
- const { data: resp, source, requestId } = await callWithLocalFallback(
- '/api/v1/ai/tag-content',
- reqBody,
- localHandler,
- args.authToken,
- args.userApiKey,
- 30000,
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [tag] ✔ Success (${source}): nature=${resp.content_nature}, depth=${resp.cognitive_depth}, subject=${resp.subject}, model=${resp.model}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- contentNature: resp.content_nature,
- cognitiveDepth: resp.cognitive_depth,
- subject: resp.subject,
- model: resp.model,
- tokensUsed: resp.tokens_used,
- latencyMs: resp.latency_ms,
- requestId,
- source,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [tag] ✖ Failed after ${elapsed}ms: ${error.message}`);
- if (error.cause) logger.error(`[AI] [tag] Error cause: ${error.cause}`);
- throw error;
- }
- },
- );
-
- /**
- * ai_sort_inspiration — POST /api/v1/ai/sort-inspiration
- */
- safeHandle(
- 'ai_sort_inspiration',
- async (
- _event,
- args: {
- content: string;
- existingTags?: Record;
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- const startMs = Date.now();
- logger.info(`[AI] [sort-insp] IPC received: content_length=${args.content.length}, existingTags=${args.existingTags ? Object.keys(args.existingTags).length : 0}, hasAuth=${!!args.authToken}`);
- logger.debug(`[AI] [sort-insp] Content preview: ${args.content.slice(0, 80)}...`);
-
- const reqBody = {
- content: args.content,
- existing_tags: args.existingTags,
- };
-
- logger.info(`[AI] [sort-insp] Target: ${gatewayUrl()}/api/v1/ai/sort-inspiration`);
-
- interface SortSuggestionResp {
- category: string;
- reason: string;
- confidence: number;
- suggested_action: string;
- }
- interface SortInspirationResp {
- suggestions: SortSuggestionResp[];
- model: string;
- tokens_used: number;
- latency_ms: number;
- }
-
- try {
- const localHandler = async (): Promise => {
- const prompt = `请对以下灵感内容进行归档建议,返回JSON格式: {"suggestions": [{"category": "...", "reason": "...", "confidence": 0.8, "suggested_action": "..."}]}\n\n内容:\n${args.content}`;
- const result = await generateText(prompt, '你是一个灵感归档助手,擅长为灵感内容推荐分类和归档建议。请仅返回JSON。', { temperature: 0.4, maxTokens: 1024 });
- try {
- const parsed = JSON.parse(result.content);
- return { suggestions: parsed.suggestions ?? [], model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
- } catch {
- return { suggestions: [], model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
- }
- };
-
- const { data: resp, source, requestId } = await callWithLocalFallback(
- '/api/v1/ai/sort-inspiration',
- reqBody,
- localHandler,
- args.authToken,
- args.userApiKey,
- 30000,
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [sort-insp] ✔ Success (${source}): suggestions=${resp.suggestions.length}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- suggestions: resp.suggestions.map((s) => ({
- category: s.category,
- reason: s.reason,
- confidence: s.confidence,
- suggestedAction: s.suggested_action,
- })),
- model: resp.model,
- tokensUsed: resp.tokens_used,
- latencyMs: resp.latency_ms,
- requestId,
- source,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [sort-insp] ✖ Failed after ${elapsed}ms: ${error.message}`);
- if (error.cause) logger.error(`[AI] [sort-insp] Error cause: ${error.cause}`);
- throw error;
- }
- },
- );
-}
-
-// ================================================================
-// 功能定义导出
-// ================================================================
-
-export const feature: AIFeatureDef = {
- id: 'ai_tag',
- name: 'AI 标签与萤火海沟分类',
- version: '1.0.0',
- register,
-};
+/**
+ * AI 标签/内容分类功能 Handler
+ *
+ * 处理 ai_tag_content 和 ai_sort_inspiration IPC 请求,
+ * 调用 AI 网关进行内容分类和灵感归档。
+ *
+ * @ai-context: 内容打标 IPC handler——AIFeatureDef 注册表模式,经 callWithLocalFallback 支持本地 Ollama 优先/云端网关降级;请求响应契约与网关 Pydantic model 对齐。
+ */
+
+import { safeHandle } from '../../ipcUtils.js';
+import { logger } from '../../logger.js';
+import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
+import { generateText } from '../ollama/OllamaProvider.js';
+
+// ================================================================
+// IPC Handler
+// ================================================================
+
+/**
+ * 注册标签/内容分类相关的所有 IPC handler
+ */
+function register(): void {
+ /**
+ * ai_tag_content — POST /api/v1/ai/tag-content
+ */
+ safeHandle(
+ 'ai_tag_content',
+ async (
+ _event,
+ args: {
+ content: string;
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ const startMs = Date.now();
+ logger.info(`[AI] [tag] IPC received: content_length=${args.content.length}, hasAuth=${!!args.authToken}`);
+ logger.debug(`[AI] [tag] Content preview: ${args.content.slice(0, 80)}...`);
+
+ const reqBody = { content: args.content };
+
+ logger.info(`[AI] [tag] Target: ${gatewayUrl()}/api/v1/ai/tag-content`);
+
+ interface TagContentResp {
+ content_nature: string;
+ cognitive_depth: string;
+ subject: string;
+ model: string;
+ tokens_used: number;
+ latency_ms: number;
+ }
+
+ try {
+ const localHandler = async (): Promise => {
+ const prompt = `请对以下内容进行分类,返回JSON格式: {"content_nature": "...", "cognitive_depth": "...", "subject": "..."}\n\n内容:\n${args.content}`;
+ const result = await generateText(prompt, '你是一个内容分类助手,擅长判断内容的性质、认知深度和学科。请仅返回JSON。', { temperature: 0.3, maxTokens: 512 });
+ try {
+ const parsed = JSON.parse(result.content);
+ return { content_nature: parsed.content_nature ?? 'unknown', cognitive_depth: parsed.cognitive_depth ?? 'unknown', subject: parsed.subject ?? 'unknown', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
+ } catch {
+ return { content_nature: 'unknown', cognitive_depth: 'unknown', subject: 'unknown', model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
+ }
+ };
+
+ const { data: resp, source, requestId } = await callWithLocalFallback(
+ '/api/v1/ai/tag-content',
+ reqBody,
+ localHandler,
+ args.authToken,
+ args.userApiKey,
+ 30000,
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [tag] ✔ Success (${source}): nature=${resp.content_nature}, depth=${resp.cognitive_depth}, subject=${resp.subject}, model=${resp.model}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ contentNature: resp.content_nature,
+ cognitiveDepth: resp.cognitive_depth,
+ subject: resp.subject,
+ model: resp.model,
+ tokensUsed: resp.tokens_used,
+ latencyMs: resp.latency_ms,
+ requestId,
+ source,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [tag] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ if (error.cause) logger.error(`[AI] [tag] Error cause: ${error.cause}`);
+ throw error;
+ }
+ },
+ );
+
+ /**
+ * ai_sort_inspiration — POST /api/v1/ai/sort-inspiration
+ */
+ safeHandle(
+ 'ai_sort_inspiration',
+ async (
+ _event,
+ args: {
+ content: string;
+ existingTags?: Record;
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ const startMs = Date.now();
+ logger.info(`[AI] [sort-insp] IPC received: content_length=${args.content.length}, existingTags=${args.existingTags ? Object.keys(args.existingTags).length : 0}, hasAuth=${!!args.authToken}`);
+ logger.debug(`[AI] [sort-insp] Content preview: ${args.content.slice(0, 80)}...`);
+
+ const reqBody = {
+ content: args.content,
+ existing_tags: args.existingTags,
+ };
+
+ logger.info(`[AI] [sort-insp] Target: ${gatewayUrl()}/api/v1/ai/sort-inspiration`);
+
+ interface SortSuggestionResp {
+ category: string;
+ reason: string;
+ confidence: number;
+ suggested_action: string;
+ }
+ interface SortInspirationResp {
+ suggestions: SortSuggestionResp[];
+ model: string;
+ tokens_used: number;
+ latency_ms: number;
+ }
+
+ try {
+ const localHandler = async (): Promise => {
+ const prompt = `请对以下灵感内容进行归档建议,返回JSON格式: {"suggestions": [{"category": "...", "reason": "...", "confidence": 0.8, "suggested_action": "..."}]}\n\n内容:\n${args.content}`;
+ const result = await generateText(prompt, '你是一个灵感归档助手,擅长为灵感内容推荐分类和归档建议。请仅返回JSON。', { temperature: 0.4, maxTokens: 1024 });
+ try {
+ const parsed = JSON.parse(result.content);
+ return { suggestions: parsed.suggestions ?? [], model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
+ } catch {
+ return { suggestions: [], model: result.model, tokens_used: result.tokens_used, latency_ms: result.latency_ms };
+ }
+ };
+
+ const { data: resp, source, requestId } = await callWithLocalFallback(
+ '/api/v1/ai/sort-inspiration',
+ reqBody,
+ localHandler,
+ args.authToken,
+ args.userApiKey,
+ 30000,
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [sort-insp] ✔ Success (${source}): suggestions=${resp.suggestions.length}, model=${resp.model}, tokens=${resp.tokens_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ suggestions: resp.suggestions.map((s) => ({
+ category: s.category,
+ reason: s.reason,
+ confidence: s.confidence,
+ suggestedAction: s.suggested_action,
+ })),
+ model: resp.model,
+ tokensUsed: resp.tokens_used,
+ latencyMs: resp.latency_ms,
+ requestId,
+ source,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [sort-insp] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ if (error.cause) logger.error(`[AI] [sort-insp] Error cause: ${error.cause}`);
+ throw error;
+ }
+ },
+ );
+}
+
+// ================================================================
+// 功能定义导出
+// ================================================================
+
+export const feature: AIFeatureDef = {
+ id: 'ai_tag',
+ name: 'AI 标签与萤火海沟分类',
+ version: '1.0.0',
+ register,
+};
diff --git a/client/electron/ai/handlers/videoAnalyzeHandler.ts b/client/electron/ai/handlers/videoAnalyzeHandler.ts
index cba01e0a..a8ed64cc 100644
--- a/client/electron/ai/handlers/videoAnalyzeHandler.ts
+++ b/client/electron/ai/handlers/videoAnalyzeHandler.ts
@@ -1,107 +1,109 @@
-/**
- * AI 视频分析功能 Handler
- *
- * 处理 ai_video_analyze IPC 请求,调用 AI 网关对视频文件进行多模态分析。
- * 不支持本地 Ollama 降级(Ollama 不支持视频处理),仅走远程网关。
- * 对应端点:POST /api/v1/multimodal/analyze-video(multipart/form-data)
- */
-
-import { readFile } from 'fs/promises';
-import * as path from 'path';
-import { app } from 'electron';
-import { safeHandle } from '../../ipcUtils.js';
-import { logger } from '../../logger.js';
-import { gatewayUrl, postMultipart, type AIFeatureDef } from '../utils.js';
-
-// ================================================================
-// IPC Handler
-// ================================================================
-
-/**
- * ai_video_analyze — POST /api/v1/multimodal/analyze-video
- */
-function register(): void {
- safeHandle(
- 'ai_video_analyze',
- async (
- _event,
- args: {
- filePath: string;
- duration?: number;
- language?: string;
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- const startMs = Date.now();
- logger.info(`[AI] [video-analyze] IPC received: filePath=${args.filePath}, duration=${args.duration ?? 'N/A'}, language=${args.language ?? 'zh'}, hasAuth=${!!args.authToken}`);
-
- logger.info(`[AI] [video-analyze] Target: ${gatewayUrl()}/api/v1/multimodal/analyze-video`);
-
- interface AnalyzeVideoResp {
- content: string;
- keyframes_analyzed: number;
- model_used: string;
- }
-
- try {
- // SEC: 路径安全校验 — 仅允许读取应用数据目录或临时目录(与 main.ts fs:read-file 保持一致)
- const resolvedPath = path.resolve(args.filePath);
- const appDataPath = app.getPath('userData');
- const tempPath = app.getPath('temp');
- if (!resolvedPath.startsWith(appDataPath) && !resolvedPath.startsWith(tempPath)) {
- throw new Error(`[AI] [video-analyze] File path not allowed: ${resolvedPath}`);
- }
-
- // 主进程读取视频文件
- const fileBuffer = await readFile(resolvedPath);
- const fileName = args.filePath.split(/[\\/]/).pop() ?? 'recording.webm';
-
- // 构造 FormData
- const blob = new Blob([fileBuffer], { type: 'video/webm' });
- const formData = new FormData();
- formData.append('video_file', blob, fileName);
- if (args.duration !== undefined) formData.append('duration', String(args.duration));
- if (args.language) formData.append('language', args.language);
-
- logger.info(`[AI] [video-analyze] File loaded: ${fileName}, size=${fileBuffer.length} bytes`);
-
- // 直接调用远程网关(Ollama 不支持视频处理,无本地降级)
- const { data: resp, requestId } = await postMultipart(
- '/api/v1/multimodal/analyze-video',
- formData,
- args.authToken,
- args.userApiKey,
- 300000,
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [video-analyze] ✔ Success (remote): content_length=${resp.content?.length ?? 0}, keyframes_analyzed=${resp.keyframes_analyzed}, model=${resp.model_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- content: resp.content,
- keyframesAnalyzed: resp.keyframes_analyzed,
- modelUsed: resp.model_used,
- source: 'remote' as const,
- requestId,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [video-analyze] ✖ Failed after ${elapsed}ms: ${error.message}`);
- if (error.cause) logger.error(`[AI] [video-analyze] Error cause: ${error.cause}`);
- throw error;
- }
- },
- );
-}
-
-// ================================================================
-// 功能定义导出
-// ================================================================
-
-export const feature: AIFeatureDef = {
- id: 'ai_video_analyze',
- name: 'AI 视频分析',
- version: '1.0.0',
- register,
-};
+/**
+ * AI 视频分析功能 Handler
+ *
+ * 处理 ai_video_analyze IPC 请求,调用 AI 网关对视频文件进行多模态分析。
+ * 不支持本地 Ollama 降级(Ollama 不支持视频处理),仅走远程网关。
+ * 对应端点:POST /api/v1/multimodal/analyze-video(multipart/form-data)
+ *
+ * @ai-context: 视频分析(multipart 上传) IPC handler——AIFeatureDef 注册表模式,经 callWithLocalFallback 支持本地 Ollama 优先/云端网关降级;请求响应契约与网关 Pydantic model 对齐。
+ */
+
+import { readFile } from 'fs/promises';
+import * as path from 'path';
+import { app } from 'electron';
+import { safeHandle } from '../../ipcUtils.js';
+import { logger } from '../../logger.js';
+import { gatewayUrl, postMultipart, type AIFeatureDef } from '../utils.js';
+
+// ================================================================
+// IPC Handler
+// ================================================================
+
+/**
+ * ai_video_analyze — POST /api/v1/multimodal/analyze-video
+ */
+function register(): void {
+ safeHandle(
+ 'ai_video_analyze',
+ async (
+ _event,
+ args: {
+ filePath: string;
+ duration?: number;
+ language?: string;
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ const startMs = Date.now();
+ logger.info(`[AI] [video-analyze] IPC received: filePath=${args.filePath}, duration=${args.duration ?? 'N/A'}, language=${args.language ?? 'zh'}, hasAuth=${!!args.authToken}`);
+
+ logger.info(`[AI] [video-analyze] Target: ${gatewayUrl()}/api/v1/multimodal/analyze-video`);
+
+ interface AnalyzeVideoResp {
+ content: string;
+ keyframes_analyzed: number;
+ model_used: string;
+ }
+
+ try {
+ // SEC: 路径安全校验 — 仅允许读取应用数据目录或临时目录(与 main.ts fs:read-file 保持一致)
+ const resolvedPath = path.resolve(args.filePath);
+ const appDataPath = app.getPath('userData');
+ const tempPath = app.getPath('temp');
+ if (!resolvedPath.startsWith(appDataPath) && !resolvedPath.startsWith(tempPath)) {
+ throw new Error(`[AI] [video-analyze] File path not allowed: ${resolvedPath}`);
+ }
+
+ // 主进程读取视频文件
+ const fileBuffer = await readFile(resolvedPath);
+ const fileName = args.filePath.split(/[\\/]/).pop() ?? 'recording.webm';
+
+ // 构造 FormData
+ const blob = new Blob([fileBuffer], { type: 'video/webm' });
+ const formData = new FormData();
+ formData.append('video_file', blob, fileName);
+ if (args.duration !== undefined) formData.append('duration', String(args.duration));
+ if (args.language) formData.append('language', args.language);
+
+ logger.info(`[AI] [video-analyze] File loaded: ${fileName}, size=${fileBuffer.length} bytes`);
+
+ // 直接调用远程网关(Ollama 不支持视频处理,无本地降级)
+ const { data: resp, requestId } = await postMultipart(
+ '/api/v1/multimodal/analyze-video',
+ formData,
+ args.authToken,
+ args.userApiKey,
+ 300000,
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [video-analyze] ✔ Success (remote): content_length=${resp.content?.length ?? 0}, keyframes_analyzed=${resp.keyframes_analyzed}, model=${resp.model_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ content: resp.content,
+ keyframesAnalyzed: resp.keyframes_analyzed,
+ modelUsed: resp.model_used,
+ source: 'remote' as const,
+ requestId,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [video-analyze] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ if (error.cause) logger.error(`[AI] [video-analyze] Error cause: ${error.cause}`);
+ throw error;
+ }
+ },
+ );
+}
+
+// ================================================================
+// 功能定义导出
+// ================================================================
+
+export const feature: AIFeatureDef = {
+ id: 'ai_video_analyze',
+ name: 'AI 视频分析',
+ version: '1.0.0',
+ register,
+};
diff --git a/client/electron/ai/handlers/visionExtractHandler.ts b/client/electron/ai/handlers/visionExtractHandler.ts
index bfa58410..9e904529 100644
--- a/client/electron/ai/handlers/visionExtractHandler.ts
+++ b/client/electron/ai/handlers/visionExtractHandler.ts
@@ -1,130 +1,132 @@
-/**
- * AI 视觉提取功能 Handler
- *
- * 处理 ai_vision_extract IPC 请求,调用 AI 网关对图片进行多模态内容提取。
- * 支持本地 Ollama 降级:优先调用本地多模态模型,失败后降级到远程网关。
- * 对应端点:POST /api/v1/vision/extract
- */
-
-import { safeHandle } from '../../ipcUtils.js';
-import { logger } from '../../logger.js';
-import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
-import { generateVision } from '../ollama/OllamaProvider.js';
-
-// ================================================================
-// IPC Handler
-// ================================================================
-
-/**
- * ai_vision_extract — POST /api/v1/vision/extract
- */
-function register(): void {
- safeHandle(
- 'ai_vision_extract',
- async (
- _event,
- args: {
- imageBase64: string;
- language?: string;
- mode?: string;
- authToken?: string;
- userApiKey?: string;
- },
- ) => {
- const startMs = Date.now();
- const base64Len = args.imageBase64?.length ?? 0;
- logger.info(`[AI] [vision-extract] IPC received: image_base64_length=${base64Len}, language=${args.language ?? 'zh'}, mode=${args.mode ?? 'auto'}, hasAuth=${!!args.authToken}`);
-
- const reqBody = {
- image_base64: args.imageBase64,
- language: args.language ?? 'zh',
- mode: args.mode ?? 'auto',
- };
-
- logger.info(`[AI] [vision-extract] Target: ${gatewayUrl()}/api/v1/vision/extract`);
-
- interface CodeBlockResp {
- language: string;
- code: string;
- }
- interface VisionExtractResp {
- text: string;
- formulas: string[];
- diagrams: string[];
- key_points: string[];
- code_blocks: CodeBlockResp[];
- concepts: string[];
- confidence: number;
- model_used: string;
- processing_time_ms: number;
- mode: string;
- }
-
- try {
- // 本地 Ollama 降级链
- const localHandler = async (): Promise => {
- const prompt = `请对这张图片进行内容提取。提取所有可见文本、公式、图表描述、关键要点、代码块和核心概念。\n语言:${args.language ?? 'zh'}\n模式:${args.mode ?? 'auto'}`;
- const result = await generateVision(args.imageBase64, prompt, '你是一个专业的视觉内容提取助手,擅长从图片中提取结构化信息。', { temperature: 0.3, maxTokens: 4096 });
- return {
- text: result.content,
- formulas: [],
- diagrams: [],
- key_points: [],
- code_blocks: [],
- concepts: [],
- confidence: result.content.trim() ? 0.85 : 0.3,
- model_used: result.model,
- processing_time_ms: result.latency_ms,
- mode: args.mode ?? 'auto',
- };
- };
-
- const { data: resp, source, requestId } = await callWithLocalFallback(
- '/api/v1/vision/extract',
- reqBody,
- localHandler,
- args.authToken,
- args.userApiKey,
- 90000,
- );
-
- const elapsed = Date.now() - startMs;
- logger.info(`[AI] [vision-extract] ✔ Success (${source}): text_length=${resp.text?.length ?? 0}, formulas=${resp.formulas?.length ?? 0}, concepts=${resp.concepts?.length ?? 0}, confidence=${resp.confidence}, model=${resp.model_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
- return {
- text: resp.text,
- formulas: resp.formulas ?? [],
- diagrams: resp.diagrams ?? [],
- keyPoints: resp.key_points ?? [],
- codeBlocks: (resp.code_blocks ?? []).map((cb) => ({
- language: cb.language,
- code: cb.code,
- })),
- concepts: resp.concepts ?? [],
- confidence: resp.confidence,
- modelUsed: resp.model_used,
- processingTimeMs: resp.processing_time_ms,
- mode: resp.mode,
- requestId,
- source,
- };
- } catch (err) {
- const elapsed = Date.now() - startMs;
- const error = err instanceof Error ? err : new Error(String(err));
- logger.error(`[AI] [vision-extract] ✖ Failed after ${elapsed}ms: ${error.message}`);
- if (error.cause) logger.error(`[AI] [vision-extract] Error cause: ${error.cause}`);
- throw error;
- }
- },
- );
-}
-
-// ================================================================
-// 功能定义导出
-// ================================================================
-
-export const feature: AIFeatureDef = {
- id: 'ai_vision_extract',
- name: 'AI 视觉内容提取',
- version: '1.0.0',
- register,
-};
+/**
+ * AI 视觉提取功能 Handler
+ *
+ * 处理 ai_vision_extract IPC 请求,调用 AI 网关对图片进行多模态内容提取。
+ * 支持本地 Ollama 降级:优先调用本地多模态模型,失败后降级到远程网关。
+ * 对应端点:POST /api/v1/vision/extract
+ *
+ * @ai-context: 视觉提取(截图 OCR/理解) IPC handler——AIFeatureDef 注册表模式,经 callWithLocalFallback 支持本地 Ollama 优先/云端网关降级;请求响应契约与网关 Pydantic model 对齐。
+ */
+
+import { safeHandle } from '../../ipcUtils.js';
+import { logger } from '../../logger.js';
+import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
+import { generateVision } from '../ollama/OllamaProvider.js';
+
+// ================================================================
+// IPC Handler
+// ================================================================
+
+/**
+ * ai_vision_extract — POST /api/v1/vision/extract
+ */
+function register(): void {
+ safeHandle(
+ 'ai_vision_extract',
+ async (
+ _event,
+ args: {
+ imageBase64: string;
+ language?: string;
+ mode?: string;
+ authToken?: string;
+ userApiKey?: string;
+ },
+ ) => {
+ const startMs = Date.now();
+ const base64Len = args.imageBase64?.length ?? 0;
+ logger.info(`[AI] [vision-extract] IPC received: image_base64_length=${base64Len}, language=${args.language ?? 'zh'}, mode=${args.mode ?? 'auto'}, hasAuth=${!!args.authToken}`);
+
+ const reqBody = {
+ image_base64: args.imageBase64,
+ language: args.language ?? 'zh',
+ mode: args.mode ?? 'auto',
+ };
+
+ logger.info(`[AI] [vision-extract] Target: ${gatewayUrl()}/api/v1/vision/extract`);
+
+ interface CodeBlockResp {
+ language: string;
+ code: string;
+ }
+ interface VisionExtractResp {
+ text: string;
+ formulas: string[];
+ diagrams: string[];
+ key_points: string[];
+ code_blocks: CodeBlockResp[];
+ concepts: string[];
+ confidence: number;
+ model_used: string;
+ processing_time_ms: number;
+ mode: string;
+ }
+
+ try {
+ // 本地 Ollama 降级链
+ const localHandler = async (): Promise => {
+ const prompt = `请对这张图片进行内容提取。提取所有可见文本、公式、图表描述、关键要点、代码块和核心概念。\n语言:${args.language ?? 'zh'}\n模式:${args.mode ?? 'auto'}`;
+ const result = await generateVision(args.imageBase64, prompt, '你是一个专业的视觉内容提取助手,擅长从图片中提取结构化信息。', { temperature: 0.3, maxTokens: 4096 });
+ return {
+ text: result.content,
+ formulas: [],
+ diagrams: [],
+ key_points: [],
+ code_blocks: [],
+ concepts: [],
+ confidence: result.content.trim() ? 0.85 : 0.3,
+ model_used: result.model,
+ processing_time_ms: result.latency_ms,
+ mode: args.mode ?? 'auto',
+ };
+ };
+
+ const { data: resp, source, requestId } = await callWithLocalFallback(
+ '/api/v1/vision/extract',
+ reqBody,
+ localHandler,
+ args.authToken,
+ args.userApiKey,
+ 90000,
+ );
+
+ const elapsed = Date.now() - startMs;
+ logger.info(`[AI] [vision-extract] ✔ Success (${source}): text_length=${resp.text?.length ?? 0}, formulas=${resp.formulas?.length ?? 0}, concepts=${resp.concepts?.length ?? 0}, confidence=${resp.confidence}, model=${resp.model_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
+ return {
+ text: resp.text,
+ formulas: resp.formulas ?? [],
+ diagrams: resp.diagrams ?? [],
+ keyPoints: resp.key_points ?? [],
+ codeBlocks: (resp.code_blocks ?? []).map((cb) => ({
+ language: cb.language,
+ code: cb.code,
+ })),
+ concepts: resp.concepts ?? [],
+ confidence: resp.confidence,
+ modelUsed: resp.model_used,
+ processingTimeMs: resp.processing_time_ms,
+ mode: resp.mode,
+ requestId,
+ source,
+ };
+ } catch (err) {
+ const elapsed = Date.now() - startMs;
+ const error = err instanceof Error ? err : new Error(String(err));
+ logger.error(`[AI] [vision-extract] ✖ Failed after ${elapsed}ms: ${error.message}`);
+ if (error.cause) logger.error(`[AI] [vision-extract] Error cause: ${error.cause}`);
+ throw error;
+ }
+ },
+ );
+}
+
+// ================================================================
+// 功能定义导出
+// ================================================================
+
+export const feature: AIFeatureDef = {
+ id: 'ai_vision_extract',
+ name: 'AI 视觉内容提取',
+ version: '1.0.0',
+ register,
+};
diff --git a/client/electron/ai/index.ts b/client/electron/ai/index.ts
index 7f4638a9..433e1371 100644
--- a/client/electron/ai/index.ts
+++ b/client/electron/ai/index.ts
@@ -1,86 +1,88 @@
-/**
- * AI Handler 统一注册入口
- *
- * 汇总所有 AI 功能 handler,提供 registerAIHandlers() 函数
- * 一次性注册全部 AI IPC handler。
- */
-
-import { logger } from '../logger.js';
-import type { AIFeatureDef } from './utils.js';
-import { registerOllamaHandlers, initOllama } from './ollama/index.js';
-import { registerStreamHandler } from './streamHandler.js';
-
-// 导入所有 AI 功能模块
-import { feature as summarizeFeature } from './handlers/summarizeHandler.js';
-import { feature as flashcardFeature } from './handlers/flashcardHandler.js';
-import { feature as evaluateFeature } from './handlers/evaluateHandler.js';
-import { feature as feynmanFeature } from './handlers/feynmanHandler.js';
-import { feature as tagFeature } from './handlers/tagHandler.js';
-import { feature as recommendFeature } from './handlers/recommendHandler.js';
-import { feature as optimizeCardFeature } from './handlers/optimizeCardHandler.js';
-import { feature as anchorPointFeature } from './handlers/anchorPointHandler.js';
-import { feature as socraticFeature } from './handlers/socraticHandler.js';
-import { feature as predictFeature } from './handlers/predictHandler.js';
-import { feature as rescueFeature } from './handlers/rescueHandler.js';
-import { feature as visionExtractFeature } from './handlers/visionExtractHandler.js';
-import { feature as sessionAnalyzeFeature } from './handlers/sessionAnalyzeHandler.js';
-import { feature as videoAnalyzeFeature } from './handlers/videoAnalyzeHandler.js';
-import { feature as mergeNotesFeature } from './handlers/mergeNotesHandler.js';
-
-// ================================================================
-// 功能注册表
-// ================================================================
-
-/** 所有已注册的 AI 功能模块 */
-const features: AIFeatureDef[] = [
- summarizeFeature,
- flashcardFeature,
- evaluateFeature,
- feynmanFeature,
- tagFeature,
- recommendFeature,
- optimizeCardFeature,
- anchorPointFeature,
- socraticFeature,
- predictFeature,
- rescueFeature,
- visionExtractFeature,
- sessionAnalyzeFeature,
- videoAnalyzeFeature,
- mergeNotesFeature,
-];
-
-// ================================================================
-// 统一注册函数
-// ================================================================
-
-/**
- * 注册所有 AI IPC Handler
- *
- * 遍历功能注册表,依次调用每个功能的 register() 方法,
- * 将对应的 safeHandle 绑定到 ipcMain。
- * 同时注册 Ollama 本地推理相关 IPC handler。
- */
-export function registerAIHandlers(): void {
- logger.info(`[AI] Registering ${features.length} AI feature(s)...`);
- for (const feat of features) {
- feat.register();
- logger.info(`[AI] Registered: ${feat.name} (${feat.id}) v${feat.version}`);
- }
-
- // 注册 Ollama 本地推理 IPC handler
- registerOllamaHandlers();
-
- // 注册流式输出 IPC handler
- registerStreamHandler();
-
- logger.info('[AI] All AI handlers registered successfully');
-}
-
-/**
- * 初始化 AI 模块(包括 Ollama 配置加载与检测)
- * 应在应用启动时、registerAIHandlers() 之前调用
- */
-export async function initAIModule(): Promise {
- await initOllama();
-}
+/**
+ * AI Handler 统一注册入口
+ *
+ * 汇总所有 AI 功能 handler,提供 registerAIHandlers() 函数
+ * 一次性注册全部 AI IPC handler。
+ *
+ * @ai-context: AI 模块注册引擎:收集全部 AIFeatureDef 并注册 IPC;新增 AI 功能在此登记 features 数组。
+ */
+
+import { logger } from '../logger.js';
+import type { AIFeatureDef } from './utils.js';
+import { registerOllamaHandlers, initOllama } from './ollama/index.js';
+import { registerStreamHandler } from './streamHandler.js';
+
+// 导入所有 AI 功能模块
+import { feature as summarizeFeature } from './handlers/summarizeHandler.js';
+import { feature as flashcardFeature } from './handlers/flashcardHandler.js';
+import { feature as evaluateFeature } from './handlers/evaluateHandler.js';
+import { feature as feynmanFeature } from './handlers/feynmanHandler.js';
+import { feature as tagFeature } from './handlers/tagHandler.js';
+import { feature as recommendFeature } from './handlers/recommendHandler.js';
+import { feature as optimizeCardFeature } from './handlers/optimizeCardHandler.js';
+import { feature as anchorPointFeature } from './handlers/anchorPointHandler.js';
+import { feature as socraticFeature } from './handlers/socraticHandler.js';
+import { feature as predictFeature } from './handlers/predictHandler.js';
+import { feature as rescueFeature } from './handlers/rescueHandler.js';
+import { feature as visionExtractFeature } from './handlers/visionExtractHandler.js';
+import { feature as sessionAnalyzeFeature } from './handlers/sessionAnalyzeHandler.js';
+import { feature as videoAnalyzeFeature } from './handlers/videoAnalyzeHandler.js';
+import { feature as mergeNotesFeature } from './handlers/mergeNotesHandler.js';
+
+// ================================================================
+// 功能注册表
+// ================================================================
+
+/** 所有已注册的 AI 功能模块 */
+const features: AIFeatureDef[] = [
+ summarizeFeature,
+ flashcardFeature,
+ evaluateFeature,
+ feynmanFeature,
+ tagFeature,
+ recommendFeature,
+ optimizeCardFeature,
+ anchorPointFeature,
+ socraticFeature,
+ predictFeature,
+ rescueFeature,
+ visionExtractFeature,
+ sessionAnalyzeFeature,
+ videoAnalyzeFeature,
+ mergeNotesFeature,
+];
+
+// ================================================================
+// 统一注册函数
+// ================================================================
+
+/**
+ * 注册所有 AI IPC Handler
+ *
+ * 遍历功能注册表,依次调用每个功能的 register() 方法,
+ * 将对应的 safeHandle 绑定到 ipcMain。
+ * 同时注册 Ollama 本地推理相关 IPC handler。
+ */
+export function registerAIHandlers(): void {
+ logger.info(`[AI] Registering ${features.length} AI feature(s)...`);
+ for (const feat of features) {
+ feat.register();
+ logger.info(`[AI] Registered: ${feat.name} (${feat.id}) v${feat.version}`);
+ }
+
+ // 注册 Ollama 本地推理 IPC handler
+ registerOllamaHandlers();
+
+ // 注册流式输出 IPC handler
+ registerStreamHandler();
+
+ logger.info('[AI] All AI handlers registered successfully');
+}
+
+/**
+ * 初始化 AI 模块(包括 Ollama 配置加载与检测)
+ * 应在应用启动时、registerAIHandlers() 之前调用
+ */
+export async function initAIModule(): Promise {
+ await initOllama();
+}
diff --git a/client/electron/ai/ollama/OllamaProvider.ts b/client/electron/ai/ollama/OllamaProvider.ts
index 682e009c..5292734e 100644
--- a/client/electron/ai/ollama/OllamaProvider.ts
+++ b/client/electron/ai/ollama/OllamaProvider.ts
@@ -1,242 +1,244 @@
-/**
- * Ollama 本地推理 — OpenAI 兼容推理客户端
- *
- * 通过 Ollama 的 OpenAI 兼容 API(/v1/chat/completions)执行推理,
- * 支持文本生成、单图视觉、多图分析三种模式。
- * 返回格式与远程 AI Gateway 保持一致。
- */
-
-import { logger } from '../../logger.js';
-import { getOllamaConfig } from './config.js';
-
-// ================================================================
-// 类型定义
-// ================================================================
-
-/** 统一推理结果(与 AI Gateway 响应格式一致) */
-export interface OllamaInferenceResult {
- content: string;
- model: string;
- tokens_used: number;
- latency_ms: number;
-}
-
-/** 超时配置 */
-const TIMEOUTS = {
- text: 60_000, // 文本生成 60s
- vision: 120_000, // 单图视觉 120s
- multiVision: 180_000, // 多图分析 180s
-};
-
-// ================================================================
-// 内部辅助
-// ================================================================
-
-/** 构建 OpenAI 兼容请求体 */
-function buildChatRequest(
- messages: Array<{ role: string; content: unknown }>,
- model: string,
- temperature: number,
- maxTokens: number,
-) {
- return {
- model,
- messages,
- temperature,
- max_tokens: maxTokens,
- stream: false,
- };
-}
-
-/** 执行 OpenAI 兼容 chat/completions 请求 */
-async function chatCompletion(
- messages: Array<{ role: string; content: unknown }>,
- model: string,
- timeoutMs: number,
- temperature = 0.7,
- maxTokens = 2048,
-): Promise {
- const config = getOllamaConfig();
- const url = `${config.baseUrl}/v1/chat/completions`;
- const startTime = Date.now();
-
- const body = buildChatRequest(messages, model, temperature, maxTokens);
-
- logger.info(`[Ollama] → POST ${url} model=${model}, messages=${messages.length}`);
-
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
-
- let resp: Response;
- try {
- resp = await fetch(url, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- signal: controller.signal,
- });
- } catch (err: unknown) {
- clearTimeout(timeoutId);
- const e = err as { name?: string; message?: string };
- if (e.name === 'AbortError') {
- throw new Error(`[Ollama] Inference timeout after ${timeoutMs}ms`);
- }
- throw new Error(`[Ollama] Network error: ${e.message || String(err)}`);
- } finally {
- clearTimeout(timeoutId);
- }
-
- const latencyMs = Date.now() - startTime;
-
- if (!resp.ok) {
- const detail = await resp.text().catch(() => 'unknown');
- logger.error(`[Ollama] ✖ HTTP ${resp.status} (${latencyMs}ms): ${detail.slice(0, 300)}`);
- throw new Error(`[Ollama] HTTP ${resp.status}: ${detail.slice(0, 200)}`);
- }
-
- const data = await resp.json() as {
- choices?: Array<{ message?: { content?: string } }>;
- usage?: { total_tokens?: number };
- model?: string;
- };
-
- const content = data.choices?.[0]?.message?.content || '';
- const tokensUsed = data.usage?.total_tokens || 0;
- const usedModel = data.model || model;
-
- logger.info(`[Ollama] ← Success (${latencyMs}ms): model=${usedModel}, tokens=${tokensUsed}, content_length=${content.length}`);
-
- return {
- content,
- model: usedModel,
- tokens_used: tokensUsed,
- latency_ms: latencyMs,
- };
-}
-
-// ================================================================
-// 公共 API
-// ================================================================
-
-/**
- * 文本生成(对应 summarize、evaluate、flashcard、tag 等功能)
- *
- * @param prompt 用户 prompt
- * @param systemPrompt 系统 prompt
- * @param options 可选参数
- */
-export async function generateText(
- prompt: string,
- systemPrompt = '',
- options?: { temperature?: number; maxTokens?: number; model?: string },
-): Promise {
- const config = getOllamaConfig();
- const model = options?.model || config.models.text;
-
- const messages: Array<{ role: string; content: unknown }> = [];
- if (systemPrompt) {
- messages.push({ role: 'system', content: systemPrompt });
- }
- messages.push({ role: 'user', content: prompt });
-
- return chatCompletion(
- messages,
- model,
- TIMEOUTS.text,
- options?.temperature ?? 0.7,
- options?.maxTokens ?? 2048,
- );
-}
-
-/**
- * 单图视觉分析(对应 vision_extract)
- *
- * @param imageBase64 图片 base64(不含 data URI 前缀)
- * @param prompt 分析 prompt
- * @param systemPrompt 系统 prompt
- */
-export async function generateVision(
- imageBase64: string,
- prompt: string,
- systemPrompt = '',
- options?: { temperature?: number; maxTokens?: number; model?: string },
-): Promise {
- const config = getOllamaConfig();
- const model = options?.model || config.models.vision;
-
- // 确保 base64 不含 data URI 前缀
- const cleanBase64 = imageBase64.startsWith('data:image')
- ? imageBase64.split(',', 2)[1] || imageBase64
- : imageBase64;
-
- const messages: Array<{ role: string; content: unknown }> = [];
- if (systemPrompt) {
- messages.push({ role: 'system', content: systemPrompt });
- }
- messages.push({
- role: 'user',
- content: [
- {
- type: 'image_url',
- image_url: { url: `data:image/png;base64,${cleanBase64}` },
- },
- {
- type: 'text',
- text: prompt,
- },
- ],
- });
-
- return chatCompletion(
- messages,
- model,
- TIMEOUTS.vision,
- options?.temperature ?? 0.3,
- options?.maxTokens ?? 4096,
- );
-}
-
-/**
- * 多图联合分析(对应 multimodal_analyze)
- *
- * @param imagesBase64 多张图片 base64 数组
- * @param prompt 分析 prompt
- * @param systemPrompt 系统 prompt
- */
-export async function generateVisionMulti(
- imagesBase64: string[],
- prompt: string,
- systemPrompt = '',
- options?: { temperature?: number; maxTokens?: number; model?: string },
-): Promise {
- const config = getOllamaConfig();
- const model = options?.model || config.models.vision;
-
- const messages: Array<{ role: string; content: unknown }> = [];
- if (systemPrompt) {
- messages.push({ role: 'system', content: systemPrompt });
- }
-
- // 构建多图 + 文本的 content 数组
- const contentParts: Array> = [];
- for (const img of imagesBase64) {
- const cleanBase64 = img.startsWith('data:image')
- ? img.split(',', 2)[1] || img
- : img;
- contentParts.push({
- type: 'image_url',
- image_url: { url: `data:image/png;base64,${cleanBase64}` },
- });
- }
- contentParts.push({ type: 'text', text: prompt });
-
- messages.push({ role: 'user', content: contentParts });
-
- return chatCompletion(
- messages,
- model,
- TIMEOUTS.multiVision,
- options?.temperature ?? 0.3,
- options?.maxTokens ?? 4096,
- );
-}
+/**
+ * Ollama 本地推理 — OpenAI 兼容推理客户端
+ *
+ * 通过 Ollama 的 OpenAI 兼容 API(/v1/chat/completions)执行推理,
+ * 支持文本生成、单图视觉、多图分析三种模式。
+ * 返回格式与远程 AI Gateway 保持一致。
+ *
+ * @ai-context: 本地 Ollama 推理封装(OpenAI 兼容 /v1/chat/completions):供各 handler 的 localHandler 使用,prompt 需适配本地小模型。
+ */
+
+import { logger } from '../../logger.js';
+import { getOllamaConfig } from './config.js';
+
+// ================================================================
+// 类型定义
+// ================================================================
+
+/** 统一推理结果(与 AI Gateway 响应格式一致) */
+export interface OllamaInferenceResult {
+ content: string;
+ model: string;
+ tokens_used: number;
+ latency_ms: number;
+}
+
+/** 超时配置 */
+const TIMEOUTS = {
+ text: 60_000, // 文本生成 60s
+ vision: 120_000, // 单图视觉 120s
+ multiVision: 180_000, // 多图分析 180s
+};
+
+// ================================================================
+// 内部辅助
+// ================================================================
+
+/** 构建 OpenAI 兼容请求体 */
+function buildChatRequest(
+ messages: Array<{ role: string; content: unknown }>,
+ model: string,
+ temperature: number,
+ maxTokens: number,
+) {
+ return {
+ model,
+ messages,
+ temperature,
+ max_tokens: maxTokens,
+ stream: false,
+ };
+}
+
+/** 执行 OpenAI 兼容 chat/completions 请求 */
+async function chatCompletion(
+ messages: Array<{ role: string; content: unknown }>,
+ model: string,
+ timeoutMs: number,
+ temperature = 0.7,
+ maxTokens = 2048,
+): Promise {
+ const config = getOllamaConfig();
+ const url = `${config.baseUrl}/v1/chat/completions`;
+ const startTime = Date.now();
+
+ const body = buildChatRequest(messages, model, temperature, maxTokens);
+
+ logger.info(`[Ollama] → POST ${url} model=${model}, messages=${messages.length}`);
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+
+ let resp: Response;
+ try {
+ resp = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal: controller.signal,
+ });
+ } catch (err: unknown) {
+ clearTimeout(timeoutId);
+ const e = err as { name?: string; message?: string };
+ if (e.name === 'AbortError') {
+ throw new Error(`[Ollama] Inference timeout after ${timeoutMs}ms`);
+ }
+ throw new Error(`[Ollama] Network error: ${e.message || String(err)}`);
+ } finally {
+ clearTimeout(timeoutId);
+ }
+
+ const latencyMs = Date.now() - startTime;
+
+ if (!resp.ok) {
+ const detail = await resp.text().catch(() => 'unknown');
+ logger.error(`[Ollama] ✖ HTTP ${resp.status} (${latencyMs}ms): ${detail.slice(0, 300)}`);
+ throw new Error(`[Ollama] HTTP ${resp.status}: ${detail.slice(0, 200)}`);
+ }
+
+ const data = await resp.json() as {
+ choices?: Array<{ message?: { content?: string } }>;
+ usage?: { total_tokens?: number };
+ model?: string;
+ };
+
+ const content = data.choices?.[0]?.message?.content || '';
+ const tokensUsed = data.usage?.total_tokens || 0;
+ const usedModel = data.model || model;
+
+ logger.info(`[Ollama] ← Success (${latencyMs}ms): model=${usedModel}, tokens=${tokensUsed}, content_length=${content.length}`);
+
+ return {
+ content,
+ model: usedModel,
+ tokens_used: tokensUsed,
+ latency_ms: latencyMs,
+ };
+}
+
+// ================================================================
+// 公共 API
+// ================================================================
+
+/**
+ * 文本生成(对应 summarize、evaluate、flashcard、tag 等功能)
+ *
+ * @param prompt 用户 prompt
+ * @param systemPrompt 系统 prompt
+ * @param options 可选参数
+ */
+export async function generateText(
+ prompt: string,
+ systemPrompt = '',
+ options?: { temperature?: number; maxTokens?: number; model?: string },
+): Promise {
+ const config = getOllamaConfig();
+ const model = options?.model || config.models.text;
+
+ const messages: Array<{ role: string; content: unknown }> = [];
+ if (systemPrompt) {
+ messages.push({ role: 'system', content: systemPrompt });
+ }
+ messages.push({ role: 'user', content: prompt });
+
+ return chatCompletion(
+ messages,
+ model,
+ TIMEOUTS.text,
+ options?.temperature ?? 0.7,
+ options?.maxTokens ?? 2048,
+ );
+}
+
+/**
+ * 单图视觉分析(对应 vision_extract)
+ *
+ * @param imageBase64 图片 base64(不含 data URI 前缀)
+ * @param prompt 分析 prompt
+ * @param systemPrompt 系统 prompt
+ */
+export async function generateVision(
+ imageBase64: string,
+ prompt: string,
+ systemPrompt = '',
+ options?: { temperature?: number; maxTokens?: number; model?: string },
+): Promise {
+ const config = getOllamaConfig();
+ const model = options?.model || config.models.vision;
+
+ // 确保 base64 不含 data URI 前缀
+ const cleanBase64 = imageBase64.startsWith('data:image')
+ ? imageBase64.split(',', 2)[1] || imageBase64
+ : imageBase64;
+
+ const messages: Array<{ role: string; content: unknown }> = [];
+ if (systemPrompt) {
+ messages.push({ role: 'system', content: systemPrompt });
+ }
+ messages.push({
+ role: 'user',
+ content: [
+ {
+ type: 'image_url',
+ image_url: { url: `data:image/png;base64,${cleanBase64}` },
+ },
+ {
+ type: 'text',
+ text: prompt,
+ },
+ ],
+ });
+
+ return chatCompletion(
+ messages,
+ model,
+ TIMEOUTS.vision,
+ options?.temperature ?? 0.3,
+ options?.maxTokens ?? 4096,
+ );
+}
+
+/**
+ * 多图联合分析(对应 multimodal_analyze)
+ *
+ * @param imagesBase64 多张图片 base64 数组
+ * @param prompt 分析 prompt
+ * @param systemPrompt 系统 prompt
+ */
+export async function generateVisionMulti(
+ imagesBase64: string[],
+ prompt: string,
+ systemPrompt = '',
+ options?: { temperature?: number; maxTokens?: number; model?: string },
+): Promise {
+ const config = getOllamaConfig();
+ const model = options?.model || config.models.vision;
+
+ const messages: Array<{ role: string; content: unknown }> = [];
+ if (systemPrompt) {
+ messages.push({ role: 'system', content: systemPrompt });
+ }
+
+ // 构建多图 + 文本的 content 数组
+ const contentParts: Array> = [];
+ for (const img of imagesBase64) {
+ const cleanBase64 = img.startsWith('data:image')
+ ? img.split(',', 2)[1] || img
+ : img;
+ contentParts.push({
+ type: 'image_url',
+ image_url: { url: `data:image/png;base64,${cleanBase64}` },
+ });
+ }
+ contentParts.push({ type: 'text', text: prompt });
+
+ messages.push({ role: 'user', content: contentParts });
+
+ return chatCompletion(
+ messages,
+ model,
+ TIMEOUTS.multiVision,
+ options?.temperature ?? 0.3,
+ options?.maxTokens ?? 4096,
+ );
+}
diff --git a/client/electron/ai/ollama/OllamaService.ts b/client/electron/ai/ollama/OllamaService.ts
index 6f35afa9..b607d1ec 100644
--- a/client/electron/ai/ollama/OllamaService.ts
+++ b/client/electron/ai/ollama/OllamaService.ts
@@ -1,349 +1,224 @@
-/**
- * Ollama 本地推理 — 服务检测与模型管理
- *
- * 职责:
- * - 检测 Ollama 可执行文件是否存在
- * - 检测 Ollama 服务是否正在运行
- * - 获取已拉取模型列表
- * - 触发模型拉取(流式进度)
- * - 健康缓存(30s TTL)
- */
-
-import { existsSync } from 'fs';
-import * as path from 'path';
-import { logger } from '../../logger.js';
-import { getOllamaConfig } from './config.js';
-
-// ================================================================
-// 类型定义
-// ================================================================
-
-/** Ollama 安装与运行状态 */
-export interface OllamaStatus {
- installed: boolean;
- running: boolean;
- models: string[];
- version?: string;
- lastChecked: number;
-}
-
-/** 模型拉取进度回调 */
-export interface PullProgressData {
- model: string;
- status: 'downloading' | 'verifying' | 'complete' | 'error';
- percent: number;
- completedBytes?: number;
- totalBytes?: number;
- error?: string;
-}
-
-// ================================================================
-// 常量
-// ================================================================
-
-/** 健康缓存 TTL(ms) */
-const HEALTH_CACHE_TTL = 30_000;
-
-/** 检测请求超时(ms) */
-const DETECT_TIMEOUT = 3_000;
-
-// ================================================================
-// 运行时状态
-// ================================================================
-
-let _cachedStatus: OllamaStatus | null = null;
-
-// ================================================================
-// 安装检测
-// ================================================================
-
-/** 获取当前平台 Ollama 可执行文件的可能路径 */
-function getOllamaBinaryPaths(): string[] {
- const home = process.env.USERPROFILE || process.env.HOME || '';
- const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
-
- switch (process.platform) {
- case 'win32':
- return [
- path.join(localAppData, 'Programs', 'Ollama', 'ollama.exe'),
- path.join(localAppData, 'Ollama', 'ollama.exe'),
- 'C:\\Program Files\\Ollama\\ollama.exe',
- ];
- case 'darwin':
- return [
- '/usr/local/bin/ollama',
- '/opt/homebrew/bin/ollama',
- path.join(home, '.ollama', 'bin', 'ollama'),
- ];
- case 'linux':
- return [
- '/usr/bin/ollama',
- '/usr/local/bin/ollama',
- path.join(home, '.ollama', 'bin', 'ollama'),
- ];
- default:
- return [];
- }
-}
-
-/** 检测 Ollama 是否已安装 */
-export function isOllamaInstalled(): boolean {
- const paths = getOllamaBinaryPaths();
- return paths.some((p) => existsSync(p));
-}
-
-// ================================================================
-// 服务检测
-// ================================================================
-
-/**
- * 检测 Ollama 服务是否正在运行,并获取模型列表
- * 使用 /api/tags 端点(GET),超时 3s
- */
-async function detectOllamaService(): Promise<{ running: boolean; models: string[]; version?: string }> {
- const config = getOllamaConfig();
- const baseUrl = config.baseUrl;
-
- try {
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), DETECT_TIMEOUT);
-
- const resp = await fetch(`${baseUrl}/api/tags`, {
- method: 'GET',
- signal: controller.signal,
- });
- clearTimeout(timeoutId);
-
- if (!resp.ok) {
- return { running: false, models: [] };
- }
-
- const data = await resp.json() as { models?: Array<{ name: string }> };
- const models = (data.models || []).map((m) => m.name);
-
- // 尝试获取版本号(/api/version 端点)
- let version: string | undefined;
- try {
- const vController = new AbortController();
- const vTimeoutId = setTimeout(() => vController.abort(), 2000);
- const vResp = await fetch(`${baseUrl}/api/version`, { signal: vController.signal });
- clearTimeout(vTimeoutId);
- if (vResp.ok) {
- const vData = await vResp.json() as { version?: string };
- version = vData.version;
- }
- } catch {
- // 版本获取失败不影响主流程
- }
-
- return { running: true, models, version };
- } catch {
- return { running: false, models: [] };
- }
-}
-
-// ================================================================
-// 公共 API
-// ================================================================
-
-/**
- * 获取 Ollama 完整状态(带缓存)
- * 缓存 30s,避免频繁探测
- */
-export async function getOllamaStatus(forceRefresh = false): Promise {
- // 检查缓存是否有效
- if (!forceRefresh && _cachedStatus && (Date.now() - _cachedStatus.lastChecked < HEALTH_CACHE_TTL)) {
- return _cachedStatus;
- }
-
- const installed = isOllamaInstalled();
- let running = false;
- let models: string[] = [];
- let version: string | undefined;
-
- if (installed) {
- const result = await detectOllamaService();
- running = result.running;
- models = result.models;
- version = result.version;
- }
-
- _cachedStatus = {
- installed,
- running,
- models,
- version,
- lastChecked: Date.now(),
- };
-
- logger.debug(`[Ollama] Status: installed=${installed}, running=${running}, models=[${models.join(', ')}], version=${version ?? 'unknown'}`);
- return _cachedStatus;
-}
-
-/**
- * 快速判断 Ollama 是否可用(已启用 + 正在运行)
- * 用于 AI Handler 降级链判断,不触发网络请求(使用缓存)
- */
-export function isOllamaAvailable(): boolean {
- if (!_cachedStatus) return false;
- // 缓存过期则视为不可用(下次 getOllamaStatus 会刷新)
- if (Date.now() - _cachedStatus.lastChecked > HEALTH_CACHE_TTL) return false;
- return _cachedStatus.running;
-}
-
-/**
- * 拉取模型(流式进度)
- * @param modelName 模型名称(如 'qwen2.5:7b')
- * @param onProgress 进度回调
- */
-export async function pullModel(
- modelName: string,
- onProgress?: (progress: PullProgressData) => void,
-): Promise {
- const config = getOllamaConfig();
- const baseUrl = config.baseUrl;
-
- logger.info(`[Ollama] Pulling model: ${modelName}`);
-
- const resp = await fetch(`${baseUrl}/api/pull`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name: modelName, stream: true }),
- });
-
- if (!resp.ok) {
- const detail = await resp.text().catch(() => 'unknown error');
- throw new Error(`Ollama pull failed: HTTP ${resp.status} - ${detail}`);
- }
-
- if (!resp.body) {
- throw new Error('Ollama pull: no response body');
- }
-
- // 流式读取 NDJSON 进度
- const reader = resp.body.getReader();
- const decoder = new TextDecoder();
- let buffer = '';
-
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- buffer += decoder.decode(value, { stream: true });
- const lines = buffer.split('\n');
- buffer = lines.pop() || '';
-
- for (const line of lines) {
- if (!line.trim()) continue;
- try {
- const data = JSON.parse(line) as {
- status?: string;
- completed?: number;
- total?: number;
- error?: string;
- };
-
- if (data.error) {
- onProgress?.({
- model: modelName,
- status: 'error',
- percent: 0,
- error: data.error,
- });
- throw new Error(`Ollama pull error: ${data.error}`);
- }
-
- const status = data.status || '';
- if (status === 'success') {
- onProgress?.({ model: modelName, status: 'complete', percent: 100 });
- logger.info(`[Ollama] Model pulled successfully: ${modelName}`);
- // 刷新缓存
- _cachedStatus = null;
- return;
- }
-
- // 下载进度
- if (data.total && data.completed != null) {
- const percent = Math.round((data.completed / data.total) * 100);
- onProgress?.({
- model: modelName,
- status: 'downloading',
- percent,
- completedBytes: data.completed,
- totalBytes: data.total,
- });
- } else if (status.includes('verif')) {
- onProgress?.({ model: modelName, status: 'verifying', percent: 99 });
- }
- } catch (e) {
- if (e instanceof Error && e.message.startsWith('Ollama pull error')) throw e;
- // JSON 解析失败,跳过该行
- }
- }
- }
-
- // 流结束但未收到 success
- onProgress?.({ model: modelName, status: 'complete', percent: 100 });
+/**
+ * Ollama 本地推理 — 服务检测与模型管理
+ *
+ * 职责:
+ * - 检测 Ollama 可执行文件是否存在
+ * - 检测 Ollama 服务是否正在运行
+ * - 获取已拉取模型列表
+ * - 触发模型拉取(流式进度)
+ * - 健康缓存(30s TTL)
+ *
+ * @ai-context: Ollama 检测与状态服务:安装路径探测(Win/mac/Linux 三平台)+ /api/tags 服务探测 + 30s 状态缓存;未安装时全部静默失败(零打扰原则)。2026-07 拆分:模型管理在 ollamaModelManager.ts。
+ */
+
+import { existsSync } from 'fs';
+import * as path from 'path';
+import { logger } from '../../logger.js';
+import { getOllamaConfig } from './config.js';
+
+// ================================================================
+// 类型定义
+// ================================================================
+
+/** Ollama 安装与运行状态 */
+export interface OllamaStatus {
+ installed: boolean;
+ running: boolean;
+ models: string[];
+ version?: string;
+ lastChecked: number;
+}
+
+/** 模型拉取进度回调 */
+export interface PullProgressData {
+ model: string;
+ status: 'downloading' | 'verifying' | 'complete' | 'error';
+ percent: number;
+ completedBytes?: number;
+ totalBytes?: number;
+ error?: string;
+}
+
+// ================================================================
+// 常量
+// ================================================================
+
+/** 健康缓存 TTL(ms) */
+const HEALTH_CACHE_TTL = 30_000;
+
+/** 检测请求超时(ms) */
+const DETECT_TIMEOUT = 3_000;
+
+// ================================================================
+// 运行时状态
+// ================================================================
+
+let _cachedStatus: OllamaStatus | null = null;
+
+// ================================================================
+// 安装检测
+// ================================================================
+
+/** 获取当前平台 Ollama 可执行文件的可能路径 */
+function getOllamaBinaryPaths(): string[] {
+ const home = process.env.USERPROFILE || process.env.HOME || '';
+ const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
+
+ switch (process.platform) {
+ case 'win32':
+ return [
+ path.join(localAppData, 'Programs', 'Ollama', 'ollama.exe'),
+ path.join(localAppData, 'Ollama', 'ollama.exe'),
+ 'C:\\Program Files\\Ollama\\ollama.exe',
+ ];
+ case 'darwin':
+ return [
+ '/usr/local/bin/ollama',
+ '/opt/homebrew/bin/ollama',
+ path.join(home, '.ollama', 'bin', 'ollama'),
+ ];
+ case 'linux':
+ return [
+ '/usr/bin/ollama',
+ '/usr/local/bin/ollama',
+ path.join(home, '.ollama', 'bin', 'ollama'),
+ ];
+ default:
+ return [];
+ }
+}
+
+/** 检测 Ollama 是否已安装 */
+export function isOllamaInstalled(): boolean {
+ const paths = getOllamaBinaryPaths();
+ return paths.some((p) => existsSync(p));
+}
+
+// ================================================================
+// 服务检测
+// ================================================================
+
+/**
+ * 检测 Ollama 服务是否正在运行,并获取模型列表
+ * 使用 /api/tags 端点(GET),超时 3s
+ */
+async function detectOllamaService(): Promise<{ running: boolean; models: string[]; version?: string }> {
+ const config = getOllamaConfig();
+ const baseUrl = config.baseUrl;
+
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), DETECT_TIMEOUT);
+
+ const resp = await fetch(`${baseUrl}/api/tags`, {
+ method: 'GET',
+ signal: controller.signal,
+ });
+ clearTimeout(timeoutId);
+
+ if (!resp.ok) {
+ return { running: false, models: [] };
+ }
+
+ const data = await resp.json() as { models?: Array<{ name: string }> };
+ const models = (data.models || []).map((m) => m.name);
+
+ // 尝试获取版本号(/api/version 端点)
+ let version: string | undefined;
+ try {
+ const vController = new AbortController();
+ const vTimeoutId = setTimeout(() => vController.abort(), 2000);
+ const vResp = await fetch(`${baseUrl}/api/version`, { signal: vController.signal });
+ clearTimeout(vTimeoutId);
+ if (vResp.ok) {
+ const vData = await vResp.json() as { version?: string };
+ version = vData.version;
+ }
+ } catch {
+ // 版本获取失败不影响主流程
+ }
+
+ return { running: true, models, version };
+ } catch {
+ return { running: false, models: [] };
+ }
+}
+
+// ================================================================
+// 公共 API
+// ================================================================
+
+/**
+ * 获取 Ollama 完整状态(带缓存)
+ * 缓存 30s,避免频繁探测
+ */
+export async function getOllamaStatus(forceRefresh = false): Promise {
+ // 检查缓存是否有效
+ if (!forceRefresh && _cachedStatus && (Date.now() - _cachedStatus.lastChecked < HEALTH_CACHE_TTL)) {
+ return _cachedStatus;
+ }
+
+ const installed = isOllamaInstalled();
+ let running = false;
+ let models: string[] = [];
+ let version: string | undefined;
+
+ if (installed) {
+ const result = await detectOllamaService();
+ running = result.running;
+ models = result.models;
+ version = result.version;
+ }
+
+ _cachedStatus = {
+ installed,
+ running,
+ models,
+ version,
+ lastChecked: Date.now(),
+ };
+
+ logger.debug(`[Ollama] Status: installed=${installed}, running=${running}, models=[${models.join(', ')}], version=${version ?? 'unknown'}`);
+ return _cachedStatus;
+}
+
+/**
+ * 快速判断 Ollama 是否可用(已启用 + 正在运行)
+ * 用于 AI Handler 降级链判断,不触发网络请求(使用缓存)
+ */
+/** 使状态缓存失效(模型增删后由 ollamaModelManager 调用) */
+export function invalidateStatusCache(): void {
_cachedStatus = null;
}
-/**
- * 删除本地模型
- * @param modelName 模型名称(如 'qwen2.5:7b')
- */
-export async function deleteModel(modelName: string): Promise {
- const config = getOllamaConfig();
- const baseUrl = config.baseUrl;
-
- logger.info(`[Ollama] Deleting model: ${modelName}`);
-
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 30_000);
-
- try {
- const resp = await fetch(`${baseUrl}/api/delete`, {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name: modelName }),
- signal: controller.signal,
- });
- clearTimeout(timeoutId);
-
- if (!resp.ok) {
- const detail = await resp.text().catch(() => 'unknown error');
- throw new Error(`Ollama delete failed: HTTP ${resp.status} - ${detail}`);
- }
-
- logger.info(`[Ollama] Model deleted successfully: ${modelName}`);
- // 清除缓存,下次获取状态时刷新模型列表
- _cachedStatus = null;
- } catch (e) {
- clearTimeout(timeoutId);
- if (e instanceof Error && e.name === 'AbortError') {
- throw new Error('Ollama delete timed out');
- }
- throw e;
- }
-}
-
-/**
- * 应用启动时执行初始检测(如果 autoDetect 开启)
- */
-export async function initOllamaDetection(): Promise {
- const config = getOllamaConfig();
- if (!config.autoDetect) {
- logger.info('[Ollama] Auto-detect disabled, skipping initial detection');
- return;
- }
-
- // 异步检测,不阻塞启动
- getOllamaStatus(true).then((status) => {
- if (status.installed && status.running) {
- logger.info(`[Ollama] Detected running Ollama v${status.version ?? '?'} with ${status.models.length} model(s)`);
- } else if (status.installed) {
- logger.info('[Ollama] Ollama installed but not running');
- }
- }).catch(() => {
- // 静默失败
- });
-}
+export function isOllamaAvailable(): boolean {
+ if (!_cachedStatus) return false;
+ // 缓存过期则视为不可用(下次 getOllamaStatus 会刷新)
+ if (Date.now() - _cachedStatus.lastChecked > HEALTH_CACHE_TTL) return false;
+ return _cachedStatus.running;
+}
+
+/**
+ * 应用启动时执行初始检测(如果 autoDetect 开启)
+ */
+export async function initOllamaDetection(): Promise {
+ const config = getOllamaConfig();
+ if (!config.autoDetect) {
+ logger.info('[Ollama] Auto-detect disabled, skipping initial detection');
+ return;
+ }
+
+ // 异步检测,不阻塞启动
+ getOllamaStatus(true).then((status) => {
+ if (status.installed && status.running) {
+ logger.info(`[Ollama] Detected running Ollama v${status.version ?? '?'} with ${status.models.length} model(s)`);
+ } else if (status.installed) {
+ logger.info('[Ollama] Ollama installed but not running');
+ }
+ }).catch(() => {
+ // 静默失败
+ });
+}
+
+// ─── 向后兼容 re-export(模型管理已拆至 ollamaModelManager.ts) ───
+export { pullModel, deleteModel } from './ollamaModelManager.js';
diff --git a/client/electron/ai/ollama/config.ts b/client/electron/ai/ollama/config.ts
index 29028333..2711cd7a 100644
--- a/client/electron/ai/ollama/config.ts
+++ b/client/electron/ai/ollama/config.ts
@@ -1,152 +1,154 @@
-/**
- * Ollama 本地推理 — 配置持久化模块
- *
- * 配置文件统一存放于 userData/ollama-config.json,
- * 与现有 ai-gateway-config.json、storage-config.json 同级。
- */
-
-import { app } from 'electron';
-import * as path from 'path';
-import { readFile, writeFile } from 'fs/promises';
-import { logger } from '../../logger.js';
-
-// ================================================================
-// 类型定义
-// ================================================================
-
-/** 模型映射配置 */
-export interface OllamaModelMapping {
- /** 通用文本模型 */
- text: string;
- /** 多模态视觉模型 */
- vision: string;
-}
-
-/** Ollama 本地推理用户配置 */
-export interface OllamaConfig {
- /** 用户是否启用本地推理 */
- enabled: boolean;
- /** Ollama 服务地址 */
- baseUrl: string;
- /** 模型映射 */
- models: OllamaModelMapping;
- /** 是否启动时自动检测 Ollama */
- autoDetect: boolean;
- /** 模型下载镜像地址(国内加速),空字符串表示使用默认 */
- registryMirror: string;
-}
-
-// ================================================================
-// 常量
-// ================================================================
-
-const CONFIG_FILE_NAME = 'ollama-config.json';
-
-/** 默认配置 */
-const DEFAULT_CONFIG: OllamaConfig = {
- enabled: false,
- baseUrl: 'http://localhost:11434',
- models: {
- text: 'qwen2.5:7b',
- vision: 'qwen2.5vl:7b',
- },
- autoDetect: true,
- registryMirror: '',
-};
-
-// ================================================================
-// 运行时状态
-// ================================================================
-
-let _config: OllamaConfig | null = null;
-
-// ================================================================
-// 公共 API
-// ================================================================
-
-/** 获取配置文件完整路径 */
-export function getConfigPath(): string {
- return path.join(app.getPath('userData'), CONFIG_FILE_NAME);
-}
-
-/**
- * 获取当前 Ollama 配置
- * 首次调用时从文件加载,后续直接返回内存缓存
- */
-export function getOllamaConfig(): OllamaConfig {
- if (_config) return { ..._config };
- return { ...DEFAULT_CONFIG };
-}
-
-/**
- * 应用启动时从持久化文件加载配置
- * 在 registerOllamaHandlers 之前调用
- */
-export async function loadOllamaConfig(): Promise {
- try {
- const configPath = getConfigPath();
- const raw = await readFile(configPath, 'utf-8');
- const parsed = JSON.parse(raw);
- _config = {
- enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULT_CONFIG.enabled,
- baseUrl: typeof parsed.baseUrl === 'string' && parsed.baseUrl.trim()
- ? parsed.baseUrl.trim().replace(/\/$/, '')
- : DEFAULT_CONFIG.baseUrl,
- models: {
- text: parsed.models?.text || DEFAULT_CONFIG.models.text,
- vision: parsed.models?.vision || DEFAULT_CONFIG.models.vision,
- },
- autoDetect: typeof parsed.autoDetect === 'boolean' ? parsed.autoDetect : DEFAULT_CONFIG.autoDetect,
- registryMirror: typeof parsed.registryMirror === 'string' ? parsed.registryMirror.trim() : DEFAULT_CONFIG.registryMirror,
- };
- logger.info(`[Ollama] Config loaded: enabled=${_config.enabled}, baseUrl=${_config.baseUrl}, text=${_config.models.text}, vision=${_config.models.vision}`);
- } catch {
- // 文件不存在或解析失败,使用默认配置
- _config = { ...DEFAULT_CONFIG };
- logger.info('[Ollama] No persisted config found, using defaults');
- }
-}
-
-/**
- * 更新 Ollama 配置并持久化
- * 支持部分更新(merge 语义)
- */
-export async function updateOllamaConfig(partial: Partial): Promise {
- const current = getOllamaConfig();
- const updated: OllamaConfig = {
- enabled: partial.enabled ?? current.enabled,
- baseUrl: partial.baseUrl
- ? partial.baseUrl.trim().replace(/\/$/, '')
- : current.baseUrl,
- models: {
- text: partial.models?.text ?? current.models.text,
- vision: partial.models?.vision ?? current.models.vision,
- },
- autoDetect: partial.autoDetect ?? current.autoDetect,
- registryMirror: partial.registryMirror !== undefined
- ? partial.registryMirror.trim()
- : current.registryMirror,
- };
-
- _config = updated;
-
- // 持久化到文件
- try {
- const configPath = getConfigPath();
- await writeFile(configPath, JSON.stringify(updated, null, 2), 'utf-8');
- logger.info(`[Ollama] Config saved: enabled=${updated.enabled}, baseUrl=${updated.baseUrl}`);
- } catch (err) {
- logger.error('[Ollama] Failed to persist config', err);
- }
-
- return { ...updated };
-}
-
-/**
- * 判断本地推理是否可用(enabled + 配置有效)
- * 注意:此函数不检测 Ollama 是否实际运行,仅检查配置开关
- */
-export function isLocalInferenceEnabled(): boolean {
- const config = getOllamaConfig();
- return config.enabled;
-}
+/**
+ * Ollama 本地推理 — 配置持久化模块
+ *
+ * 配置文件统一存放于 userData/ollama-config.json,
+ * 与现有 ai-gateway-config.json、storage-config.json 同级。
+ *
+ * @ai-context: Ollama 配置持久化(userData/ollama-config.json 路径不可改):baseUrl/enabled/autoDetect/镜像地址;渲染进程经 IPC 读写。
+ */
+
+import { app } from 'electron';
+import * as path from 'path';
+import { readFile, writeFile } from 'fs/promises';
+import { logger } from '../../logger.js';
+
+// ================================================================
+// 类型定义
+// ================================================================
+
+/** 模型映射配置 */
+export interface OllamaModelMapping {
+ /** 通用文本模型 */
+ text: string;
+ /** 多模态视觉模型 */
+ vision: string;
+}
+
+/** Ollama 本地推理用户配置 */
+export interface OllamaConfig {
+ /** 用户是否启用本地推理 */
+ enabled: boolean;
+ /** Ollama 服务地址 */
+ baseUrl: string;
+ /** 模型映射 */
+ models: OllamaModelMapping;
+ /** 是否启动时自动检测 Ollama */
+ autoDetect: boolean;
+ /** 模型下载镜像地址(国内加速),空字符串表示使用默认 */
+ registryMirror: string;
+}
+
+// ================================================================
+// 常量
+// ================================================================
+
+const CONFIG_FILE_NAME = 'ollama-config.json';
+
+/** 默认配置 */
+const DEFAULT_CONFIG: OllamaConfig = {
+ enabled: false,
+ baseUrl: 'http://localhost:11434',
+ models: {
+ text: 'qwen2.5:7b',
+ vision: 'qwen2.5vl:7b',
+ },
+ autoDetect: true,
+ registryMirror: '',
+};
+
+// ================================================================
+// 运行时状态
+// ================================================================
+
+let _config: OllamaConfig | null = null;
+
+// ================================================================
+// 公共 API
+// ================================================================
+
+/** 获取配置文件完整路径 */
+export function getConfigPath(): string {
+ return path.join(app.getPath('userData'), CONFIG_FILE_NAME);
+}
+
+/**
+ * 获取当前 Ollama 配置
+ * 首次调用时从文件加载,后续直接返回内存缓存
+ */
+export function getOllamaConfig(): OllamaConfig {
+ if (_config) return { ..._config };
+ return { ...DEFAULT_CONFIG };
+}
+
+/**
+ * 应用启动时从持久化文件加载配置
+ * 在 registerOllamaHandlers 之前调用
+ */
+export async function loadOllamaConfig(): Promise {
+ try {
+ const configPath = getConfigPath();
+ const raw = await readFile(configPath, 'utf-8');
+ const parsed = JSON.parse(raw);
+ _config = {
+ enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULT_CONFIG.enabled,
+ baseUrl: typeof parsed.baseUrl === 'string' && parsed.baseUrl.trim()
+ ? parsed.baseUrl.trim().replace(/\/$/, '')
+ : DEFAULT_CONFIG.baseUrl,
+ models: {
+ text: parsed.models?.text || DEFAULT_CONFIG.models.text,
+ vision: parsed.models?.vision || DEFAULT_CONFIG.models.vision,
+ },
+ autoDetect: typeof parsed.autoDetect === 'boolean' ? parsed.autoDetect : DEFAULT_CONFIG.autoDetect,
+ registryMirror: typeof parsed.registryMirror === 'string' ? parsed.registryMirror.trim() : DEFAULT_CONFIG.registryMirror,
+ };
+ logger.info(`[Ollama] Config loaded: enabled=${_config.enabled}, baseUrl=${_config.baseUrl}, text=${_config.models.text}, vision=${_config.models.vision}`);
+ } catch {
+ // 文件不存在或解析失败,使用默认配置
+ _config = { ...DEFAULT_CONFIG };
+ logger.info('[Ollama] No persisted config found, using defaults');
+ }
+}
+
+/**
+ * 更新 Ollama 配置并持久化
+ * 支持部分更新(merge 语义)
+ */
+export async function updateOllamaConfig(partial: Partial): Promise {
+ const current = getOllamaConfig();
+ const updated: OllamaConfig = {
+ enabled: partial.enabled ?? current.enabled,
+ baseUrl: partial.baseUrl
+ ? partial.baseUrl.trim().replace(/\/$/, '')
+ : current.baseUrl,
+ models: {
+ text: partial.models?.text ?? current.models.text,
+ vision: partial.models?.vision ?? current.models.vision,
+ },
+ autoDetect: partial.autoDetect ?? current.autoDetect,
+ registryMirror: partial.registryMirror !== undefined
+ ? partial.registryMirror.trim()
+ : current.registryMirror,
+ };
+
+ _config = updated;
+
+ // 持久化到文件
+ try {
+ const configPath = getConfigPath();
+ await writeFile(configPath, JSON.stringify(updated, null, 2), 'utf-8');
+ logger.info(`[Ollama] Config saved: enabled=${updated.enabled}, baseUrl=${updated.baseUrl}`);
+ } catch (err) {
+ logger.error('[Ollama] Failed to persist config', err);
+ }
+
+ return { ...updated };
+}
+
+/**
+ * 判断本地推理是否可用(enabled + 配置有效)
+ * 注意:此函数不检测 Ollama 是否实际运行,仅检查配置开关
+ */
+export function isLocalInferenceEnabled(): boolean {
+ const config = getOllamaConfig();
+ return config.enabled;
+}
diff --git a/client/electron/ai/ollama/index.ts b/client/electron/ai/ollama/index.ts
index ef1143a4..1c7326d8 100644
--- a/client/electron/ai/ollama/index.ts
+++ b/client/electron/ai/ollama/index.ts
@@ -1,122 +1,124 @@
-/**
- * Ollama 本地推理 — IPC Handler 注册入口
- *
- * 注册 Ollama 相关 IPC channel:
- * - ollama:get-status → 返回 OllamaStatus
- * - ollama:set-config → 更新配置
- * - ollama:pull-model → 触发模型下载(流式进度推送)
- */
-
-import { ipcMain, BrowserWindow } from 'electron';
-import { logger } from '../../logger.js';
-import { getOllamaStatus, pullModel, deleteModel, initOllamaDetection } from './OllamaService.js';
-import { loadOllamaConfig, updateOllamaConfig, getOllamaConfig } from './config.js';
-
-// ================================================================
-// IPC Handler 注册
-// ================================================================
-
-/**
- * 注册所有 Ollama 相关 IPC Handler
- * 在 registerAIHandlers() 中调用
- */
-export function registerOllamaHandlers(): void {
- logger.info('[Ollama] Registering IPC handlers...');
-
- // ---- 获取状态 ----
- ipcMain.handle('ollama:get-status', async (_event, forceRefresh?: boolean) => {
- try {
- const status = await getOllamaStatus(forceRefresh ?? false);
- const config = getOllamaConfig();
- return { status, config };
- } catch (err) {
- logger.error('[Ollama] get-status failed:', err);
- return {
- status: { installed: false, running: false, models: [], lastChecked: Date.now() },
- config: getOllamaConfig(),
- };
- }
- });
-
- // ---- 更新配置 ----
- ipcMain.handle('ollama:set-config', async (_event, partial: Record) => {
- try {
- const updated = await updateOllamaConfig(partial);
- logger.info(`[Ollama] Config updated via IPC: enabled=${updated.enabled}`);
- return updated;
- } catch (err) {
- logger.error('[Ollama] set-config failed:', err);
- throw err;
- }
- });
-
- // ---- 拉取模型 ----
- ipcMain.handle('ollama:pull-model', async (event, modelName: string) => {
- if (!modelName || typeof modelName !== 'string') {
- throw new Error('Invalid model name');
- }
-
- logger.info(`[Ollama] Pull model requested: ${modelName}`);
-
- // 获取发送进度的窗口
- const senderWindow = BrowserWindow.fromWebContents(event.sender);
-
- try {
- await pullModel(modelName, (progress) => {
- // 向渲染进程推送进度
- if (senderWindow && !senderWindow.isDestroyed()) {
- senderWindow.webContents.send('ollama:pull-progress', progress);
- }
- });
- return { success: true };
- } catch (err) {
- const errorMsg = err instanceof Error ? err.message : String(err);
- logger.error(`[Ollama] Pull model failed: ${errorMsg}`);
- // 推送错误进度
- if (senderWindow && !senderWindow.isDestroyed()) {
- senderWindow.webContents.send('ollama:pull-progress', {
- model: modelName,
- status: 'error',
- percent: 0,
- error: errorMsg,
- });
- }
- throw err;
- }
- });
-
- // ---- 删除模型 ----
- ipcMain.handle('ollama:delete-model', async (_event, modelName: string) => {
- if (!modelName || typeof modelName !== 'string') {
- throw new Error('Invalid model name');
- }
-
- logger.info(`[Ollama] Delete model requested: ${modelName}`);
-
- try {
- await deleteModel(modelName);
- return { success: true };
- } catch (err) {
- const errorMsg = err instanceof Error ? err.message : String(err);
- logger.error(`[Ollama] Delete model failed: ${errorMsg}`);
- throw err;
- }
- });
-
- logger.info('[Ollama] All IPC handlers registered');
-}
-
-/**
- * 初始化 Ollama 模块
- * 在应用启动时调用(registerAIHandlers 之前)
- */
-export async function initOllama(): Promise {
- await loadOllamaConfig();
- await initOllamaDetection();
-}
-
-// 统一导出
-export { getOllamaStatus, pullModel, deleteModel } from './OllamaService.js';
-export { getOllamaConfig, updateOllamaConfig, isLocalInferenceEnabled } from './config.js';
-export { generateText, generateVision, generateVisionMulti } from './OllamaProvider.js';
-export { isOllamaAvailable } from './OllamaService.js';
+/**
+ * Ollama 本地推理 — IPC Handler 注册入口
+ *
+ * 注册 Ollama 相关 IPC channel:
+ * - ollama:get-status → 返回 OllamaStatus
+ * - ollama:set-config → 更新配置
+ * - ollama:pull-model → 触发模型下载(流式进度推送)
+ *
+ * @ai-context: Ollama 模块 IPC 注册出口(状态/配置/模型管理通道)。
+ */
+
+import { ipcMain, BrowserWindow } from 'electron';
+import { logger } from '../../logger.js';
+import { getOllamaStatus, pullModel, deleteModel, initOllamaDetection } from './OllamaService.js';
+import { loadOllamaConfig, updateOllamaConfig, getOllamaConfig } from './config.js';
+
+// ================================================================
+// IPC Handler 注册
+// ================================================================
+
+/**
+ * 注册所有 Ollama 相关 IPC Handler
+ * 在 registerAIHandlers() 中调用
+ */
+export function registerOllamaHandlers(): void {
+ logger.info('[Ollama] Registering IPC handlers...');
+
+ // ---- 获取状态 ----
+ ipcMain.handle('ollama:get-status', async (_event, forceRefresh?: boolean) => {
+ try {
+ const status = await getOllamaStatus(forceRefresh ?? false);
+ const config = getOllamaConfig();
+ return { status, config };
+ } catch (err) {
+ logger.error('[Ollama] get-status failed:', err);
+ return {
+ status: { installed: false, running: false, models: [], lastChecked: Date.now() },
+ config: getOllamaConfig(),
+ };
+ }
+ });
+
+ // ---- 更新配置 ----
+ ipcMain.handle('ollama:set-config', async (_event, partial: Record) => {
+ try {
+ const updated = await updateOllamaConfig(partial);
+ logger.info(`[Ollama] Config updated via IPC: enabled=${updated.enabled}`);
+ return updated;
+ } catch (err) {
+ logger.error('[Ollama] set-config failed:', err);
+ throw err;
+ }
+ });
+
+ // ---- 拉取模型 ----
+ ipcMain.handle('ollama:pull-model', async (event, modelName: string) => {
+ if (!modelName || typeof modelName !== 'string') {
+ throw new Error('Invalid model name');
+ }
+
+ logger.info(`[Ollama] Pull model requested: ${modelName}`);
+
+ // 获取发送进度的窗口
+ const senderWindow = BrowserWindow.fromWebContents(event.sender);
+
+ try {
+ await pullModel(modelName, (progress) => {
+ // 向渲染进程推送进度
+ if (senderWindow && !senderWindow.isDestroyed()) {
+ senderWindow.webContents.send('ollama:pull-progress', progress);
+ }
+ });
+ return { success: true };
+ } catch (err) {
+ const errorMsg = err instanceof Error ? err.message : String(err);
+ logger.error(`[Ollama] Pull model failed: ${errorMsg}`);
+ // 推送错误进度
+ if (senderWindow && !senderWindow.isDestroyed()) {
+ senderWindow.webContents.send('ollama:pull-progress', {
+ model: modelName,
+ status: 'error',
+ percent: 0,
+ error: errorMsg,
+ });
+ }
+ throw err;
+ }
+ });
+
+ // ---- 删除模型 ----
+ ipcMain.handle('ollama:delete-model', async (_event, modelName: string) => {
+ if (!modelName || typeof modelName !== 'string') {
+ throw new Error('Invalid model name');
+ }
+
+ logger.info(`[Ollama] Delete model requested: ${modelName}`);
+
+ try {
+ await deleteModel(modelName);
+ return { success: true };
+ } catch (err) {
+ const errorMsg = err instanceof Error ? err.message : String(err);
+ logger.error(`[Ollama] Delete model failed: ${errorMsg}`);
+ throw err;
+ }
+ });
+
+ logger.info('[Ollama] All IPC handlers registered');
+}
+
+/**
+ * 初始化 Ollama 模块
+ * 在应用启动时调用(registerAIHandlers 之前)
+ */
+export async function initOllama(): Promise {
+ await loadOllamaConfig();
+ await initOllamaDetection();
+}
+
+// 统一导出
+export { getOllamaStatus, pullModel, deleteModel } from './OllamaService.js';
+export { getOllamaConfig, updateOllamaConfig, isLocalInferenceEnabled } from './config.js';
+export { generateText, generateVision, generateVisionMulti } from './OllamaProvider.js';
+export { isOllamaAvailable } from './OllamaService.js';
diff --git a/client/electron/ai/ollama/ollamaModelManager.ts b/client/electron/ai/ollama/ollamaModelManager.ts
new file mode 100644
index 00000000..970ea206
--- /dev/null
+++ b/client/electron/ai/ollama/ollamaModelManager.ts
@@ -0,0 +1,148 @@
+/**
+ * Ollama 模型管理(拉取/删除)
+ *
+ * @ai-context: 从 OllamaService.ts 拆出。pullModel 流式读取 /api/pull
+ * 的 NDJSON 进度(downloading→verifying→complete),progress 回调供
+ * IPC 转发到渲染进程进度条;操作成功后必须 invalidateStatusCache()
+ * 使状态缓存失效,否则模型列表不刷新。
+ * @ai-context: 模型下载走用户配置的 baseUrl(含国内镜像支持,见
+ * ollama/config.ts);deleteModel 30s 超时。
+ */
+import { logger } from '../../logger.js';
+import { getOllamaConfig } from './config.js';
+import { invalidateStatusCache, type PullProgressData } from './OllamaService.js';
+
+/**
+ * 拉取模型(流式进度)
+ * @param modelName 模型名称(如 'qwen2.5:7b')
+ * @param onProgress 进度回调
+ */
+export async function pullModel(
+ modelName: string,
+ onProgress?: (progress: PullProgressData) => void,
+): Promise {
+ const config = getOllamaConfig();
+ const baseUrl = config.baseUrl;
+
+ logger.info(`[Ollama] Pulling model: ${modelName}`);
+
+ const resp = await fetch(`${baseUrl}/api/pull`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: modelName, stream: true }),
+ });
+
+ if (!resp.ok) {
+ const detail = await resp.text().catch(() => 'unknown error');
+ throw new Error(`Ollama pull failed: HTTP ${resp.status} - ${detail}`);
+ }
+
+ if (!resp.body) {
+ throw new Error('Ollama pull: no response body');
+ }
+
+ // 流式读取 NDJSON 进度
+ const reader = resp.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+
+ for (const line of lines) {
+ if (!line.trim()) continue;
+ try {
+ const data = JSON.parse(line) as {
+ status?: string;
+ completed?: number;
+ total?: number;
+ error?: string;
+ };
+
+ if (data.error) {
+ onProgress?.({
+ model: modelName,
+ status: 'error',
+ percent: 0,
+ error: data.error,
+ });
+ throw new Error(`Ollama pull error: ${data.error}`);
+ }
+
+ const status = data.status || '';
+ if (status === 'success') {
+ onProgress?.({ model: modelName, status: 'complete', percent: 100 });
+ logger.info(`[Ollama] Model pulled successfully: ${modelName}`);
+ // 刷新缓存
+ invalidateStatusCache();
+ return;
+ }
+
+ // 下载进度
+ if (data.total && data.completed != null) {
+ const percent = Math.round((data.completed / data.total) * 100);
+ onProgress?.({
+ model: modelName,
+ status: 'downloading',
+ percent,
+ completedBytes: data.completed,
+ totalBytes: data.total,
+ });
+ } else if (status.includes('verif')) {
+ onProgress?.({ model: modelName, status: 'verifying', percent: 99 });
+ }
+ } catch (e) {
+ if (e instanceof Error && e.message.startsWith('Ollama pull error')) throw e;
+ // JSON 解析失败,跳过该行
+ }
+ }
+ }
+
+ // 流结束但未收到 success
+ onProgress?.({ model: modelName, status: 'complete', percent: 100 });
+ invalidateStatusCache();
+}
+
+/**
+ * 删除本地模型
+ * @param modelName 模型名称(如 'qwen2.5:7b')
+ */
+export async function deleteModel(modelName: string): Promise {
+ const config = getOllamaConfig();
+ const baseUrl = config.baseUrl;
+
+ logger.info(`[Ollama] Deleting model: ${modelName}`);
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 30_000);
+
+ try {
+ const resp = await fetch(`${baseUrl}/api/delete`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: modelName }),
+ signal: controller.signal,
+ });
+ clearTimeout(timeoutId);
+
+ if (!resp.ok) {
+ const detail = await resp.text().catch(() => 'unknown error');
+ throw new Error(`Ollama delete failed: HTTP ${resp.status} - ${detail}`);
+ }
+
+ logger.info(`[Ollama] Model deleted successfully: ${modelName}`);
+ // 清除缓存,下次获取状态时刷新模型列表
+ invalidateStatusCache();
+ } catch (e) {
+ clearTimeout(timeoutId);
+ if (e instanceof Error && e.name === 'AbortError') {
+ throw new Error('Ollama delete timed out');
+ }
+ throw e;
+ }
+}
diff --git a/client/electron/ai/streamHandler.ts b/client/electron/ai/streamHandler.ts
index 7002c364..d1f6a933 100644
--- a/client/electron/ai/streamHandler.ts
+++ b/client/electron/ai/streamHandler.ts
@@ -1,152 +1,154 @@
-/**
- * AI 流式输出 IPC Handler
- *
- * 处理 ai:stream:start IPC 请求,通过 postJsonStream() 获取 SSE 流,
- * 将 chunk 以 50ms 节流推送到渲染进程。
- *
- * 通信协议:
- * - 渲染进程 → 主进程:ipcRenderer.invoke('ai:stream:start', { requestId, method, payload })
- * - 主进程 → 渲染进程:
- * - 'ai:stream:chunk' { requestId, chunk }
- * - 'ai:stream:end' { requestId }
- * - 'ai:stream:error' { requestId, error }
- */
-
-import { safeHandle } from '../ipcUtils.js';
-import { logger } from '../logger.js';
-import { postJsonStream } from './utils.js';
-
-// ================================================================
-// 节流配置
-// ================================================================
-
-/** IPC chunk 推送节流间隔(ms),防止高频 chunk 风暴 */
-const CHUNK_THROTTLE_MS = 50;
-
-/** 活跃的流式请求 AbortController 映射(requestId → controller) */
-const activeStreams = new Map();
-
-// ================================================================
-// IPC Handler 注册
-// ================================================================
-
-/**
- * 注册 ai:stream:start handler
- *
- * 渲染进程通过 invoke 发起流式请求,主进程逐 chunk 推送到渲染进程。
- * 使用 50ms 节流合并高频 chunk,防止 IPC 风暴。
- */
-export function registerStreamHandler(): void {
- safeHandle(
- 'ai:stream:start',
- async (
- event,
- args: {
- requestId: string;
- /** API 路径,如 /api/v1/ai/summarize/stream */
- method: string;
- /** 请求体 */
- payload: Record;
- /** 认证 token */
- authToken?: string;
- /** 用户 API Key */
- userApiKey?: string;
- },
- ) => {
- const { requestId, method, payload, authToken, userApiKey } = args;
- const sender = event.sender;
-
- logger.info(`[AI] [stream] Start: requestId=${requestId}, method=${method}`);
-
- // 为该流式请求创建 AbortController
- const abortController = new AbortController();
- activeStreams.set(requestId, abortController);
-
- // 50ms 节流缓冲
- let chunkBuffer = '';
- let throttleTimer: ReturnType | null = null;
- let cancelled = false;
-
- /** 将缓冲区内的 chunk 一次性推送到渲染进程 */
- function flushChunks(): void {
- if (cancelled || !chunkBuffer) return;
- if (sender.isDestroyed()) return;
- sender.send('ai:stream:chunk', { requestId, chunk: chunkBuffer });
- chunkBuffer = '';
- throttleTimer = null;
- }
-
- try {
- const stream = postJsonStream(
- method,
- payload,
- authToken,
- userApiKey,
- );
-
- for await (const chunk of stream) {
- if (cancelled || abortController.signal.aborted) {
- cancelled = true;
- break;
- }
-
- chunkBuffer += chunk;
-
- // 启动节流定时器(仅在无定时器运行时)
- if (!throttleTimer) {
- throttleTimer = setTimeout(flushChunks, CHUNK_THROTTLE_MS);
- }
- }
-
- // 流结束:刷出剩余缓冲
- if (throttleTimer) {
- clearTimeout(throttleTimer);
- throttleTimer = null;
- }
- flushChunks();
-
- // 推送结束信号
- if (!cancelled && !sender.isDestroyed()) {
- sender.send('ai:stream:end', { requestId });
- }
-
- logger.info(`[AI] [stream] Complete: requestId=${requestId}`);
- return { ok: true, requestId };
- } catch (err) {
- // 流异常:刷出剩余缓冲,推送错误信号
- if (throttleTimer) {
- clearTimeout(throttleTimer);
- throttleTimer = null;
- }
- flushChunks();
-
- const errorMessage = err instanceof Error ? err.message : String(err);
- logger.error(`[AI] [stream] Error: requestId=${requestId}, error=${errorMessage}`);
-
- if (!cancelled && !sender.isDestroyed()) {
- sender.send('ai:stream:error', { requestId, error: errorMessage });
- }
-
- return { ok: false, requestId, error: errorMessage };
- } finally {
- activeStreams.delete(requestId);
- }
- },
- );
-
- // 注册取消流式请求的 handler
- safeHandle(
- 'ai:stream:cancel',
- async (_event, args: { requestId: string }) => {
- const { requestId } = args;
- logger.info(`[AI] [stream] Cancel requested: requestId=${requestId}`);
- const controller = activeStreams.get(requestId);
- if (controller) {
- controller.abort();
- activeStreams.delete(requestId);
- logger.info(`[AI] [stream] Cancelled: requestId=${requestId}`);
- }
- return { ok: true };
- },
- );
-}
+/**
+ * AI 流式输出 IPC Handler
+ *
+ * 处理 ai:stream:start IPC 请求,通过 postJsonStream() 获取 SSE 流,
+ * 将 chunk 以 50ms 节流推送到渲染进程。
+ *
+ * 通信协议:
+ * - 渲染进程 → 主进程:ipcRenderer.invoke('ai:stream:start', { requestId, method, payload })
+ * - 主进程 → 渲染进程:
+ * - 'ai:stream:chunk' { requestId, chunk }
+ * - 'ai:stream:end' { requestId }
+ * - 'ai:stream:error' { requestId, error }
+ *
+ * @ai-context: AI 流式请求 IPC(ai:stream:start):postJsonStream 逐 chunk 经 ai:stream:chunk/end/error 事件推回渲染进程,requestId 关联。
+ */
+
+import { safeHandle } from '../ipcUtils.js';
+import { logger } from '../logger.js';
+import { postJsonStream } from './utils.js';
+
+// ================================================================
+// 节流配置
+// ================================================================
+
+/** IPC chunk 推送节流间隔(ms),防止高频 chunk 风暴 */
+const CHUNK_THROTTLE_MS = 50;
+
+/** 活跃的流式请求 AbortController 映射(requestId → controller) */
+const activeStreams = new Map();
+
+// ================================================================
+// IPC Handler 注册
+// ================================================================
+
+/**
+ * 注册 ai:stream:start handler
+ *
+ * 渲染进程通过 invoke 发起流式请求,主进程逐 chunk 推送到渲染进程。
+ * 使用 50ms 节流合并高频 chunk,防止 IPC 风暴。
+ */
+export function registerStreamHandler(): void {
+ safeHandle(
+ 'ai:stream:start',
+ async (
+ event,
+ args: {
+ requestId: string;
+ /** API 路径,如 /api/v1/ai/summarize/stream */
+ method: string;
+ /** 请求体 */
+ payload: Record;
+ /** 认证 token */
+ authToken?: string;
+ /** 用户 API Key */
+ userApiKey?: string;
+ },
+ ) => {
+ const { requestId, method, payload, authToken, userApiKey } = args;
+ const sender = event.sender;
+
+ logger.info(`[AI] [stream] Start: requestId=${requestId}, method=${method}`);
+
+ // 为该流式请求创建 AbortController
+ const abortController = new AbortController();
+ activeStreams.set(requestId, abortController);
+
+ // 50ms 节流缓冲
+ let chunkBuffer = '';
+ let throttleTimer: ReturnType | null = null;
+ let cancelled = false;
+
+ /** 将缓冲区内的 chunk 一次性推送到渲染进程 */
+ function flushChunks(): void {
+ if (cancelled || !chunkBuffer) return;
+ if (sender.isDestroyed()) return;
+ sender.send('ai:stream:chunk', { requestId, chunk: chunkBuffer });
+ chunkBuffer = '';
+ throttleTimer = null;
+ }
+
+ try {
+ const stream = postJsonStream(
+ method,
+ payload,
+ authToken,
+ userApiKey,
+ );
+
+ for await (const chunk of stream) {
+ if (cancelled || abortController.signal.aborted) {
+ cancelled = true;
+ break;
+ }
+
+ chunkBuffer += chunk;
+
+ // 启动节流定时器(仅在无定时器运行时)
+ if (!throttleTimer) {
+ throttleTimer = setTimeout(flushChunks, CHUNK_THROTTLE_MS);
+ }
+ }
+
+ // 流结束:刷出剩余缓冲
+ if (throttleTimer) {
+ clearTimeout(throttleTimer);
+ throttleTimer = null;
+ }
+ flushChunks();
+
+ // 推送结束信号
+ if (!cancelled && !sender.isDestroyed()) {
+ sender.send('ai:stream:end', { requestId });
+ }
+
+ logger.info(`[AI] [stream] Complete: requestId=${requestId}`);
+ return { ok: true, requestId };
+ } catch (err) {
+ // 流异常:刷出剩余缓冲,推送错误信号
+ if (throttleTimer) {
+ clearTimeout(throttleTimer);
+ throttleTimer = null;
+ }
+ flushChunks();
+
+ const errorMessage = err instanceof Error ? err.message : String(err);
+ logger.error(`[AI] [stream] Error: requestId=${requestId}, error=${errorMessage}`);
+
+ if (!cancelled && !sender.isDestroyed()) {
+ sender.send('ai:stream:error', { requestId, error: errorMessage });
+ }
+
+ return { ok: false, requestId, error: errorMessage };
+ } finally {
+ activeStreams.delete(requestId);
+ }
+ },
+ );
+
+ // 注册取消流式请求的 handler
+ safeHandle(
+ 'ai:stream:cancel',
+ async (_event, args: { requestId: string }) => {
+ const { requestId } = args;
+ logger.info(`[AI] [stream] Cancel requested: requestId=${requestId}`);
+ const controller = activeStreams.get(requestId);
+ if (controller) {
+ controller.abort();
+ activeStreams.delete(requestId);
+ logger.info(`[AI] [stream] Cancelled: requestId=${requestId}`);
+ }
+ return { ok: true };
+ },
+ );
+}
diff --git a/client/electron/ai/utils.ts b/client/electron/ai/utils.ts
index 9b35e88b..a37a245f 100644
--- a/client/electron/ai/utils.ts
+++ b/client/electron/ai/utils.ts
@@ -1,505 +1,42 @@
-/**
- * AI 网关公共工具函数
- *
- * 提取自 aiHandlers.ts,包含所有 AI handler 共用的
- * POST 请求辅助函数、网关地址管理和功能注册表接口定义。
- */
-
-import { app } from 'electron';
-import * as path from 'path';
-import { randomUUID } from 'crypto';
-import { readFile, writeFile } from 'fs/promises';
-import { logger } from '../logger.js';
-import { isLocalInferenceEnabled } from './ollama/config.js';
-import { isOllamaAvailable } from './ollama/OllamaService.js';
-
-// ================================================================
-// 常量
-// ================================================================
-
-const DEFAULT_GATEWAY_URL = 'https://entropydecrease.com';
-const GATEWAY_CONFIG_FILE = 'ai-gateway-config.json';
-
-// ── 运行时网关地址(渲染进程通过 IPC 同步) ──
-let _runtimeGatewayUrl: string | null = null;
-
-/** 记录 gatewayUrl() 是否已打印过首次解析日志,避免重复输出 */
-let _gatewayFirstResolveLogged = false;
-
-/**
- * 判定当前是否为开发模式
- * 可靠依据:electron:dev 脚本设置 NODE_ENV=development,安装包运行时 app.isPackaged=true
- */
-export function isDevMode(): boolean {
- return process.env.NODE_ENV === 'development' || !app.isPackaged;
-}
-
-/**
- * 获取 AI 网关地址
- *
- * 按模式分流优先级:
- * - 开发模式:环境变量 > 运行时 IPC > 默认值
- * - 生产模式:运行时 IPC > 持久化文件(已存入_runtimeGatewayUrl) > 环境变量 > 默认值
- */
-export function gatewayUrl(): string {
- const url = _resolveGatewayUrl();
- if (!_gatewayFirstResolveLogged) {
- _gatewayFirstResolveLogged = true;
- const dev = isDevMode();
- const source = dev
- ? (process.env.VITE_AI_GATEWAY_URL
- ? `env (VITE_AI_GATEWAY_URL=${process.env.VITE_AI_GATEWAY_URL})`
- : _runtimeGatewayUrl
- ? 'runtime (IPC)'
- : 'DEFAULT (hardcoded fallback)')
- : (_runtimeGatewayUrl
- ? 'runtime (IPC/persisted)'
- : process.env.VITE_AI_GATEWAY_URL
- ? `env (VITE_AI_GATEWAY_URL=${process.env.VITE_AI_GATEWAY_URL})`
- : 'DEFAULT (hardcoded fallback)');
- logger.info(`[AI] Gateway URL resolved: ${url} [source: ${source}, mode: ${dev ? 'dev' : 'prod'}]`);
- if (!process.env.VITE_AI_GATEWAY_URL && !_runtimeGatewayUrl) {
- logger.warn('[AI] Gateway URL fell back to DEFAULT. Set VITE_AI_GATEWAY_URL in .env or configure via AI settings.');
- }
- }
- return url;
-}
-
-/** 内部解析逻辑,按模式分流优先级 */
-function _resolveGatewayUrl(): string {
- if (isDevMode()) {
- // 开发模式:环境变量优先,持久化不覆盖开发配置
- return process.env.VITE_AI_GATEWAY_URL || _runtimeGatewayUrl || DEFAULT_GATEWAY_URL;
- }
- // 生产模式:运行时/持久化 > 环境变量 > 默认值
- return _runtimeGatewayUrl || process.env.VITE_AI_GATEWAY_URL || DEFAULT_GATEWAY_URL;
-}
-
-/**
- * 设置运行时网关地址(由渲染进程通过 IPC 调用)
- * 同时持久化到 userData 目录,确保主进程重启后仍可用
- */
-export async function setRuntimeGatewayUrl(url: string): Promise {
- _runtimeGatewayUrl = url;
- // 重置首次解析日志标记,使下次 gatewayUrl() 重新打印来源
- _gatewayFirstResolveLogged = false;
- logger.info(`[AI] Runtime gateway URL set via IPC: ${url}`);
- // 开发模式不写入持久化文件,防止调试数据污染生产配置
- if (isDevMode()) {
- logger.info('[AI] Dev mode: skip persisting gateway URL to file');
- return;
- }
- // 持久化到文件
- try {
- const configPath = path.join(app.getPath('userData'), GATEWAY_CONFIG_FILE);
- await writeFile(configPath, JSON.stringify({ gatewayUrl: url }), 'utf-8');
- } catch (err) {
- logger.error('[AI-Gateway] Failed to persist gateway URL', err);
- }
-}
-
-/**
- * 应用启动时从持久化文件加载网关地址
- * 在 registerAIHandlers 之前调用
- */
-export async function loadPersistedGatewayUrl(): Promise {
- if (isDevMode()) {
- logger.info('[AI] Dev mode: skip loading persisted gateway URL (using .env config)');
- return;
- }
- try {
- const configPath = path.join(app.getPath('userData'), GATEWAY_CONFIG_FILE);
- const raw = await readFile(configPath, 'utf-8');
- const config = JSON.parse(raw);
- if (config.gatewayUrl) {
- _runtimeGatewayUrl = config.gatewayUrl;
- logger.info(`[AI] Loaded persisted gateway URL from file: ${config.gatewayUrl}`);
- }
- } catch {
- // 文件不存在或解析失败,静默忽略
- }
-}
-
-// ================================================================
-// AI 功能注册表接口定义
-// ================================================================
-
-/**
- * AI 功能定义接口
- *
- * 每个 AI handler 文件导出一个符合此接口的对象,
- * 由 ai/index.ts 统一收集并注册到 IPC 系统。
- */
-export interface AIFeatureDef {
- /** 功能唯一标识符,对应 IPC channel 名称 */
- id: string;
- /** 功能显示名称 */
- name: string;
- /** 功能版本 */
- version: string;
- /** 注册函数,由注册引擎调用 */
- register: () => void;
-}
-
-// ================================================================
-// 通用 POST 请求辅助函数
-// ================================================================
-
-/**
- * 通用 POST 请求辅助函数:
- * 1. 将请求体序列化为 JSON
- * 2. 如有 authToken,添加 Authorization header
- * 3. HTTP 失败时抛出包含状态码和详情的错误字符串
- * 4. 返回解析后的 JSON 响应
- */
-export async function postJson(
- apiPath: string,
- body: TReq,
- authToken?: string,
- userApiKey?: string,
- timeoutMs: number = 60000,
-): Promise<{ data: TRes; requestId: string | undefined }> {
- const base = gatewayUrl();
- if (!base) {
- throw new Error('[AI] Gateway URL not configured. Set VITE_AI_GATEWAY_URL in .env or configure via AI settings');
- }
- const url = `${base}${apiPath}`;
- const startTime = Date.now();
- const clientRequestId = randomUUID();
-
- // ── 请求前日志 ──
- logger.info(`[AI] → POST ${url} [req-id: ${clientRequestId}]`);
- logger.debug(`[AI] Request config: timeout=${timeoutMs}ms, hasAuth=${!!authToken}, hasUserKey=${!!userApiKey}, bodyKeys=${Object.keys(body as Record).join(',')}`);
-
- const headers: Record = {
- 'Content-Type': 'application/json',
- 'X-Request-ID': clientRequestId,
- };
- if (authToken) {
- headers['Authorization'] = `Bearer ${authToken}`;
- }
- if (userApiKey) {
- headers['X-User-API-Key'] = userApiKey;
- }
-
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
-
- let resp: Response;
- try {
- resp = await fetch(url, {
- method: 'POST',
- headers,
- body: JSON.stringify(body),
- signal: controller.signal,
- });
- } catch (networkError: unknown) {
- const elapsed = Date.now() - startTime;
- const err = networkError as { name?: string; message?: string; cause?: unknown };
- if (err.name === 'AbortError') {
- logger.error(`[AI] ✖ TIMEOUT ${url} after ${elapsed}ms`);
- throw new Error(`Request timeout after ${timeoutMs}ms`);
- }
- // 详细网络错误诊断
- const cause = err.cause ? String(err.cause) : '';
- const errDetail = err.message || String(networkError);
- logger.error(`[AI] ✖ NETWORK_ERROR ${url} after ${elapsed}ms: ${errDetail}${cause ? ` (cause: ${cause})` : ''}`);
- // 识别常见网络错误码
- if (/ECONNREFUSED/i.test(errDetail)) {
- logger.error('[AI] Hint: Connection refused — check if AI Gateway service is running and the URL is correct');
- } else if (/ENOTFOUND/i.test(errDetail)) {
- logger.error('[AI] Hint: DNS resolution failed — check the gateway URL hostname');
- } else if (/ETIMEDOUT/i.test(errDetail)) {
- logger.error('[AI] Hint: Connection timed out — check network connectivity and firewall rules');
- }
- throw new Error(`Network error: ${errDetail}`);
- } finally {
- clearTimeout(timeoutId);
- }
-
- const elapsed = Date.now() - startTime;
- const requestId = resp.headers.get('ai-gateway-request-id') ?? undefined;
-
- if (!resp.ok) {
- const detail = await resp.text().catch(() => 'unknown error');
- // 截取响应体前 500 字符防止日志爆炸
- const detailPreview = detail.length > 500 ? `${detail.slice(0, 500)}...(+${detail.length - 500} chars)` : detail;
- logger.error(`[AI] ✖ HTTP ${resp.status} ${url} (${elapsed}ms) [req-id: ${requestId ?? clientRequestId}]: ${detailPreview}`);
- throw new Error(`HTTP ${resp.status}: ${detail}`);
- }
-
- logger.info(`[AI] ← ${resp.status} ${url} (${elapsed}ms)${requestId ? ` [req-id: ${requestId}]` : ''}`);
-
- try {
- const data = (await resp.json()) as TRes;
- return { data, requestId };
- } catch (e) {
- logger.error(`[AI] Response JSON parse error for ${url}: ${e}`);
- throw new Error(`Response parse error: ${e}`);
- }
-}
-
-/**
- * Multipart POST 请求辅助函数:
- * 1. 不设置 Content-Type header(Node.js fetch 自动设置 multipart/form-data; boundary=...)
- * 2. body 直接传 FormData(不做 JSON.stringify)
- * 3. 默认超时 300000ms(5 分钟,视频文件较大)
- * 4. 其余逻辑(gatewayUrl、AbortController、日志、request-id、错误处理)与 postJson 保持一致
- */
-export async function postMultipart(
- apiPath: string,
- formData: FormData,
- authToken?: string,
- userApiKey?: string,
- timeoutMs: number = 300000,
-): Promise<{ data: TRes; requestId: string | undefined }> {
- const base = gatewayUrl();
- if (!base) {
- throw new Error('[AI] Gateway URL not configured. Set VITE_AI_GATEWAY_URL in .env or configure via AI settings');
- }
- const url = `${base}${apiPath}`;
- const startTime = Date.now();
- const clientRequestId = randomUUID();
-
- // ── 请求前日志 ──
- logger.info(`[AI] → POST ${url} [req-id: ${clientRequestId}]`);
- logger.debug(`[AI] Request config: timeout=${timeoutMs}ms, hasAuth=${!!authToken}, hasUserKey=${!!userApiKey}, body=FormData`);
-
- const headers: Record = {
- 'X-Request-ID': clientRequestId,
- };
- if (authToken) {
- headers['Authorization'] = `Bearer ${authToken}`;
- }
- if (userApiKey) {
- headers['X-User-API-Key'] = userApiKey;
- }
-
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
-
- let resp: Response;
- try {
- resp = await fetch(url, {
- method: 'POST',
- headers,
- body: formData,
- signal: controller.signal,
- });
- } catch (networkError: unknown) {
- const elapsed = Date.now() - startTime;
- const err = networkError as { name?: string; message?: string; cause?: unknown };
- if (err.name === 'AbortError') {
- logger.error(`[AI] ✖ TIMEOUT ${url} after ${elapsed}ms`);
- throw new Error(`Request timeout after ${timeoutMs}ms`);
- }
- // 详细网络错误诊断
- const cause = err.cause ? String(err.cause) : '';
- const errDetail = err.message || String(networkError);
- logger.error(`[AI] ✖ NETWORK_ERROR ${url} after ${elapsed}ms: ${errDetail}${cause ? ` (cause: ${cause})` : ''}`);
- // 识别常见网络错误码
- if (/ECONNREFUSED/i.test(errDetail)) {
- logger.error('[AI] Hint: Connection refused — check if AI Gateway service is running and the URL is correct');
- } else if (/ENOTFOUND/i.test(errDetail)) {
- logger.error('[AI] Hint: DNS resolution failed — check the gateway URL hostname');
- } else if (/ETIMEDOUT/i.test(errDetail)) {
- logger.error('[AI] Hint: Connection timed out — check network connectivity and firewall rules');
- }
- throw new Error(`Network error: ${errDetail}`);
- } finally {
- clearTimeout(timeoutId);
- }
-
- const elapsed = Date.now() - startTime;
- const requestId = resp.headers.get('ai-gateway-request-id') ?? undefined;
-
- if (!resp.ok) {
- const detail = await resp.text().catch(() => 'unknown error');
- // 截取响应体前 500 字符防止日志爆炸
- const detailPreview = detail.length > 500 ? `${detail.slice(0, 500)}...(+${detail.length - 500} chars)` : detail;
- logger.error(`[AI] ✖ HTTP ${resp.status} ${url} (${elapsed}ms) [req-id: ${requestId ?? clientRequestId}]: ${detailPreview}`);
- throw new Error(`HTTP ${resp.status}: ${detail}`);
- }
-
- logger.info(`[AI] ← ${resp.status} ${url} (${elapsed}ms)${requestId ? ` [req-id: ${requestId}]` : ''}`);
-
- try {
- const data = (await resp.json()) as TRes;
- return { data, requestId };
- } catch (e) {
- logger.error(`[AI] Response JSON parse error for ${url}: ${e}`);
- throw new Error(`Response parse error: ${e}`);
- }
-}
-
-// ================================================================
-// 本地 Ollama 降级链
-// ================================================================
-
-/**
- * 带本地 Ollama 降级的调用函数
- *
- * 逻辑:
- * 1. 检查 OllamaConfig.enabled && OllamaService.isRunning()
- * 2. 是 → 调用 localHandler(),成功则返回 { source: 'local' }
- * 3. 本地失败/未启用 → 调用现有 postJson()(远程 AI Gateway)
- *
- * @param apiPath 远程 API 路径
- * @param body 请求体
- * @param localHandler 本地 Ollama 调用函数
- * @param authToken 认证 token
- * @param userApiKey 用户 API Key
- * @param timeoutMs 远程调用超时
- */
-export async function callWithLocalFallback(
- apiPath: string,
- body: TReq,
- localHandler: () => Promise,
- authToken?: string,
- userApiKey?: string,
- timeoutMs: number = 60000,
-): Promise<{ data: TRes; source: 'local' | 'remote'; requestId?: string }> {
- // 检查本地 Ollama 是否可用
- if (isLocalInferenceEnabled() && isOllamaAvailable()) {
- try {
- const localResult = await localHandler();
- logger.info(`[AI] ← Local Ollama success for ${apiPath}`);
- return { data: localResult, source: 'local' };
- } catch (localErr) {
- const errMsg = localErr instanceof Error ? localErr.message : String(localErr);
- logger.warn(`[AI] Local Ollama failed for ${apiPath}, falling back to remote: ${errMsg}`);
- // 本地失败,降级到远程
- }
- }
-
- // 远程 AI Gateway 调用
- const { data, requestId } = await postJson(
- apiPath,
- body,
- authToken,
- userApiKey,
- timeoutMs,
- );
- return { data, source: 'remote', requestId };
-}
-
-// ================================================================
-// 流式 POST 请求辅助函数(SSE 解析)
-// ================================================================
-
-/**
- * 流式 POST 请求:解析 SSE data: 行,逐 chunk yield 文本
- *
- * @param apiPath API 路径(如 /api/v1/ai/summarize/stream)
- * @param body 请求体
- * @param authToken 认证 token
- * @param userApiKey 用户 API Key
- * @param timeoutMs 超时时间
- */
-export async function* postJsonStream(
- apiPath: string,
- body: TReq,
- authToken?: string,
- userApiKey?: string,
- timeoutMs: number = 300000,
-): AsyncGenerator {
- const base = gatewayUrl();
- if (!base) {
- throw new Error('[AI] Gateway URL not configured');
- }
- const url = `${base}${apiPath}`;
- const clientRequestId = randomUUID();
-
- logger.info(`[AI] → POST (stream) ${url} [req-id: ${clientRequestId}]`);
-
- const headers: Record = {
- 'Content-Type': 'application/json',
- 'X-Request-ID': clientRequestId,
- };
- if (authToken) {
- headers['Authorization'] = `Bearer ${authToken}`;
- }
- if (userApiKey) {
- headers['X-User-API-Key'] = userApiKey;
- }
-
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
-
- let resp: Response;
- try {
- resp = await fetch(url, {
- method: 'POST',
- headers,
- body: JSON.stringify(body),
- signal: controller.signal,
- });
- } catch (networkError: unknown) {
- const err = networkError as { name?: string; message?: string };
- if (err.name === 'AbortError') {
- throw new Error(`Stream request timeout after ${timeoutMs}ms`);
- }
- throw new Error(`Stream network error: ${err.message || String(networkError)}`);
- } finally {
- clearTimeout(timeoutId);
- }
-
- const requestId = resp.headers.get('ai-gateway-request-id') ?? undefined;
-
- if (!resp.ok) {
- const detail = await resp.text().catch(() => 'unknown error');
- logger.error(`[AI] ✖ Stream HTTP ${resp.status} ${url} [req-id: ${requestId ?? clientRequestId}]: ${detail.slice(0, 200)}`);
- throw new Error(`Stream HTTP ${resp.status}: ${detail}`);
- }
-
- logger.info(`[AI] ← Stream started ${url}${requestId ? ` [req-id: ${requestId}]` : ''}`);
-
- if (!resp.body) {
- throw new Error('Stream response body is null');
- }
-
- const reader = resp.body.getReader();
- const decoder = new TextDecoder();
- let buffer = '';
-
- try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- buffer += decoder.decode(value, { stream: true });
-
- // 按 \n\n 分割 SSE 事件
- const events = buffer.split('\n\n');
- buffer = events.pop() || '';
-
- for (const event of events) {
- const lines = event.split('\n');
- for (const line of lines) {
- if (line.startsWith('data: ')) {
- const data = line.slice(6).trim();
- if (data === '[DONE]') {
- return;
- }
- try {
- const parsed = JSON.parse(data);
- if (parsed.error) {
- throw new Error(`Stream error: ${parsed.error}`);
- }
- if (parsed.chunk) {
- yield parsed.chunk;
- }
- } catch (e) {
- // JSON 解析失败,尝试作为纯文本
- if (data && data !== '[DONE]') {
- yield data;
- }
- }
- }
- }
- }
- }
- } finally {
- reader.releaseLock();
- }
-}
+/**
+ * AI 网关公共工具 — 统一出口 barrel
+ *
+ * @ai-context: 2026-07 拆分——地址管理在 gatewayConfig、HTTP 请求在
+ * gatewayHttp、SSE 流式在 gatewayStream;本文件保留 AIFeatureDef 接口
+ * 定义并 re-export 全部符号,全部 handler 的旧导入路径零改动。
+ */
+
+// ================================================================
+// AI 功能注册表接口定义
+// ================================================================
+
+/**
+ * AI 功能定义接口
+ *
+ * 每个 AI handler 文件导出一个符合此接口的对象,
+ * 由 ai/index.ts 统一收集并注册到 IPC 系统。
+ */
+export interface AIFeatureDef {
+ /** 功能唯一标识符,对应 IPC channel 名称 */
+ id: string;
+ /** 功能显示名称 */
+ name: string;
+ /** 功能版本 */
+ version: string;
+ /** 注册函数,由注册引擎调用 */
+ register: () => void;
+}
+
+// ================================================================
+// 向后兼容 re-export
+// ================================================================
+
+export {
+ isDevMode, gatewayUrl, setRuntimeGatewayUrl, loadPersistedGatewayUrl,
+} from './gatewayConfig.js';
+export {
+ postJson, postMultipart, callWithLocalFallback,
+} from './gatewayHttp.js';
+export {
+ postJsonStream,
+} from './gatewayStream.js';
diff --git a/client/electron/audioCapture.ts b/client/electron/audioCapture.ts
index 54c8420a..85757a9b 100644
--- a/client/electron/audioCapture.ts
+++ b/client/electron/audioCapture.ts
@@ -1,201 +1,203 @@
-/**
- * Electron 主进程系统音频捕获模块
- *
- * 架构:
- * 1. 主进程使用 desktopCapturer.getSources({ types: ['screen'] }) 枚举屏幕源作为音频环回候选
- * 2. 将音频 sourceId 传递给渲染进程
- * 3. 渲染进程通过 getUserMedia + chromeMediaSource: 'desktop' 获取 MediaStream
- * 4. 渲染进程使用 Web Audio API (AudioContext + ScriptProcessor) 切片
- * 5. PCM 数据块通过 IPC 回传主进程,添加单调时间戳后推送给消费者
- */
-
-import { desktopCapturer, DesktopCapturerSource, BrowserWindow } from 'electron';
-import { logger } from './logger';
-
-// ================================================================
-// 类型定义
-// ================================================================
-
-/** 音频源信息 */
-export interface AudioSourceInfo {
- id: string;
- name: string;
-}
-
-/** 音频捕获配置 */
-export interface AudioCaptureOptions {
- chunkDurationMs: number; // 音频块时长(ms),默认 5000
- sampleRate: number; // 采样率,默认 16000
- channels: number; // 声道数,默认 1(单声道)
-}
-
-/** 音频块数据(主进程 → 渲染进程) */
-export interface AudioChunk {
- audioBuffer: ArrayBuffer; // PCM Float32 数据
- sampleRate: number;
- channels: number;
- durationMs: number;
- timestamp: number; // 单调递增时间戳 (ms)
-}
-
-/** 渲染进程上报的原始音频块 */
-interface RendererAudioChunk {
- audioBuffer: ArrayBuffer;
- sampleRate: number;
- channels: number;
- durationMs: number;
-}
-
-// ================================================================
-// 默认配置
-// ================================================================
-
-const DEFAULT_OPTIONS: AudioCaptureOptions = {
- chunkDurationMs: 5000,
- sampleRate: 16000,
- channels: 1,
-};
-
-/** 单调递增时间戳生成器 */
-let lastTimestamp = 0;
-function monotonicTimestamp(): number {
- const now = Date.now();
- lastTimestamp = now > lastTimestamp ? now : lastTimestamp + 1;
- return lastTimestamp;
-}
-
-// ================================================================
-// 音频源枚举
-// ================================================================
-
-/**
- * 列出所有可用的系统音频源
- *
- * Electron 中系统音频环回(WASAPI Loopback)通过桌面捕获源实现:
- * 任意 screen/window 源均可配合 getUserMedia({ chromeMediaSource: 'desktop' })
- * 捕获系统音频。因此这里枚举 screen 类型源作为音频采集候选。
- */
-export async function listAudioSources(): Promise {
- const sources: DesktopCapturerSource[] = await desktopCapturer.getSources({
- types: ['screen'],
- thumbnailSize: { width: 1, height: 1 }, // 枚举不需要缩略图
- });
-
- return sources.map((src) => ({
- id: src.id,
- name: `系统音频 - ${src.name}`,
- }));
-}
-
-// ================================================================
-// 音频捕获管理器
-// ================================================================
-
-export class AudioCapture {
- private readonly options: AudioCaptureOptions;
- private readonly onChunk: (chunk: AudioChunk) => void;
- private capturing = false;
- private disposed = false;
- private boundWin: BrowserWindow | null = null;
-
- constructor(
- options: Partial,
- onChunk: (chunk: AudioChunk) => void,
- ) {
- this.options = { ...DEFAULT_OPTIONS, ...options };
- this.onChunk = onChunk;
- }
-
- /** 是否正在捕获 */
- get isCapturing(): boolean {
- return this.capturing;
- }
-
- /** 当前配置(只读) */
- get config(): Readonly {
- return this.options;
- }
-
- /**
- * 开始音频捕获
- *
- * 1. 如果未指定 sourceId,自动选择第一个可用的系统音频源
- * 2. 向渲染进程发送启动指令(含 sourceId + 配置)
- * 3. 渲染进程负责 getUserMedia 和音频切片
- */
- async start(win: BrowserWindow, sourceId?: string): Promise {
- if (this.capturing || this.disposed) return;
-
- // 解析音频源
- let resolvedSourceId = sourceId ?? null;
- if (!resolvedSourceId) {
- const sources = await listAudioSources();
- if (sources.length === 0) {
- console.warn('[AudioCapture] 未找到可用的系统音频源');
- throw new Error('No audio source available');
- }
- resolvedSourceId = sources[0].id;
- logger.info(`[AudioCapture] 自动选择音频源: ${sources[0].name} (${resolvedSourceId})`);
- }
-
- this.capturing = true;
- this.boundWin = win;
-
- logger.info(
- `[AudioCapture] 开始捕获, sourceId=${resolvedSourceId}, ` +
- `chunkDurationMs=${this.options.chunkDurationMs}, ` +
- `sampleRate=${this.options.sampleRate}, channels=${this.options.channels}`,
- );
-
- // 通知渲染进程开始音频采集
- if (!win.isDestroyed()) {
- win.webContents.send('audio_capture_do_start', {
- sourceId: resolvedSourceId,
- options: this.options,
- });
- }
- }
-
- /**
- * 停止音频捕获
- */
- stop(): void {
- if (!this.capturing) return;
-
- this.capturing = false;
- logger.info('[AudioCapture] 停止捕获');
-
- // 通知渲染进程停止音频采集
- if (this.boundWin && !this.boundWin.isDestroyed()) {
- this.boundWin.webContents.send('audio_capture_do_stop');
- }
- this.boundWin = null;
- }
-
- /**
- * 接收来自渲染进程的音频块数据
- * 由 IPC handler 调用,添加单调时间戳后通过回调发出
- */
- handleRendererChunk(data: RendererAudioChunk): void {
- if (!this.capturing || this.disposed) return;
-
- const chunk: AudioChunk = {
- audioBuffer: data.audioBuffer,
- sampleRate: data.sampleRate,
- channels: data.channels,
- durationMs: data.durationMs,
- timestamp: monotonicTimestamp(),
- };
-
- this.onChunk(chunk);
- }
-
- /**
- * 销毁实例,释放所有资源
- */
- dispose(): void {
- this.stop();
- this.disposed = true;
- logger.info('[AudioCapture] 已销毁');
- }
-}
+/**
+ * Electron 主进程系统音频捕获模块
+ *
+ * 架构:
+ * 1. 主进程使用 desktopCapturer.getSources({ types: ['screen'] }) 枚举屏幕源作为音频环回候选
+ * 2. 将音频 sourceId 传递给渲染进程
+ * 3. 渲染进程通过 getUserMedia + chromeMediaSource: 'desktop' 获取 MediaStream
+ * 4. 渲染进程使用 Web Audio API (AudioContext + ScriptProcessor) 切片
+ * 5. PCM 数据块通过 IPC 回传主进程,添加单调时间戳后推送给消费者
+ *
+ * @ai-context: 系统音频捕获:渲染进程 getDisplayMedia 采集、主进程聚合分块。
+ */
+
+import { desktopCapturer, DesktopCapturerSource, BrowserWindow } from 'electron';
+import { logger } from './logger';
+
+// ================================================================
+// 类型定义
+// ================================================================
+
+/** 音频源信息 */
+export interface AudioSourceInfo {
+ id: string;
+ name: string;
+}
+
+/** 音频捕获配置 */
+export interface AudioCaptureOptions {
+ chunkDurationMs: number; // 音频块时长(ms),默认 5000
+ sampleRate: number; // 采样率,默认 16000
+ channels: number; // 声道数,默认 1(单声道)
+}
+
+/** 音频块数据(主进程 → 渲染进程) */
+export interface AudioChunk {
+ audioBuffer: ArrayBuffer; // PCM Float32 数据
+ sampleRate: number;
+ channels: number;
+ durationMs: number;
+ timestamp: number; // 单调递增时间戳 (ms)
+}
+
+/** 渲染进程上报的原始音频块 */
+interface RendererAudioChunk {
+ audioBuffer: ArrayBuffer;
+ sampleRate: number;
+ channels: number;
+ durationMs: number;
+}
+
+// ================================================================
+// 默认配置
+// ================================================================
+
+const DEFAULT_OPTIONS: AudioCaptureOptions = {
+ chunkDurationMs: 5000,
+ sampleRate: 16000,
+ channels: 1,
+};
+
+/** 单调递增时间戳生成器 */
+let lastTimestamp = 0;
+function monotonicTimestamp(): number {
+ const now = Date.now();
+ lastTimestamp = now > lastTimestamp ? now : lastTimestamp + 1;
+ return lastTimestamp;
+}
+
+// ================================================================
+// 音频源枚举
+// ================================================================
+
+/**
+ * 列出所有可用的系统音频源
+ *
+ * Electron 中系统音频环回(WASAPI Loopback)通过桌面捕获源实现:
+ * 任意 screen/window 源均可配合 getUserMedia({ chromeMediaSource: 'desktop' })
+ * 捕获系统音频。因此这里枚举 screen 类型源作为音频采集候选。
+ */
+export async function listAudioSources(): Promise {
+ const sources: DesktopCapturerSource[] = await desktopCapturer.getSources({
+ types: ['screen'],
+ thumbnailSize: { width: 1, height: 1 }, // 枚举不需要缩略图
+ });
+
+ return sources.map((src) => ({
+ id: src.id,
+ name: `系统音频 - ${src.name}`,
+ }));
+}
+
+// ================================================================
+// 音频捕获管理器
+// ================================================================
+
+export class AudioCapture {
+ private readonly options: AudioCaptureOptions;
+ private readonly onChunk: (chunk: AudioChunk) => void;
+ private capturing = false;
+ private disposed = false;
+ private boundWin: BrowserWindow | null = null;
+
+ constructor(
+ options: Partial,
+ onChunk: (chunk: AudioChunk) => void,
+ ) {
+ this.options = { ...DEFAULT_OPTIONS, ...options };
+ this.onChunk = onChunk;
+ }
+
+ /** 是否正在捕获 */
+ get isCapturing(): boolean {
+ return this.capturing;
+ }
+
+ /** 当前配置(只读) */
+ get config(): Readonly {
+ return this.options;
+ }
+
+ /**
+ * 开始音频捕获
+ *
+ * 1. 如果未指定 sourceId,自动选择第一个可用的系统音频源
+ * 2. 向渲染进程发送启动指令(含 sourceId + 配置)
+ * 3. 渲染进程负责 getUserMedia 和音频切片
+ */
+ async start(win: BrowserWindow, sourceId?: string): Promise {
+ if (this.capturing || this.disposed) return;
+
+ // 解析音频源
+ let resolvedSourceId = sourceId ?? null;
+ if (!resolvedSourceId) {
+ const sources = await listAudioSources();
+ if (sources.length === 0) {
+ console.warn('[AudioCapture] 未找到可用的系统音频源');
+ throw new Error('No audio source available');
+ }
+ resolvedSourceId = sources[0].id;
+ logger.info(`[AudioCapture] 自动选择音频源: ${sources[0].name} (${resolvedSourceId})`);
+ }
+
+ this.capturing = true;
+ this.boundWin = win;
+
+ logger.info(
+ `[AudioCapture] 开始捕获, sourceId=${resolvedSourceId}, ` +
+ `chunkDurationMs=${this.options.chunkDurationMs}, ` +
+ `sampleRate=${this.options.sampleRate}, channels=${this.options.channels}`,
+ );
+
+ // 通知渲染进程开始音频采集
+ if (!win.isDestroyed()) {
+ win.webContents.send('audio_capture_do_start', {
+ sourceId: resolvedSourceId,
+ options: this.options,
+ });
+ }
+ }
+
+ /**
+ * 停止音频捕获
+ */
+ stop(): void {
+ if (!this.capturing) return;
+
+ this.capturing = false;
+ logger.info('[AudioCapture] 停止捕获');
+
+ // 通知渲染进程停止音频采集
+ if (this.boundWin && !this.boundWin.isDestroyed()) {
+ this.boundWin.webContents.send('audio_capture_do_stop');
+ }
+ this.boundWin = null;
+ }
+
+ /**
+ * 接收来自渲染进程的音频块数据
+ * 由 IPC handler 调用,添加单调时间戳后通过回调发出
+ */
+ handleRendererChunk(data: RendererAudioChunk): void {
+ if (!this.capturing || this.disposed) return;
+
+ const chunk: AudioChunk = {
+ audioBuffer: data.audioBuffer,
+ sampleRate: data.sampleRate,
+ channels: data.channels,
+ durationMs: data.durationMs,
+ timestamp: monotonicTimestamp(),
+ };
+
+ this.onChunk(chunk);
+ }
+
+ /**
+ * 销毁实例,释放所有资源
+ */
+ dispose(): void {
+ this.stop();
+ this.disposed = true;
+ logger.info('[AudioCapture] 已销毁');
+ }
+}
diff --git a/client/electron/captureHandlers.ts b/client/electron/captureHandlers.ts
index b0b3423e..2422be10 100644
--- a/client/electron/captureHandlers.ts
+++ b/client/electron/captureHandlers.ts
@@ -1,421 +1,26 @@
-/**
- * 屏幕截图 & 系统音频 & 视频录制 IPC Handler
- *
- * 从 main.ts 拆分而来,管理截图采集、音频捕获和视频录制的生命周期。
- */
-
-import { BrowserWindow, ipcMain, desktopCapturer } from 'electron';
-import { ScreenCapture } from './screenCapture.js';
-import type { ScreenCaptureOptions, ScreenshotFrameData } from './screenCapture.js';
-import { AudioCapture, listAudioSources } from './audioCapture.js';
-import type { AudioCaptureOptions, AudioChunk } from './audioCapture.js';
-import { VideoRecorder } from './videoRecorder.js';
-import type { VideoRecordOptions } from './videoRecorder.js';
-import { safeHandle, getMainWindowId } from './ipcUtils.js';
-import { logger } from './logger.js';
-import { scoreAndFilterWindows } from './windowScorer.js';
-
-// ================================================================
-// 模块级状态
-// ================================================================
-
-/** 当前活跃的截图采集实例 */
-let activeCapture: ScreenCapture | null = null;
-
-/** 当前活跃的音频捕获实例 */
-let activeAudioCapture: AudioCapture | null = null;
-
-/** @ai-context Path C 全程录制:当前活跃的视频录制实例 */
-let activeVideoRecorder: VideoRecorder | null = null;
-
-/** screen_capture_start 防抖:500ms 内多次调用只响应最后一次 */
-let startDebounceTimer: ReturnType | null = null;
-const START_DEBOUNCE_MS = 500;
-
-/** 单调递增会话标识,用于防止 stop 后残留的 debounce 重启采集 */
-let captureSessionToken = 0;
-
-/** 窗口监听轮询定时器 */
-let windowWatchTimer: ReturnType | null = null;
-
-/** 上一次窗口列表的 id 集合(用于 diff 检测变化) */
-let lastWindowIds: Set = new Set();
-
-const WINDOW_WATCH_INTERVAL_MS = 3000;
-
-// ================================================================
-// 公共 API
-// ================================================================
-
-/**
- * 注册所有截图 & 音频相关的 IPC handler
- */
-export function registerCaptureHandlers(): void {
- // ---- 屏幕截图 ----
-
- safeHandle(
- 'screen_capture_start',
- async (event, options: ScreenCaptureOptions) => {
- // 防抖:500ms 内多次调用只响应最后一次
- if (startDebounceTimer) {
- clearTimeout(startDebounceTimer);
- startDebounceTimer = null;
- }
-
- // 记录本次会话 token,用于 debounce 回调时校验是否仍然有效
- const token = ++captureSessionToken;
-
- return new Promise<{ success: boolean }>((resolve) => {
- startDebounceTimer = setTimeout(() => {
- startDebounceTimer = null;
-
- // 如果在 debounce 期间调用了 stop(token 已变),放弃本次启动
- if (token !== captureSessionToken) {
- logger.info('[IPC] screen_capture_start debounce 已过期(stop 后残留),跳过');
- resolve({ success: false });
- return;
- }
-
- // 幂等:先清理旧实例
- if (activeCapture) {
- activeCapture.dispose();
- activeCapture = null;
- }
-
- const senderWin = BrowserWindow.fromWebContents(event.sender);
-
- // 对采集参数做边界校验,防止非法值导致主进程异常
- const safeOptions = options || {};
- if (typeof safeOptions.interval === 'number' && (safeOptions.interval < 100 || safeOptions.interval > 60000)) {
- safeOptions.interval = 5000;
- }
-
- activeCapture = new ScreenCapture(safeOptions, (frame: ScreenshotFrameData) => {
- if (senderWin && !senderWin.isDestroyed()) {
- senderWin.webContents.send('screen_capture_frame', frame);
- }
- });
-
- activeCapture.start();
- logger.info('[IPC] screen_capture_start 已启动(防抖后)');
- resolve({ success: true });
- }, START_DEBOUNCE_MS);
- });
- },
- );
-
- safeHandle('screen_capture_stop', async () => {
- // 递增 token,使残留的 debounce 回调失效
- captureSessionToken++;
-
- // 清理防抖定时器
- if (startDebounceTimer) {
- clearTimeout(startDebounceTimer);
- startDebounceTimer = null;
- }
-
- if (activeCapture) {
- activeCapture.dispose();
- activeCapture = null;
- logger.info('[IPC] screen_capture_stop 已停止');
- }
- return { success: true };
- });
-
- safeHandle('screen_list_windows', async () => {
- try {
- const sources = await desktopCapturer.getSources({
- types: ['window'],
- thumbnailSize: { width: 240, height: 135 },
- });
-
- const rawWindows = sources.map((src) => {
- const thumb = src.thumbnail.isEmpty() ? src.thumbnail : src.thumbnail.resize({ width: 120 });
- return {
- id: src.id,
- title: src.name,
- thumbnail: thumb.toDataURL(),
- };
- });
-
- // 智能评分、过滤与排序
- return scoreAndFilterWindows(rawWindows);
- } catch (err) {
- logger.error('[IPC] screen_list_windows failed:', err);
- return [];
- }
- });
-
- // ---- 窗口变化监听(轮询) ----
-
- safeHandle('screen_watch_windows_start', async () => {
- if (windowWatchTimer) return { success: true }; // 已在监听
-
- logger.info('[IPC] 窗口监听已启动, interval=' + WINDOW_WATCH_INTERVAL_MS + 'ms');
-
- windowWatchTimer = setInterval(async () => {
- try {
- const sources = await desktopCapturer.getSources({
- types: ['window'],
- thumbnailSize: { width: 240, height: 135 },
- });
-
- const currentIds = new Set(sources.map((s) => s.id));
-
- // 检测是否有变化(新增或关闭窗口)
- const hasChanged =
- currentIds.size !== lastWindowIds.size ||
- [...currentIds].some((id) => !lastWindowIds.has(id));
-
- if (hasChanged) {
- lastWindowIds = currentIds;
-
- const rawWindows = sources.map((src) => {
- const thumb = src.thumbnail.isEmpty() ? src.thumbnail : src.thumbnail.resize({ width: 120 });
- return { id: src.id, title: src.name, thumbnail: thumb.toDataURL() };
- });
-
- const scored = scoreAndFilterWindows(rawWindows);
-
- // 推送到渲染进程
- const mainWindowId = getMainWindowId();
- if (mainWindowId) {
- const win = BrowserWindow.fromId(mainWindowId);
- if (win && !win.isDestroyed()) {
- win.webContents.send('screen_windows_changed', scored);
- }
- }
- }
- } catch (err) {
- logger.error('[IPC] window watch poll error:', err);
- }
- }, WINDOW_WATCH_INTERVAL_MS);
-
- return { success: true };
- });
-
- safeHandle('screen_watch_windows_stop', async () => {
- if (windowWatchTimer) {
- clearInterval(windowWatchTimer);
- windowWatchTimer = null;
- lastWindowIds = new Set();
- logger.info('[IPC] 窗口监听已停止');
- }
- return { success: true };
- });
-
- // ---- 系统音频捕获 ----
-
- safeHandle('audio_list_sources', async () => {
- try {
- return await listAudioSources();
- } catch (err) {
- logger.error('[IPC] audio_list_sources failed:', err);
- return [];
- }
- });
-
- safeHandle(
- 'audio_capture_start',
- async (event, options?: Partial & { sourceId?: string }) => {
- if (activeAudioCapture) {
- activeAudioCapture.dispose();
- activeAudioCapture = null;
- }
-
- const senderWin = BrowserWindow.fromWebContents(event.sender);
- if (!senderWin || senderWin.isDestroyed()) {
- return { success: false, error: 'No valid window' };
- }
-
- const captureOptions: Partial = {
- chunkDurationMs: options?.chunkDurationMs,
- sampleRate: options?.sampleRate,
- channels: options?.channels,
- };
-
- activeAudioCapture = new AudioCapture(captureOptions, (chunk: AudioChunk) => {
- if (senderWin && !senderWin.isDestroyed()) {
- senderWin.webContents.send('audio_capture_chunk', chunk);
- }
- });
-
- try {
- await activeAudioCapture.start(senderWin, options?.sourceId);
- logger.info('[IPC] audio_capture_start 已启动');
- return { success: true };
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- logger.error('[IPC] audio_capture_start failed:', message);
- activeAudioCapture.dispose();
- activeAudioCapture = null;
- return { success: false, error: message };
- }
- },
- );
-
- safeHandle('audio_capture_stop', async () => {
- if (activeAudioCapture) {
- activeAudioCapture.dispose();
- activeAudioCapture = null;
- logger.info('[IPC] audio_capture_stop 已停止');
- }
- return { success: true };
- });
-
- ipcMain.on(
- 'audio_capture_chunk',
- (_event, data: unknown) => {
- // SEC-005: sender 验证,仅接受主窗口的音频数据
- const mainId = getMainWindowId();
- if (mainId !== null && _event.sender.id !== mainId) {
- logger.warn(
- `[IPC] Sender verification failed for "audio_capture_chunk": ` +
- `expected sender.id=${mainId}, got ${_event.sender.id}`
- );
- return;
- }
-
- // SEC: 运行时类型断言 — 防止恶意或格式错误的数据
- if (
- !data ||
- typeof data !== 'object' ||
- !('audioBuffer' in data) ||
- typeof (data as Record).sampleRate !== 'number' ||
- typeof (data as Record).channels !== 'number' ||
- typeof (data as Record).durationMs !== 'number'
- ) {
- logger.warn('[IPC] audio_capture_chunk: invalid data format, dropping chunk');
- return;
- }
-
- const chunk = data as { audioBuffer: ArrayBuffer; sampleRate: number; channels: number; durationMs: number };
- if (activeAudioCapture && activeAudioCapture.isCapturing) {
- activeAudioCapture.handleRendererChunk(chunk);
- }
- },
- );
-
- // ---- Path C 视频录制 ----
-
- safeHandle(
- 'video_record_start',
- async (event, sourceId: string, options?: VideoRecordOptions) => {
- // 幂等:先清理旧实例
- if (activeVideoRecorder) {
- activeVideoRecorder.dispose();
- activeVideoRecorder = null;
- }
-
- const senderWin = BrowserWindow.fromWebContents(event.sender);
- if (!senderWin || senderWin.isDestroyed()) {
- return { success: false, error: 'No valid window' };
- }
-
- activeVideoRecorder = new VideoRecorder(options);
- activeVideoRecorder.bindWindow(senderWin);
-
- try {
- activeVideoRecorder.startRecording(sourceId, options);
- logger.info('[IPC] video_record_start 已启动');
- return { success: true };
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- logger.error('[IPC] video_record_start failed:', message);
- activeVideoRecorder.dispose();
- activeVideoRecorder = null;
- return { success: false, error: message };
- }
- },
- );
-
- safeHandle('video_record_stop', async () => {
- if (!activeVideoRecorder) {
- return { success: false, error: 'No active recording' };
- }
- try {
- const filePath = await activeVideoRecorder.stopRecording();
- const finalStatus = activeVideoRecorder.status;
- logger.info('[IPC] video_record_stop 已完成');
- activeVideoRecorder.dispose();
- activeVideoRecorder = null;
- return { success: true, filePath, finalStatus };
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- logger.error('[IPC] video_record_stop failed:', message);
- activeVideoRecorder?.dispose();
- activeVideoRecorder = null;
- return { success: false, error: message };
- }
- });
-
- safeHandle('video_record_pause', async () => {
- activeVideoRecorder?.pauseRecording();
- return { success: true };
- });
-
- safeHandle('video_record_resume', async () => {
- activeVideoRecorder?.resumeRecording();
- return { success: true };
- });
-
- safeHandle('video_record_status', async () => {
- return activeVideoRecorder?.status ?? {
- isRecording: false, isPaused: false, duration: 0, fileSizeBytes: 0, filePath: null,
- };
- });
-
- // 渲染进程回传视频数据块
- ipcMain.on('video_record_chunk', (_event, chunkBuffer: ArrayBuffer) => {
- // SEC-005: sender 验证
- const mainId = getMainWindowId();
- if (mainId !== null && _event.sender.id !== mainId) {
- logger.warn(
- `[IPC] Sender verification failed for "video_record_chunk": ` +
- `expected sender.id=${mainId}, got ${_event.sender.id}`
- );
- return;
- }
- activeVideoRecorder?.handleRendererChunk(chunkBuffer);
- });
-
- // 渲染进程上报录制错误
- ipcMain.on('video_record_error', (_event, errorInfo: { message: string }) => {
- const mainId = getMainWindowId();
- if (mainId !== null && _event.sender.id !== mainId) {
- logger.warn(
- `[IPC] Sender verification failed for "video_record_error": ` +
- `expected sender.id=${mainId}, got ${_event.sender.id}`
- );
- return;
- }
- activeVideoRecorder?.handleRendererError(errorInfo);
- });
-}
-
-// ================================================================
-// 清理
-// ================================================================
-
-/**
- * 释放所有活跃的采集实例
- */
-export function disposeCaptureHandlers(): void {
- // 清理防抖定时器
- if (startDebounceTimer) {
- clearTimeout(startDebounceTimer);
- startDebounceTimer = null;
- }
- if (activeCapture) {
- activeCapture.dispose();
- activeCapture = null;
- }
- if (activeAudioCapture) {
- activeAudioCapture.dispose();
- activeAudioCapture = null;
- }
- if (activeVideoRecorder) {
- activeVideoRecorder.dispose();
- activeVideoRecorder = null;
- }
-}
+/**
+ * 屏幕截图 & 系统音频 & 视频录制 IPC Handler — 编排入口
+ *
+ * @ai-context: 2026-07 拆分——屏幕截图/窗口监听在 screenCaptureHandlers、
+ * 音频/视频在 mediaCaptureHandlers;本文件仅保留注册与清理编排,
+ * main.ts 的调用点(registerCaptureHandlers/disposeCaptureHandlers)
+ * 签名不变。
+ */
+import { registerScreenCaptureHandlers, disposeScreenCaptureHandlers } from './screenCaptureHandlers.js';
+import { registerMediaCaptureHandlers, disposeMediaCaptureHandlers } from './mediaCaptureHandlers.js';
+
+/**
+ * 注册所有截图 & 音频 & 视频相关的 IPC handler
+ */
+export function registerCaptureHandlers(): void {
+ registerScreenCaptureHandlers();
+ registerMediaCaptureHandlers();
+}
+
+/**
+ * 释放所有活跃的采集实例
+ */
+export function disposeCaptureHandlers(): void {
+ disposeScreenCaptureHandlers();
+ disposeMediaCaptureHandlers();
+}
diff --git a/client/electron/cspPolicy.ts b/client/electron/cspPolicy.ts
new file mode 100644
index 00000000..60408ca5
--- /dev/null
+++ b/client/electron/cspPolicy.ts
@@ -0,0 +1,62 @@
+/**
+ * CSP 安全策略注入(SEC-005)
+ *
+ * @ai-context: 从 main.ts 拆出。connect-src 白名单每次请求动态计算,
+ * 确保用户运行时修改网关 URL 立即生效。生产 script-src 必须保留
+ * 'wasm-unsafe-eval'——否则 automerge WASM 被 CSP 拦截、顶层 import
+ * 失败、应用卡启动画面(仅放开 WASM 编译,不放开 JS eval)。
+ * @ai-context: 开发模式需 unsafe-eval + worker-src blob:(Vite 8 HMR
+ * 用 blob: 创建 SharedWorker)。修改 CSP 前必须全链路回归启动流程。
+ */
+import { session } from 'electron';
+import { logger } from './logger.js';
+import { gatewayUrl } from './ai/utils.js';
+
+/** 生产网关默认域(与 ai/utils DEFAULT_GATEWAY_URL 保持一致) */
+const DEFAULT_GATEWAY = 'https://entropydecrease.com';
+
+/**
+ * 动态构建 connect-src 白名单
+ * 每次请求时重新计算,确保运行时网关 URL 变更(如用户通过设置页修改)能及时生效
+ */
+function buildExtraConnectSrc(): string {
+ const extraOrigins = new Set();
+ // 当前运行时网关 URL(可能已被用户通过 IPC 修改)
+ const currentGateway = gatewayUrl();
+ if (currentGateway && currentGateway !== DEFAULT_GATEWAY) {
+ extraOrigins.add(currentGateway);
+ }
+ // 环境变量中的 API 地址
+ const configuredApi = process.env.VITE_API_BASE_URL || '';
+ if (configuredApi && configuredApi !== DEFAULT_GATEWAY) {
+ extraOrigins.add(configuredApi);
+ }
+ return extraOrigins.size > 0 ? ` ${[...extraOrigins].join(' ')}` : '';
+}
+
+/**
+ * 注入 CSP 响应头(app ready 后调用一次)
+ */
+export function installCspPolicy(isDev: boolean): void {
+ try {
+ session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
+ const extraConnectSrc = buildExtraConnectSrc();
+ // 开发环境:允许 unsafe-inline/unsafe-eval(Vite HMR 需要),worker-src 需要 blob:
+ // 生产环境:禁止 unsafe-eval,保留 unsafe-inline(Tailwind 运行时需要)
+ const csp = isDev
+ ? `default-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:* ws://localhost:*; worker-src 'self' blob: http://localhost:*; connect-src 'self' http://localhost:* ws://localhost:* https://*.supabase.co wss://*.supabase.co https://entropydecrease.com wss://entropydecrease.com${extraConnectSrc}; img-src 'self' data: blob: https://*.supabase.co; font-src 'self' data:;`
+ : `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.supabase.co; font-src 'self' data:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://entropydecrease.com wss://entropydecrease.com${extraConnectSrc}; frame-ancestors 'none';`;
+
+ callback({
+ responseHeaders: {
+ ...details.responseHeaders,
+ 'Content-Security-Policy': [csp],
+ },
+ });
+ });
+ logger.info(`[SEC] CSP policy injected (${isDev ? 'development' : 'production'} mode, dynamic gateway tracking enabled)`);
+ } catch (err) {
+ // CSP 注入失败时记录错误但不阻塞启动
+ logger.error('[SEC] Failed to inject CSP policy', err);
+ }
+}
diff --git a/client/electron/db/dbFileMigrator.ts b/client/electron/db/dbFileMigrator.ts
index 0b37900e..28f835bd 100644
--- a/client/electron/db/dbFileMigrator.ts
+++ b/client/electron/db/dbFileMigrator.ts
@@ -1,239 +1,241 @@
-/**
- * 数据库文件迁移模块
- *
- * 负责将 SQLite 数据库文件(含 WAL/SHM)从一个目录复制到另一个目录,
- * 包含完整性校验和备份功能。
- *
- * 注意:WAL checkpoint 应在调用本模块之前由 sqliteService.checkpointAndClose() 完成。
- */
-
-import { copyFile, mkdir, stat, access, rm } from 'fs/promises';
-import { createHash } from 'crypto';
-import { createReadStream } from 'fs';
-import * as path from 'path';
-import Database from 'better-sqlite3';
-import { logger } from '../logger.js';
-
-// ================================================================
-// 类型定义
-// ================================================================
-
-export interface MigrationResult {
- success: boolean;
- sourcePath: string;
- targetPath: string;
- error?: string;
- filesCopied?: string[];
-}
-
-// ================================================================
-// 常量
-// ================================================================
-
-const DB_FILES = ['keban.db', 'keban.db-wal', 'keban.db-shm'];
-
-// ================================================================
-// 内部工具函数
-// ================================================================
-
-/**
- * 计算文件的 SHA-256 哈希
- */
-function computeFileHash(filePath: string): Promise {
- return new Promise((resolve, reject) => {
- const hash = createHash('sha256');
- const stream = createReadStream(filePath);
- stream.on('data', (data) => hash.update(data));
- stream.on('end', () => resolve(hash.digest('hex')));
- stream.on('error', reject);
- });
-}
-
-/**
- * 检查文件是否存在
- */
-async function fileExists(filePath: string): Promise {
- try {
- await access(filePath);
- return true;
- } catch {
- return false;
- }
-}
-
-/**
- * 检查目标磁盘可用空间是否足够
- */
-async function checkDiskSpace(targetDir: string, requiredBytes: number): Promise {
- try {
- // 使用 fs.statfs(Node.js 18.15+)检查磁盘空间
- const { statfs } = await import('fs/promises');
- const stats = await statfs(targetDir);
- const availableBytes = stats.bavail * stats.bsize;
- return availableBytes >= requiredBytes * 1.1; // 留 10% 余量
- } catch (err) {
- const errInfo = err instanceof Error ? err.message : String(err);
- logger.warn(`[DBMigrator] Failed to check disk space, proceeding anyway: ${errInfo}`);
- return true; // 无法检查时不阻塞
- }
-}
-
-/**
- * 清理失败的迁移(删除目标目录中的数据库文件副本)
- */
-async function cleanupFailedMigration(targetDir: string): Promise {
- for (const fileName of DB_FILES) {
- const filePath = path.join(targetDir, fileName);
- try {
- if (await fileExists(filePath)) {
- await rm(filePath);
- logger.info(`[DBMigrator] Cleaned up failed copy: ${fileName}`);
- }
- } catch (err) {
- logger.error(`[DBMigrator] Failed to cleanup ${fileName}`, err);
- }
- }
-}
-
-// ================================================================
-// 公共 API
-// ================================================================
-
-/**
- * 迁移数据库文件从源目录到目标目录
- *
- * 复制 keban.db / keban.db-wal / keban.db-shm,并在每个文件复制后
- * 进行 SHA-256 完整性校验。校验失败时自动清理已复制文件。
- */
-export async function migrateDatabaseFiles(
- sourceDir: string,
- targetDir: string,
-): Promise {
- const sourceDb = path.join(sourceDir, 'keban.db');
-
- // 1. 检查源数据库存在
- if (!(await fileExists(sourceDb))) {
- return {
- success: false,
- sourcePath: sourceDir,
- targetPath: targetDir,
- error: '源数据库文件不存在: ' + sourceDb,
- };
- }
-
- try {
- // 2. 确保目标目录存在
- await mkdir(targetDir, { recursive: true });
-
- // 3. 检查磁盘空间
- const sourceStats = await stat(sourceDb);
- let totalSize = sourceStats.size;
-
- // 计算所有存在的文件大小
- for (const fileName of DB_FILES) {
- const filePath = path.join(sourceDir, fileName);
- if (await fileExists(filePath)) {
- const fileStat = await stat(filePath);
- totalSize += fileStat.size;
- }
- }
-
- const hasSpace = await checkDiskSpace(targetDir, totalSize);
- if (!hasSpace) {
- return {
- success: false,
- sourcePath: sourceDir,
- targetPath: targetDir,
- error: '目标磁盘空间不足,请确保至少有 ' + Math.ceil(totalSize * 1.1 / 1024 / 1024) + ' MB 可用空间',
- };
- }
-
- // 4. 复制所有数据库文件
- const copiedFiles: string[] = [];
-
- for (const fileName of DB_FILES) {
- const sourcePath = path.join(sourceDir, fileName);
- const targetPath = path.join(targetDir, fileName);
-
- if (await fileExists(sourcePath)) {
- logger.info(`[DBMigrator] Copying ${fileName}...`);
- await copyFile(sourcePath, targetPath);
- copiedFiles.push(fileName);
-
- // 5. 校验完整性(SHA-256)
- const sourceHash = await computeFileHash(sourcePath);
- const targetHash = await computeFileHash(targetPath);
-
- if (sourceHash !== targetHash) {
- // 校验失败,清理已复制文件
- logger.error(`[DBMigrator] Hash mismatch for ${fileName}`);
- await cleanupFailedMigration(targetDir);
- return {
- success: false,
- sourcePath: sourceDir,
- targetPath: targetDir,
- error: `文件完整性校验失败: ${fileName}`,
- };
- }
-
- logger.info(`[DBMigrator] ${fileName} copied and verified`);
- }
- }
-
- logger.info(`[DBMigrator] Migration completed: ${copiedFiles.length} files copied`);
- return {
- success: true,
- sourcePath: sourceDir,
- targetPath: targetDir,
- filesCopied: copiedFiles,
- };
- } catch (err) {
- const errorMsg = err instanceof Error ? err.message : String(err);
- logger.error('[DBMigrator] Migration failed', err);
- await cleanupFailedMigration(targetDir);
- return {
- success: false,
- sourcePath: sourceDir,
- targetPath: targetDir,
- error: '迁移失败: ' + errorMsg,
- };
- }
-}
-
-/**
- * 验证数据库文件完整性
- *
- * 使用 SQLite PRAGMA integrity_check 检测数据库是否完好。
- */
-export function verifyDatabaseIntegrity(dbPath: string): boolean {
- let tempDb: Database.Database | null = null;
- try {
- tempDb = new Database(dbPath, { readonly: true });
- const result = tempDb.pragma('integrity_check', { simple: true });
- const ok = result === 'ok';
- logger.info(`[DBMigrator] Integrity check: ${result}`);
- return ok;
- } catch (err) {
- logger.error('[DBMigrator] Integrity check failed', err);
- return false;
- } finally {
- if (tempDb) {
- try { tempDb.close(); } catch { /* ignore */ }
- }
- }
-}
-
-/**
- * 为旧路径数据库创建备份
- * 将 keban.db / keban.db-wal / keban.db-shm 各复制为 .bak(同目录)
- */
-export async function createBackup(dbDir: string): Promise {
- for (const fileName of DB_FILES) {
- const sourcePath = path.join(dbDir, fileName);
- const backupPath = path.join(dbDir, fileName + '.bak');
- if (await fileExists(sourcePath)) {
- await copyFile(sourcePath, backupPath);
- logger.info(`[DBMigrator] Backup created: ${fileName}.bak`);
- }
- }
-}
+/**
+ * 数据库文件迁移模块
+ *
+ * 负责将 SQLite 数据库文件(含 WAL/SHM)从一个目录复制到另一个目录,
+ * 包含完整性校验和备份功能。
+ *
+ * 注意:WAL checkpoint 应在调用本模块之前由 sqliteService.checkpointAndClose() 完成。
+ *
+ * @ai-context: SQLite 数据库文件迁移(keban.db/-wal/-shm 三文件为存量用户数据标识,永久豁免品牌改名):复制后校验、旧库改 .bak。
+ */
+
+import { copyFile, mkdir, stat, access, rm } from 'fs/promises';
+import { createHash } from 'crypto';
+import { createReadStream } from 'fs';
+import * as path from 'path';
+import Database from 'better-sqlite3';
+import { logger } from '../logger.js';
+
+// ================================================================
+// 类型定义
+// ================================================================
+
+export interface MigrationResult {
+ success: boolean;
+ sourcePath: string;
+ targetPath: string;
+ error?: string;
+ filesCopied?: string[];
+}
+
+// ================================================================
+// 常量
+// ================================================================
+
+const DB_FILES = ['keban.db', 'keban.db-wal', 'keban.db-shm'];
+
+// ================================================================
+// 内部工具函数
+// ================================================================
+
+/**
+ * 计算文件的 SHA-256 哈希
+ */
+function computeFileHash(filePath: string): Promise {
+ return new Promise((resolve, reject) => {
+ const hash = createHash('sha256');
+ const stream = createReadStream(filePath);
+ stream.on('data', (data) => hash.update(data));
+ stream.on('end', () => resolve(hash.digest('hex')));
+ stream.on('error', reject);
+ });
+}
+
+/**
+ * 检查文件是否存在
+ */
+async function fileExists(filePath: string): Promise {
+ try {
+ await access(filePath);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * 检查目标磁盘可用空间是否足够
+ */
+async function checkDiskSpace(targetDir: string, requiredBytes: number): Promise {
+ try {
+ // 使用 fs.statfs(Node.js 18.15+)检查磁盘空间
+ const { statfs } = await import('fs/promises');
+ const stats = await statfs(targetDir);
+ const availableBytes = stats.bavail * stats.bsize;
+ return availableBytes >= requiredBytes * 1.1; // 留 10% 余量
+ } catch (err) {
+ const errInfo = err instanceof Error ? err.message : String(err);
+ logger.warn(`[DBMigrator] Failed to check disk space, proceeding anyway: ${errInfo}`);
+ return true; // 无法检查时不阻塞
+ }
+}
+
+/**
+ * 清理失败的迁移(删除目标目录中的数据库文件副本)
+ */
+async function cleanupFailedMigration(targetDir: string): Promise {
+ for (const fileName of DB_FILES) {
+ const filePath = path.join(targetDir, fileName);
+ try {
+ if (await fileExists(filePath)) {
+ await rm(filePath);
+ logger.info(`[DBMigrator] Cleaned up failed copy: ${fileName}`);
+ }
+ } catch (err) {
+ logger.error(`[DBMigrator] Failed to cleanup ${fileName}`, err);
+ }
+ }
+}
+
+// ================================================================
+// 公共 API
+// ================================================================
+
+/**
+ * 迁移数据库文件从源目录到目标目录
+ *
+ * 复制 keban.db / keban.db-wal / keban.db-shm,并在每个文件复制后
+ * 进行 SHA-256 完整性校验。校验失败时自动清理已复制文件。
+ */
+export async function migrateDatabaseFiles(
+ sourceDir: string,
+ targetDir: string,
+): Promise {
+ const sourceDb = path.join(sourceDir, 'keban.db');
+
+ // 1. 检查源数据库存在
+ if (!(await fileExists(sourceDb))) {
+ return {
+ success: false,
+ sourcePath: sourceDir,
+ targetPath: targetDir,
+ error: '源数据库文件不存在: ' + sourceDb,
+ };
+ }
+
+ try {
+ // 2. 确保目标目录存在
+ await mkdir(targetDir, { recursive: true });
+
+ // 3. 检查磁盘空间
+ const sourceStats = await stat(sourceDb);
+ let totalSize = sourceStats.size;
+
+ // 计算所有存在的文件大小
+ for (const fileName of DB_FILES) {
+ const filePath = path.join(sourceDir, fileName);
+ if (await fileExists(filePath)) {
+ const fileStat = await stat(filePath);
+ totalSize += fileStat.size;
+ }
+ }
+
+ const hasSpace = await checkDiskSpace(targetDir, totalSize);
+ if (!hasSpace) {
+ return {
+ success: false,
+ sourcePath: sourceDir,
+ targetPath: targetDir,
+ error: '目标磁盘空间不足,请确保至少有 ' + Math.ceil(totalSize * 1.1 / 1024 / 1024) + ' MB 可用空间',
+ };
+ }
+
+ // 4. 复制所有数据库文件
+ const copiedFiles: string[] = [];
+
+ for (const fileName of DB_FILES) {
+ const sourcePath = path.join(sourceDir, fileName);
+ const targetPath = path.join(targetDir, fileName);
+
+ if (await fileExists(sourcePath)) {
+ logger.info(`[DBMigrator] Copying ${fileName}...`);
+ await copyFile(sourcePath, targetPath);
+ copiedFiles.push(fileName);
+
+ // 5. 校验完整性(SHA-256)
+ const sourceHash = await computeFileHash(sourcePath);
+ const targetHash = await computeFileHash(targetPath);
+
+ if (sourceHash !== targetHash) {
+ // 校验失败,清理已复制文件
+ logger.error(`[DBMigrator] Hash mismatch for ${fileName}`);
+ await cleanupFailedMigration(targetDir);
+ return {
+ success: false,
+ sourcePath: sourceDir,
+ targetPath: targetDir,
+ error: `文件完整性校验失败: ${fileName}`,
+ };
+ }
+
+ logger.info(`[DBMigrator] ${fileName} copied and verified`);
+ }
+ }
+
+ logger.info(`[DBMigrator] Migration completed: ${copiedFiles.length} files copied`);
+ return {
+ success: true,
+ sourcePath: sourceDir,
+ targetPath: targetDir,
+ filesCopied: copiedFiles,
+ };
+ } catch (err) {
+ const errorMsg = err instanceof Error ? err.message : String(err);
+ logger.error('[DBMigrator] Migration failed', err);
+ await cleanupFailedMigration(targetDir);
+ return {
+ success: false,
+ sourcePath: sourceDir,
+ targetPath: targetDir,
+ error: '迁移失败: ' + errorMsg,
+ };
+ }
+}
+
+/**
+ * 验证数据库文件完整性
+ *
+ * 使用 SQLite PRAGMA integrity_check 检测数据库是否完好。
+ */
+export function verifyDatabaseIntegrity(dbPath: string): boolean {
+ let tempDb: Database.Database | null = null;
+ try {
+ tempDb = new Database(dbPath, { readonly: true });
+ const result = tempDb.pragma('integrity_check', { simple: true });
+ const ok = result === 'ok';
+ logger.info(`[DBMigrator] Integrity check: ${result}`);
+ return ok;
+ } catch (err) {
+ logger.error('[DBMigrator] Integrity check failed', err);
+ return false;
+ } finally {
+ if (tempDb) {
+ try { tempDb.close(); } catch { /* ignore */ }
+ }
+ }
+}
+
+/**
+ * 为旧路径数据库创建备份
+ * 将 keban.db / keban.db-wal / keban.db-shm 各复制为 .bak(同目录)
+ */
+export async function createBackup(dbDir: string): Promise {
+ for (const fileName of DB_FILES) {
+ const sourcePath = path.join(dbDir, fileName);
+ const backupPath = path.join(dbDir, fileName + '.bak');
+ if (await fileExists(sourcePath)) {
+ await copyFile(sourcePath, backupPath);
+ logger.info(`[DBMigrator] Backup created: ${fileName}.bak`);
+ }
+ }
+}
diff --git a/client/electron/db/dbIpcHandlers.ts b/client/electron/db/dbIpcHandlers.ts
new file mode 100644
index 00000000..e0d39e55
--- /dev/null
+++ b/client/electron/db/dbIpcHandlers.ts
@@ -0,0 +1,158 @@
+/**
+ * 数据访问 IPC handlers(db:*,v1.0.0)
+ *
+ * @ai-context: 从 main.ts 拆出。安全模型:表名白名单 + 方法白名单,
+ * 不暴露原始 SQL 给渲染进程;ALLOWED_TABLES 与 schema.ts 建表清单
+ * 必须同步维护,新增业务表需两处登记。
+ * @ai-context: SqliteRow 以 Record+id 约束替代 any(动态表名场景
+ * 无法静态推导具体行类型,这是类型收窄的边界)。
+ */
+import { safeHandle } from '../ipcUtils.js';
+import { getConnection } from './sqliteService.js';
+import SqliteRepository from './sqliteRepository.js';
+
+/** 动态表名场景下的通用行类型(替代 any 的受控收窄) */
+type SqliteRow = Record & { id: string };
+
+/** 允许的表名白名单(防止 SQL 注入,不暴露原始 SQL) */
+const ALLOWED_TABLES = new Set([
+ 'notes', 'note_folders', 'flashcard_decks', 'flashcards',
+ 'flashcard_reviews', 'feynman_notes', 'feynman_summaries',
+ 'feynman_weak_points', 'operation_log', 'app_settings',
+ 'sync_conflicts', 'offline_queue', 'study_check_ins',
+ 'achievements', 'pomodoro_goals', 'pomodoro_sessions',
+ 'pomodoro_settings', 'window_captures', 'consent',
+ 'user_profile', 'inspirations', 'search_index',
+]);
+
+/** camelCase → snake_case(用于表名映射) */
+const TABLE_NAME_MAP: Record = {
+ notes: 'notes',
+ noteFolders: 'note_folders',
+ flashcardDecks: 'flashcard_decks',
+ flashcards: 'flashcards',
+ flashcardReviews: 'flashcard_reviews',
+ feynmanNotes: 'feynman_notes',
+ feynmanSummaries: 'feynman_summaries',
+ feynmanWeakPoints: 'feynman_weak_points',
+ operationLog: 'operation_log',
+ appSettings: 'app_settings',
+ syncConflicts: 'sync_conflicts',
+ offlineQueue: 'offline_queue',
+ studyCheckIns: 'study_check_ins',
+ achievements: 'achievements',
+ pomodoroGoals: 'pomodoro_goals',
+ pomodoroSessions: 'pomodoro_sessions',
+ pomodoroSettings: 'pomodoro_settings',
+ windowCaptures: 'window_captures',
+ consent: 'consent',
+ userProfile: 'user_profile',
+ inspirations: 'inspirations',
+ searchIndex: 'search_index',
+};
+
+function resolveTable(table: string): string {
+ const snakeName = TABLE_NAME_MAP[table] || table;
+ if (!ALLOWED_TABLES.has(snakeName)) {
+ throw new Error(`[DB] Table "${table}" is not in the allowed whitelist`);
+ }
+ return snakeName;
+}
+
+/**
+ * 注册全部 db:* IPC handlers(app ready 后调用一次)
+ */
+export function registerDbIpcHandlers(): void {
+ /** db:query — 查询:接收 { table, method, args } → 调用 SqliteRepository 对应方法 */
+ safeHandle('db:query', async (_event, params: { table: string; method: string; args?: unknown[] }) => {
+ const tableName = resolveTable(params.table);
+ const repo = new SqliteRepository(tableName);
+ const allowedMethods = ['getAll', 'getById', 'count'] as const;
+ const method = params.method as (typeof allowedMethods)[number];
+ if (!allowedMethods.includes(method)) {
+ throw new Error(`[DB] Query method "${params.method}" is not allowed`);
+ }
+ const fn = repo[method];
+ if (typeof fn !== 'function') {
+ throw new Error(`[DB] Method "${params.method}" does not exist on repository`);
+ }
+ return await (fn as (...args: unknown[]) => Promise).call(repo, ...(params.args ?? []));
+ });
+
+ /** db:insert — 插入:接收 { table, item } → create() */
+ safeHandle('db:insert', async (_event, params: { table: string; item: Record }) => {
+ const tableName = resolveTable(params.table);
+ const repo = new SqliteRepository(tableName);
+ return await repo.create(params.item as SqliteRow);
+ });
+
+ /** db:update — 更新:接收 { table, id, changes } → update() */
+ safeHandle('db:update', async (_event, params: { table: string; id: string; changes: Record }) => {
+ const tableName = resolveTable(params.table);
+ const repo = new SqliteRepository(tableName);
+ return await repo.update(params.id, params.changes as Partial);
+ });
+
+ /** db:delete — 删除:接收 { table, id } → delete() */
+ safeHandle('db:delete', async (_event, params: { table: string; id: string }) => {
+ const tableName = resolveTable(params.table);
+ const repo = new SqliteRepository(tableName);
+ return await repo.delete(params.id);
+ });
+
+ /** db:search — 搜索:LIKE 模糊匹配(FTS5 在 T0.5 实现) */
+ safeHandle('db:search', async (_event, params: { table: string; query: string }) => {
+ const tableName = resolveTable(params.table);
+ const dbConn = getConnection();
+ const like = `%${params.query}%`;
+ // 对 notes 表搜索 title 和 content,其余表搜索所有 TEXT 列
+ if (tableName === 'notes') {
+ return dbConn.prepare(
+ `SELECT * FROM notes WHERE title LIKE ? OR content LIKE ?`
+ ).all(like, like);
+ }
+ // 通用回退:获取表的 TEXT 列并搜索
+ const colInfo = dbConn.prepare(`PRAGMA table_info("${tableName}")`).all() as Array<{ name: string; type: string }>;
+ const textCols = colInfo.filter((c) => c.type === 'TEXT' && c.name !== 'id');
+ if (textCols.length === 0) return [];
+ const where = textCols.map((c) => `"${c.name}" LIKE ?`).join(' OR ');
+ return dbConn.prepare(`SELECT * FROM "${tableName}" WHERE ${where}`).all(...textCols.map(() => like));
+ });
+
+ /** db:batch — 批量操作:事务执行 */
+ safeHandle('db:batch', async (_event, params: { operations: Array<{ type: string; table: string; [key: string]: unknown }> }) => {
+ const dbConn = getConnection();
+
+ const txn = dbConn.transaction(() => {
+ for (const op of params.operations) {
+ const tableName = resolveTable(op.table as string);
+ switch (op.type) {
+ case 'insert': {
+ const item = op.item as Record;
+ const cols = Object.keys(item);
+ const placeholders = cols.map(() => '?').join(', ');
+ const sql = `INSERT INTO "${tableName}" (${cols.map((c) => `"${c}"`).join(', ')}) VALUES (${placeholders})`;
+ dbConn.prepare(sql).run(...Object.values(item));
+ break;
+ }
+ case 'update': {
+ const changes = op.changes as Record;
+ const entries = Object.entries(changes);
+ if (entries.length === 0) break;
+ const setClauses = entries.map(([col]) => `"${col}" = ?`).join(', ');
+ const sql = `UPDATE "${tableName}" SET ${setClauses} WHERE id = ?`;
+ dbConn.prepare(sql).run(...entries.map(([, v]) => v), op.id as string);
+ break;
+ }
+ case 'delete':
+ dbConn.prepare(`DELETE FROM "${tableName}" WHERE id = ?`).run(op.id as string);
+ break;
+ default:
+ throw new Error(`[DB] Unknown batch operation type: ${op.type}`);
+ }
+ }
+ });
+ txn();
+ return { success: true };
+ });
+}
diff --git a/client/electron/db/fts5Search.ts b/client/electron/db/fts5Search.ts
index d547e951..e5492bbd 100644
--- a/client/electron/db/fts5Search.ts
+++ b/client/electron/db/fts5Search.ts
@@ -1,135 +1,137 @@
-/**
- * 基于 SQLite FTS5 的全文搜索引擎
- * unicode61 分词器 + BM25 排序,为笔记/闪卡/灵感等提供本地全文搜索。
- */
-import type Database from 'better-sqlite3';
-import { getConnection } from './sqliteService.js';
-import { logger } from '../logger.js';
-
-// ================================================================
-// 类型定义
-// ================================================================
-
-export interface SearchOptions {
- table?: string; // 限定搜索表
- limit?: number; // 结果数量上限,默认 20
- offset?: number; // 偏移量
- highlight?: boolean; // 是否返回高亮片段
-}
-
-export interface SearchResult {
- id: string;
- table: string;
- title: string;
- snippet: string; // 匹配片段(高亮可选)
- rank: number; // BM25 排序分数
-}
-
-export interface IndexTableInput {
- name: string;
- rows: Array<{ id: string; title?: string; content: string }>;
-}
-
-// ================================================================
-// FTS5 虚拟表 DDL
-// ================================================================
-
-const FTS5_DDL = /* sql */ `
-CREATE VIRTUAL TABLE IF NOT EXISTS fts_content USING fts5(
- id UNINDEXED, table_name UNINDEXED, title, content,
- tokenize='unicode61 remove_diacritics 2'
-);`;
-
-// ================================================================
-// 公共 API
-// ================================================================
-
-/** 创建 FTS5 虚拟表(幂等) */
-export function initializeFTS(db: Database.Database): void {
- db.exec(FTS5_DDL);
- logger.info('[FTS5] Full-text search virtual table initialized');
-}
-
-/** 索引或更新一条文档(先删后插保证幂等) */
-export function indexDocument(table: string, id: string, title: string, content: string): void {
- const db = getConnection();
- db.prepare(`DELETE FROM fts_content WHERE id = ? AND table_name = ?`).run(id, table);
- db.prepare(`INSERT INTO fts_content (id, table_name, title, content) VALUES (?, ?, ?, ?)`)
- .run(id, table, title, content);
-}
-
-/** 从全文索引中删除一条文档 */
-export function removeDocument(table: string, id: string): void {
- getConnection()
- .prepare(`DELETE FROM fts_content WHERE id = ? AND table_name = ?`)
- .run(id, table);
-}
-
-/**
- * 执行全文搜索
- *
- * 使用 FTS5 MATCH + BM25 排序 + snippet 高亮。
- * query 支持 FTS5 查询语法(AND、OR、NOT、前缀 *)。
- */
-export function search(query: string, options?: SearchOptions): SearchResult[] {
- if (!query?.trim()) return [];
-
- const db = getConnection();
- const limit = options?.limit ?? 20;
- const offset = options?.offset ?? 0;
- const useHighlight = options?.highlight ?? true;
-
- const snippetFn = useHighlight
- ? `snippet(fts_content, 3, '', '', '...', 64)`
- : `snippet(fts_content, 3, '', '', '...', 64)`;
-
- const hasTableFilter = !!options?.table;
- const whereClause = hasTableFilter
- ? `WHERE fts_content MATCH ? AND table_name = ?`
- : `WHERE fts_content MATCH ?`;
-
- const sql = /* sql */ `
- SELECT id, table_name, title,
- ${snippetFn} as snippet,
- bm25(fts_content) as rank
- FROM fts_content
- ${whereClause}
- ORDER BY rank
- LIMIT ? OFFSET ?`;
-
- const params: (string | number)[] = hasTableFilter
- ? [query, options!.table!, limit, offset]
- : [query, limit, offset];
-
- try {
- const rows = db.prepare(sql).all(...params) as Array<{
- id: string; table_name: string; title: string; snippet: string; rank: number;
- }>;
- return rows.map((r) => ({
- id: r.id, table: r.table_name, title: r.title, snippet: r.snippet, rank: r.rank,
- }));
- } catch (err) {
- logger.error('[FTS5] Search failed', err);
- return [];
- }
-}
-
-/** 批量重建全文索引(事务内先清空再逐表写入) */
-export function rebuildIndex(tables: IndexTableInput[]): void {
- const db = getConnection();
- const insertStmt = db.prepare(
- `INSERT INTO fts_content (id, table_name, title, content) VALUES (?, ?, ?, ?)`,
- );
-
- const transaction = db.transaction(() => {
- db.exec(`DELETE FROM fts_content`);
- for (const t of tables) {
- for (const row of t.rows) {
- insertStmt.run(row.id, t.name, row.title ?? '', row.content);
- }
- }
- });
-
- transaction();
- logger.info(`[FTS5] Index rebuilt for ${tables.length} table(s)`);
-}
+/**
+ * 基于 SQLite FTS5 的全文搜索引擎
+ * unicode61 分词器 + BM25 排序,为笔记/闪卡/灵感等提供本地全文搜索。
+ *
+ * @ai-context: SQLite FTS5 全文搜索封装(若可用),LIKE 搜索的升级路径。
+ */
+import type Database from 'better-sqlite3';
+import { getConnection } from './sqliteService.js';
+import { logger } from '../logger.js';
+
+// ================================================================
+// 类型定义
+// ================================================================
+
+export interface SearchOptions {
+ table?: string; // 限定搜索表
+ limit?: number; // 结果数量上限,默认 20
+ offset?: number; // 偏移量
+ highlight?: boolean; // 是否返回高亮片段
+}
+
+export interface SearchResult {
+ id: string;
+ table: string;
+ title: string;
+ snippet: string; // 匹配片段(高亮可选)
+ rank: number; // BM25 排序分数
+}
+
+export interface IndexTableInput {
+ name: string;
+ rows: Array<{ id: string; title?: string; content: string }>;
+}
+
+// ================================================================
+// FTS5 虚拟表 DDL
+// ================================================================
+
+const FTS5_DDL = /* sql */ `
+CREATE VIRTUAL TABLE IF NOT EXISTS fts_content USING fts5(
+ id UNINDEXED, table_name UNINDEXED, title, content,
+ tokenize='unicode61 remove_diacritics 2'
+);`;
+
+// ================================================================
+// 公共 API
+// ================================================================
+
+/** 创建 FTS5 虚拟表(幂等) */
+export function initializeFTS(db: Database.Database): void {
+ db.exec(FTS5_DDL);
+ logger.info('[FTS5] Full-text search virtual table initialized');
+}
+
+/** 索引或更新一条文档(先删后插保证幂等) */
+export function indexDocument(table: string, id: string, title: string, content: string): void {
+ const db = getConnection();
+ db.prepare(`DELETE FROM fts_content WHERE id = ? AND table_name = ?`).run(id, table);
+ db.prepare(`INSERT INTO fts_content (id, table_name, title, content) VALUES (?, ?, ?, ?)`)
+ .run(id, table, title, content);
+}
+
+/** 从全文索引中删除一条文档 */
+export function removeDocument(table: string, id: string): void {
+ getConnection()
+ .prepare(`DELETE FROM fts_content WHERE id = ? AND table_name = ?`)
+ .run(id, table);
+}
+
+/**
+ * 执行全文搜索
+ *
+ * 使用 FTS5 MATCH + BM25 排序 + snippet 高亮。
+ * query 支持 FTS5 查询语法(AND、OR、NOT、前缀 *)。
+ */
+export function search(query: string, options?: SearchOptions): SearchResult[] {
+ if (!query?.trim()) return [];
+
+ const db = getConnection();
+ const limit = options?.limit ?? 20;
+ const offset = options?.offset ?? 0;
+ const useHighlight = options?.highlight ?? true;
+
+ const snippetFn = useHighlight
+ ? `snippet(fts_content, 3, '', '', '...', 64)`
+ : `snippet(fts_content, 3, '', '', '...', 64)`;
+
+ const hasTableFilter = !!options?.table;
+ const whereClause = hasTableFilter
+ ? `WHERE fts_content MATCH ? AND table_name = ?`
+ : `WHERE fts_content MATCH ?`;
+
+ const sql = /* sql */ `
+ SELECT id, table_name, title,
+ ${snippetFn} as snippet,
+ bm25(fts_content) as rank
+ FROM fts_content
+ ${whereClause}
+ ORDER BY rank
+ LIMIT ? OFFSET ?`;
+
+ const params: (string | number)[] = hasTableFilter
+ ? [query, options!.table!, limit, offset]
+ : [query, limit, offset];
+
+ try {
+ const rows = db.prepare(sql).all(...params) as Array<{
+ id: string; table_name: string; title: string; snippet: string; rank: number;
+ }>;
+ return rows.map((r) => ({
+ id: r.id, table: r.table_name, title: r.title, snippet: r.snippet, rank: r.rank,
+ }));
+ } catch (err) {
+ logger.error('[FTS5] Search failed', err);
+ return [];
+ }
+}
+
+/** 批量重建全文索引(事务内先清空再逐表写入) */
+export function rebuildIndex(tables: IndexTableInput[]): void {
+ const db = getConnection();
+ const insertStmt = db.prepare(
+ `INSERT INTO fts_content (id, table_name, title, content) VALUES (?, ?, ?, ?)`,
+ );
+
+ const transaction = db.transaction(() => {
+ db.exec(`DELETE FROM fts_content`);
+ for (const t of tables) {
+ for (const row of t.rows) {
+ insertStmt.run(row.id, t.name, row.title ?? '', row.content);
+ }
+ }
+ });
+
+ transaction();
+ logger.info(`[FTS5] Index rebuilt for ${tables.length} table(s)`);
+}
diff --git a/client/electron/db/migration.ts b/client/electron/db/migration.ts
index 831e49fa..d80f654b 100644
--- a/client/electron/db/migration.ts
+++ b/client/electron/db/migration.ts
@@ -1,196 +1,198 @@
-/**
- * 数据迁移脚本(IndexedDB → SQLite)
- *
- * 协调模式:渲染进程读取 IndexedDB → IPC → 主进程写入 SQLite。
- * 迁移前自动备份、失败保留 IndexedDB 可重试、脚本幂等执行。
- */
-
-import type Database from 'better-sqlite3';
-import { getConnection } from './sqliteService.js';
-import { logger } from '../logger.js';
-
-// ================================================================
-// 类型 & 常量
-// ================================================================
-
-export interface MigrationStatus {
- completed: boolean;
- currentTable: string;
- tablesTotal: number;
- tablesCompleted: number;
- rowsMigrated: number;
- error?: string;
-}
-
-/** Dexie 表名 → SQLite 表名映射 */
-const TABLE_MAPPING: Array<{ dexie: string; sqlite: string }> = [
- { dexie: 'notes', sqlite: 'notes' },
- { dexie: 'noteFolders', sqlite: 'note_folders' },
- { dexie: 'flashcardDecks', sqlite: 'flashcard_decks' },
- { dexie: 'flashcards', sqlite: 'flashcards' },
- { dexie: 'flashcardReviews', sqlite: 'flashcard_reviews' },
- { dexie: 'feynmanNotes', sqlite: 'feynman_notes' },
- { dexie: 'feynmanSummaries', sqlite: 'feynman_summaries' },
- { dexie: 'feynmanWeakPoints', sqlite: 'feynman_weak_points' },
- { dexie: 'pomodoroSessions', sqlite: 'pomodoro_sessions' },
- { dexie: 'pomodoroSettings', sqlite: 'pomodoro_settings' },
- { dexie: 'appSettings', sqlite: 'app_settings' },
- { dexie: 'operationLog', sqlite: 'operation_log' },
- { dexie: 'syncConflicts', sqlite: 'sync_conflicts' },
- { dexie: 'offlineQueue', sqlite: 'offline_queue' },
- { dexie: 'studyCheckIns', sqlite: 'study_check_ins' },
- { dexie: 'achievements', sqlite: 'achievements' },
- { dexie: 'pomodoroGoals', sqlite: 'pomodoro_goals' },
- { dexie: 'windowCaptures', sqlite: 'window_captures' },
- { dexie: 'consent', sqlite: 'consent' },
- { dexie: 'userProfile', sqlite: 'user_profile' },
- { dexie: 'inspirations', sqlite: 'inspirations' },
-];
-
-/** 合法的 SQLite 表名集合 */
-const VALID_SQLITE_TABLES = new Set(TABLE_MAPPING.map((m) => m.sqlite));
-
-/** camelCase → snake_case */
-function toSnake(s: string): string {
- return s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
-}
-
-// ================================================================
-// 核心
-// ================================================================
-
-/** 检查是否需要迁移:无 migrationComplete 标记 + SQLite 无业务数据 → 需要迁移 */
-export function needsMigration(db: Database.Database): boolean {
- try {
- const row = db.prepare(`SELECT value FROM app_settings WHERE "key" = 'migrationComplete'`).get() as { value: string } | undefined;
- if (row?.value === 'true') return false;
- const notesCount = db.prepare('SELECT COUNT(*) AS cnt FROM notes').get() as { cnt: number };
- if (notesCount.cnt > 0) return false;
- return true;
- } catch (err) {
- logger.error('[Migration] Failed to check migration status', err);
- return false;
- }
-}
-
-/**
- * 导入单表数据到 SQLite(INSERT OR REPLACE)
- *
- * @param sqliteTable - SQLite 表名(snake_case,需在白名单内)
- * @param rows - 数据行数组(camelCase keys,由渲染进程发送)
- * @returns 成功插入的行数
- */
-export function importTable(sqliteTable: string, rows: Record[]): number {
- if (!VALID_SQLITE_TABLES.has(sqliteTable)) {
- throw new Error(`[Migration] Table "${sqliteTable}" is not in the migration whitelist`);
- }
-
- const db = getConnection();
- if (rows.length === 0) {
- logger.info(`[Migration] Table "${sqliteTable}" — 0 rows to import, skipping`);
- return 0;
- }
-
- // 构建列名(从第一行推断,camelCase → snake_case)
- const cols = Object.keys(rows[0]).map(toSnake);
-
- const placeholders = cols.map(() => '?').join(', ');
- const colList = cols.map((c) => `"${c}"`).join(', ');
- const sql = `INSERT OR REPLACE INTO "${sqliteTable}" (${colList}) VALUES (${placeholders})`;
- const stmt = db.prepare(sql);
-
- const txn = db.transaction((items: Record[]) => {
- for (const item of items) {
- const values = Object.keys(item).map((k) => {
- const val = item[k];
- // 自动 JSON 序列化对象/数组字段
- if (val !== null && typeof val === 'object' && !(val instanceof Date)) {
- return JSON.stringify(val);
- }
- // boolean → INTEGER
- if (typeof val === 'boolean') return val ? 1 : 0;
- // Date → ISO string
- if (val instanceof Date) return val.toISOString();
- return val;
- });
- stmt.run(...values);
- }
- });
-
- txn(rows);
- logger.info(`[Migration] Imported ${rows.length} rows into "${sqliteTable}"`);
- return rows.length;
-}
-
-/**
- * 标记迁移完成
- *
- * 写入 migrationComplete 标记到 app_settings + 运行 PRAGMA integrity_check
- */
-export function completeMigration(db: Database.Database): { ok: boolean; integrity: string } {
- const now = new Date().toISOString();
-
- db.prepare(
- `INSERT OR REPLACE INTO app_settings (id, "key", value, updated_at) VALUES (?, 'migrationComplete', 'true', ?)`
- ).run(crypto.randomUUID(), now);
-
- // 运行完整性检查
- const result = db.pragma('integrity_check', { simple: true }) as string;
- const ok = result === 'ok';
-
- if (ok) {
- logger.info('[Migration] Migration completed successfully, integrity check passed');
- } else {
- logger.error(`[Migration] Integrity check failed: ${result}`);
- }
-
- return { ok, integrity: result };
-}
-
-// ================================================================
-// IPC handler 注册
-// ================================================================
-
-/**
- * 注册迁移相关 IPC handler(在 main.ts 的 app.whenReady 中调用)
- *
- * 需要外部传入 safeHandle 以避免循环依赖。
- */
-export function registerMigrationHandlers(
- safeHandle: (channel: string, handler: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => Promise) => void,
-): void {
- const db = getConnection();
-
- /** migration:check — 检查是否需要迁移 */
- safeHandle('migration:check', async () => {
- const needed = needsMigration(db);
- logger.info(`[Migration] Check result: ${needed ? 'migration needed' : 'no migration needed'}`);
- return { needed, tableMapping: TABLE_MAPPING };
- });
-
- /** migration:import-table — 导入单表数据 */
- safeHandle('migration:import-table', async (_event, params: { table: string; rows: Record[] }) => {
- try {
- const count = importTable(params.table, params.rows);
- return { success: true, rowsImported: count };
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- logger.error(`[Migration] Failed to import table "${params.table}"`, err);
- return { success: false, error: msg };
- }
- });
-
- /** migration:complete — 标记迁移完成 */
- safeHandle('migration:complete', async () => {
- try {
- const result = completeMigration(db);
- return { success: result.ok, integrity: result.integrity };
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- logger.error('[Migration] Failed to complete migration', err);
- return { success: false, error: msg };
- }
- });
-
- logger.info('[Migration] Migration IPC handlers registered');
-}
+/**
+ * 数据迁移脚本(IndexedDB → SQLite)
+ *
+ * 协调模式:渲染进程读取 IndexedDB → IPC → 主进程写入 SQLite。
+ * 迁移前自动备份、失败保留 IndexedDB 可重试、脚本幂等执行。
+ *
+ * @ai-context: IndexedDB→SQLite 数据迁移 IPC:表映射/分批导入;safeHandle 经参数注入避免循环依赖。
+ */
+
+import type Database from 'better-sqlite3';
+import { getConnection } from './sqliteService.js';
+import { logger } from '../logger.js';
+
+// ================================================================
+// 类型 & 常量
+// ================================================================
+
+export interface MigrationStatus {
+ completed: boolean;
+ currentTable: string;
+ tablesTotal: number;
+ tablesCompleted: number;
+ rowsMigrated: number;
+ error?: string;
+}
+
+/** Dexie 表名 → SQLite 表名映射 */
+const TABLE_MAPPING: Array<{ dexie: string; sqlite: string }> = [
+ { dexie: 'notes', sqlite: 'notes' },
+ { dexie: 'noteFolders', sqlite: 'note_folders' },
+ { dexie: 'flashcardDecks', sqlite: 'flashcard_decks' },
+ { dexie: 'flashcards', sqlite: 'flashcards' },
+ { dexie: 'flashcardReviews', sqlite: 'flashcard_reviews' },
+ { dexie: 'feynmanNotes', sqlite: 'feynman_notes' },
+ { dexie: 'feynmanSummaries', sqlite: 'feynman_summaries' },
+ { dexie: 'feynmanWeakPoints', sqlite: 'feynman_weak_points' },
+ { dexie: 'pomodoroSessions', sqlite: 'pomodoro_sessions' },
+ { dexie: 'pomodoroSettings', sqlite: 'pomodoro_settings' },
+ { dexie: 'appSettings', sqlite: 'app_settings' },
+ { dexie: 'operationLog', sqlite: 'operation_log' },
+ { dexie: 'syncConflicts', sqlite: 'sync_conflicts' },
+ { dexie: 'offlineQueue', sqlite: 'offline_queue' },
+ { dexie: 'studyCheckIns', sqlite: 'study_check_ins' },
+ { dexie: 'achievements', sqlite: 'achievements' },
+ { dexie: 'pomodoroGoals', sqlite: 'pomodoro_goals' },
+ { dexie: 'windowCaptures', sqlite: 'window_captures' },
+ { dexie: 'consent', sqlite: 'consent' },
+ { dexie: 'userProfile', sqlite: 'user_profile' },
+ { dexie: 'inspirations', sqlite: 'inspirations' },
+];
+
+/** 合法的 SQLite 表名集合 */
+const VALID_SQLITE_TABLES = new Set(TABLE_MAPPING.map((m) => m.sqlite));
+
+/** camelCase → snake_case */
+function toSnake(s: string): string {
+ return s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
+}
+
+// ================================================================
+// 核心
+// ================================================================
+
+/** 检查是否需要迁移:无 migrationComplete 标记 + SQLite 无业务数据 → 需要迁移 */
+export function needsMigration(db: Database.Database): boolean {
+ try {
+ const row = db.prepare(`SELECT value FROM app_settings WHERE "key" = 'migrationComplete'`).get() as { value: string } | undefined;
+ if (row?.value === 'true') return false;
+ const notesCount = db.prepare('SELECT COUNT(*) AS cnt FROM notes').get() as { cnt: number };
+ if (notesCount.cnt > 0) return false;
+ return true;
+ } catch (err) {
+ logger.error('[Migration] Failed to check migration status', err);
+ return false;
+ }
+}
+
+/**
+ * 导入单表数据到 SQLite(INSERT OR REPLACE)
+ *
+ * @param sqliteTable - SQLite 表名(snake_case,需在白名单内)
+ * @param rows - 数据行数组(camelCase keys,由渲染进程发送)
+ * @returns 成功插入的行数
+ */
+export function importTable(sqliteTable: string, rows: Record[]): number {
+ if (!VALID_SQLITE_TABLES.has(sqliteTable)) {
+ throw new Error(`[Migration] Table "${sqliteTable}" is not in the migration whitelist`);
+ }
+
+ const db = getConnection();
+ if (rows.length === 0) {
+ logger.info(`[Migration] Table "${sqliteTable}" — 0 rows to import, skipping`);
+ return 0;
+ }
+
+ // 构建列名(从第一行推断,camelCase → snake_case)
+ const cols = Object.keys(rows[0]).map(toSnake);
+
+ const placeholders = cols.map(() => '?').join(', ');
+ const colList = cols.map((c) => `"${c}"`).join(', ');
+ const sql = `INSERT OR REPLACE INTO "${sqliteTable}" (${colList}) VALUES (${placeholders})`;
+ const stmt = db.prepare(sql);
+
+ const txn = db.transaction((items: Record[]) => {
+ for (const item of items) {
+ const values = Object.keys(item).map((k) => {
+ const val = item[k];
+ // 自动 JSON 序列化对象/数组字段
+ if (val !== null && typeof val === 'object' && !(val instanceof Date)) {
+ return JSON.stringify(val);
+ }
+ // boolean → INTEGER
+ if (typeof val === 'boolean') return val ? 1 : 0;
+ // Date → ISO string
+ if (val instanceof Date) return val.toISOString();
+ return val;
+ });
+ stmt.run(...values);
+ }
+ });
+
+ txn(rows);
+ logger.info(`[Migration] Imported ${rows.length} rows into "${sqliteTable}"`);
+ return rows.length;
+}
+
+/**
+ * 标记迁移完成
+ *
+ * 写入 migrationComplete 标记到 app_settings + 运行 PRAGMA integrity_check
+ */
+export function completeMigration(db: Database.Database): { ok: boolean; integrity: string } {
+ const now = new Date().toISOString();
+
+ db.prepare(
+ `INSERT OR REPLACE INTO app_settings (id, "key", value, updated_at) VALUES (?, 'migrationComplete', 'true', ?)`
+ ).run(crypto.randomUUID(), now);
+
+ // 运行完整性检查
+ const result = db.pragma('integrity_check', { simple: true }) as string;
+ const ok = result === 'ok';
+
+ if (ok) {
+ logger.info('[Migration] Migration completed successfully, integrity check passed');
+ } else {
+ logger.error(`[Migration] Integrity check failed: ${result}`);
+ }
+
+ return { ok, integrity: result };
+}
+
+// ================================================================
+// IPC handler 注册
+// ================================================================
+
+/**
+ * 注册迁移相关 IPC handler(在 main.ts 的 app.whenReady 中调用)
+ *
+ * 需要外部传入 safeHandle 以避免循环依赖。
+ */
+export function registerMigrationHandlers(
+ safeHandle: (channel: string, handler: (event: Electron.IpcMainInvokeEvent, ...args: Args) => Promise) => void,
+): void {
+ const db = getConnection();
+
+ /** migration:check — 检查是否需要迁移 */
+ safeHandle('migration:check', async () => {
+ const needed = needsMigration(db);
+ logger.info(`[Migration] Check result: ${needed ? 'migration needed' : 'no migration needed'}`);
+ return { needed, tableMapping: TABLE_MAPPING };
+ });
+
+ /** migration:import-table — 导入单表数据 */
+ safeHandle('migration:import-table', async (_event, params: { table: string; rows: Record[] }) => {
+ try {
+ const count = importTable(params.table, params.rows);
+ return { success: true, rowsImported: count };
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ logger.error(`[Migration] Failed to import table "${params.table}"`, err);
+ return { success: false, error: msg };
+ }
+ });
+
+ /** migration:complete — 标记迁移完成 */
+ safeHandle('migration:complete', async () => {
+ try {
+ const result = completeMigration(db);
+ return { success: result.ok, integrity: result.integrity };
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ logger.error('[Migration] Failed to complete migration', err);
+ return { success: false, error: msg };
+ }
+ });
+
+ logger.info('[Migration] Migration IPC handlers registered');
+}
diff --git a/client/electron/db/schema.ts b/client/electron/db/schema.ts
index a477fff3..fee521b7 100644
--- a/client/electron/db/schema.ts
+++ b/client/electron/db/schema.ts
@@ -1,212 +1,214 @@
-/**
- * SQLite Schema DDL — 全部建表语句 + 索引 + 初始化入口
- * 列类型映射: string→TEXT, number→REAL/INTEGER, boolean→INTEGER, Date→TEXT(ISO), Array/Object→TEXT(JSON)
- */
-import type Database from 'better-sqlite3';
-
-export const SCHEMA_VERSION = 4;
-
-export const SCHEMA_DDL = /* sql */ `
-CREATE TABLE IF NOT EXISTS pomodoro_sessions (
- id TEXT PRIMARY KEY, mode TEXT NOT NULL CHECK (mode IN ('class','self_study')),
- subject TEXT, duration INTEGER NOT NULL CHECK (duration > 0),
- actual_duration INTEGER NOT NULL CHECK (actual_duration >= 0),
- completed_at TEXT NOT NULL, interrupted INTEGER NOT NULL DEFAULT 0, goal TEXT
-);
-CREATE TABLE IF NOT EXISTS pomodoro_settings (
- id TEXT PRIMARY KEY, work_duration INTEGER NOT NULL DEFAULT 25,
- short_break_duration INTEGER NOT NULL DEFAULT 5, long_break_duration INTEGER NOT NULL DEFAULT 15,
- long_break_interval INTEGER NOT NULL DEFAULT 4, auto_start_break INTEGER NOT NULL DEFAULT 0,
- auto_start_work INTEGER NOT NULL DEFAULT 0, sound_enabled INTEGER NOT NULL DEFAULT 1,
- notification_enabled INTEGER NOT NULL DEFAULT 1, class_duration INTEGER NOT NULL DEFAULT 45
-);
-CREATE TABLE IF NOT EXISTS notes (
- id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '',
- template TEXT NOT NULL DEFAULT 'free' CHECK (template IN ('outline','cornell','mindmap','free','qa','blank','video')),
- folder_id TEXT, tags TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
- word_count INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, video_note_type TEXT,
- FOREIGN KEY (folder_id) REFERENCES note_folders(id) ON DELETE SET NULL
-);
-CREATE TABLE IF NOT EXISTS note_folders (
- id TEXT PRIMARY KEY, name TEXT NOT NULL, parent_id TEXT, color TEXT,
- created_at TEXT NOT NULL, "order" INTEGER NOT NULL DEFAULT 0,
- FOREIGN KEY (parent_id) REFERENCES note_folders(id) ON DELETE SET NULL
-);
-CREATE TABLE IF NOT EXISTS flashcard_decks (
- id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, parent_id TEXT, color TEXT,
- created_at TEXT NOT NULL, updated_at TEXT NOT NULL, "order" INTEGER NOT NULL DEFAULT 0,
- FOREIGN KEY (parent_id) REFERENCES flashcard_decks(id) ON DELETE SET NULL
-);
-CREATE TABLE IF NOT EXISTS flashcards (
- id TEXT PRIMARY KEY, deck_id TEXT NOT NULL, front TEXT NOT NULL DEFAULT '',
- back TEXT NOT NULL DEFAULT '', type TEXT NOT NULL DEFAULT 'basic' CHECK (type IN ('basic','cloze','multi_choice')),
- ease_factor REAL NOT NULL DEFAULT 2.5, "interval" REAL NOT NULL DEFAULT 0,
- repetitions INTEGER NOT NULL DEFAULT 0, lapses INTEGER NOT NULL DEFAULT 0,
- due_date TEXT NOT NULL, last_review_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
- source_note_id TEXT, "order" INTEGER NOT NULL DEFAULT 0,
- FOREIGN KEY (deck_id) REFERENCES flashcard_decks(id) ON DELETE CASCADE,
- FOREIGN KEY (source_note_id) REFERENCES notes(id) ON DELETE SET NULL
-);
-CREATE TABLE IF NOT EXISTS flashcard_reviews (
- id TEXT PRIMARY KEY, card_id TEXT NOT NULL, deck_id TEXT NOT NULL,
- rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 4),
- ease_factor_before REAL NOT NULL, ease_factor_after REAL NOT NULL,
- interval_before REAL NOT NULL, interval_after REAL NOT NULL,
- reviewed_at TEXT NOT NULL, time_spent REAL NOT NULL DEFAULT 0,
- confidence TEXT CHECK (confidence IS NULL OR confidence IN ('low','medium','high')),
- golden_error INTEGER,
- FOREIGN KEY (card_id) REFERENCES flashcards(id) ON DELETE CASCADE,
- FOREIGN KEY (deck_id) REFERENCES flashcard_decks(id) ON DELETE CASCADE
-);
-CREATE TABLE IF NOT EXISTS feynman_notes (
- id TEXT PRIMARY KEY, concept TEXT NOT NULL, explanation TEXT NOT NULL DEFAULT '',
- status TEXT NOT NULL DEFAULT 'not_started' CHECK (status IN ('not_started','in_progress','completed')),
- current_step INTEGER NOT NULL DEFAULT 1 CHECK (current_step BETWEEN 1 AND 4),
- self_rating INTEGER CHECK (self_rating IS NULL OR self_rating BETWEEN 1 AND 5),
- created_at TEXT NOT NULL, updated_at TEXT NOT NULL, completed_at TEXT
-);
-CREATE TABLE IF NOT EXISTS feynman_summaries (
- id TEXT PRIMARY KEY, note_id TEXT NOT NULL, summary TEXT NOT NULL DEFAULT '',
- created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
- FOREIGN KEY (note_id) REFERENCES feynman_notes(id) ON DELETE CASCADE
-);
-CREATE TABLE IF NOT EXISTS feynman_weak_points (
- id TEXT PRIMARY KEY, note_id TEXT NOT NULL, text TEXT NOT NULL DEFAULT '',
- position TEXT NOT NULL DEFAULT '{}', mastered INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL,
- FOREIGN KEY (note_id) REFERENCES feynman_notes(id) ON DELETE CASCADE
-);
-CREATE TABLE IF NOT EXISTS operation_log (
- id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL,
- operation TEXT NOT NULL CHECK (operation IN ('create','update','delete')),
- payload TEXT, created_at TEXT NOT NULL, synced INTEGER NOT NULL DEFAULT 0,
- version INTEGER NOT NULL DEFAULT 0, device_id TEXT NOT NULL DEFAULT '', patch TEXT
-);
-CREATE TABLE IF NOT EXISTS app_settings (
- id TEXT PRIMARY KEY, "key" TEXT NOT NULL UNIQUE, value TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL
-);
-CREATE TABLE IF NOT EXISTS sync_conflicts (
- id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL,
- local_data TEXT NOT NULL, remote_data TEXT NOT NULL,
- local_version INTEGER NOT NULL, remote_version INTEGER NOT NULL,
- status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','resolved-local','resolved-remote','resolved-manual')),
- created_at TEXT NOT NULL, resolved_at TEXT
-);
-CREATE TABLE IF NOT EXISTS offline_queue (
- id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL,
- operation TEXT NOT NULL CHECK (operation IN ('create','update','delete')),
- payload TEXT, version INTEGER NOT NULL DEFAULT 0, device_id TEXT NOT NULL DEFAULT '',
- created_at TEXT NOT NULL, retry_count INTEGER NOT NULL DEFAULT 0, next_retry_at REAL
-);
-CREATE TABLE IF NOT EXISTS study_check_ins (
- id TEXT PRIMARY KEY, "date" TEXT NOT NULL UNIQUE, check_in_time TEXT NOT NULL,
- modules_used TEXT NOT NULL DEFAULT '[]', streak_days INTEGER NOT NULL DEFAULT 0
-);
-CREATE TABLE IF NOT EXISTS achievements (
- id TEXT PRIMARY KEY, "key" TEXT NOT NULL UNIQUE, title TEXT NOT NULL,
- description TEXT NOT NULL DEFAULT '', icon TEXT NOT NULL DEFAULT '', unlocked_at TEXT NOT NULL
-);
-CREATE TABLE IF NOT EXISTS pomodoro_goals (
- id TEXT PRIMARY KEY, text TEXT NOT NULL, use_count INTEGER NOT NULL DEFAULT 0, last_used_at TEXT NOT NULL
-);
-CREATE TABLE IF NOT EXISTS window_captures (
- id TEXT PRIMARY KEY, note_id TEXT, target_window TEXT NOT NULL DEFAULT '',
- mode TEXT NOT NULL DEFAULT 'vision' CHECK (mode IN ('vision','audio','both')),
- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','completed')),
- segments TEXT NOT NULL DEFAULT '[]', started_at TEXT NOT NULL, ended_at TEXT, total_duration REAL,
- FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE SET NULL
-);
-CREATE TABLE IF NOT EXISTS consent (
- id TEXT PRIMARY KEY, type TEXT NOT NULL CHECK (type IN ('privacy','terms')),
- version TEXT NOT NULL, accepted_at TEXT NOT NULL
-);
-CREATE TABLE IF NOT EXISTS user_profile (
- id TEXT PRIMARY KEY, user_id TEXT NOT NULL, email TEXT NOT NULL DEFAULT '',
- display_name TEXT NOT NULL DEFAULT '', bio TEXT NOT NULL DEFAULT '',
- avatar_url TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL
-);
-CREATE TABLE IF NOT EXISTS inspirations (
- id TEXT PRIMARY KEY, content TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '{}',
- tags_manually_edited INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
- sort_status TEXT CHECK (sort_status IS NULL OR sort_status IN ('pending','sorting','sorted','confirmed','transformed')),
- sort_result TEXT
-);
-CREATE TABLE IF NOT EXISTS predictions (
- id TEXT PRIMARY KEY,
- note_id TEXT NOT NULL,
- question TEXT NOT NULL,
- user_guess TEXT,
- ai_answer TEXT NOT NULL,
- accuracy TEXT CHECK(accuracy IN ('correct', 'partial', 'incorrect')),
- difficulty INTEGER,
- related_concepts TEXT,
- created_at TEXT NOT NULL,
- FOREIGN KEY (note_id) REFERENCES notes(id)
-);
-CREATE TABLE IF NOT EXISTS search_index (
- id INTEGER PRIMARY KEY AUTOINCREMENT, note_id TEXT NOT NULL,
- tokens TEXT NOT NULL DEFAULT '[]', title TEXT NOT NULL DEFAULT '',
- content TEXT NOT NULL DEFAULT '', updated_at REAL NOT NULL DEFAULT 0,
- entity_id TEXT, entity_type TEXT,
- FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
-);
--- 高频查询列索引
-CREATE INDEX IF NOT EXISTS idx_notes_folder_id ON notes(folder_id);
-CREATE INDEX IF NOT EXISTS idx_flashcards_deck_id ON flashcards(deck_id);
-CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_completed_at ON pomodoro_sessions(completed_at);
-CREATE INDEX IF NOT EXISTS idx_flashcard_reviews_reviewed_at ON flashcard_reviews(reviewed_at);
-CREATE INDEX IF NOT EXISTS idx_feynman_notes_created_at ON feynman_notes(created_at);
-CREATE INDEX IF NOT EXISTS idx_predictions_note_id ON predictions(note_id);
-CREATE INDEX IF NOT EXISTS idx_predictions_created_at ON predictions(created_at);
-`;
-
-/** v3 迁移 DDL:CRDT 同步引擎元数据表(条件执行) */
-export const SCHEMA_V3_DDL = /* sql */ `
-CREATE TABLE IF NOT EXISTS crdt_docs (
- table_name TEXT PRIMARY KEY,
- snapshot TEXT NOT NULL DEFAULT '',
- last_heads TEXT NOT NULL DEFAULT '',
- updated_at TEXT NOT NULL
-);
-CREATE TABLE IF NOT EXISTS crdt_changes (
- seq INTEGER PRIMARY KEY AUTOINCREMENT,
- table_name TEXT NOT NULL,
- entity_id TEXT NOT NULL,
- changeset TEXT NOT NULL,
- operation TEXT NOT NULL CHECK (operation IN ('create','update','delete')),
- created_at TEXT NOT NULL
-);
-CREATE INDEX IF NOT EXISTS idx_crdt_changes_table_name ON crdt_changes(table_name);
-`;
-
-/** 执行 DDL 并设置 PRAGMA user_version。幂等调用(CREATE IF NOT EXISTS)。 */
-export function initializeSchema(db: Database.Database): void {
- db.exec(SCHEMA_DDL);
-
- // v2 迁移:FSRS-5 扩展字段(条件 ALTER TABLE,幂等)
- const currentVersion = db.pragma('user_version', { simple: true }) as number;
- if (currentVersion < 2) {
- try {
- db.exec(`ALTER TABLE flashcards ADD COLUMN stability REAL DEFAULT NULL`);
- } catch { /* 列已存在 */ }
- try {
- db.exec(`ALTER TABLE flashcards ADD COLUMN difficulty REAL DEFAULT NULL`);
- } catch { /* 列已存在 */ }
- }
-
- // v3 迁移:CRDT 同步引擎元数据表
- if (currentVersion < 3) {
- db.exec(SCHEMA_V3_DDL);
- }
-
- // v4 迁移:search_index 表增加 entity_id 和 entity_type 列
- if (currentVersion < 4) {
- try {
- db.exec(`ALTER TABLE search_index ADD COLUMN entity_id TEXT`);
- } catch { /* 列已存在 */ }
- try {
- db.exec(`ALTER TABLE search_index ADD COLUMN entity_type TEXT`);
- } catch { /* 列已存在 */ }
- }
-
- db.pragma(`user_version = ${SCHEMA_VERSION}`);
-}
+/**
+ * SQLite Schema DDL — 全部建表语句 + 索引 + 初始化入口
+ * 列类型映射: string→TEXT, number→REAL/INTEGER, boolean→INTEGER, Date→TEXT(ISO), Array/Object→TEXT(JSON)
+ *
+ * @ai-context: SQLite 建表 DDL 唯一权威源——新增表需同步 dbIpcHandlers.ALLOWED_TABLES 白名单。
+ */
+import type Database from 'better-sqlite3';
+
+export const SCHEMA_VERSION = 4;
+
+export const SCHEMA_DDL = /* sql */ `
+CREATE TABLE IF NOT EXISTS pomodoro_sessions (
+ id TEXT PRIMARY KEY, mode TEXT NOT NULL CHECK (mode IN ('class','self_study')),
+ subject TEXT, duration INTEGER NOT NULL CHECK (duration > 0),
+ actual_duration INTEGER NOT NULL CHECK (actual_duration >= 0),
+ completed_at TEXT NOT NULL, interrupted INTEGER NOT NULL DEFAULT 0, goal TEXT
+);
+CREATE TABLE IF NOT EXISTS pomodoro_settings (
+ id TEXT PRIMARY KEY, work_duration INTEGER NOT NULL DEFAULT 25,
+ short_break_duration INTEGER NOT NULL DEFAULT 5, long_break_duration INTEGER NOT NULL DEFAULT 15,
+ long_break_interval INTEGER NOT NULL DEFAULT 4, auto_start_break INTEGER NOT NULL DEFAULT 0,
+ auto_start_work INTEGER NOT NULL DEFAULT 0, sound_enabled INTEGER NOT NULL DEFAULT 1,
+ notification_enabled INTEGER NOT NULL DEFAULT 1, class_duration INTEGER NOT NULL DEFAULT 45
+);
+CREATE TABLE IF NOT EXISTS notes (
+ id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '',
+ template TEXT NOT NULL DEFAULT 'free' CHECK (template IN ('outline','cornell','mindmap','free','qa','blank','video')),
+ folder_id TEXT, tags TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
+ word_count INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, video_note_type TEXT,
+ FOREIGN KEY (folder_id) REFERENCES note_folders(id) ON DELETE SET NULL
+);
+CREATE TABLE IF NOT EXISTS note_folders (
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, parent_id TEXT, color TEXT,
+ created_at TEXT NOT NULL, "order" INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY (parent_id) REFERENCES note_folders(id) ON DELETE SET NULL
+);
+CREATE TABLE IF NOT EXISTS flashcard_decks (
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, parent_id TEXT, color TEXT,
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL, "order" INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY (parent_id) REFERENCES flashcard_decks(id) ON DELETE SET NULL
+);
+CREATE TABLE IF NOT EXISTS flashcards (
+ id TEXT PRIMARY KEY, deck_id TEXT NOT NULL, front TEXT NOT NULL DEFAULT '',
+ back TEXT NOT NULL DEFAULT '', type TEXT NOT NULL DEFAULT 'basic' CHECK (type IN ('basic','cloze','multi_choice')),
+ ease_factor REAL NOT NULL DEFAULT 2.5, "interval" REAL NOT NULL DEFAULT 0,
+ repetitions INTEGER NOT NULL DEFAULT 0, lapses INTEGER NOT NULL DEFAULT 0,
+ due_date TEXT NOT NULL, last_review_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
+ source_note_id TEXT, "order" INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY (deck_id) REFERENCES flashcard_decks(id) ON DELETE CASCADE,
+ FOREIGN KEY (source_note_id) REFERENCES notes(id) ON DELETE SET NULL
+);
+CREATE TABLE IF NOT EXISTS flashcard_reviews (
+ id TEXT PRIMARY KEY, card_id TEXT NOT NULL, deck_id TEXT NOT NULL,
+ rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 4),
+ ease_factor_before REAL NOT NULL, ease_factor_after REAL NOT NULL,
+ interval_before REAL NOT NULL, interval_after REAL NOT NULL,
+ reviewed_at TEXT NOT NULL, time_spent REAL NOT NULL DEFAULT 0,
+ confidence TEXT CHECK (confidence IS NULL OR confidence IN ('low','medium','high')),
+ golden_error INTEGER,
+ FOREIGN KEY (card_id) REFERENCES flashcards(id) ON DELETE CASCADE,
+ FOREIGN KEY (deck_id) REFERENCES flashcard_decks(id) ON DELETE CASCADE
+);
+CREATE TABLE IF NOT EXISTS feynman_notes (
+ id TEXT PRIMARY KEY, concept TEXT NOT NULL, explanation TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'not_started' CHECK (status IN ('not_started','in_progress','completed')),
+ current_step INTEGER NOT NULL DEFAULT 1 CHECK (current_step BETWEEN 1 AND 4),
+ self_rating INTEGER CHECK (self_rating IS NULL OR self_rating BETWEEN 1 AND 5),
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL, completed_at TEXT
+);
+CREATE TABLE IF NOT EXISTS feynman_summaries (
+ id TEXT PRIMARY KEY, note_id TEXT NOT NULL, summary TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
+ FOREIGN KEY (note_id) REFERENCES feynman_notes(id) ON DELETE CASCADE
+);
+CREATE TABLE IF NOT EXISTS feynman_weak_points (
+ id TEXT PRIMARY KEY, note_id TEXT NOT NULL, text TEXT NOT NULL DEFAULT '',
+ position TEXT NOT NULL DEFAULT '{}', mastered INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL,
+ FOREIGN KEY (note_id) REFERENCES feynman_notes(id) ON DELETE CASCADE
+);
+CREATE TABLE IF NOT EXISTS operation_log (
+ id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL,
+ operation TEXT NOT NULL CHECK (operation IN ('create','update','delete')),
+ payload TEXT, created_at TEXT NOT NULL, synced INTEGER NOT NULL DEFAULT 0,
+ version INTEGER NOT NULL DEFAULT 0, device_id TEXT NOT NULL DEFAULT '', patch TEXT
+);
+CREATE TABLE IF NOT EXISTS app_settings (
+ id TEXT PRIMARY KEY, "key" TEXT NOT NULL UNIQUE, value TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS sync_conflicts (
+ id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL,
+ local_data TEXT NOT NULL, remote_data TEXT NOT NULL,
+ local_version INTEGER NOT NULL, remote_version INTEGER NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','resolved-local','resolved-remote','resolved-manual')),
+ created_at TEXT NOT NULL, resolved_at TEXT
+);
+CREATE TABLE IF NOT EXISTS offline_queue (
+ id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL,
+ operation TEXT NOT NULL CHECK (operation IN ('create','update','delete')),
+ payload TEXT, version INTEGER NOT NULL DEFAULT 0, device_id TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL, retry_count INTEGER NOT NULL DEFAULT 0, next_retry_at REAL
+);
+CREATE TABLE IF NOT EXISTS study_check_ins (
+ id TEXT PRIMARY KEY, "date" TEXT NOT NULL UNIQUE, check_in_time TEXT NOT NULL,
+ modules_used TEXT NOT NULL DEFAULT '[]', streak_days INTEGER NOT NULL DEFAULT 0
+);
+CREATE TABLE IF NOT EXISTS achievements (
+ id TEXT PRIMARY KEY, "key" TEXT NOT NULL UNIQUE, title TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '', icon TEXT NOT NULL DEFAULT '', unlocked_at TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS pomodoro_goals (
+ id TEXT PRIMARY KEY, text TEXT NOT NULL, use_count INTEGER NOT NULL DEFAULT 0, last_used_at TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS window_captures (
+ id TEXT PRIMARY KEY, note_id TEXT, target_window TEXT NOT NULL DEFAULT '',
+ mode TEXT NOT NULL DEFAULT 'vision' CHECK (mode IN ('vision','audio','both')),
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','completed')),
+ segments TEXT NOT NULL DEFAULT '[]', started_at TEXT NOT NULL, ended_at TEXT, total_duration REAL,
+ FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE SET NULL
+);
+CREATE TABLE IF NOT EXISTS consent (
+ id TEXT PRIMARY KEY, type TEXT NOT NULL CHECK (type IN ('privacy','terms')),
+ version TEXT NOT NULL, accepted_at TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS user_profile (
+ id TEXT PRIMARY KEY, user_id TEXT NOT NULL, email TEXT NOT NULL DEFAULT '',
+ display_name TEXT NOT NULL DEFAULT '', bio TEXT NOT NULL DEFAULT '',
+ avatar_url TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS inspirations (
+ id TEXT PRIMARY KEY, content TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '{}',
+ tags_manually_edited INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
+ sort_status TEXT CHECK (sort_status IS NULL OR sort_status IN ('pending','sorting','sorted','confirmed','transformed')),
+ sort_result TEXT
+);
+CREATE TABLE IF NOT EXISTS predictions (
+ id TEXT PRIMARY KEY,
+ note_id TEXT NOT NULL,
+ question TEXT NOT NULL,
+ user_guess TEXT,
+ ai_answer TEXT NOT NULL,
+ accuracy TEXT CHECK(accuracy IN ('correct', 'partial', 'incorrect')),
+ difficulty INTEGER,
+ related_concepts TEXT,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY (note_id) REFERENCES notes(id)
+);
+CREATE TABLE IF NOT EXISTS search_index (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, note_id TEXT NOT NULL,
+ tokens TEXT NOT NULL DEFAULT '[]', title TEXT NOT NULL DEFAULT '',
+ content TEXT NOT NULL DEFAULT '', updated_at REAL NOT NULL DEFAULT 0,
+ entity_id TEXT, entity_type TEXT,
+ FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
+);
+-- 高频查询列索引
+CREATE INDEX IF NOT EXISTS idx_notes_folder_id ON notes(folder_id);
+CREATE INDEX IF NOT EXISTS idx_flashcards_deck_id ON flashcards(deck_id);
+CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_completed_at ON pomodoro_sessions(completed_at);
+CREATE INDEX IF NOT EXISTS idx_flashcard_reviews_reviewed_at ON flashcard_reviews(reviewed_at);
+CREATE INDEX IF NOT EXISTS idx_feynman_notes_created_at ON feynman_notes(created_at);
+CREATE INDEX IF NOT EXISTS idx_predictions_note_id ON predictions(note_id);
+CREATE INDEX IF NOT EXISTS idx_predictions_created_at ON predictions(created_at);
+`;
+
+/** v3 迁移 DDL:CRDT 同步引擎元数据表(条件执行) */
+export const SCHEMA_V3_DDL = /* sql */ `
+CREATE TABLE IF NOT EXISTS crdt_docs (
+ table_name TEXT PRIMARY KEY,
+ snapshot TEXT NOT NULL DEFAULT '',
+ last_heads TEXT NOT NULL DEFAULT '',
+ updated_at TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS crdt_changes (
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
+ table_name TEXT NOT NULL,
+ entity_id TEXT NOT NULL,
+ changeset TEXT NOT NULL,
+ operation TEXT NOT NULL CHECK (operation IN ('create','update','delete')),
+ created_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_crdt_changes_table_name ON crdt_changes(table_name);
+`;
+
+/** 执行 DDL 并设置 PRAGMA user_version。幂等调用(CREATE IF NOT EXISTS)。 */
+export function initializeSchema(db: Database.Database): void {
+ db.exec(SCHEMA_DDL);
+
+ // v2 迁移:FSRS-5 扩展字段(条件 ALTER TABLE,幂等)
+ const currentVersion = db.pragma('user_version', { simple: true }) as number;
+ if (currentVersion < 2) {
+ try {
+ db.exec(`ALTER TABLE flashcards ADD COLUMN stability REAL DEFAULT NULL`);
+ } catch { /* 列已存在 */ }
+ try {
+ db.exec(`ALTER TABLE flashcards ADD COLUMN difficulty REAL DEFAULT NULL`);
+ } catch { /* 列已存在 */ }
+ }
+
+ // v3 迁移:CRDT 同步引擎元数据表
+ if (currentVersion < 3) {
+ db.exec(SCHEMA_V3_DDL);
+ }
+
+ // v4 迁移:search_index 表增加 entity_id 和 entity_type 列
+ if (currentVersion < 4) {
+ try {
+ db.exec(`ALTER TABLE search_index ADD COLUMN entity_id TEXT`);
+ } catch { /* 列已存在 */ }
+ try {
+ db.exec(`ALTER TABLE search_index ADD COLUMN entity_type TEXT`);
+ } catch { /* 列已存在 */ }
+ }
+
+ db.pragma(`user_version = ${SCHEMA_VERSION}`);
+}
diff --git a/client/electron/db/sqliteRepository.ts b/client/electron/db/sqliteRepository.ts
index cfaeba60..092c7686 100644
--- a/client/electron/db/sqliteRepository.ts
+++ b/client/electron/db/sqliteRepository.ts
@@ -1,195 +1,197 @@
-/**
- * SQLite 通用仓库 — 实现 IRepository 接口
- *
- * 在 Electron 主进程中直接调用 better-sqlite3(不走 IPC)。
- * 自动处理 camelCase ↔ snake_case 转换、JSON 序列化/反序列化、boolean ↔ INTEGER 映射。
- */
-
-import type { IRepository } from '../../src/lib/storage/interfaces';
-import type Database from 'better-sqlite3';
-import { getConnection } from './sqliteService';
-
-// ================================================================
-// 工具函数
-// ================================================================
-
-/** camelCase → snake_case */
-function toSnake(s: string): string {
- return s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
-}
-
-/** snake_case → camelCase */
-function toCamel(s: string): string {
- return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
-}
-
-// ================================================================
-// 每张表的元数据:哪些列是 JSON / Boolean
-// ================================================================
-
-interface TableMeta {
- jsonFields: string[]; // camelCase 字段名,值需 JSON.stringify / JSON.parse
- boolFields: string[]; // camelCase 字段名,SQLite 中为 INTEGER(0/1)
-}
-
-const TABLE_META: Record = {
- notes: { jsonFields: ['tags'], boolFields: ['pinned'] },
- flashcards: { jsonFields: [], boolFields: [] },
- flashcardReviews: { jsonFields: [], boolFields: ['goldenError'] },
- feynmanWeakPoints: { jsonFields: ['position'], boolFields: ['mastered'] },
- operationLog: { jsonFields: [], boolFields: ['synced'] },
- syncConflicts: { jsonFields: [], boolFields: [] },
- offlineQueue: { jsonFields: [], boolFields: [] },
- studyCheckIns: { jsonFields: ['modulesUsed'], boolFields: [] },
- windowCaptures: { jsonFields: ['segments'], boolFields: [] },
- inspirations: { jsonFields: ['tags', 'sortResult'], boolFields: ['tagsManuallyEdited'] },
- searchIndex: { jsonFields: ['tokens'], boolFields: [] },
- pomodoroSessions: { jsonFields: [], boolFields: ['interrupted'] },
- pomodoroSettings: { jsonFields: [], boolFields: ['autoStartBreak', 'autoStartWork', 'soundEnabled', 'notificationEnabled'] },
-};
-
-function getMeta(tableName: string): TableMeta {
- return TABLE_META[tableName] ?? { jsonFields: [], boolFields: [] };
-}
-
-// ================================================================
-// 行 ↔ 实体转换
-// ================================================================
-
-/** SQLite 行(snake_case keys)→ TypeScript 实体(camelCase keys) */
-function rowToEntity(row: Record, meta: TableMeta): T {
- const jsonSet = new Set(meta.jsonFields);
- const boolSet = new Set(meta.boolFields);
- const result: Record = {};
-
- for (const [snakeKey, raw] of Object.entries(row)) {
- const camelKey = toCamel(snakeKey);
- if (jsonSet.has(camelKey) && typeof raw === 'string') {
- try { result[camelKey] = JSON.parse(raw); } catch { result[camelKey] = raw; }
- } else if (boolSet.has(camelKey)) {
- result[camelKey] = raw === 1 || raw === true;
- } else {
- result[camelKey] = raw;
- }
- }
- return result as T;
-}
-
-/** TypeScript 实体(camelCase keys)→ SQL 参数对象(snake_case keys) */
-function entityToRow(entity: Record, meta: TableMeta): Record {
- const jsonSet = new Set(meta.jsonFields);
- const boolSet = new Set(meta.boolFields);
- const row: Record = {};
-
- for (const [camelKey, value] of Object.entries(entity)) {
- const snakeKey = toSnake(camelKey);
- if (value === undefined) continue;
- if (jsonSet.has(camelKey)) {
- row[snakeKey] = JSON.stringify(value);
- } else if (boolSet.has(camelKey)) {
- row[snakeKey] = value ? 1 : 0;
- } else {
- row[snakeKey] = value;
- }
- }
- return row;
-}
-
-// ================================================================
-// SQL 安全工具
-// ================================================================
-
-/** 用双引号包裹列名,防止 SQLite 保留字冲突 */
-function q(col: string): string {
- return `"${col}"`;
-}
-
-// ================================================================
-// SqliteRepository
-// ================================================================
-
-export default class SqliteRepository implements IRepository {
- private tableName: string;
- private meta: TableMeta;
-
- constructor(tableName: string) {
- this.tableName = tableName;
- this.meta = getMeta(tableName);
- }
-
- private get db(): Database.Database {
- return getConnection();
- }
-
- async getAll(): Promise {
- const rows = this.db.prepare(`SELECT * FROM ${this.tableName}`).all() as Record[];
- return rows.map((r) => rowToEntity(r, this.meta));
- }
-
- async getById(id: string): Promise {
- const row = this.db.prepare(`SELECT * FROM ${this.tableName} WHERE id = ?`).get(id) as
- | Record
- | undefined;
- return row ? rowToEntity(row, this.meta) : undefined;
- }
-
- async create(item: Omit & { id: string }): Promise {
- const row = entityToRow(item as unknown as Record, this.meta);
- const cols = Object.keys(row);
- const placeholders = cols.map(() => '?').join(', ');
- const sql = `INSERT INTO ${this.tableName} (${cols.map(q).join(', ')}) VALUES (${placeholders})`;
- this.db.prepare(sql).run(...Object.values(row));
- return item.id;
- }
-
- async update(id: string, changes: Partial): Promise {
- const row = entityToRow(changes as Record, this.meta);
- const entries = Object.entries(row);
- if (entries.length === 0) return;
- const setClauses = entries.map(([col]) => `${q(col)} = ?`).join(', ');
- const sql = `UPDATE ${this.tableName} SET ${setClauses} WHERE id = ?`;
- this.db.prepare(sql).run(...entries.map(([, v]) => v), id);
- }
-
- async delete(id: string): Promise {
- this.db.prepare(`DELETE FROM ${this.tableName} WHERE id = ?`).run(id);
- }
-
- async find(predicate: (item: T) => boolean): Promise {
- const all = await this.getAll();
- return all.filter(predicate);
- }
-
- async bulkCreate(items: (Omit & { id: string })[]): Promise {
- if (items.length === 0) return [];
- const rows = items.map((item) => entityToRow(item as unknown as Record, this.meta));
- const cols = Object.keys(rows[0]);
- const placeholders = cols.map(() => '?').join(', ');
- const sql = `INSERT INTO ${this.tableName} (${cols.map(q).join(', ')}) VALUES (${placeholders})`;
- const stmt = this.db.prepare(sql);
-
- const txn = this.db.transaction((entries: Record[]) => {
- for (const entry of entries) {
- stmt.run(...cols.map((c) => entry[c]));
- }
- });
- txn(rows);
- return items.map((item) => item.id);
- }
-
- async bulkDelete(ids: string[]): Promise {
- if (ids.length === 0) return;
- const placeholders = ids.map(() => '?').join(', ');
- const sql = `DELETE FROM ${this.tableName} WHERE id IN (${placeholders})`;
- this.db.prepare(sql).run(...ids);
- }
-
- async count(): Promise {
- const row = this.db.prepare(`SELECT COUNT(*) AS cnt FROM ${this.tableName}`).get() as { cnt: number };
- return row.cnt;
- }
-
- async clear(): Promise {
- this.db.prepare(`DELETE FROM ${this.tableName}`).run();
- }
-}
+/**
+ * SQLite 通用仓库 — 实现 IRepository 接口
+ *
+ * 在 Electron 主进程中直接调用 better-sqlite3(不走 IPC)。
+ * 自动处理 camelCase ↔ snake_case 转换、JSON 序列化/反序列化、boolean ↔ INTEGER 映射。
+ *
+ * @ai-context: SQLite 通用仓储(泛型 CRUD),表名由调用方经白名单校验后传入。
+ */
+
+import type { IRepository } from '../../src/lib/storage/interfaces';
+import type Database from 'better-sqlite3';
+import { getConnection } from './sqliteService';
+
+// ================================================================
+// 工具函数
+// ================================================================
+
+/** camelCase → snake_case */
+function toSnake(s: string): string {
+ return s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
+}
+
+/** snake_case → camelCase */
+function toCamel(s: string): string {
+ return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
+}
+
+// ================================================================
+// 每张表的元数据:哪些列是 JSON / Boolean
+// ================================================================
+
+interface TableMeta {
+ jsonFields: string[]; // camelCase 字段名,值需 JSON.stringify / JSON.parse
+ boolFields: string[]; // camelCase 字段名,SQLite 中为 INTEGER(0/1)
+}
+
+const TABLE_META: Record = {
+ notes: { jsonFields: ['tags'], boolFields: ['pinned'] },
+ flashcards: { jsonFields: [], boolFields: [] },
+ flashcardReviews: { jsonFields: [], boolFields: ['goldenError'] },
+ feynmanWeakPoints: { jsonFields: ['position'], boolFields: ['mastered'] },
+ operationLog: { jsonFields: [], boolFields: ['synced'] },
+ syncConflicts: { jsonFields: [], boolFields: [] },
+ offlineQueue: { jsonFields: [], boolFields: [] },
+ studyCheckIns: { jsonFields: ['modulesUsed'], boolFields: [] },
+ windowCaptures: { jsonFields: ['segments'], boolFields: [] },
+ inspirations: { jsonFields: ['tags', 'sortResult'], boolFields: ['tagsManuallyEdited'] },
+ searchIndex: { jsonFields: ['tokens'], boolFields: [] },
+ pomodoroSessions: { jsonFields: [], boolFields: ['interrupted'] },
+ pomodoroSettings: { jsonFields: [], boolFields: ['autoStartBreak', 'autoStartWork', 'soundEnabled', 'notificationEnabled'] },
+};
+
+function getMeta(tableName: string): TableMeta {
+ return TABLE_META[tableName] ?? { jsonFields: [], boolFields: [] };
+}
+
+// ================================================================
+// 行 ↔ 实体转换
+// ================================================================
+
+/** SQLite 行(snake_case keys)→ TypeScript 实体(camelCase keys) */
+function rowToEntity(row: Record, meta: TableMeta): T {
+ const jsonSet = new Set(meta.jsonFields);
+ const boolSet = new Set(meta.boolFields);
+ const result: Record = {};
+
+ for (const [snakeKey, raw] of Object.entries(row)) {
+ const camelKey = toCamel(snakeKey);
+ if (jsonSet.has(camelKey) && typeof raw === 'string') {
+ try { result[camelKey] = JSON.parse(raw); } catch { result[camelKey] = raw; }
+ } else if (boolSet.has(camelKey)) {
+ result[camelKey] = raw === 1 || raw === true;
+ } else {
+ result[camelKey] = raw;
+ }
+ }
+ return result as T;
+}
+
+/** TypeScript 实体(camelCase keys)→ SQL 参数对象(snake_case keys) */
+function entityToRow(entity: Record, meta: TableMeta): Record {
+ const jsonSet = new Set(meta.jsonFields);
+ const boolSet = new Set(meta.boolFields);
+ const row: Record = {};
+
+ for (const [camelKey, value] of Object.entries(entity)) {
+ const snakeKey = toSnake(camelKey);
+ if (value === undefined) continue;
+ if (jsonSet.has(camelKey)) {
+ row[snakeKey] = JSON.stringify(value);
+ } else if (boolSet.has(camelKey)) {
+ row[snakeKey] = value ? 1 : 0;
+ } else {
+ row[snakeKey] = value;
+ }
+ }
+ return row;
+}
+
+// ================================================================
+// SQL 安全工具
+// ================================================================
+
+/** 用双引号包裹列名,防止 SQLite 保留字冲突 */
+function q(col: string): string {
+ return `"${col}"`;
+}
+
+// ================================================================
+// SqliteRepository
+// ================================================================
+
+export default class SqliteRepository implements IRepository {
+ private tableName: string;
+ private meta: TableMeta;
+
+ constructor(tableName: string) {
+ this.tableName = tableName;
+ this.meta = getMeta(tableName);
+ }
+
+ private get db(): Database.Database {
+ return getConnection();
+ }
+
+ async getAll(): Promise {
+ const rows = this.db.prepare(`SELECT * FROM ${this.tableName}`).all() as Record[];
+ return rows.map((r) => rowToEntity(r, this.meta));
+ }
+
+ async getById(id: string): Promise {
+ const row = this.db.prepare(`SELECT * FROM ${this.tableName} WHERE id = ?`).get(id) as
+ | Record
+ | undefined;
+ return row ? rowToEntity(row, this.meta) : undefined;
+ }
+
+ async create(item: Omit & { id: string }): Promise {
+ const row = entityToRow(item as unknown as Record, this.meta);
+ const cols = Object.keys(row);
+ const placeholders = cols.map(() => '?').join(', ');
+ const sql = `INSERT INTO ${this.tableName} (${cols.map(q).join(', ')}) VALUES (${placeholders})`;
+ this.db.prepare(sql).run(...Object.values(row));
+ return item.id;
+ }
+
+ async update(id: string, changes: Partial): Promise {
+ const row = entityToRow(changes as Record, this.meta);
+ const entries = Object.entries(row);
+ if (entries.length === 0) return;
+ const setClauses = entries.map(([col]) => `${q(col)} = ?`).join(', ');
+ const sql = `UPDATE ${this.tableName} SET ${setClauses} WHERE id = ?`;
+ this.db.prepare(sql).run(...entries.map(([, v]) => v), id);
+ }
+
+ async delete(id: string): Promise {
+ this.db.prepare(`DELETE FROM ${this.tableName} WHERE id = ?`).run(id);
+ }
+
+ async find(predicate: (item: T) => boolean): Promise {
+ const all = await this.getAll();
+ return all.filter(predicate);
+ }
+
+ async bulkCreate(items: (Omit & { id: string })[]): Promise {
+ if (items.length === 0) return [];
+ const rows = items.map((item) => entityToRow(item as unknown as Record, this.meta));
+ const cols = Object.keys(rows[0]);
+ const placeholders = cols.map(() => '?').join(', ');
+ const sql = `INSERT INTO ${this.tableName} (${cols.map(q).join(', ')}) VALUES (${placeholders})`;
+ const stmt = this.db.prepare(sql);
+
+ const txn = this.db.transaction((entries: Record[]) => {
+ for (const entry of entries) {
+ stmt.run(...cols.map((c) => entry[c]));
+ }
+ });
+ txn(rows);
+ return items.map((item) => item.id);
+ }
+
+ async bulkDelete(ids: string[]): Promise {
+ if (ids.length === 0) return;
+ const placeholders = ids.map(() => '?').join(', ');
+ const sql = `DELETE FROM ${this.tableName} WHERE id IN (${placeholders})`;
+ this.db.prepare(sql).run(...ids);
+ }
+
+ async count(): Promise {
+ const row = this.db.prepare(`SELECT COUNT(*) AS cnt FROM ${this.tableName}`).get() as { cnt: number };
+ return row.cnt;
+ }
+
+ async clear(): Promise