Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Project Overview

NeoCompanion is a local-first desktop AI assistant built with **Tauri v2** (Rust) + **Vue 3** (Vite) + **Fastify** (TypeScript sidecar) + **SQLite** (Drizzle ORM).
NeoCompanion is a local-first desktop AI assistant built with **Tauri v2** (Rust) + **Vue 3** (Vite) + **Fastify** (TypeScript sidecar) + **SQLite** (`node:sqlite`).

It provides:

Expand All @@ -22,7 +22,7 @@ Tauri Rust Core
Fastify Sidecar
├─ REST API for tasks, focus timer, AI chat, TTS, weather, hooks, windows
├─ WebSocket hub at /ws for streaming AI replies and companion feedback
└─ SQLite via Drizzle ORM
└─ SQLite via the built-in Node.js `node:sqlite` driver

Vue 3 Frontend
├─ 5 views selected by ?view= query param
Expand All @@ -36,7 +36,7 @@ Vue 3 Frontend
| `apps/desktop/src-tauri/` | Rust backend, Tauri commands, window config |
| `apps/desktop/src/` | Vue 3 frontend, views, components, composables |
| `packages/server-local/` | Fastify sidecar with all business logic |
| `packages/db/` | SQLite schema and stores via Drizzle ORM |
| `packages/db/` | SQLite schema, migrations, and stores via `node:sqlite` |
| `packages/shared/` | Shared TypeScript types |
| `packages/ai/` | DeepSeek streaming chat adapter |
| `packages/tts/` | MiMo TTS adapter |
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ NeoCompanion 的能力由浅入深分为四层:

**知识与 AI 层**——在单一本地工作空间中组织项目、Markdown 笔记、任务与看板;通过全文检索和向量检索为 AI 对话提供可核验的本地上下文。

**Hook 与系统层**——安全本地多通道 Hook(HTTP / UDS / File Watcher / MQTT);浮动权限审批气泡;本地隐私感知引擎;本地长期记忆。
**Hook 与系统层**——本地 Hook(HTTP / WebSocket 已实现;UDS / File Watcher / MQTT 规划中);浮动权限审批气泡;本地隐私感知引擎;本地长期记忆。

---

Expand All @@ -145,7 +145,7 @@ NeoCompanion 的能力由浅入深分为四层:
| 桌面运行时 | **Tauri v2** (Rust) |
| 前端 UI | **Vue 3** + Vite + Pinia + TanStack Query |
| 本地服务 | **Fastify** (TypeScript Sidecar) |
| 数据库 | **SQLite** (Drizzle ORM) |
| 数据库 | **SQLite** (`node:sqlite` + FTS5 + sqlite-vec) |
| AI | 聊天模型适配器 + OpenAI-compatible Embedding Adapter |

架构核心:Tauri (Rust) 提供系统级能力与系统钥匙链 → Fastify (TypeScript) 处理业务逻辑、知识索引与 AI 调度 → Vue 提供 UI → SQLite 统一存储业务数据。知识工作空间已接入 FTS5 全文索引、可选 sqlite-vec 混合检索、文件镜像与带来源的 RAG。
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
IndexStatus,
KnowledgeSource,
Task,
TaskListResponse,
TtsResult,
WeatherSummary,
WsMessage
Expand Down Expand Up @@ -77,7 +78,7 @@ function getAuthToken(): Promise<string> {

export const api = {
health: () => request<{ ok: boolean }>("/health"),
listTasks: () => request<Task[]>("/api/tasks"),
listTasks: () => request<TaskListResponse>("/api/tasks").then((r) => r.items),
createTask: (title: string) => request<Task>("/api/tasks", { method: "POST", body: JSON.stringify({ title }) }),
patchTask: (id: string, patch: Partial<Pick<Task, "title" | "status">>) =>
request<Task>(`/api/tasks/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
Expand Down
121 changes: 67 additions & 54 deletions docs/ARCHITECTURE.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

| 工具 | 版本 | 说明 |
|------|------|------|
| Node.js | 22.x | 建议使用 LTS |
| Node.js | 24.x | 必需;数据库使用内置 `node:sqlite` |
| pnpm | 10.32+ | 由 `packageManager` 字段强制锁定 |
| Rust | stable | 用于 Tauri v2 后端 |
| Git | 任意 | |
Expand Down Expand Up @@ -104,7 +104,7 @@ NeoCompanion/
├── apps/desktop/src-tauri/ # Rust / Tauri 后端
├── apps/desktop/src/ # Vue 3 前端
├── packages/server-local/ # Fastify sidecar
├── packages/db/ # SQLite + Drizzle ORM
├── packages/db/ # SQLite + node:sqlite
├── packages/shared/ # 共享 TypeScript 类型
├── packages/ai/ # DeepSeek 聊天适配器
├── packages/tts/ # MiMo TTS 适配器
Expand All @@ -117,9 +117,9 @@ NeoCompanion/

## 常见问题

### `pnpm install` 在 `better-sqlite3` 上失败
### 启动时报 `node:sqlite` 不可用

`better-sqlite3` 是原生模块。请确保 Node.js 版本兼容,且 `node-gyp` 能找到 Python。Windows 上建议安装 Visual Studio Build Tools 的"使用 C++ 的桌面开发"工作负载
项目要求 Node.js 24+。请先运行 `node --version`;旧版 Node 无法加载内置 SQLite 驱动。`sqlite-vec` 仍按平台安装可加载扩展,若向量检索不可用,请重新执行 `pnpm install` 并确认对应平台包未被包管理器裁剪

### Linux 上 Tauri 构建失败

Expand Down
72 changes: 72 additions & 0 deletions docs/SPIKE_NODE_SQLITE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Spike: node:sqlite 迁移可行性验证

- **日期**: 2026-06-22
- **环境**: Windows 10 (win32 x64), Node v24.14.0
- **脚本**: `scripts/spike-node-sqlite.mjs`
- **目的**: 验证 `better-sqlite3` → `node:sqlite` 迁移的可行性,为 SEA 打包扫清障碍

## 背景

`ARCHITECTURE.md` §6.4 设计 sidecar 用 Node.js SEA 打包分发,但 SEA 无法装载原生 `.node` 插件(如 `better-sqlite3`),需迁移到内置的 `node:sqlite`。本 spike 验证 5 个未知项。

## 验证结果

| # | 验证项 | 结果 | 详情 |
|---|--------|------|------|
| 1 | node:sqlite 可用性 | ✅ PASS | Node v24.14.0,`DatabaseSync` 导入成功(有 experimental 警告) |
| 2 | FTS5(trigram,CJK) | ✅ PASS | `ENABLE_FTS5` 编译选项已启用;trigram tokenizer 可创建;3字中文词 MATCH 命中 |
| 3 | sqlite-vec 加载 + KNN | ✅ PASS | `sqlite-vec@0.1.9` 经 `loadExtension` 加载成功;vec0 虚拟表创建 + KNN 查询正确返回最近邻 |
| 4 | 旧 ORM/驱动依赖清理 | ✅ PASS | `drizzle-orm` 与 `better-sqlite3` 均已不可解析 |
| 5 | transaction shim(嵌套+回滚) | ✅ PASS | 手写 `BEGIN/COMMIT/ROLLBACK` + `SAVEPOINT` 嵌套方案可行 |

### 关键发现

**1. FTS5 在 Windows 的 node:sqlite 中可用**

- `PRAGMA compile_options` 确认 `ENABLE_FTS5` 已编译进 Node 内置 SQLite。
- trigram tokenizer 可正常创建和查询。
- 注意:trigram tokenizer 要求查询词 ≥3 字符(按 Unicode 码点),2字中文词(如"向量")无法 MATCH,需走 LIKE fallback —— 这与项目现有 `searchFts` 的 `useMatch` 逻辑一致(`packages/db/src/index.ts`)。

**2. sqlite-vec 与 node:sqlite 兼容**

- `sqlite-vec` 的 `load(db)` 仅要求 db 对象有 `loadExtension(path)` 方法,`DatabaseSync` 满足。
- 关键前提:构造时必须传 `{ allowExtension: true }`,否则 `loadExtension` 抛异常。
- 平台二进制(`sqlite-vec-windows-x64`)是 `.dll` 而非 `.node`,可作为 loose asset 随 SEA 分发。

**3. Drizzle node-sqlite driver 尚未进入稳定版**

- 当前 npm 稳定版 `drizzle-orm@0.45.2` **没有** `node-sqlite` driver(目录和 exports 均无)。
- 经查 `drizzle-orm@1.0.0-rc.4-5d5b77c`(rc 通道)**已包含**完整 `node-sqlite` driver(`driver.js`/`session.js`/`migrator.js`)。
- 如果坚持保留 ORM,迁移需使用 `1.0.0-rc.*` 通道;本项目最终选择直接使用参数化 SQL,避免引入该预发布依赖。

**4. transaction API 差异可填补**

- `node:sqlite` 的 `DatabaseSync` 没有 `.transaction()` 方法(better-sqlite3 有)。
- 项目有 8 处 `sqlite.transaction(fn)()` 调用,可用手写 `withTransaction(db, fn)` shim(BEGIN/COMMIT/ROLLBACK + SAVEPOINT 嵌套)替代,spike 已验证可行。

## 结论

### 已选择直接使用 node:sqlite

| 维度 | 状态 |
|------|------|
| node:sqlite 运行时 | ✅ 可用(项目固定 Node 24+) |
| FTS5 | ✅ 可用(ENABLE_FTS5 已编译) |
| sqlite-vec | ✅ 兼容(需 `{ allowExtension: true }`) |
| transaction 重写 | ✅ 已使用 SAVEPOINT 支持嵌套与局部回滚 |
| 旧 ORM/驱动 | ✅ 已清除 |

### 已采用方案

- 数据库 store 直接使用参数化 SQL 和内部行类型,不引入预发布 ORM。
- 数据库连接启用 WAL、外键、5 秒 busy timeout 和扩展加载。
- 事务统一使用 `BEGIN` / `SAVEPOINT` / `ROLLBACK TO`,支持镜像导入触发索引重建时的嵌套调用。
- `sqlite-vec` 的平台 `.dll` / `.so` / `.dylib` 仍需作为 SEA loose asset 分发;本次迁移不等于完成生产 sidecar 打包。

## 复现

```bash
node scripts/spike-node-sqlite.mjs
```

预期输出:5 PASS / 0 FAIL。
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=24.0.0"
},
"packageManager": "pnpm@10.32.0",
"scripts": {
"dev": "node scripts/dev.mjs web",
Expand All @@ -19,7 +22,6 @@
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild"
]
}
Expand Down
3 changes: 0 additions & 3 deletions packages/db/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,9 @@
},
"dependencies": {
"@neo-companion/shared": "workspace:*",
"better-sqlite3": "12.10.0",
"drizzle-orm": "0.45.2",
"sqlite-vec": "^0.1.9"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.10.1",
"typescript": "6.0.3",
"vitest": "4.1.7"
Expand Down
131 changes: 127 additions & 4 deletions packages/db/src/db.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { describe, expect, it } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { createDatabase, createFocusStore, createKnowledgeStore, createTaskStore, createWindowEventStore } from "./index";

// better-sqlite3's native binding may be unavailable in some environments
// (e.g. Node 24 without a prebuilt binary). The knowledge store requires the
// sqlite path; skip its tests when only the memory fallback is reachable.
// The knowledge store requires the sqlite path; skip its tests when only the
// memory fallback is reachable.
const SQLITE_AVAILABLE = createDatabase(":memory:").kind === "sqlite";

describe("db stores", () => {
Expand All @@ -14,7 +17,8 @@ describe("db stores", () => {

const task = tasks.create("写 v1 骨架");
expect(task.status).toBe("open");
expect(tasks.list()).toHaveLength(1);
expect(tasks.list().items).toHaveLength(1);
expect(tasks.list().total).toBe(1);

const done = tasks.patch(task.id, { status: "done" });
expect(done?.completedAt).toBeTruthy();
Expand Down Expand Up @@ -43,6 +47,74 @@ describe("db stores", () => {
});

describe.skipIf(!SQLITE_AVAILABLE)("knowledge store", () => {
it("supports nested transactions and rolls back only the failing savepoint", () => {
const database = createDatabase(":memory:");
const kw = createKnowledgeStore(database);

kw.runInTransaction(() => {
kw.createProject({ title: "outer-before" });
try {
kw.runInTransaction(() => {
kw.createProject({ title: "inner-rollback" });
throw new Error("intentional nested rollback");
});
} catch {
// The outer transaction must remain usable after the savepoint rollback.
}
const project = kw.createProject({ title: "outer-after" });
const note = kw.createNote(project.id, "nested index");
kw.reindexNote(note, (text) => [{ content: text, contentHash: "nested-hash" }]);
});

expect(kw.listProjects().map((project) => project.title)).toEqual(["outer-before", "outer-after"]);
expect(kw.searchFts(null, "nested index", 5)).toHaveLength(1);
database.close();
});

it("reorders tasks within the uncolumned lane while preserving null storage", () => {
const database = createDatabase(":memory:");
const kw = createKnowledgeStore(database);

const project = kw.createProject({ title: "uncolumned" });
const first = kw.createTask(project.id, "", "first");
const second = kw.createTask(project.id, "", "second");
kw.updateTask(first.id, { order: 0 });
kw.updateTask(second.id, { order: 1 });

kw.moveTask(second.id, "", 0);

const ordered = kw.tasksForProject(project.id);
expect(ordered.map((task) => task.id)).toEqual([second.id, first.id]);
expect(ordered.map((task) => task.order)).toEqual([0, 1]);
expect(ordered.map((task) => task.columnId)).toEqual(["", ""]);
if (database.kind === "sqlite") {
const rows = database.sqlite
.prepare('SELECT id, column_id FROM knowledge_tasks WHERE id IN (?, ?) ORDER BY "order"')
.all(second.id, first.id) as unknown as Array<{ id: string; column_id: string | null }>;
expect(rows.map((row) => row.column_id)).toEqual([null, null]);
}
database.close();
});

it("moves a columned task to the uncolumned lane as null storage", () => {
const database = createDatabase(":memory:");
const kw = createKnowledgeStore(database);

const project = kw.createProject({ title: "move-to-uncolumned" });
const column = kw.createColumn(project.id, { title: "todo", status: "todo", order: 0 });
const task = kw.createTask(project.id, column.id, "columned");

kw.moveTask(task.id, "", 0);

const moved = kw.tasksForProject(project.id)[0];
expect(moved).toMatchObject({ id: task.id, columnId: "", order: 0 });
if (database.kind === "sqlite") {
const row = database.sqlite.prepare("SELECT column_id FROM knowledge_tasks WHERE id = ?").get(task.id) as { column_id: string | null };
expect(row.column_id).toBeNull();
}
database.close();
});

it("persists projects, notes, columns, tasks with mock-compatible shapes", () => {
const database = createDatabase(":memory:");
const kw = createKnowledgeStore(database);
Expand Down Expand Up @@ -91,6 +163,57 @@ describe.skipIf(!SQLITE_AVAILABLE)("knowledge store", () => {
});
});

describe.skipIf(!SQLITE_AVAILABLE)("legacy database compatibility", () => {
it("opens an existing database and preserves rows while applying migrations", () => {
const dir = mkdtempSync(join(tmpdir(), "neo-db-legacy-"));
const filename = join(dir, "legacy.sqlite");
const legacy = new DatabaseSync(filename);
const vector = new Uint8Array(new Float32Array([0.25, 0.75]).buffer);

try {
legacy.exec(`
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE TABLE embedding_cache (
content_hash TEXT PRIMARY KEY,
embedding BLOB NOT NULL,
model TEXT NOT NULL,
dimensions INTEGER NOT NULL
);
`);
legacy.prepare("INSERT INTO tasks (id, title, status, created_at, completed_at) VALUES (?, ?, ?, ?, ?)")
.run("legacy-task", "preserved task", "open", "2026-01-01T00:00:00.000Z", null);
legacy.prepare("INSERT INTO embedding_cache (content_hash, embedding, model, dimensions) VALUES (?, ?, ?, ?)")
.run("legacy-hash", vector, "legacy-model", 2);
} finally {
legacy.close();
}

const database = createDatabase(filename);
try {
expect(database.kind).toBe("sqlite");
expect(createTaskStore(database).list().items[0]).toMatchObject({
id: "legacy-task",
title: "preserved task"
});
expect(createKnowledgeStore(database).getCachedEmbedding("legacy-hash", "legacy-model")?.vector)
.toEqual(expect.arrayContaining([0.25, 0.75]));
if (database.kind === "sqlite") {
const versions = database.sqlite.prepare("SELECT version FROM schema_migrations ORDER BY version").all();
expect(versions).toHaveLength(5);
}
} finally {
database.close();
rmSync(dir, { recursive: true, force: true });
}
});
});

describe.skipIf(!SQLITE_AVAILABLE)("knowledge vector index", () => {
// Minimal chunk fn: one chunk per whole text (db test stays free of the
// server-local chunker). content-hash via a simple substring stamp.
Expand Down
Loading
Loading