diff --git a/.env.example b/.env.example index 778dac2..4e0fb43 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,10 @@ MIMO_API_KEY= MIMO_TTS_VOICE=茉莉 NEO_CITY=Beijing NEO_SERVER_PORT=10103 + +# Embedding provider (Phase 3) — separate from chat; DeepSeek has no embeddings. +# The desktop UI pushes these to the sidecar via PUT /api/knowledge/embedding-config; +# these env vars are a fallback for headless/CI runs. +EMBEDDING_API_KEY= +EMBEDDING_BASE_URL=https://api.openai.com +EMBEDDING_MODEL=text-embedding-3-small diff --git a/CLAUDE.md b/CLAUDE.md index b297274..d85c754 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,8 @@ Vue 3 Frontend ## Current State Notes -- The **knowledge workspace** UI exists but is backed by a front-end mock (`useKnowledgeMock.ts`). The real SQLite-backed API, FTS5 full-text search, and `sqlite-vec` vector retrieval described in the architecture doc are **planned for v2**, not yet implemented. +- The **knowledge workspace** is backed by SQLite CRUD, transactional FTS5 indexing, optional `sqlite-vec` hybrid retrieval, file mirror import/export, and cited AI Ask/Chat flows. `useKnowledgeMock.ts` remains only as a preview fallback when the authenticated sidecar is unavailable. +- Every Sidecar REST and WebSocket request requires the shared `APP_AUTH_TOKEN`; use the root `pnpm dev` / `pnpm dev:tauri` launchers so development processes receive the same ephemeral token. - The codebase still uses `pet`/`companion` identifiers in many places (`components/pet/`, `usePetState.ts`, `pet.css`). The product-facing terminology is **assistant**, but a full code-level rename is out of scope for documentation-only work. - The sidecar runs on `http://127.0.0.1:10103` by default (configurable via `NEO_SERVER_PORT`). diff --git a/README.md b/README.md index aec7463..5621432 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ NeoCompanion 的能力由浅入深分为四层: | 数据库 | **SQLite** (Drizzle ORM) | | AI | 聊天模型适配器 + OpenAI-compatible Embedding Adapter | -架构核心:Tauri (Rust) 提供系统级能力 → Fastify (TypeScript) 处理业务逻辑与 AI 调度 → Vue 提供 UI → SQLite 统一存储业务数据。v2 计划引入 FTS5 全文索引与 sqlite-vec 向量索引,当前版本尚未实现。 +架构核心:Tauri (Rust) 提供系统级能力与系统钥匙链 → Fastify (TypeScript) 处理业务逻辑、知识索引与 AI 调度 → Vue 提供 UI → SQLite 统一存储业务数据。知识工作空间已接入 FTS5 全文索引、可选 sqlite-vec 混合检索、文件镜像与带来源的 RAG。 详见 [**系统架构设计**](docs/ARCHITECTURE.md)。 diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 381bc18..87a45a5 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -1861,6 +1861,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2054,10 +2064,12 @@ dependencies = [ name = "neo-companion" version = "0.1.0" dependencies = [ + "keyring", "serde", "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-plugin-wallpaper", "tauri-plugin-window-state", @@ -2229,6 +2241,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2762,6 +2775,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3445,6 +3482,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -4577,6 +4656,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4610,13 +4698,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -4647,6 +4752,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4659,6 +4770,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4671,12 +4788,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -4689,6 +4818,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -4701,6 +4836,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -4713,6 +4854,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -4725,6 +4872,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" @@ -5029,6 +5182,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index d54f36d..2f29656 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -19,6 +19,7 @@ tauri-plugin-dialog = "2" tauri-plugin-window-state = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +keyring = "3" [target.'cfg(target_os = "windows")'.dependencies] tauri-plugin-wallpaper = "3.0.0" diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 482f326..233bbe8 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2,6 +2,40 @@ use tauri::menu::{Menu, MenuItem}; use tauri::tray::{TrayIconBuilder, TrayIconEvent}; use tauri::{LogicalSize, Manager, Size, WindowEvent}; +#[tauri::command] +fn get_app_auth_token() -> Result { + std::env::var("APP_AUTH_TOKEN").map_err(|_| "APP_AUTH_TOKEN is required".to_string()) +} + +const KEYRING_SERVICE: &str = "com.neocompanion.desktop"; +const EMBEDDING_KEY_ACCOUNT: &str = "embedding-api-key"; + +fn embedding_key_entry() -> Result { + keyring::Entry::new(KEYRING_SERVICE, EMBEDDING_KEY_ACCOUNT).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn set_embedding_api_key(api_key: String) -> Result<(), String> { + embedding_key_entry()?.set_password(&api_key).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn get_embedding_api_key() -> Result, String> { + match embedding_key_entry()?.get_password() { + Ok(value) => Ok(Some(value)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(error) => Err(error.to_string()), + } +} + +#[tauri::command] +fn delete_embedding_api_key() -> Result<(), String> { + match embedding_key_entry()?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(error) => Err(error.to_string()), + } +} + pub fn run() { let builder = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) @@ -12,6 +46,12 @@ pub fn run() { let builder = builder.plugin(tauri_plugin_wallpaper::init()); builder + .invoke_handler(tauri::generate_handler![ + get_app_auth_token, + set_embedding_api_key, + get_embedding_api_key, + delete_embedding_api_key + ]) .setup(|app| { let show = MenuItem::with_id(app, "show", "显示", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 1073967..a46648f 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -1,14 +1,52 @@ -import type { ChatMessage, FocusSession, Task, TtsResult, WeatherSummary, WsMessage } from "@neo-companion/shared"; +import type { + AiAnswer, + AiRetrievalMode, + ChatMessage, + ContextLevel, + FocusSession, + IndexStatus, + KnowledgeSource, + Task, + TtsResult, + WeatherSummary, + WsMessage +} from "@neo-companion/shared"; import type { BoardColumn, KnowledgeNote, KnowledgeProject, KnowledgeTask, TaskStatus } from "./composables/useKnowledgeMock"; +import { invoke } from "@tauri-apps/api/core"; + +export interface EmbeddingConfigInput { + provider?: string; + baseUrl?: string; + apiKey?: string | null; + model?: string; + apiKeySource?: "keychain" | "env" | "legacy" | "none"; +} +export interface EmbeddingConfigStatus { + provider: string; + baseUrl: string; + model: string; + configured: boolean; + apiKeySource: "keychain" | "env" | "legacy" | "none"; + legacyMigrationRequired: boolean; +} +export interface AiChatRequest { + message: string; + mode?: AiRetrievalMode; + projectId?: string | null; + context?: { notes: Record; tasks: Record }; + conversationId?: string; +} export const API_BASE = import.meta.env.VITE_NEO_SERVER_URL ?? "http://127.0.0.1:10103"; export const WS_BASE = API_BASE.replace(/^http/, "ws"); async function request(path: string, options?: RequestInit): Promise { + const token = await getAuthToken(); const response = await fetch(`${API_BASE}${path}`, { ...options, headers: { "Content-Type": "application/json", + Authorization: `Bearer ${token}`, ...options?.headers } }); @@ -18,9 +56,25 @@ async function request(path: string, options?: RequestInit): Promise { throw new Error(body.error ?? response.statusText); } + if (response.status === 204) return undefined as T; + return response.json() as Promise; } +let authTokenPromise: Promise | null = null; +function getAuthToken(): Promise { + if (!authTokenPromise) { + const envToken = import.meta.env.VITE_NEO_AUTH_TOKEN as string | undefined; + const isTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; + authTokenPromise = isTauri + ? invoke("get_app_auth_token") + : envToken + ? Promise.resolve(envToken) + : Promise.reject(new Error("VITE_NEO_AUTH_TOKEN is required")); + } + return authTokenPromise; +} + export const api = { health: () => request<{ ok: boolean }>("/health"), listTasks: () => request("/api/tasks"), @@ -32,6 +86,8 @@ export const api = { completeFocus: (id: string) => request(`/api/focus/${id}/complete`, { method: "POST" }), weather: () => request("/api/weather"), chat: (message: string) => request<{ text: string }>("/api/ai/chat", { method: "POST", body: JSON.stringify({ message }) }), + aiChat: (req: AiChatRequest) => + request("/api/ai/chat", { method: "POST", body: JSON.stringify(req) }), speak: (text: string, style?: string) => request("/api/tts/speak", { method: "POST", body: JSON.stringify({ text, style }) }), @@ -81,25 +137,65 @@ export const api = { knowledgeMirrorExport: (path?: string) => request<{ projects: number; notes: number; columns: number; tasks: number }>("/api/knowledge/mirror/export", { method: "POST", body: JSON.stringify({ path }) }), knowledgeMirrorImport: (path?: string) => - request<{ importedProjects: number; importedNotes: number; importedColumns: number; importedTasks: number; skipped: number }>("/api/knowledge/mirror/import", { method: "POST", body: JSON.stringify({ path }) }) + request<{ importedProjects: number; importedNotes: number; importedColumns: number; importedTasks: number; skipped: number; reindexedNotes: number; reindexedTasks: number; errors: string[] }>("/api/knowledge/mirror/import", { method: "POST", body: JSON.stringify({ path }) }), + + // ── Knowledge retrieval + embedding config (Phase 3) ── + knowledgeSearch: (query: string, projectId?: string | null, limit = 20) => + request(`/api/knowledge/search?q=${encodeURIComponent(query)}${projectId ? `&projectId=${encodeURIComponent(projectId)}` : ""}&limit=${limit}`), + knowledgeIndexStatus: () => request("/api/knowledge/index-status"), + knowledgeReindex: (embeddingModel?: string) => + request<{ notes: number; tasks: number }>("/api/knowledge/reindex", { method: "POST", body: JSON.stringify({ embeddingModel }) }), + knowledgeGetEmbeddingConfig: () => + request("/api/knowledge/embedding-config"), + knowledgeSetEmbeddingConfig: (config: EmbeddingConfigInput) => + request<{ ok: boolean }>("/api/knowledge/embedding-config", { method: "PUT", body: JSON.stringify(config) }), + knowledgeClaimLegacyEmbeddingSecret: () => + request<{ apiKey: string }>("/api/knowledge/embedding-config/legacy-secret/claim", { method: "POST" }), + knowledgeClearLegacyEmbeddingSecret: () => + request("/api/knowledge/embedding-config/legacy-secret", { method: "DELETE" }) }; +/** Hydrate the sidecar's in-memory embedding secret from the OS keychain. */ +export async function bootstrapEmbeddingSecret(): Promise { + const isTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; + if (!isTauri) return; + const status = await api.knowledgeGetEmbeddingConfig(); + if (status.legacyMigrationRequired) { + const legacy = await api.knowledgeClaimLegacyEmbeddingSecret(); + await invoke("set_embedding_api_key", { apiKey: legacy.apiKey }); + await api.knowledgeSetEmbeddingConfig({ apiKey: legacy.apiKey, apiKeySource: "keychain" }); + await api.knowledgeClearLegacyEmbeddingSecret(); + return; + } + const stored = await invoke("get_embedding_api_key"); + if (stored) await api.knowledgeSetEmbeddingConfig({ apiKey: stored, apiKeySource: "keychain" }); +} + let activeWs: WebSocket | null = null; export function connectWs(onMessage: (message: WsMessage) => void) { - const ws = new WebSocket(`${WS_BASE}/ws`); - activeWs = ws; - ws.addEventListener("message", (event) => { - onMessage(JSON.parse(event.data) as WsMessage); + let ws: WebSocket | null = null; + let heartbeat: number | null = null; + let cancelled = false; + void getAuthToken().then((token) => { + if (cancelled) return; + ws = new WebSocket(`${WS_BASE}/ws`, ["neo-companion", `auth.${token}`]); + activeWs = ws; + ws.addEventListener("message", (event) => { + onMessage(JSON.parse(event.data) as WsMessage); + }); + heartbeat = window.setInterval(() => { + if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" })); + }, 30000); + }).catch((error: unknown) => { + onMessage({ type: "ai:error", payload: { message: error instanceof Error ? error.message : "authentication failed" } }); }); - const heartbeat = window.setInterval(() => { - if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" })); - }, 30000); return () => { - window.clearInterval(heartbeat); + cancelled = true; + if (heartbeat !== null) window.clearInterval(heartbeat); activeWs = null; - ws.close(); + ws?.close(); }; } diff --git a/apps/desktop/src/components/knowledge/KnowledgeAiPanel.vue b/apps/desktop/src/components/knowledge/KnowledgeAiPanel.vue new file mode 100644 index 0000000..6dfd452 --- /dev/null +++ b/apps/desktop/src/components/knowledge/KnowledgeAiPanel.vue @@ -0,0 +1,159 @@ + + + diff --git a/apps/desktop/src/components/knowledge/ProjectWorkspace.vue b/apps/desktop/src/components/knowledge/ProjectWorkspace.vue index 14c8115..10133e5 100644 --- a/apps/desktop/src/components/knowledge/ProjectWorkspace.vue +++ b/apps/desktop/src/components/knowledge/ProjectWorkspace.vue @@ -1,16 +1,19 @@