diff --git a/docs/claude-code-interface.md b/docs/claude-code-interface.md new file mode 100644 index 00000000..d1b88e51 --- /dev/null +++ b/docs/claude-code-interface.md @@ -0,0 +1,300 @@ +# Claude Code Local Reading Assistant Interface + +This document describes the desktop-only interface used by the ReadAny AI panel +when it is backed by the local Claude Code CLI. The goal is to keep the existing +chat UI and `StreamingChat` API stable while replacing the model execution path. + +## Scope + +- Desktop Tauri only. +- No local HTTP bridge is started. +- The frontend still calls `StreamingChat.stream(options)`. +- `StreamingChat` routes to `streamClaudeCodeAgent`, which calls a platform + service method implemented by the Tauri desktop adapter. +- Claude Code model/provider configuration stays outside ReadAny. In practice, + DeepSeek is selected through the user's local Claude Code environment or + Claude Code configuration. ReadAny does not store provider API keys. + +## Frontend entry point + +The public chat surface remains: + +```ts +new StreamingChat().stream({ + thread, + book, + semanticContext, + enabledSkills, + isVectorized, + aiConfig, + deepThinking, + spoilerFree, + getAvailableTools, + onToken, + onComplete, + onAbort, + onError, + onToolCall, + onToolResult, + onReasoning, + onCitation, +}); +``` + +`StreamingChat.stream` converts ReadAny chat state into a Claude Code prompt and +maps Claude Code stream events back to the existing callbacks: + +| Agent event | Existing callback | +| --- | --- | +| `token` | `onToken(content)` | +| `reasoning` | `onReasoning(content, stepType)` | +| `tool_call` | `onToolCall(name, args)` | +| `tool_result` | `onToolResult(name, result)` | +| `citation` | `onCitation(citation)` | +| `error` | `onError(error)` | + +## Platform service contract + +`IPlatformService` exposes the optional desktop-only methods below. + +```ts +export interface ClaudeCodeChatRequest { + requestId: string; + prompt: string; + systemPrompt: string; + effort?: "low" | "medium" | "high" | "xhigh" | "max"; + model?: string; + tools?: string[]; + disallowedTools?: string[]; +} + +export interface ClaudeCodeChatHandlers { + signal?: AbortSignal; + onStdoutLine: (line: string) => void; + onStderr?: (content: string) => void; +} + +export interface IPlatformService { + runClaudeCodeChat?( + request: ClaudeCodeChatRequest, + handlers: ClaudeCodeChatHandlers, + ): Promise; + abortClaudeCodeChat?(requestId: string): Promise; + checkClaudeCode?(): Promise<{ available: boolean; version?: string; error?: string }>; + extractBookChapter?(filePath: string, chapterIndex: number): Promise; +} +``` + +Mobile and web builds do not implement these methods. They should either keep +the previous AI route or show a clear unsupported-local-mode error. + +## Tauri command interface + +The desktop adapter calls three Tauri commands: + +| Command | Purpose | +| --- | --- | +| `claude_code_check` | Runs `claude --version` and reports availability. | +| `claude_code_chat` | Starts one Claude Code CLI request and emits stream events. | +| `claude_code_abort` | Cancels a running request by `requestId` and kills the child process. | + +`claude_code_chat` receives the camelCase `ClaudeCodeChatRequest` object from +TypeScript. Rust deserializes it as: + +```rust +pub struct ClaudeCodeChatRequest { + request_id: String, + prompt: String, + system_prompt: String, + effort: Option, + model: Option, + tools: Option>, + disallowed_tools: Option>, +} +``` + +It starts Claude Code with: + +```text +claude -p + --input-format text + --output-format stream-json + --verbose + --include-partial-messages + --system-prompt + --permission-mode bypassPermissions + --tools +``` + +The current reading assistant passes only: + +```ts +tools: ["WebSearch", "WebFetch"] +``` + +This allows web lookup while avoiding file-editing and shell-style tools inside +the reading assistant. + +On Windows, the child process is started with `CREATE_NO_WINDOW`, so Claude Code +does not open an extra console window. + +## Tauri event stream + +The Rust command emits `claude-code-chat-event` through the Tauri window. + +```ts +type ClaudeCodeChatEvent = { + requestId: string; + kind: "stdout" | "stderr" | "exit"; + line?: string; + content?: string; + code?: number; + error?: string; +}; +``` + +- `stdout.line` is one `stream-json` line from Claude Code. +- `stderr.content` contains diagnostics and is not treated as failure by itself. +- `exit.code` is emitted after the child exits. +- A non-zero process exit becomes a user-visible error unless the request was + cancelled. + +## Claude Code stream mapping + +The parser accepts Claude Code `stream-json` lines and emits ReadAny agent +events. + +| Claude Code stream-json payload | ReadAny event | +| --- | --- | +| `content_block_delta` with `text_delta` | `token` | +| `content_block_delta` with `thinking_delta` | `reasoning` | +| `content_block_start` with `tool_use` | `tool_call` | +| `user.message.content[].type === "tool_result"` | `tool_result` | +| `result.is_error === true` | `error` | +| final `result.result` without streamed text | fallback `token` | + +Partial assistant messages are de-duplicated by message id so cumulative text is +not rendered twice. + +## ReadAny tool protocol + +ReadAny library operations are not Claude Code MCP tools. They are a local +protocol interpreted by `streamClaudeCodeAgent`. + +Claude Code may request ReadAny operations by ending a response with either: + +````text +```readany +search-book: = +list-highlights: +list-notes: +``` +```` + +or: + +````text +```readany-ops +list-skills +``` +```` + +Supported operation examples: + +| Operation | Meaning | +| --- | --- | +| `search-book: bookId=query` | Search vectorized book chunks. | +| `list-highlights: bookId` | List highlights for one book. | +| `list-notes: bookId` | List notes for one book. | +| `list-bookmarks: bookId` | List bookmarks for one book. | +| `list-threads: bookId` | List chat threads for one book. | +| `add-note: bookId=title|content` | Add a book note. | +| `add-bookmark: bookId=label` | Add a bookmark. | +| `list-skills` | List enabled reading skills. | + +Book references are resolved in this order: + +1. `current`, `current-book`, or the current book title maps to the open book id. +2. Direct `getBook(reference)` lookup. +3. Exact title match. +4. Unique partial title match. + +## Sync behavior + +Claude Code conversations are stored through the same ReadAny chat tables as the +original AI panel: + +- `threads` +- `messages` + +Per-book reading memory is stored in: + +- `book_memories` + +Full-section translation output is stored as first-class SQL data in: + +- `chapter_translations` + +These tables are part of the normal WebDAV/S3/LAN sync snapshot, so Claude Code +chat history, per-book memory, and completed full-section translations sync the +same way as built-in reading data. Paragraph-level translation KV cache remains +a local compatibility and speed layer; when legacy cached chapter translations +are restored, ReadAny migrates them into `chapter_translations` so future syncs +use the durable record. + +After operations execute, ReadAny emits visible tool cards, appends the results +to a continuation prompt, and calls Claude Code again. The agent can run up to +three ReadAny operation rounds for one user message. Repeated identical +operations are skipped. + +## Reading context injection + +For reading requests, the agent attempts to include richer local context before +calling Claude Code: + +- current book metadata and `Book ID`; +- selected text quotes; +- visible reading context; +- current chapter text from local chunks when available; +- extracted chapter text from the local book file as a fallback; +- per-book memory. + +When `spoilerFree` is enabled, context should not include content beyond the +current reading position. + +## Per-book memory + +Each book can have one compacted memory row in `book_memories`. + +The prompt-facing shape is: + +```ts +export interface BookMemory { + bookId: string; + summary: string; + focus: string[]; + openQuestions: string[]; + recentQuestions: string[]; + lastChapterTitle?: string; + lastChapterIndex?: number; + lastPositionPercent?: number; + totalMessages: number; + lastCompactedAt: number; + compactedMessageCount: number; + updatedAt: number; +} +``` + +Memory is updated after an exchange through `updateBookMemoryAfterExchange`. +It is compacted when it exceeds configured list/summary limits or after 24 +messages since the last compaction. This keeps repeated reading sessions useful +without allowing the prompt to grow unbounded. + +## Security notes + +- Do not commit provider API keys. +- ReadAny should not persist DeepSeek or Anthropic API keys for this local mode. +- Claude Code credentials and model routing are owned by the user's local Claude + Code installation. +- The reading assistant should keep `tools` restricted to web-read tools unless + a future feature explicitly needs a broader permission model. diff --git a/packages/app-expo/src/lib/platform/expo-platform-service.ts b/packages/app-expo/src/lib/platform/expo-platform-service.ts index 96b24b37..f695d924 100644 --- a/packages/app-expo/src/lib/platform/expo-platform-service.ts +++ b/packages/app-expo/src/lib/platform/expo-platform-service.ts @@ -104,9 +104,28 @@ export class ExpoPlatformService implements IPlatformService { // ---- Language / Locale ---- async getLocale(): Promise { - // Use React Native's I18nManager to get device locale - const { I18nManager } = require("react-native"); - return I18nManager.localeIdentifier || "en_US"; + const { I18nManager, NativeModules, Platform } = require("react-native"); + const candidates: unknown[] = + Platform.OS === "ios" + ? [ + NativeModules.SettingsManager?.settings?.AppleLocale, + NativeModules.SettingsManager?.settings?.AppleLanguages?.[0], + NativeModules.PlatformConstants?.localeIdentifier, + I18nManager.localeIdentifier, + ] + : [ + NativeModules.I18nManager?.localeIdentifier, + NativeModules.PlatformConstants?.localeIdentifier, + I18nManager.localeIdentifier, + ]; + + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) { + return candidate; + } + } + + return "en_US"; } // ---- File picker (expo-document-picker) ---- diff --git a/packages/app/src-tauri/Cargo.lock b/packages/app/src-tauri/Cargo.lock index 767e4d43..c492811e 100644 --- a/packages/app/src-tauri/Cargo.lock +++ b/packages/app/src-tauri/Cargo.lock @@ -5497,6 +5497,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/packages/app/src-tauri/Cargo.toml b/packages/app/src-tauri/Cargo.toml index e7c8538a..b1619389 100644 --- a/packages/app/src-tauri/Cargo.toml +++ b/packages/app/src-tauri/Cargo.toml @@ -29,7 +29,7 @@ serde_json = "1" tauri-plugin-single-instance = "2" tauri-plugin-websocket = "2" serde = { version = "1", features = ["derive"] } -tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "process", "sync"] } sha2 = "0.10" local-ip-address = "0.6.10" axum = "0.8.8" diff --git a/packages/app/src-tauri/src/claude_code.rs b/packages/app/src-tauri/src/claude_code.rs new file mode 100644 index 00000000..5497bc54 --- /dev/null +++ b/packages/app/src-tauri/src/claude_code.rs @@ -0,0 +1,347 @@ +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + process::Stdio, + sync::Mutex, +}; +use tauri::{Emitter, State, WebviewWindow}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + process::Command, + sync::oneshot, +}; + +const EVENT_NAME: &str = "claude-code-chat-event"; + +#[derive(Default)] +pub struct ClaudeCodeState { + cancels: Mutex>>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeCodeChatRequest { + request_id: String, + prompt: String, + system_prompt: String, + effort: Option, + model: Option, + tools: Option>, + disallowed_tools: Option>, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct ClaudeCodeChatEvent { + request_id: String, + kind: String, + line: Option, + content: Option, + code: Option, + error: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeCodeCheckResult { + available: bool, + version: Option, + error: Option, +} + +#[tauri::command] +pub async fn claude_code_check() -> Result { + let output = new_claude_command() + .arg("--version") + .output() + .await + .map_err(|err| err.to_string())?; + let text = if output.stdout.is_empty() { + String::from_utf8_lossy(&output.stderr).trim().to_string() + } else { + String::from_utf8_lossy(&output.stdout).trim().to_string() + }; + + Ok(ClaudeCodeCheckResult { + available: output.status.success(), + version: output.status.success().then_some(text.clone()), + error: (!output.status.success()).then_some(text), + }) +} + +#[tauri::command] +pub async fn claude_code_abort( + state: State<'_, ClaudeCodeState>, + request_id: String, +) -> Result<(), String> { + if let Some(cancel) = state + .cancels + .lock() + .map_err(|err| err.to_string())? + .remove(&request_id) + { + let _ = cancel.send(()); + } + Ok(()) +} + +#[tauri::command] +pub async fn claude_code_chat( + window: WebviewWindow, + state: State<'_, ClaudeCodeState>, + request: ClaudeCodeChatRequest, +) -> Result<(), String> { + let mut command = new_claude_command(); + command + .arg("-p") + .arg("--input-format") + .arg("text") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--include-partial-messages") + .arg("--system-prompt") + .arg(&request.system_prompt) + .arg("--permission-mode") + .arg("bypassPermissions") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + if let Some(model) = request + .model + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + command.arg("--model").arg(model); + } + + if let Some(effort) = request + .effort + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + command.arg("--effort").arg(effort); + } + + if let Some(tools) = request.tools.as_ref().filter(|items| !items.is_empty()) { + command.arg("--tools").arg(tools.join(",")); + } + + if let Some(disallowed_tools) = request + .disallowed_tools + .as_ref() + .filter(|items| !items.is_empty()) + { + command + .arg("--disallowedTools") + .arg(disallowed_tools.join(",")); + } + + let mut child = command.spawn().map_err(|err| { + format!( + "Failed to start Claude Code. Make sure the claude CLI is installed and available: {}", + err + ) + })?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| "Claude Code stdout unavailable".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "Claude Code stderr unavailable".to_string())?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "Claude Code stdin unavailable".to_string())?; + + stdin + .write_all(request.prompt.as_bytes()) + .await + .map_err(|err| err.to_string())?; + stdin.shutdown().await.map_err(|err| err.to_string())?; + drop(stdin); + + let stdout_window = window.clone(); + let stdout_request_id = request.request_id.clone(); + let stdout_handle = tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let _ = emit_event( + &stdout_window, + ClaudeCodeChatEvent { + request_id: stdout_request_id.clone(), + kind: "stdout".to_string(), + line: Some(line), + content: None, + code: None, + error: None, + }, + ); + } + }); + + let stderr_window = window.clone(); + let stderr_request_id = request.request_id.clone(); + let stderr_handle = tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let _ = emit_event( + &stderr_window, + ClaudeCodeChatEvent { + request_id: stderr_request_id.clone(), + kind: "stderr".to_string(), + line: None, + content: Some(line), + code: None, + error: None, + }, + ); + } + }); + + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + state + .cancels + .lock() + .map_err(|err| err.to_string())? + .insert(request.request_id.clone(), cancel_tx); + + let mut cancelled = false; + let status = tokio::select! { + status = child.wait() => status.map_err(|err| err.to_string())?, + _ = cancel_rx => { + cancelled = true; + let _ = child.kill().await; + child.wait().await.map_err(|err| err.to_string())? + } + }; + + let _ = state + .cancels + .lock() + .map_err(|err| err.to_string())? + .remove(&request.request_id); + + let _ = stdout_handle.await; + let _ = stderr_handle.await; + + emit_event( + &window, + ClaudeCodeChatEvent { + request_id: request.request_id.clone(), + kind: "exit".to_string(), + line: None, + content: None, + code: status.code(), + error: None, + }, + ) + .map_err(|err| err.to_string())?; + + if cancelled { + return Ok(()); + } + + if !status.success() { + return Err(format!( + "Claude Code exited unsuccessfully with code {}", + status + .code() + .map_or_else(|| "unknown".to_string(), |code| code.to_string()) + )); + } + + Ok(()) +} + +fn emit_event(window: &WebviewWindow, event: ClaudeCodeChatEvent) -> tauri::Result<()> { + window.emit(EVENT_NAME, event) +} + +fn new_claude_command() -> Command { + let executable = resolve_claude_executable(); + let mut command = if is_windows_command_script(&executable) { + let mut command = Command::new("cmd"); + command.arg("/C").arg(executable); + command + } else { + Command::new(executable) + }; + configure_hidden_process(&mut command); + command +} + +#[cfg(target_os = "windows")] +fn configure_hidden_process(command: &mut Command) { + const CREATE_NO_WINDOW: u32 = 0x08000000; + command.creation_flags(CREATE_NO_WINDOW); +} + +#[cfg(not(target_os = "windows"))] +fn configure_hidden_process(_command: &mut Command) {} + +#[cfg(target_os = "windows")] +fn is_windows_command_script(path: &Path) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .map(|extension| matches!(extension.to_ascii_lowercase().as_str(), "cmd" | "bat")) + .unwrap_or(false) +} + +#[cfg(not(target_os = "windows"))] +fn is_windows_command_script(_path: &Path) -> bool { + false +} + +#[cfg(target_os = "windows")] +fn resolve_claude_executable() -> PathBuf { + if let Ok(path) = std::env::var("CLAUDE_EXE") { + let path = PathBuf::from(path); + if path.exists() { + return path; + } + } + + if let Ok(appdata) = std::env::var("APPDATA") { + let npm_root = PathBuf::from(appdata).join("npm"); + let candidates = [ + npm_root.join("claude.exe"), + npm_root + .join("node_modules") + .join("@anthropic-ai") + .join("claude-code") + .join("bin") + .join("claude.exe"), + npm_root.join("claude.cmd"), + npm_root + .join("node_modules") + .join("@anthropic-ai") + .join("claude-code") + .join("bin") + .join("claude.cmd"), + ]; + for candidate in candidates { + if candidate.exists() { + return candidate; + } + } + } + + PathBuf::from("claude.cmd") +} + +#[cfg(not(target_os = "windows"))] +fn resolve_claude_executable() -> PathBuf { + if let Ok(path) = std::env::var("CLAUDE_EXE") { + let path = PathBuf::from(path); + if path.exists() { + return path; + } + } + PathBuf::from("claude") +} diff --git a/packages/app/src-tauri/src/db/schema.rs b/packages/app/src-tauri/src/db/schema.rs index e2b70f5a..5883cf7c 100644 --- a/packages/app/src-tauri/src/db/schema.rs +++ b/packages/app/src-tauri/src/db/schema.rs @@ -85,6 +85,41 @@ pub fn initialize(db_path: &Path) -> Result<()> { created_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS book_memories ( + book_id TEXT PRIMARY KEY REFERENCES books(id) ON DELETE CASCADE, + summary TEXT NOT NULL DEFAULT '', + focus TEXT NOT NULL DEFAULT '[]', + open_questions TEXT NOT NULL DEFAULT '[]', + recent_questions TEXT NOT NULL DEFAULT '[]', + last_chapter_title TEXT, + last_chapter_index INTEGER, + last_position_percent REAL, + total_messages INTEGER NOT NULL DEFAULT 0, + last_compacted_at INTEGER NOT NULL DEFAULT 0, + compacted_message_count INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0, + sync_version INTEGER DEFAULT 0, + last_modified_by TEXT + ); + + CREATE TABLE IF NOT EXISTS chapter_translations ( + id TEXT PRIMARY KEY, + book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE, + section_index INTEGER NOT NULL, + source_lang TEXT NOT NULL, + target_lang TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT '', + model TEXT, + source_hash TEXT NOT NULL, + paragraphs TEXT NOT NULL DEFAULT '[]', + original_visible INTEGER NOT NULL DEFAULT 1, + translation_visible INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + sync_version INTEGER DEFAULT 0, + last_modified_by TEXT + ); + CREATE TABLE IF NOT EXISTS reading_sessions ( id TEXT PRIMARY KEY, book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE, @@ -127,6 +162,8 @@ pub fn initialize(db_path: &Path) -> Result<()> { CREATE INDEX IF NOT EXISTS idx_notes_book ON notes(book_id); CREATE INDEX IF NOT EXISTS idx_bookmarks_book ON bookmarks(book_id); CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id); + CREATE INDEX IF NOT EXISTS idx_book_memories_updated ON book_memories(updated_at); + CREATE INDEX IF NOT EXISTS idx_chapter_translations_book ON chapter_translations(book_id); CREATE INDEX IF NOT EXISTS idx_reading_sessions_book ON reading_sessions(book_id); CREATE INDEX IF NOT EXISTS idx_chunks_book ON chunks(book_id); ", @@ -143,7 +180,8 @@ pub fn initialize(db_path: &Path) -> Result<()> { // --- Sync migrations --- // Migration 4: Add updated_at and file_hash to books - let _ = conn.execute_batch("ALTER TABLE books ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0"); + let _ = + conn.execute_batch("ALTER TABLE books ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0"); let _ = conn.execute_batch("ALTER TABLE books ADD COLUMN file_hash TEXT"); let _ = conn.execute_batch("UPDATE books SET updated_at = added_at WHERE updated_at = 0"); @@ -172,24 +210,22 @@ pub fn initialize(db_path: &Path) -> Result<()> { // Migration 7: Add sync_version and last_modified_by to all synced tables let _ = conn.execute_batch("ALTER TABLE books ADD COLUMN sync_version INTEGER DEFAULT 0"); let _ = conn.execute_batch("ALTER TABLE books ADD COLUMN last_modified_by TEXT"); - let _ = - conn.execute_batch("ALTER TABLE highlights ADD COLUMN sync_version INTEGER DEFAULT 0"); + let _ = conn.execute_batch("ALTER TABLE highlights ADD COLUMN sync_version INTEGER DEFAULT 0"); let _ = conn.execute_batch("ALTER TABLE highlights ADD COLUMN last_modified_by TEXT"); let _ = conn.execute_batch("ALTER TABLE notes ADD COLUMN sync_version INTEGER DEFAULT 0"); let _ = conn.execute_batch("ALTER TABLE notes ADD COLUMN last_modified_by TEXT"); - let _ = - conn.execute_batch("ALTER TABLE bookmarks ADD COLUMN sync_version INTEGER DEFAULT 0"); + let _ = conn.execute_batch("ALTER TABLE bookmarks ADD COLUMN sync_version INTEGER DEFAULT 0"); let _ = conn.execute_batch("ALTER TABLE bookmarks ADD COLUMN last_modified_by TEXT"); let _ = conn.execute_batch("ALTER TABLE threads ADD COLUMN sync_version INTEGER DEFAULT 0"); let _ = conn.execute_batch("ALTER TABLE threads ADD COLUMN last_modified_by TEXT"); - let _ = - conn.execute_batch("ALTER TABLE messages ADD COLUMN sync_version INTEGER DEFAULT 0"); + let _ = conn.execute_batch("ALTER TABLE messages ADD COLUMN sync_version INTEGER DEFAULT 0"); let _ = conn.execute_batch("ALTER TABLE messages ADD COLUMN last_modified_by TEXT"); - let _ = conn.execute_batch( - "ALTER TABLE reading_sessions ADD COLUMN sync_version INTEGER DEFAULT 0", - ); - let _ = - conn.execute_batch("ALTER TABLE reading_sessions ADD COLUMN last_modified_by TEXT"); + let _ = conn + .execute_batch("ALTER TABLE chapter_translations ADD COLUMN sync_version INTEGER DEFAULT 0"); + let _ = conn.execute_batch("ALTER TABLE chapter_translations ADD COLUMN last_modified_by TEXT"); + let _ = conn + .execute_batch("ALTER TABLE reading_sessions ADD COLUMN sync_version INTEGER DEFAULT 0"); + let _ = conn.execute_batch("ALTER TABLE reading_sessions ADD COLUMN last_modified_by TEXT"); let _ = conn.execute_batch("ALTER TABLE skills ADD COLUMN sync_version INTEGER DEFAULT 0"); let _ = conn.execute_batch("ALTER TABLE skills ADD COLUMN last_modified_by TEXT"); diff --git a/packages/app/src-tauri/src/lib.rs b/packages/app/src-tauri/src/lib.rs index cfa2ef58..5c7bd698 100644 --- a/packages/app/src-tauri/src/lib.rs +++ b/packages/app/src-tauri/src/lib.rs @@ -1,8 +1,10 @@ +mod claude_code; mod db; mod storage; mod sync; mod vector; +use claude_code::ClaudeCodeState; use std::sync::Mutex; use tauri::Manager; use vector::VectorDBState; @@ -28,7 +30,11 @@ pub fn run() { .manage(VectorDBState { db: Mutex::new(None), }) + .manage(ClaudeCodeState::default()) .invoke_handler(tauri::generate_handler![ + claude_code::claude_code_check, + claude_code::claude_code_chat, + claude_code::claude_code_abort, sync::commands::sync_vacuum_into, sync::commands::sync_integrity_check, sync::commands::sync_hash_file, diff --git a/packages/app/src-tauri/src/vector/mod.rs b/packages/app/src-tauri/src/vector/mod.rs index cf8b107d..f9d074eb 100644 --- a/packages/app/src-tauri/src/vector/mod.rs +++ b/packages/app/src-tauri/src/vector/mod.rs @@ -4,6 +4,7 @@ use rusqlite::{Connection, params}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::Mutex; +use std::time::Duration; use tauri::{AppHandle, Manager}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -60,7 +61,7 @@ impl VectorDB { ))); } - conn.execute("PRAGMA busy_timeout=5000", [])?; + conn.busy_timeout(Duration::from_millis(5000))?; let _: String = conn.query_row("PRAGMA journal_mode=WAL", [], |row| row.get(0))?; conn.execute( diff --git a/packages/app/src-tauri/tauri.conf.json b/packages/app/src-tauri/tauri.conf.json index d5d829a8..f54efe5b 100644 --- a/packages/app/src-tauri/tauri.conf.json +++ b/packages/app/src-tauri/tauri.conf.json @@ -4,9 +4,9 @@ "version": "1.3.2", "identifier": "com.readany.app", "build": { - "beforeDevCommand": "pnpm dev", + "beforeDevCommand": "corepack pnpm dev", "devUrl": "http://localhost:1420", - "beforeBuildCommand": "pnpm build", + "beforeBuildCommand": "corepack pnpm build", "frontendDist": "../dist" }, "app": { diff --git a/packages/app/src/components/chat/ChatPage.tsx b/packages/app/src/components/chat/ChatPage.tsx index 58263cf9..356d399c 100644 --- a/packages/app/src/components/chat/ChatPage.tsx +++ b/packages/app/src/components/chat/ChatPage.tsx @@ -1,11 +1,9 @@ -import { ConfigGuideDialog, type ConfigGuideType } from "@/components/shared/ConfigGuideDialog"; /** * ChatPage — standalone full-page chat for general conversations. */ import { useStreamingChat } from "@/hooks/use-streaming-chat"; import { useChatReaderStore } from "@/stores/chat-reader-store"; import { useChatStore } from "@/stores/chat-store"; -import { useSettingsStore } from "@/stores/settings-store"; import { getPlatformService } from "@readany/core/services"; import type { CitationPart } from "@readany/core/types"; import { @@ -18,7 +16,6 @@ import { getMonthLabel, groupThreadsByTime, mergeMessagesWithStreaming, - providerRequiresApiKey, } from "@readany/core/utils"; import { BookOpen, @@ -60,7 +57,9 @@ function ThreadsSidebar({
-
@@ -91,14 +90,19 @@ function ThreadsSidebar({ const olderByMonth = new Map(); for (const thread of grouped.older) { const monthLabel = getMonthLabel(thread.updatedAt); - if (!olderByMonth.has(monthLabel)) { - olderByMonth.set(monthLabel, []); + let monthThreads = olderByMonth.get(monthLabel); + if (!monthThreads) { + monthThreads = []; + olderByMonth.set(monthLabel, monthThreads); } - olderByMonth.get(monthLabel)!.push(thread); + monthThreads.push(thread); } const sortedMonths = [...olderByMonth.keys()].sort((a, b) => b.localeCompare(a)); for (const month of sortedMonths) { - sections.push({ key: month, label: month, threads: olderByMonth.get(month)! }); + const monthThreads = olderByMonth.get(month); + if (monthThreads) { + sections.push({ key: month, label: month, threads: monthThreads }); + } } return sections.map(({ key, label, threads }) => { @@ -117,13 +121,16 @@ function ThreadsSidebar({ return (
{ - onSelect(thread.id); - onClose(); - }} className={`group flex cursor-pointer items-start gap-2 rounded-lg px-3 py-2.5 transition-colors ${thread.id === activeThreadId ? "bg-primary/10 text-primary" : "text-foreground hover:bg-muted"}`} > -
+ ))}
@@ -219,7 +227,6 @@ export function ChatPage() { const [showThreads, setShowThreads] = useState(false); const [showExportMenu, setShowExportMenu] = useState(false); - const [configGuide, setConfigGuide] = useState(null); const exportMenuRef = useRef(null); useEffect(() => { @@ -245,14 +252,6 @@ export function ChatPage() { const handleSend = useCallback( async (content: string, deepThinking = false, spoilerFree = false) => { - const { aiConfig } = useSettingsStore.getState(); - const endpoint = aiConfig.endpoints.find((e) => e.id === aiConfig.activeEndpointId); - const needsKey = endpoint ? providerRequiresApiKey(endpoint.provider) : true; - if (!endpoint || (needsKey && !endpoint.apiKey) || !aiConfig.activeModel) { - setConfigGuide("ai"); - return; - } - // /chats page should only use general threads (no bookId) if (!activeThreadId) { await createThread(undefined, content.slice(0, 50)); @@ -417,8 +416,6 @@ export function ChatPage() {
- - setConfigGuide(null)} /> ); } diff --git a/packages/app/src/components/chat/ChatPanel.tsx b/packages/app/src/components/chat/ChatPanel.tsx index e0bfbd9b..4fdb59b3 100644 --- a/packages/app/src/components/chat/ChatPanel.tsx +++ b/packages/app/src/components/chat/ChatPanel.tsx @@ -1,10 +1,8 @@ -import { ConfigGuideDialog, type ConfigGuideType } from "@/components/shared/ConfigGuideDialog"; /** * ChatPanel — book-scoped sidebar chat panel. */ import { useStreamingChat } from "@/hooks/use-streaming-chat"; import { useChatStore } from "@/stores/chat-store"; -import { useSettingsStore } from "@/stores/settings-store"; import { getPlatformService } from "@readany/core/services"; import type { Book, CitationPart } from "@readany/core/types"; import { @@ -17,7 +15,6 @@ import { getMonthLabel, groupThreadsByTime, mergeMessagesWithStreaming, - providerRequiresApiKey, } from "@readany/core/utils"; import { ClipboardCopy, @@ -74,7 +71,6 @@ export function ChatPanel({ book, onNavigateToCitation }: ChatPanelProps) { const [showThreadList, setShowThreadList] = useState(false); const [showExportMenu, setShowExportMenu] = useState(false); const [attachedQuotes, setAttachedQuotes] = useState([]); - const [configGuide, setConfigGuide] = useState(null); const popoverRef = useRef(null); const exportMenuRef = useRef(null); @@ -104,14 +100,6 @@ export function ChatPanel({ book, onNavigateToCitation }: ChatPanelProps) { const handleSend = useCallback( (content: string, deepThinking = false, spoilerFree = false, quotes?: AttachedQuote[]) => { - const { aiConfig } = useSettingsStore.getState(); - const endpoint = aiConfig.endpoints.find((e) => e.id === aiConfig.activeEndpointId); - const needsKey = endpoint ? providerRequiresApiKey(endpoint.provider) : true; - if (!endpoint || (needsKey && !endpoint.apiKey) || !aiConfig.activeModel) { - setConfigGuide("ai"); - return; - } - sendMessage(content, bookId, deepThinking, spoilerFree, quotes); setAttachedQuotes([]); }, @@ -339,14 +327,19 @@ export function ChatPanel({ book, onNavigateToCitation }: ChatPanelProps) { const olderByMonth = new Map(); for (const thread of grouped.older) { const monthLabel = getMonthLabel(thread.updatedAt); - if (!olderByMonth.has(monthLabel)) { - olderByMonth.set(monthLabel, []); + let monthThreads = olderByMonth.get(monthLabel); + if (!monthThreads) { + monthThreads = []; + olderByMonth.set(monthLabel, monthThreads); } - olderByMonth.get(monthLabel)!.push(thread); + monthThreads.push(thread); } const sortedMonths = [...olderByMonth.keys()].sort((a, b) => b.localeCompare(a)); for (const month of sortedMonths) { - sections.push({ key: month, label: month, threads: olderByMonth.get(month)! }); + const monthThreads = olderByMonth.get(month); + if (monthThreads) { + sections.push({ key: month, label: month, threads: monthThreads }); + } } return sections.map(({ key, label, threads }) => { @@ -370,9 +363,12 @@ export function ChatPanel({ book, onNavigateToCitation }: ChatPanelProps) { ? "bg-primary/10 text-primary" : "text-neutral-600 hover:bg-muted" }`} - onClick={() => handleSelectThread(thread.id)} > -
+
- - setConfigGuide(null)} /> ); } diff --git a/packages/app/src/components/chat/ModelSelector.tsx b/packages/app/src/components/chat/ModelSelector.tsx index 198b071a..2609ed29 100644 --- a/packages/app/src/components/chat/ModelSelector.tsx +++ b/packages/app/src/components/chat/ModelSelector.tsx @@ -1,99 +1,50 @@ /** - * ModelSelector — inline model switcher for chat headers. - * Shows all models across all endpoints, grouped by endpoint name. - * Selecting a model from a different endpoint also switches the active endpoint. + * ModelSelector - compact status pill for the local Claude Code backend. */ -import { useSettingsStore } from "@/stores/settings-store"; -import { Check, ChevronDown } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; +import { getPlatformService } from "@readany/core/services"; +import { Check, Terminal, TriangleAlert } from "lucide-react"; +import { useEffect, useState } from "react"; export function ModelSelector() { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const popoverRef = useRef(null); + const [status, setStatus] = useState<"checking" | "ready" | "error">("checking"); + const [label, setLabel] = useState("Claude Code"); - const { aiConfig, setActiveModel, setActiveEndpoint } = useSettingsStore(); - const currentModel = aiConfig.activeModel; - const activeEndpointId = aiConfig.activeEndpointId; - - // Collect all endpoints that have models - const endpointsWithModels = aiConfig.endpoints.filter((ep) => ep.models.length > 0); - const totalModels = endpointsWithModels.reduce((sum, ep) => sum + ep.models.length, 0); - const multipleEndpoints = endpointsWithModels.length > 1; - - // Close on outside click useEffect(() => { - if (!open) return; - const handler = (e: MouseEvent) => { - if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) setOpen(false); + let cancelled = false; + getPlatformService() + .checkClaudeCode?.() + .then((result) => { + if (cancelled) return; + if (result?.available) { + setStatus("ready"); + setLabel(result.version || "Claude Code"); + } else { + setStatus("error"); + setLabel("Claude Code"); + } + }) + .catch(() => { + if (cancelled) return; + setStatus("error"); + }); + return () => { + cancelled = true; }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [open]); - - // Derive display name: truncate long model names - const displayName = currentModel - ? currentModel.length > 20 - ? `${currentModel.slice(0, 18)}...` - : currentModel - : t("chat.currentModel"); + }, []); - const canSwitch = totalModels > 1; - - const handleSelect = (endpointId: string, model: string) => { - if (endpointId !== activeEndpointId) { - setActiveEndpoint(endpointId); - } - setActiveModel(model); - setOpen(false); - }; + const Icon = status === "error" ? TriangleAlert : status === "ready" ? Check : Terminal; return ( -
- - - {open && canSwitch && ( -
-
- {endpointsWithModels.map((ep) => ( -
- {multipleEndpoints && ( -
- {ep.name || ep.baseUrl} -
- )} - {ep.models.map((model) => { - const isActive = model === currentModel && ep.id === activeEndpointId; - return ( - - ); - })} -
- ))} -
-
- )} +
+ + {label}
); } diff --git a/packages/app/src/components/chat/PartRenderer.tsx b/packages/app/src/components/chat/PartRenderer.tsx index 8ac1b0dd..c9e082fe 100644 --- a/packages/app/src/components/chat/PartRenderer.tsx +++ b/packages/app/src/components/chat/PartRenderer.tsx @@ -211,6 +211,11 @@ const TOOL_LABEL_KEYS: Record = { searchAllNotes: "toolLabels.searchAllNotes", getReadingStats: "toolLabels.getReadingStats", getSkills: "toolLabels.getSkills", + readBookMemory: "toolLabels.readBookMemory", + searchBook: "toolLabels.searchBook", + listData: "toolLabels.listData", + WebSearch: "toolLabels.WebSearch", + WebFetch: "toolLabels.WebFetch", mindmap: "toolLabels.mindmap", }; @@ -285,7 +290,7 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) { {key}:{" "} {typeof value === "string" && value.length > 100 - ? value.slice(0, 100) + "..." + ? `${value.slice(0, 100)}...` : String(value)}
@@ -302,7 +307,7 @@ function ToolCallPartView({ part }: { part: ToolCallPart }) {
                       {typeof part.result === "string" && part.result.length > 500
-                        ? part.result.slice(0, 500) + "..."
+                        ? `${part.result.slice(0, 500)}...`
                         : JSON.stringify(part.result, null, 2)}
                     
diff --git a/packages/app/src/lib/db/migrations.ts b/packages/app/src/lib/db/migrations.ts index 41312004..8a09af8b 100644 --- a/packages/app/src/lib/db/migrations.ts +++ b/packages/app/src/lib/db/migrations.ts @@ -37,6 +37,30 @@ const migrations: Migration[] = [ "CREATE INDEX IF NOT EXISTS idx_books_group ON books(group_id)", ], }, + { + version: 4, + description: "Persist full chapter translations", + up: [ + `CREATE TABLE IF NOT EXISTS chapter_translations ( + id TEXT PRIMARY KEY, + book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE, + section_index INTEGER NOT NULL, + source_lang TEXT NOT NULL, + target_lang TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT '', + model TEXT, + source_hash TEXT NOT NULL, + paragraphs TEXT NOT NULL DEFAULT '[]', + original_visible INTEGER NOT NULL DEFAULT 1, + translation_visible INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + sync_version INTEGER DEFAULT 0, + last_modified_by TEXT + )`, + "CREATE INDEX IF NOT EXISTS idx_chapter_translations_book ON chapter_translations(book_id)", + ], + }, ]; /** Run pending migrations */ diff --git a/packages/app/src/lib/db/schema.sql b/packages/app/src/lib/db/schema.sql index c45a64ea..8e8c03ff 100644 --- a/packages/app/src/lib/db/schema.sql +++ b/packages/app/src/lib/db/schema.sql @@ -97,6 +97,24 @@ CREATE TABLE IF NOT EXISTS messages ( created_at INTEGER NOT NULL ); +CREATE TABLE IF NOT EXISTS chapter_translations ( + id TEXT PRIMARY KEY, + book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE, + section_index INTEGER NOT NULL, + source_lang TEXT NOT NULL, + target_lang TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT '', + model TEXT, + source_hash TEXT NOT NULL, + paragraphs TEXT NOT NULL DEFAULT '[]', + original_visible INTEGER NOT NULL DEFAULT 1, + translation_visible INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + sync_version INTEGER DEFAULT 0, + last_modified_by TEXT +); + CREATE TABLE IF NOT EXISTS reading_sessions ( id TEXT PRIMARY KEY, book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE, @@ -144,6 +162,7 @@ CREATE INDEX IF NOT EXISTS idx_bookmarks_book ON bookmarks(book_id); CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id); CREATE INDEX IF NOT EXISTS idx_threads_book ON threads(book_id); CREATE INDEX IF NOT EXISTS idx_reading_sessions_book ON reading_sessions(book_id); +CREATE INDEX IF NOT EXISTS idx_chapter_translations_book ON chapter_translations(book_id); CREATE INDEX IF NOT EXISTS idx_chunks_book ON chunks(book_id); CREATE INDEX IF NOT EXISTS idx_books_last_opened ON books(last_opened_at DESC); CREATE INDEX IF NOT EXISTS idx_books_group ON books(group_id); diff --git a/packages/app/src/lib/platform/tauri-platform-service.ts b/packages/app/src/lib/platform/tauri-platform-service.ts index f8568963..a86264e7 100644 --- a/packages/app/src/lib/platform/tauri-platform-service.ts +++ b/packages/app/src/lib/platform/tauri-platform-service.ts @@ -7,6 +7,9 @@ * All Tauri imports are dynamic so the module graph stays clean in SSR/test contexts. */ import type { + ClaudeCodeChatHandlers, + ClaudeCodeChatRequest, + ExtractedBookChapter, FetchOptions, FilePickerOptions, IDatabase, @@ -15,9 +18,15 @@ import type { UpdateInfo, WebSocketOptions, } from "@readany/core/services"; +import { extractBookChapters } from "../rag/book-extractor"; +import { resolveDesktopDataPath } from "../storage/desktop-library-root"; const TAURI_LAN_RUNTIME_ERROR = "Tauri desktop runtime is required to use the LAN sender. Open the desktop app instead of the browser dev server."; +const TAURI_CLAUDE_CODE_RUNTIME_ERROR = + "Tauri desktop runtime is required to use Claude Code local chat. Open the desktop app instead of the browser dev server."; +const CLAUDE_CODE_EVENT = "claude-code-chat-event"; +const extractedBookCache = new Map>(); function isTauriRuntimeAvailable(): boolean { return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; @@ -29,6 +38,12 @@ function ensureTauriRuntimeForLAN(): void { } } +function ensureTauriRuntimeForClaudeCode(): void { + if (!isTauriRuntimeAvailable()) { + throw new Error(TAURI_CLAUDE_CODE_RUNTIME_ERROR); + } +} + /** Adapter: wraps Tauri SQL plugin instance as IDatabase */ function isClosedPoolError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); @@ -265,6 +280,80 @@ export class TauriPlatformService implements IPlatformService { }; } + // ---- Claude Code ---- + + async runClaudeCodeChat( + request: ClaudeCodeChatRequest, + handlers: ClaudeCodeChatHandlers, + ): Promise { + ensureTauriRuntimeForClaudeCode(); + const { invoke } = await import("@tauri-apps/api/core"); + const { listen } = await import("@tauri-apps/api/event"); + + if (handlers.signal?.aborted) { + await this.abortClaudeCodeChat(request.requestId); + return; + } + + const unlisten = await listen<{ + requestId: string; + kind: "stdout" | "stderr" | "exit"; + line?: string; + content?: string; + code?: number; + error?: string; + }>(CLAUDE_CODE_EVENT, (event) => { + const payload = event.payload; + if (!payload || payload.requestId !== request.requestId) return; + if (payload.kind === "stdout" && payload.line !== undefined) { + handlers.onStdoutLine(payload.line); + } else if (payload.kind === "stderr" && payload.content !== undefined) { + handlers.onStderr?.(payload.content); + } + }); + + const abortHandler = () => { + void this.abortClaudeCodeChat(request.requestId); + }; + handlers.signal?.addEventListener("abort", abortHandler, { once: true }); + + try { + await invoke("claude_code_chat", { request }); + } finally { + handlers.signal?.removeEventListener("abort", abortHandler); + unlisten(); + } + } + + async abortClaudeCodeChat(requestId: string): Promise { + ensureTauriRuntimeForClaudeCode(); + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("claude_code_abort", { requestId }); + } + + async checkClaudeCode(): Promise<{ available: boolean; version?: string; error?: string }> { + ensureTauriRuntimeForClaudeCode(); + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("claude_code_check"); + } + + async extractBookChapter( + filePath: string, + chapterIndex: number, + ): Promise { + const resolvedPath = await resolveDesktopDataPath(filePath); + let extraction = extractedBookCache.get(resolvedPath); + if (!extraction) { + extraction = extractBookChapters(resolvedPath); + extractedBookCache.set(resolvedPath, extraction); + } + + const chapters = await extraction; + return ( + chapters.find((chapter) => chapter.index === chapterIndex) ?? chapters[chapterIndex] ?? null + ); + } + // ---- App info ---- async getAppVersion(): Promise { diff --git a/packages/core/src/ai/__tests__/book-memory.test.ts b/packages/core/src/ai/__tests__/book-memory.test.ts new file mode 100644 index 00000000..074c524d --- /dev/null +++ b/packages/core/src/ai/__tests__/book-memory.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + compactBookMemory, + createEmptyBookMemory, + mergeBookMemoryExchange, + renderBookMemoryForPrompt, +} from "../book-memory"; + +describe("book memory", () => { + it("records reading position, selected focus, and recent questions for one book", () => { + const memory = mergeBookMemoryExchange( + createEmptyBookMemory("book-1"), + { + userInput: "Why does the author compare reality and fantasy?", + assistantText: "The answer focuses on the chapter's argument.", + selectedQuotes: [ + { id: "quote-1", text: "reality is structured by fantasy", source: "Preface" }, + ], + chapterTitle: "Preface", + chapterIndex: 0, + positionPercent: 0.12, + }, + 1000, + ); + + expect(memory.bookId).toBe("book-1"); + expect(memory.lastChapterTitle).toBe("Preface"); + expect(memory.lastChapterIndex).toBe(0); + expect(memory.lastPositionPercent).toBe(0.12); + expect(memory.recentQuestions[0]).toContain("Why does the author"); + expect(memory.focus[0]).toContain("Preface"); + expect(renderBookMemoryForPrompt(memory)).toContain("Book memory"); + }); + + it("compacts long rolling memory so prompt context stays bounded", () => { + let memory = createEmptyBookMemory("book-1"); + for (let index = 0; index < 40; index++) { + memory = mergeBookMemoryExchange( + memory, + { + userInput: `Question ${index}: explain this argument in detail?`, + assistantText: `Answer ${index}: this was a long answer about the argument.`, + chapterTitle: `Chapter ${index % 3}`, + chapterIndex: index % 3, + positionPercent: index / 100, + }, + 1000 + index, + ); + } + + const compacted = compactBookMemory(memory, 5000); + + expect(compacted.recentQuestions.length).toBeLessThanOrEqual(12); + expect(compacted.focus.length).toBeLessThanOrEqual(12); + expect(compacted.summary.length).toBeLessThanOrEqual(2500); + expect(compacted.lastCompactedAt).toBe(5000); + }); +}); diff --git a/packages/core/src/ai/__tests__/claude-code.test.ts b/packages/core/src/ai/__tests__/claude-code.test.ts new file mode 100644 index 00000000..d52fbd0a --- /dev/null +++ b/packages/core/src/ai/__tests__/claude-code.test.ts @@ -0,0 +1,523 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildChapterContextFromChunks, + buildChapterContextFromExtractedChapter, + buildContextToolEvents, + createClaudeCodeStreamParser, + executeReadAnyOpsDetailed, + parseReadAnyOps, + preExecuteReadAnyTools, + resolveReadAnyBookReference, + shouldAttachFullChapter, + streamClaudeCodeAgent, +} from "../claude-code"; + +const { runClaudeCodeChatMock, ragSearchMock } = vi.hoisted(() => ({ + runClaudeCodeChatMock: vi.fn(), + ragSearchMock: vi.fn(), +})); + +vi.mock("../../services/platform", () => ({ + getPlatformService: () => ({ + runClaudeCodeChat: runClaudeCodeChatMock, + }), +})); + +vi.mock("../../rag", () => ({ + search: ragSearchMock, +})); + +// Mock DB +vi.mock("../../db/database", () => { + const mockBooks = [ + { + id: "book-1", + meta: { title: "斜目而视", author: "齐泽克", language: "zh-CN" }, + format: "epub", + progress: 0.32, + isVectorized: true, + addedAt: 1700000000000, + lastOpenedAt: 1715000000000, + }, + { + id: "book-2", + meta: { title: "存在与时间", author: "海德格尔", language: "zh-CN" }, + format: "pdf", + progress: 0, + isVectorized: false, + addedAt: 1700010000000, + lastOpenedAt: 1700010000000, + }, + { + id: "book-3", + meta: { title: "Being and Time", author: "Heidegger", language: "en" }, + format: "epub", + progress: 1, + isVectorized: true, + addedAt: 1700020000000, + lastOpenedAt: 1715000000000, + }, + ]; + return { + getBooks: vi.fn().mockResolvedValue(mockBooks), + getBook: vi.fn().mockImplementation(async (id: string) => mockBooks.find((book) => book.id === id) || null), + getBookMemory: vi.fn().mockResolvedValue(null), + getChunks: vi.fn().mockResolvedValue([]), + getAllHighlights: vi.fn().mockResolvedValue([ + { text: "关键在于...", note: "重要概念", bookId: "book-1", chapterTitle: "第一章", color: "yellow", createdAt: Date.now() }, + ]), + getAllNotes: vi.fn().mockResolvedValue([ + { title: "读书笔记1", content: "这本书讨论了...", bookId: "book-1", chapterTitle: "第一章", tags: [], createdAt: Date.now() }, + ]), + getReadingSessionsByDateRange: vi.fn().mockResolvedValue([ + { totalActiveTime: 1800000, pagesRead: 25 }, + { totalActiveTime: 3600000, pagesRead: 50 }, + ]), + getSkills: vi.fn().mockResolvedValue([ + { id: "skill-1", name: "Smart Summary", description: "Summarize a chapter" }, + ]), + }; +}); + +describe("preExecuteReadAnyTools", () => { + it("detects library_query intent from Chinese keywords", async () => { + const result = await preExecuteReadAnyTools({ + userInput: "我的书库有哪些书?", + book: null, + }); + expect(result.intent).toBe("library_query"); + expect(result.librarySummary).toBeDefined(); + expect(result.librarySummary).toContain("书库详情"); + expect(result.librarySummary).toContain("斜目而视"); + expect(result.librarySummary).toContain("存在与时间"); + }); + + it("detects reading_stats intent", async () => { + const result = await preExecuteReadAnyTools({ + userInput: "我的阅读统计如何?", + book: null, + }); + expect(result.intent).toBe("reading_stats"); + expect(result.statsSummary).toBeDefined(); + expect(result.statsSummary).toContain("阅读统计"); + expect(result.statsSummary).toContain("3 本书"); + }); + + it("detects highlight_note intent", async () => { + const result = await preExecuteReadAnyTools({ + userInput: "看看我的笔记", + book: null, + }); + expect(result.intent).toBe("highlight_note"); + expect(result.notesSummary).toBeDefined(); + expect(result.notesSummary).toContain("关键在于"); + }); + + it("returns general intent for unrelated queries", async () => { + const result = await preExecuteReadAnyTools({ + userInput: "今天天气怎么样", + book: null, + }); + expect(result.intent).toBe("general"); + }); + + it("always provides librarySummary even for non-library queries", async () => { + const result = await preExecuteReadAnyTools({ + userInput: "解释一下存在主义", + book: null, + }); + expect(result.librarySummary).toBeDefined(); + expect(result.librarySummary).toContain("书库概况:共 3 本书"); + }); + + it("filters books by reading status", async () => { + const result = await preExecuteReadAnyTools({ + userInput: "我在读哪些书?", + book: null, + }); + expect(result.intent).toBe("library_query"); + expect(result.librarySummary).toContain("斜目而视"); + expect(result.librarySummary).not.toContain("存在与时间"); + expect(result.librarySummary).not.toContain("Being and Time"); + }); +}); + +// Test buildContextToolEvents behavior +describe("buildContextToolEvents", () => { + it("only emits chapter event when chapterContext is available", () => { + const events = buildContextToolEvents({ + requestedFullChapter: true, + chapterContext: { + chapterTitle: "第一章", + chapterIndex: 0, + content: "正文内容", + source: "chunks", + chunks: [{ content: "正文内容", cfi: "cfi-1", chapterTitle: "第一章", chapterIndex: 0 }], + totalTokens: 100, + }, + bookMemory: null, + }); + expect(events).toHaveLength(2); // tool_call + tool_result + const toolCall = events[0] as { type: string; name: string; args: Record }; + expect(toolCall.name).toBe("getCurrentChapter"); + }); + + it("skips chapter event when chapterContext is null", () => { + const events = buildContextToolEvents({ + requestedFullChapter: true, + chapterContext: null, + bookMemory: null, + }); + expect(events).toHaveLength(0); + }); +}); + +describe("Claude Code stream parser", () => { + it("emits only text deltas from cumulative partial assistant messages", () => { + const parser = createClaudeCodeStreamParser(); + + const first = parser.parseLine( + JSON.stringify({ + type: "assistant", + message: { id: "msg-1", content: [{ type: "text", text: "Hello" }] }, + }), + ); + const second = parser.parseLine( + JSON.stringify({ + type: "assistant", + message: { id: "msg-1", content: [{ type: "text", text: "Hello, world" }] }, + }), + ); + + expect(first).toEqual([{ type: "token", content: "Hello" }]); + expect(second).toEqual([{ type: "token", content: ", world" }]); + }); + + it("maps Claude Code stream_event text and thinking deltas", () => { + const parser = createClaudeCodeStreamParser(); + + const text = parser.parseLine( + JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "OK" }, + }, + }), + ); + const thinking = parser.parseLine( + JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "thinking_delta", thinking: "Checking context" }, + }, + }), + ); + + expect(text).toEqual([{ type: "token", content: "OK" }]); + expect(thinking).toEqual([ + { type: "reasoning", content: "Checking context", stepType: "thinking" }, + ]); + expect( + parser.parseLine( + JSON.stringify({ + type: "assistant", + message: { id: "msg-actual", content: [{ type: "text", text: "OK" }] }, + }), + ), + ).toEqual([]); + }); + + it("maps Claude Code tool use and tool result content to existing stream events", () => { + const parser = createClaudeCodeStreamParser(); + + const toolCall = parser.parseLine( + JSON.stringify({ + type: "assistant", + message: { + id: "msg-2", + content: [ + { + type: "tool_use", + id: "tool-1", + name: "WebSearch", + input: { query: "Looking Awry Zizek" }, + }, + ], + }, + }), + ); + const toolResult = parser.parseLine( + JSON.stringify({ + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tool-1", + content: "Search complete", + }, + ], + }, + }), + ); + + expect(toolCall).toEqual([ + { type: "tool_call", name: "WebSearch", args: { query: "Looking Awry Zizek" } }, + ]); + expect(toolResult).toEqual([ + { type: "tool_result", name: "WebSearch", result: "Search complete" }, + ]); + }); +}); + +describe("ReadAny operation protocol", () => { + it("resolves a model-provided book title to the real ReadAny book id", async () => { + await expect(resolveReadAnyBookReference("Being and Time", null)).resolves.toBe("book-3"); + }); + + it("parses readany blocks that use a book title containing spaces", () => { + const ops = parseReadAnyOps( + [ + "让我用书库内置的语义搜索来查找论文的结果和讨论部分。", + "", + "```readany", + "search-book: Microsoft Word - 07-p2088-1905-0681=模拟结果 焊缝熔深 匙孔演化 结论", + "list-highlights: Microsoft Word - 07-p2088-1905-0681", + "list-notes: Microsoft Word - 07-p2088-1905-0681", + "```", + ].join("\n"), + ); + + expect(ops).toEqual([ + { + action: "searchBook", + params: { + bookId: "Microsoft Word - 07-p2088-1905-0681", + query: "模拟结果 焊缝熔深 匙孔演化 结论", + }, + }, + { + action: "listData", + params: { type: "highlights", bookId: "Microsoft Word - 07-p2088-1905-0681" }, + }, + { + action: "listData", + params: { type: "notes", bookId: "Microsoft Word - 07-p2088-1905-0681" }, + }, + ]); + }); + + it("continues Claude Code after executing requested ReadAny operations", async () => { + runClaudeCodeChatMock + .mockImplementationOnce(async (_request, handlers) => { + handlers.onStdoutLine( + JSON.stringify({ + type: "assistant", + message: { + id: "first", + content: [ + { + type: "text", + text: "让我先查看技能。\n```readany\nlist-skills\n```", + }, + ], + }, + }), + ); + }) + .mockImplementationOnce(async (_request, handlers) => { + handlers.onStdoutLine( + JSON.stringify({ + type: "assistant", + message: { + id: "second", + content: [{ type: "text", text: "根据工具结果,最终回答如下。" }], + }, + }), + ); + }); + + const events = []; + for await (const event of streamClaudeCodeAgent( + { + thread: { id: "thread-1", title: "test", messages: [], createdAt: 1, updatedAt: 1 }, + book: null, + semanticContext: null, + isVectorized: false, + }, + "请列出技能后继续回答", + )) { + events.push(event); + } + + expect(runClaudeCodeChatMock).toHaveBeenCalledTimes(2); + expect(runClaudeCodeChatMock.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ tools: ["WebSearch", "WebFetch"] }), + ); + expect(events).toContainEqual({ type: "tool_call", name: "listData", args: { type: "skills" } }); + expect(events).toContainEqual( + expect.objectContaining({ type: "tool_result", name: "listData" }), + ); + expect(events).toContainEqual({ type: "token", content: "根据工具结果,最终回答如下。" }); + }); + + it("returns complete matched chunks without 300-character truncation", async () => { + const longContent = `RESULTS:${"x".repeat(1200)}:CONCLUSION`; + ragSearchMock.mockResolvedValueOnce([ + { + score: 0.91, + chunk: { + id: "chunk-1", + bookId: "book-3", + chapterIndex: 8, + chapterTitle: "Results and discussion", + content: longContent, + cfi: "", + createdAt: Date.now(), + }, + }, + ]); + + const [result] = await executeReadAnyOpsDetailed([ + { + action: "searchBook", + params: { bookId: "Being and Time", query: "results conclusion" }, + }, + ]); + + expect(result.result).toContain(longContent); + expect(result.result).not.toContain("..."); + }); +}); + +describe("Claude Code reading context", () => { + it("rebuilds a full chapter from chunks in numeric chunk order", () => { + const context = buildChapterContextFromChunks( + [ + makeChunk({ id: "book-1-0-10", content: "tenth" }), + makeChunk({ id: "book-1-0-2", content: "second" }), + makeChunk({ id: "book-1-0-0", content: "first" }), + ], + 0, + ); + + expect(context?.content).toBe("first\n\nsecond\n\ntenth"); + expect(context?.chunks).toHaveLength(3); + expect(context?.totalTokens).toBeGreaterThan(0); + }); + + it("builds a full chapter context from extracted local book text when chunks are missing", () => { + const context = buildChapterContextFromExtractedChapter({ + index: 3, + title: "\u5e8f \u8a00", + content: "\u7b2c\u4e00\u6bb5\n\n\u7b2c\u4e8c\u6bb5", + segments: [ + { text: "\u7b2c\u4e00\u6bb5", cfi: "epubcfi(/6/8!/4/2)" }, + { text: "\u7b2c\u4e8c\u6bb5", cfi: "epubcfi(/6/8!/4/4)" }, + ], + }); + + expect(context).toMatchObject({ + chapterTitle: "\u5e8f \u8a00", + chapterIndex: 3, + content: "\u7b2c\u4e00\u6bb5\n\n\u7b2c\u4e8c\u6bb5", + source: "file", + }); + expect(context?.chunks).toEqual([ + { + chapterTitle: "\u5e8f \u8a00", + chapterIndex: 3, + content: "\u7b2c\u4e00\u6bb5", + cfi: "epubcfi(/6/8!/4/2)", + }, + { + chapterTitle: "\u5e8f \u8a00", + chapterIndex: 3, + content: "\u7b2c\u4e8c\u6bb5", + cfi: "epubcfi(/6/8!/4/4)", + }, + ]); + }); + + it("emits visible context tool events before the Claude Code call", () => { + const events = buildContextToolEvents({ + requestedFullChapter: true, + chapterContext: { + chapterTitle: "\u5e8f \u8a00", + chapterIndex: 3, + content: "\u6b63\u6587", + chunks: [], + totalTokens: 8, + source: "file", + }, + bookMemory: { + bookId: "book-1", + summary: "\u8bfb\u8005\u5173\u6ce8\u62c9\u5eb7", + focus: [], + openQuestions: [], + recentQuestions: [], + totalMessages: 2, + lastCompactedAt: 0, + compactedMessageCount: 0, + updatedAt: 1, + }, + }); + + expect(events).toEqual([ + { type: "tool_call", name: "readBookMemory", args: { bookId: "book-1" } }, + { + type: "tool_result", + name: "readBookMemory", + result: expect.objectContaining({ totalMessages: 2 }), + }, + { + type: "tool_call", + name: "getCurrentChapter", + args: { chapterIndex: 3, requestedFullChapter: true }, + }, + { + type: "tool_result", + name: "getCurrentChapter", + result: expect.objectContaining({ source: "file", totalTokens: 8 }), + }, + ]); + }); + + it("attaches full chapter for chapter-level reading requests and selected quote requests", () => { + expect(shouldAttachFullChapter("\u603b\u7ed3\u8fd9\u4e00\u7ae0", [])).toBe(true); + expect(shouldAttachFullChapter("\u5173\u4e8e\u4ee5\u4e0b\u6587\u672c", [])).toBe(true); + expect( + shouldAttachFullChapter("analyze this quote", [ + { id: "q1", text: "quote", source: "preface" }, + ]), + ).toBe(true); + expect(shouldAttachFullChapter("hello", [])).toBe(false); + }); +}); + +function makeChunk( + overrides: Partial<{ + id: string; + bookId: string; + chapterIndex: number; + chapterTitle: string; + content: string; + tokenCount: number; + startCfi: string; + endCfi: string; + }>, +) { + return { + id: "book-1-0-0", + bookId: "book-1", + chapterIndex: 0, + chapterTitle: "Preface", + content: "content", + tokenCount: 1, + startCfi: "cfi-0", + endCfi: "cfi-1", + ...overrides, + }; +} diff --git a/packages/core/src/ai/book-memory.ts b/packages/core/src/ai/book-memory.ts new file mode 100644 index 00000000..423f547b --- /dev/null +++ b/packages/core/src/ai/book-memory.ts @@ -0,0 +1,217 @@ +import type { AttachedQuote } from "../types"; + +export interface BookMemory { + bookId: string; + summary: string; + focus: string[]; + openQuestions: string[]; + recentQuestions: string[]; + lastChapterTitle?: string; + lastChapterIndex?: number; + lastPositionPercent?: number; + totalMessages: number; + lastCompactedAt: number; + compactedMessageCount: number; + updatedAt: number; +} + +export interface BookMemoryExchange { + userInput: string; + assistantText: string; + selectedQuotes?: AttachedQuote[]; + chapterTitle?: string; + chapterIndex?: number; + positionPercent?: number; +} + +export const BOOK_MEMORY_LIMITS = { + maxSummaryChars: 2500, + maxFocusItems: 12, + maxOpenQuestions: 10, + maxRecentQuestions: 12, + maxQuestionChars: 600, + maxFocusChars: 500, + compactEveryMessages: 24, +}; + +export function createEmptyBookMemory(bookId: string, now = Date.now()): BookMemory { + return { + bookId, + summary: "", + focus: [], + openQuestions: [], + recentQuestions: [], + totalMessages: 0, + lastCompactedAt: 0, + compactedMessageCount: 0, + updatedAt: now, + }; +} + +export function mergeBookMemoryExchange( + current: BookMemory | null, + exchange: BookMemoryExchange, + now = Date.now(), +): BookMemory { + const memory = current ? cloneBookMemory(current) : createEmptyBookMemory("", now); + const userQuestion = normalizeMemoryLine(exchange.userInput, BOOK_MEMORY_LIMITS.maxQuestionChars); + + if (userQuestion) { + memory.recentQuestions = uniqueRecent( + [userQuestion, ...memory.recentQuestions], + BOOK_MEMORY_LIMITS.maxRecentQuestions, + ); + if (isQuestionLike(userQuestion)) { + memory.openQuestions = uniqueRecent( + [userQuestion, ...memory.openQuestions], + BOOK_MEMORY_LIMITS.maxOpenQuestions, + ); + } + } + + const focusItems = buildFocusItems(exchange); + if (focusItems.length > 0) { + memory.focus = uniqueRecent([...focusItems, ...memory.focus], BOOK_MEMORY_LIMITS.maxFocusItems); + } + + if (exchange.chapterTitle) memory.lastChapterTitle = exchange.chapterTitle; + if (exchange.chapterIndex !== undefined) memory.lastChapterIndex = exchange.chapterIndex; + if (exchange.positionPercent !== undefined) memory.lastPositionPercent = exchange.positionPercent; + memory.bookId = memory.bookId || ""; + memory.totalMessages += 2; + memory.updatedAt = now; + + const nextSummary = appendExchangeSummary(memory.summary, exchange); + memory.summary = trimToLimit(nextSummary, BOOK_MEMORY_LIMITS.maxSummaryChars); + + if (shouldCompactBookMemory(memory)) { + return compactBookMemory(memory, now); + } + + return memory; +} + +export function shouldCompactBookMemory(memory: BookMemory): boolean { + if (memory.summary.length > BOOK_MEMORY_LIMITS.maxSummaryChars) return true; + if (memory.focus.length > BOOK_MEMORY_LIMITS.maxFocusItems) return true; + if (memory.recentQuestions.length > BOOK_MEMORY_LIMITS.maxRecentQuestions) return true; + if (memory.openQuestions.length > BOOK_MEMORY_LIMITS.maxOpenQuestions) return true; + const messagesSinceCompact = memory.totalMessages - memory.compactedMessageCount; + return messagesSinceCompact >= BOOK_MEMORY_LIMITS.compactEveryMessages; +} + +export function compactBookMemory(memory: BookMemory, now = Date.now()): BookMemory { + const compacted = cloneBookMemory(memory); + compacted.focus = uniqueRecent(compacted.focus, BOOK_MEMORY_LIMITS.maxFocusItems); + compacted.openQuestions = uniqueRecent( + compacted.openQuestions, + BOOK_MEMORY_LIMITS.maxOpenQuestions, + ); + compacted.recentQuestions = uniqueRecent( + compacted.recentQuestions, + BOOK_MEMORY_LIMITS.maxRecentQuestions, + ); + compacted.summary = trimToLimit( + [ + compacted.summary, + compacted.focus.length > 0 ? `Current focus: ${compacted.focus.join("; ")}` : "", + compacted.recentQuestions.length > 0 + ? `Recent questions: ${compacted.recentQuestions.slice(0, 6).join("; ")}` + : "", + ] + .filter(Boolean) + .join("\n"), + BOOK_MEMORY_LIMITS.maxSummaryChars, + ); + compacted.lastCompactedAt = now; + compacted.compactedMessageCount = compacted.totalMessages; + compacted.updatedAt = now; + return compacted; +} + +export function renderBookMemoryForPrompt(memory: BookMemory | null): string { + if (!memory) return ""; + const lines = [ + "# Book memory", + memory.summary ? `Summary:\n${memory.summary}` : "", + memory.focus.length > 0 ? `Focus:\n${memory.focus.map((item) => `- ${item}`).join("\n")}` : "", + memory.openQuestions.length > 0 + ? `Open questions:\n${memory.openQuestions.map((item) => `- ${item}`).join("\n")}` + : "", + memory.recentQuestions.length > 0 + ? `Recent questions:\n${memory.recentQuestions.map((item) => `- ${item}`).join("\n")}` + : "", + memory.lastChapterTitle + ? `Last reading position: ${memory.lastChapterTitle}${ + memory.lastChapterIndex !== undefined ? ` (chapter ${memory.lastChapterIndex})` : "" + }${memory.lastPositionPercent !== undefined ? `, ${Math.round(memory.lastPositionPercent * 100)}%` : ""}` + : "", + ].filter(Boolean); + return lines.length > 1 ? lines.join("\n\n") : ""; +} + +function cloneBookMemory(memory: BookMemory): BookMemory { + return { + ...memory, + focus: [...memory.focus], + openQuestions: [...memory.openQuestions], + recentQuestions: [...memory.recentQuestions], + }; +} + +function buildFocusItems(exchange: BookMemoryExchange): string[] { + const items: string[] = []; + const chapter = + exchange.chapterTitle || + (exchange.chapterIndex !== undefined ? `chapter ${exchange.chapterIndex}` : ""); + if (chapter) items.push(`Reading ${chapter}`); + for (const quote of exchange.selectedQuotes ?? []) { + const source = quote.source || chapter || "selected text"; + const text = normalizeMemoryLine(quote.text, BOOK_MEMORY_LIMITS.maxFocusChars); + if (text) items.push(`${source}: ${text}`); + } + return items; +} + +function appendExchangeSummary(summary: string, exchange: BookMemoryExchange): string { + const userQuestion = normalizeMemoryLine(exchange.userInput, 300); + const assistantTakeaway = normalizeMemoryLine(exchange.assistantText, 360); + if (!userQuestion && !assistantTakeaway) return summary; + const chapter = exchange.chapterTitle ? `[${exchange.chapterTitle}] ` : ""; + const entry = `${chapter}User asked: ${userQuestion || "(selected text)"}. Assistant answered: ${ + assistantTakeaway || "(no durable answer captured)" + }.`; + return [summary, entry].filter(Boolean).join("\n"); +} + +function normalizeMemoryLine(value: string | undefined, maxChars: number): string { + const normalized = (value || "").replace(/\s+/g, " ").trim(); + if (!normalized) return ""; + return normalized.length <= maxChars ? normalized : `${normalized.slice(0, maxChars - 1)}...`; +} + +function uniqueRecent(items: string[], limit: number): string[] { + const seen = new Set(); + const result: string[] = []; + for (const item of items) { + const normalized = normalizeMemoryLine(item, 800); + const key = normalized.toLowerCase(); + if (!normalized || seen.has(key)) continue; + seen.add(key); + result.push(normalized); + if (result.length >= limit) break; + } + return result; +} + +function trimToLimit(value: string, limit: number): string { + if (value.length <= limit) return value; + const suffix = value.slice(Math.max(0, value.length - limit + 32)); + return `[Earlier memory compacted]\n${suffix}`; +} + +function isQuestionLike(value: string): boolean { + return /(?:[?\uFF1F]|why|how|what|explain|analy[sz]e|summary|summari[sz]e|\u4e3a\u4ec0\u4e48|\u5982\u4f55|\u600e\u4e48|\u89e3\u91ca|\u5206\u6790|\u603b\u7ed3)/i.test( + value, + ); +} diff --git a/packages/core/src/ai/claude-code.ts b/packages/core/src/ai/claude-code.ts new file mode 100644 index 00000000..354fadb0 --- /dev/null +++ b/packages/core/src/ai/claude-code.ts @@ -0,0 +1,1882 @@ +import i18n from "i18next"; +import { + getBookMemory, + getBooks, + getChunks, + getAllHighlights, + getAllNotes, + getReadingSessionsByDateRange, + getBook, + updateBook, + getGroups, + insertGroup, + updateGroup as updateGroupDb, + deleteGroup as deleteGroupDb, + getBookmarks, + insertBookmark, + deleteBookmark, + getSkills, + getHighlightStats, + getThreads, + insertNote, + getNotes, +} from "../db/database"; +import { search as ragSearch } from "../rag"; +import { emitLibraryChanged } from "../events/library-events"; +import { debouncedSave, loadFromFS } from "../stores/persist"; +import { estimateTokens } from "../rag/chunker"; +import { getPlatformService } from "../services/platform"; +import type { ExtractedBookChapter } from "../services/platform"; +import type { AttachedQuote, Book, SemanticContext, Thread } from "../types"; +import type { AgentStreamEvent } from "./agents/reading-agent"; +import { type BookMemory, renderBookMemoryForPrompt } from "./book-memory"; +import type { ProcessedMessage } from "./message-pipeline"; +import { getReadingContextSnapshot } from "./reading-context-service"; + +type ClaudeContentBlock = { + type?: string; + id?: string; + name?: string; + input?: Record; + tool_use_id?: string; + content?: unknown; + text?: string; +}; + +type ClaudeStreamPayload = Record; + +interface ParserState { + assistantTextById: Map; + toolNamesById: Map; + streamTextEmitted: boolean; + fallbackTextEmitted: boolean; +} + +export interface ChapterContext { + chapterTitle: string; + chapterIndex: number; + content: string; + source: "chunks" | "file"; + chunks: Array<{ + content: string; + cfi: string; + chapterTitle: string; + chapterIndex: number; + }>; + totalTokens: number; +} + +export interface PreExecutedContext { + intent: "library_query" | "reading_stats" | "highlight_note" | "library_organize" | "search_book" | "list_query" | "general"; + librarySummary?: string; + statsSummary?: string; + notesSummary?: string; + classifyData?: string; + searchResult?: string; + listResult?: string; +} + +export interface ReadAnyOp { + action: string; + params: Record; +} + +export interface ClaudeCodeStreamParser { + parseLine(line: string): AgentStreamEvent[]; +} + +export function createClaudeCodeStreamParser(): ClaudeCodeStreamParser { + const state: ParserState = createParserState(); + return { + parseLine(line: string): AgentStreamEvent[] { + return parseClaudeCodeStreamLine(line, state); + }, + }; +} + +function createParserState(): ParserState { + return { + assistantTextById: new Map(), + toolNamesById: new Map(), + streamTextEmitted: false, + fallbackTextEmitted: false, + }; +} + +export function parseClaudeCodeStreamLine( + line: string, + state: ParserState = createParserState(), +): AgentStreamEvent[] { + const trimmed = line.trim(); + if (!trimmed) return []; + + let payload: ClaudeStreamPayload; + try { + payload = JSON.parse(trimmed); + } catch { + return [{ type: "reasoning", content: trimmed, stepType: "thinking" }]; + } + + const events: AgentStreamEvent[] = []; + + if (payload.type === "system" && payload.subtype === "init") { + const model = typeof payload.model === "string" ? ` (${payload.model})` : ""; + events.push({ + type: "reasoning", + content: `Claude Code started${model}.`, + stepType: "thinking", + }); + } + + events.push(...readStreamEvent(payload, state)); + + if (payload.type === "assistant" && payload.message) { + events.push(...readAssistantMessage(payload.message, state)); + } + + if (payload.type === "user" && payload.message) { + events.push(...readToolResults(payload.message, state)); + } + + if (payload.type === "result" && payload.is_error) { + events.push({ + type: "error", + error: String(payload.result || payload.error || "Claude Code failed"), + }); + } else if (payload.type === "result" && !state.fallbackTextEmitted && payload.result) { + events.push({ type: "token", content: String(payload.result) }); + state.fallbackTextEmitted = true; + } + + return events; +} + +function readStreamEvent(payload: ClaudeStreamPayload, state: ParserState): AgentStreamEvent[] { + const event = payload.type === "stream_event" ? payload.event : payload; + if (!event || typeof event !== "object") return []; + + if (event.type === "content_block_start" && event.content_block?.type === "tool_use") { + const block = event.content_block as ClaudeContentBlock; + if (!block.name) return []; + const toolId = block.id || `${block.name}:${event.index ?? state.toolNamesById.size}`; + if (state.toolNamesById.has(toolId)) return []; + state.toolNamesById.set(toolId, block.name); + return [{ type: "tool_call", name: block.name, args: block.input || {} }]; + } + + if (event.type !== "content_block_delta") return []; + + const delta = event.delta; + if (delta?.type === "text_delta" && typeof delta.text === "string") { + state.streamTextEmitted = true; + state.fallbackTextEmitted = true; + return [{ type: "token", content: delta.text }]; + } + + if (delta?.type === "thinking_delta" && typeof delta.thinking === "string") { + return [{ type: "reasoning", content: delta.thinking, stepType: "thinking" }]; + } + + return []; +} + +function readAssistantMessage(message: any, state: ParserState): AgentStreamEvent[] { + const events: AgentStreamEvent[] = []; + const messageId = String(message.id || "assistant"); + const content = Array.isArray(message.content) ? message.content : []; + const text = content + .filter((block: ClaudeContentBlock) => block?.type === "text") + .map((block: ClaudeContentBlock) => block.text || "") + .join(""); + const previousText = state.assistantTextById.get(messageId) || ""; + + if (text && text !== previousText && !state.streamTextEmitted) { + const delta = text.startsWith(previousText) ? text.slice(previousText.length) : text; + if (delta) events.push({ type: "token", content: delta }); + state.fallbackTextEmitted = true; + } + if (text) state.assistantTextById.set(messageId, text); + + for (const block of content as ClaudeContentBlock[]) { + if (block?.type !== "tool_use" || !block.name) continue; + const toolId = block.id || `${block.name}:${events.length}`; + if (state.toolNamesById.has(toolId)) continue; + state.toolNamesById.set(toolId, block.name); + events.push({ type: "tool_call", name: block.name, args: block.input || {} }); + } + + return events; +} + +function readToolResults(message: any, state: ParserState): AgentStreamEvent[] { + const content = Array.isArray(message.content) ? message.content : []; + return (content as ClaudeContentBlock[]) + .filter((block) => block?.type === "tool_result") + .map((block) => ({ + type: "tool_result" as const, + name: state.toolNamesById.get(block.tool_use_id || "") || "tool_result", + result: normalizeToolResult(block.content), + })); +} + +function normalizeToolResult(content: unknown): unknown { + if (Array.isArray(content)) { + return content + .map((item) => { + if (typeof item === "string") return item; + if (item && typeof item === "object" && "text" in item) { + return String((item as { text: unknown }).text); + } + return JSON.stringify(item); + }) + .join("\n"); + } + return content; +} + +export function shouldAttachFullChapter(content: string, quotes: AttachedQuote[] = []): boolean { + if (quotes.length > 0) return true; + const normalized = content.toLowerCase(); + // \u5bbd\u677e\u5339\u914d\uff1a\u5927\u591a\u6570\u9605\u8bfb\u76f8\u5173\u7684\u95ee\u9898\u90fd\u9700\u8981\u7ae0\u8282\u4e0a\u4e0b\u6587 + // \u53ea\u6709\u7eaf\u4e66\u5e93\u7ba1\u7406\u3001\u7eaf\u7edf\u8ba1\u3001\u7eaf\u95ee\u5019\u7b49\u95ee\u9898\u624d\u4e0d\u9700\u8981 + const skipPatterns = [ + "\u4e66\u5e93", "library", "\u6709\u54ea\u4e9b\u4e66", "my books", + "\u7edf\u8ba1", "stats", "reading time", + "\u4f60\u597d", "hello", "hi", "\u8c22\u8c22", "thank", + "\u6807\u7b7e", "tag", "\u5206\u7c7b", "classify", + ]; + if (skipPatterns.some((p) => normalized.includes(p)) && normalized.length < 50) { + return false; + } + + const attachKeywords = [ + // \u4e2d\u6587\uff1a\u7ae0\u8282/\u5185\u5bb9\u76f8\u5173 + "\u8fd9\u4e00\u7ae0", "\u672c\u7ae0", "\u5f53\u524d\u7ae0", "\u8fd9\u7ae0", + "\u7ae0\u8282", "\u6574\u7ae0", "\u5168\u6587", "\u8fd9\u4e00\u8282", "\u672c\u8282", + "\u5e8f\u8a00", "\u603b\u7ed3", "\u6982\u62ec", "\u5206\u6790", "\u8bba\u8bc1", + "\u68b3\u7406", "\u4ee5\u4e0b\u6587\u672c", "\u8fd9\u6bb5\u6587\u672c", + "\u5185\u5bb9", "\u89e3\u91ca", "\u8bf4\u660e", "\u8bb2\u8ff0", + "\u89c2\u70b9", "\u4e3b\u9898", "\u4eba\u7269", "\u60c5\u8282", + "\u5f15\u7528", "\u6458\u5f55", "\u6ce8\u91ca", + // English: chapter/content related + "chapter", "section", "selected text", "quote", + "summary", "summarize", "argument", "explain", + "content", "plot", "character", "theme", + "analyze", "describe", "what is", "what are", + "meaning", "concept", + ]; + return attachKeywords.some((keyword) => normalized.includes(keyword)); +} + +export function buildChapterContextFromChunks( + chunks: Array<{ + id: string; + chapterIndex: number; + chapterTitle: string; + content: string; + tokenCount?: number; + startCfi?: string; + }>, + chapterIndex: number, +): ChapterContext | null { + const chapterChunks = chunks + .filter((chunk) => chunk.chapterIndex === chapterIndex) + .sort(compareChunkOrder); + if (chapterChunks.length === 0) return null; + + const content = joinChunkContent(chapterChunks.map((chunk) => chunk.content)); + return { + chapterTitle: chapterChunks[0]?.chapterTitle || "Unknown", + chapterIndex, + content, + source: "chunks", + chunks: chapterChunks.map((chunk) => ({ + content: chunk.content, + cfi: chunk.startCfi || "", + chapterTitle: chunk.chapterTitle, + chapterIndex: chunk.chapterIndex, + })), + totalTokens: estimateTokens(content), + }; +} + +export function buildChapterContextFromExtractedChapter( + chapter: ExtractedBookChapter, +): ChapterContext | null { + const content = chapter.content.trim(); + if (!content) return null; + + const segments = chapter.segments?.length ? chapter.segments : [{ text: content, cfi: "" }]; + + return { + chapterTitle: chapter.title || `Section ${chapter.index + 1}`, + chapterIndex: chapter.index, + content, + source: "file", + chunks: segments.map((segment) => ({ + content: segment.text, + cfi: segment.cfi, + chapterTitle: chapter.title || `Section ${chapter.index + 1}`, + chapterIndex: chapter.index, + })), + totalTokens: estimateTokens(content), + }; +} + +/** + * 根据用户输入检测意图,预执行 ReadAny 工具并返回格式化上下文。 + * 这些结果会被注入到用户 prompt 中,使 Claude Code 能够基于真实数据回答。 + */ +export async function preExecuteReadAnyTools(options: { + userInput: string; + book: Book | null; +}): Promise { + const input = options.userInput.toLowerCase(); + const result: PreExecutedContext = { intent: "general" }; + + // 检测书库查询意图 + const libraryKeywords = [ + "书库", "我的书", "有哪些书", "library", "my books", "书籍列表", + "书单", "藏书", "在读", "已读完", "未读", + ]; + const statsKeywords = [ + "阅读统计", "阅读时长", "阅读数据", "统计", "reading stats", + "reading time", "读了多久", "读了多少", + ]; + const notesKeywords = [ + "笔记", "标注", "划线", "highlight", "note", "annotation", + "我的标注", "我的笔记", "摘录", + ]; + const organizeKeywords = [ + "整理", "分类", "标签", "标记", "organize", "classify", + "tag", "归类", "分门别类", "管理书库", "打理", "去重", + "归类整理", "重新整理", "帮我整理", + ]; + + if (libraryKeywords.some((kw) => input.includes(kw))) { + result.intent = "library_query"; + } + if (statsKeywords.some((kw) => input.includes(kw))) { + result.intent = "reading_stats"; + } + if (notesKeywords.some((kw) => input.includes(kw))) { + result.intent = "highlight_note"; + } + if (organizeKeywords.some((kw) => input.includes(kw))) { + result.intent = "library_organize"; + } + + // 检测书内搜索意图(仅当未匹配到书库查询意图时) + const searchKeywords = ["搜寻", "查找", "搜索", "search", "找一下", "帮我找", "搜一下", "find", "找一找"]; + const listKeywords = ["列出", "有哪些", "查看", "list", "show", "有什么", "多少个", "多少条", "书签", "对话", "技能", "skills"]; + + if (searchKeywords.some((kw) => input.includes(kw)) && result.intent === "general" && options.book?.isVectorized) { + result.intent = "search_book"; + } + if (listKeywords.some((kw) => input.includes(kw)) && result.intent === "general") { + result.intent = "list_query"; + } + // organize 优先级最高 + if (organizeKeywords.some((kw) => input.includes(kw))) { + result.intent = "library_organize"; + } + + try { + // 获取书库总览(对所有意图都提供轻量书库上下文) + const books = await getBooks(); + const totalBooks = books.length; + const inProgress = books.filter((b) => b.progress > 0 && b.progress < 1).length; + const completed = books.filter((b) => b.progress >= 1).length; + + result.librarySummary = [ + `书库概况:共 ${totalBooks} 本书,${inProgress} 本在读,${completed} 本已读完`, + books.length > 0 + ? `最近书籍:${books + .slice(0, 10) + .map( + (b) => + `《${b.meta.title || "未知"}》${b.meta.author ? ` (${b.meta.author})` : ""} [${Math.round((b.progress || 0) * 100)}%]${b.isVectorized ? " ✓已向量化" : ""}`, + ) + .join(";")}` + : "书库为空", + ].join("\n"); + + // 书库查询:提供完整书单 + if (result.intent === "library_query") { + const statusFilter = input.includes("在读") + ? "reading" + : input.includes("读完") || input.includes("completed") + ? "completed" + : input.includes("未读") || input.includes("unread") + ? "unread" + : undefined; + + let filteredBooks = books; + if (statusFilter === "unread") { + filteredBooks = books.filter((b) => !b.progress || b.progress === 0); + } else if (statusFilter === "reading") { + filteredBooks = books.filter((b) => b.progress > 0 && b.progress < 1); + } else if (statusFilter === "completed") { + filteredBooks = books.filter((b) => b.progress >= 1); + } + + result.librarySummary = [ + `书库详情(${statusFilter ? statusFilter : "全部"}):共 ${filteredBooks.length} 本`, + ...filteredBooks.map( + (b) => + `- 《${b.meta.title || "未知"}》${b.meta.author ? ` 作者:${b.meta.author}` : ""} | 进度:${Math.round((b.progress || 0) * 100)}% | 格式:${b.format || "未知"} | ${b.isVectorized ? "已向量化" : "未向量化"}`, + ), + ].join("\n"); + } + + // 阅读统计 + if (result.intent === "reading_stats") { + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 30); + const sessions = await getReadingSessionsByDateRange(startDate, endDate); + const totalMs = sessions.reduce((sum, s) => sum + s.totalActiveTime, 0); + const totalPages = sessions.reduce((sum, s) => sum + s.pagesRead, 0); + + result.statsSummary = [ + "近30天阅读统计:", + `- 阅读会话数:${sessions.length} 次`, + `- 总阅读时间:${Math.round(totalMs / 60000)} 分钟`, + `- 总阅读页数:${totalPages} 页`, + `- 书库总览:${totalBooks} 本书,${inProgress} 本在读,${completed} 本已读完`, + ].join("\n"); + } + + // 标注/笔记查询 + if (result.intent === "highlight_note") { + const highlights = await getAllHighlights(20); + const notes = await getAllNotes(20); + + if (highlights.length > 0 || notes.length > 0) { + const bookMap = new Map(books.map((b) => [b.id, b.meta.title])); + const parts: string[] = ["最近的标注和笔记:"]; + + if (highlights.length > 0) { + parts.push("## 最近划线"); + for (const h of highlights.slice(0, 10)) { + const bookTitle = bookMap.get(h.bookId) || "未知"; + parts.push( + `- [${bookTitle}] ${h.text.slice(0, 100)}${h.text.length > 100 ? "..." : ""}${h.note ? ` (笔记:${h.note.slice(0, 60)})` : ""}`, + ); + } + } + + if (notes.length > 0) { + parts.push("## 最近笔记"); + for (const n of notes.slice(0, 10)) { + parts.push(`- ${n.title}: ${n.content.slice(0, 100)}`); + } + } + + result.notesSummary = parts.join("\n"); + } + } + + // 整理书库意图:预执行 classifyBooks 获取分类数据 + if (result.intent === "library_organize") { + const allTags = [...new Set(books.flatMap((b) => b.tags || []))]; + const groups = await getGroups(); + const groupMap = new Map(groups.map((g) => [g.id, g.name])); + const uncategorizedWithContent = await Promise.all( + books + .filter((b) => (b.tags || []).length === 0) + .map(async (b) => { + let toc: string[] = []; + let contentSample = ""; + try { + const chunks = await getChunks(b.id); + if (chunks.length > 0) { + const chapters = new Map(); + for (const chunk of chunks) { + if (!chapters.has(chunk.chapterIndex)) { + chapters.set(chunk.chapterIndex, chunk.chapterTitle); + } + } + toc = Array.from(chapters.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([, title]) => title); + contentSample = chunks + .slice(0, 3) + .map((c) => c.content) + .join("\n") + .slice(0, 800); + } + } catch { /* ok */ } + return { + id: b.id, + title: b.meta.title, + author: b.meta.author, + description: b.meta.description, + subjects: b.meta.subjects, + language: b.meta.language, + format: b.format, + progress: Math.round((b.progress || 0) * 100), + isVectorized: b.isVectorized, + currentTags: b.tags || [], + toc, + contentSample, + }; + }), + ); + const allGroups = [...new Set([ + ...groups.map(g => g.name), + ...books.map(b => b.groupId ? (groupMap.get(b.groupId) || "") : ""), + ])].filter(Boolean); + + result.classifyData = [ + `书库整理数据:共 ${books.length} 本书`, + `已有分组:${allGroups.length > 0 ? allGroups.join("、") : "(无)"}`, + `已有标签:${allTags.length > 0 ? allTags.join("、") : "(无)"}`, + `全部书籍:`, + ...books.map( + (b) => { + const groupName = b.groupId ? (groupMap.get(b.groupId) || "") : ""; + return `- [${b.id}] 《${b.meta.title || "未知"}》${b.meta.author ? ` 作者: ${b.meta.author}` : ""} | 分组: ${groupName || "(未分组)"} | 标签: ${(b.tags || []).join(", ") || "(无)"} | 语言: ${b.meta.language || "未知"} | 进度: ${Math.round((b.progress || 0) * 100)}%`; + }, + ), + uncategorizedWithContent.length > 0 + ? `注意:${uncategorizedWithContent.length} 本无标签,需重点整理` + : "", + ] + .filter(Boolean) + .join("\n"); + } + + // 书内搜索意图:预执行 RAG 搜索 + if (result.intent === "search_book" && options.book?.isVectorized) { + try { + // 尝试从用户输入中提取搜索词 + const searchTerm = input.replace(/搜索|查找|搜寻|帮我找|搜一下|找一下|find|search/gi, "").trim(); + if (searchTerm && searchTerm.length > 0) { + const searchResults = await ragSearch({ query: searchTerm, bookId: options.book.id, topK: 5, mode: "hybrid", threshold: 0.3 }); + if (searchResults.length > 0) { + result.searchResult = [ + `RAG 搜索结果("${searchTerm}",共 ${searchResults.length} 条):`, + ...searchResults.map((r, i) => + `[${i + 1}] 第${r.chunk.chapterIndex}章 ${r.chunk.chapterTitle || ""} (相关度 ${Math.round(r.score * 100)}%)\n${r.chunk.content.slice(0, 400)}${r.chunk.content.length > 400 ? "…" : ""}` + ), + ].join("\n\n"); + } + } + } catch { /* 搜索失败不影响主流程 */ } + } + + // 列表查询意图:预执行列表操作 + if (result.intent === "list_query") { + try { + const parts: string[] = []; + if (["技能", "skills"].some((kw) => input.includes(kw))) { + const skills = await getSkills(); + if (skills.length > 0) { + parts.push(`可用技能 (${skills.length}):\n${skills.map((s) => `- ${s.name}: ${s.description || "(无描述)"}`).join("\n")}`); + } + } + if (["书签", "bookmark"].some((kw) => input.includes(kw)) && options.book) { + const bookmarks = await getBookmarks(options.book.id); + if (bookmarks.length > 0) { + parts.push(`书签 (${bookmarks.length}):\n${bookmarks.map((b, i) => `[${i + 1}] ${b.label || "(无标签)"}`).join("\n")}`); + } + } + if (["标注", "高亮", "highlight"].some((kw) => input.includes(kw))) { + const stats = await getHighlightStats(); + parts.push(`标注统计:${stats.totalHighlights} 条高亮,${stats.highlightsWithNotes} 条带笔记,涉及 ${stats.totalBooks} 本书`); + } + if (["对话", "thread", "记录"].some((kw) => input.includes(kw)) && options.book) { + const threads = await getThreads(options.book.id); + if (threads.length > 0) { + parts.push(`对话记录 (${threads.length}):\n${threads.map((t, i) => `[${i + 1}] ${t.title || "无标题"} (${t.messages?.length || 0}条)`).join("\n")}`); + } + } + if (parts.length > 0) result.listResult = parts.join("\n\n"); + } catch { /* 列表查询失败不影响主流程 */ } + } + } catch (err) { + console.warn("[ClaudeCode] Pre-execution failed:", err); + } + + return result; +} + +/** 解析 Claude Code 回复中的操作指令 */ +export function parseReadAnyOps(text: string): ReadAnyOp[] { + const ops: ReadAnyOp[] = []; + + // 方法1:解析 ```readany-ops 代码块(新格式) + const blockRegex = /```readany(?:-ops)?[^\S\r\n]*\r?\n([\s\S]*?)```/gi; + let blockMatch; + while ((blockMatch = blockRegex.exec(text)) !== null) { + const lines = blockMatch[1].split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + // tag: bookId=标签1, 标签2 + const tagMatch = trimmed.match(/^tag:\s*(.+?)\s*=\s*(.+)$/); + if (tagMatch) { + const bookId = tagMatch[1]; + const tags = tagMatch[2].split(/[,,]/).map((t) => t.trim()).filter(Boolean); + ops.push({ action: "tagBooks", params: { assignments: [{ bookId, tags }] } }); + continue; + } + + // create-tag: 标签名 + const createMatch = trimmed.match(/^create-tag:\s*(.+)$/); + if (createMatch) { + const tagName = createMatch[1].trim(); + ops.push({ action: "manageBookTags", params: { action: "create", tags: [tagName] } }); + continue; + } + + // rename-tag: 旧名=新名 + const renameMatch = trimmed.match(/^rename-tag:\s*(.+?)\s*=\s*(.+)$/); + if (renameMatch) { + ops.push({ action: "manageBookTags", params: { action: "rename", tag: renameMatch[1].trim(), newTag: renameMatch[2].trim() } }); + continue; + } + + // set-book-tags: bookId=标签1, 标签2 + const setMatch = trimmed.match(/^set-book-tags:\s*(.+?)\s*=\s*(.+)$/); + if (setMatch) { + const bookId = setMatch[1]; + const tags = setMatch[2].split(/[,,]/).map((t) => t.trim()).filter(Boolean); + ops.push({ action: "manageBookTags", params: { action: "setBookTags", bookId, tags } }); + continue; + } + + // create-group: 分组名 + const createGroupMatch = trimmed.match(/^create-group:\s*(.+)$/); + if (createGroupMatch) { + ops.push({ action: "manageGroup", params: { action: "create", name: createGroupMatch[1].trim() } }); + continue; + } + + // rename-group: 旧名=新名 + const renameGroupMatch = trimmed.match(/^rename-group:\s*(.+?)\s*=\s*(.+)$/); + if (renameGroupMatch) { + ops.push({ action: "manageGroup", params: { action: "rename", oldName: renameGroupMatch[1].trim(), newName: renameGroupMatch[2].trim() } }); + continue; + } + + // delete-group: 分组名 + const deleteGroupMatch = trimmed.match(/^delete-group:\s*(.+)$/); + if (deleteGroupMatch) { + ops.push({ action: "manageGroup", params: { action: "delete", name: deleteGroupMatch[1].trim() } }); + continue; + } + + // move-to-group: 分组名=bookId1, bookId2, ... + const moveGroupMatch = trimmed.match(/^move-to-group:\s*(\S.*?)\s*=\s*(.+)$/); + if (moveGroupMatch) { + const groupName = moveGroupMatch[1].trim(); + const bookIds = moveGroupMatch[2].split(/[,,]/).map((s) => s.trim()).filter(Boolean); + ops.push({ action: "manageGroup", params: { action: "moveBooks", groupName, bookIds } }); + continue; + } + + // search-book: bookId=查询关键词 + const searchBookMatch = trimmed.match(/^search-book:\s*(.+?)\s*=\s*(.+)$/); + if (searchBookMatch) { + ops.push({ action: "searchBook", params: { bookId: searchBookMatch[1], query: searchBookMatch[2].trim() } }); + continue; + } + + // add-bookmark: bookId=标签标题 + const addBookmarkMatch = trimmed.match(/^add-bookmark:\s*(.+?)\s*=\s*(.+)$/); + if (addBookmarkMatch) { + ops.push({ action: "bookmark", params: { action: "create", bookId: addBookmarkMatch[1], label: addBookmarkMatch[2].trim() } }); + continue; + } + + // remove-bookmark: bookmarkId + const removeBookmarkMatch = trimmed.match(/^remove-bookmark:\s*(\S+)$/); + if (removeBookmarkMatch) { + ops.push({ action: "bookmark", params: { action: "delete", id: removeBookmarkMatch[1] } }); + continue; + } + + // list-bookmarks: bookId + const listBookmarksMatch = trimmed.match(/^list-bookmarks:\s*(.+?)\s*$/); + if (listBookmarksMatch) { + ops.push({ action: "listData", params: { type: "bookmarks", bookId: listBookmarksMatch[1] } }); + continue; + } + + // list-highlights: bookId + const listHighlightsMatch = trimmed.match(/^list-highlights:\s*(.+?)\s*$/); + if (listHighlightsMatch) { + ops.push({ action: "listData", params: { type: "highlights", bookId: listHighlightsMatch[1] } }); + continue; + } + + // highlight-stats + if (trimmed.match(/^highlight-stats\b/)) { + ops.push({ action: "listData", params: { type: "highlightStats" } }); + continue; + } + + // list-notes: bookId + const listNotesMatch = trimmed.match(/^list-notes:\s*(.+?)\s*$/); + if (listNotesMatch) { + ops.push({ action: "listData", params: { type: "notes", bookId: listNotesMatch[1] } }); + continue; + } + + // list-skills + if (trimmed.match(/^list-skills\b/)) { + ops.push({ action: "listData", params: { type: "skills" } }); + continue; + } + + // list-threads: bookId + const listThreadsMatch = trimmed.match(/^list-threads:\s*(.+?)\s*$/); + if (listThreadsMatch) { + ops.push({ action: "listData", params: { type: "threads", bookId: listThreadsMatch[1] } }); + continue; + } + + // add-note: bookId=标题|内容 + const addNoteMatch = trimmed.match(/^add-note:\s*(.+?)\s*=\s*(.+)$/); + if (addNoteMatch) { + const parts = addNoteMatch[2].split("|").map((s) => s.trim()); + ops.push({ action: "note", params: { action: "create", bookId: addNoteMatch[1], title: parts[0] || "", content: parts[1] || parts[0] || "" } }); + continue; + } + } + } + + // 方法2:解析 XML 标签(旧格式兼容) + const xmlRegex = //g; + let xmlMatch; + while ((xmlMatch = xmlRegex.exec(text)) !== null) { + try { + const action = xmlMatch[1]; + const params = xmlMatch[2] ? JSON.parse(xmlMatch[2]) : {}; + ops.push({ action, params }); + } catch { + console.warn("[ClaudeCode] Failed to parse ReadAny XML op:", xmlMatch[0]); + } + } + + // 后处理:合并 tagBooks 操作(同一书的多次 tag 合并) + const mergedOps: ReadAnyOp[] = []; + const tagAssignments = new Map(); + for (const op of ops) { + if (op.action === "tagBooks" && op.params.assignments) { + for (const a of op.params.assignments as Array<{ bookId: string; tags: string[] }>) { + const existing = tagAssignments.get(a.bookId) || []; + tagAssignments.set(a.bookId, [...new Set([...existing, ...a.tags])]); + } + } else { + mergedOps.push(op); + } + } + if (tagAssignments.size > 0) { + const assignments = Array.from(tagAssignments.entries()).map(([bookId, tags]) => ({ bookId, tags })); + mergedOps.unshift({ action: "tagBooks", params: { assignments } }); + } + + return mergedOps; +} + +/** 执行一个 ReadAny 操作,返回结果文本 */ +function normalizeBookReference(value: string): string { + return value + .trim() + .replace(/^["'“”‘’《〈]|["'“”‘’》〉]$/g, "") + .replace(/\s+/g, " ") + .toLocaleLowerCase(); +} + +export async function resolveReadAnyBookReference( + reference: string, + currentBook: Book | null, +): Promise { + const trimmed = reference.trim(); + const normalized = normalizeBookReference(trimmed); + if (!normalized) return trimmed; + + if ( + currentBook && + (["current", "current-book", "当前书", "当前书籍", "本书"].includes(normalized) || + normalizeBookReference(currentBook.meta.title || "") === normalized) + ) { + return currentBook.id; + } + + try { + const direct = await getBook(trimmed); + if (direct) return direct.id; + } catch { + // Fall through to title matching. + } + + const books = await getBooks(); + const exactMatches = books.filter( + (book) => normalizeBookReference(book.meta.title || "") === normalized, + ); + if (exactMatches.length === 1) return exactMatches[0].id; + + const partialMatches = books.filter((book) => { + const title = normalizeBookReference(book.meta.title || ""); + return title.length > 0 && (title.includes(normalized) || normalized.includes(title)); + }); + return partialMatches.length === 1 ? partialMatches[0].id : trimmed; +} + +async function resolveReadAnyOpBookReferences( + op: ReadAnyOp, + currentBook: Book | null, +): Promise { + const params = { ...op.params }; + + if (typeof params.bookId === "string") { + params.bookId = await resolveReadAnyBookReference(params.bookId, currentBook); + } + + if (Array.isArray(params.bookIds)) { + params.bookIds = await Promise.all( + params.bookIds.map((bookId) => + typeof bookId === "string" + ? resolveReadAnyBookReference(bookId, currentBook) + : Promise.resolve(bookId), + ), + ); + } + + if (Array.isArray(params.assignments)) { + params.assignments = await Promise.all( + params.assignments.map(async (assignment) => { + if ( + !assignment || + typeof assignment !== "object" || + typeof (assignment as { bookId?: unknown }).bookId !== "string" + ) { + return assignment; + } + return { + ...assignment, + bookId: await resolveReadAnyBookReference( + (assignment as { bookId: string }).bookId, + currentBook, + ), + }; + }), + ); + } + + return { ...op, params }; +} + +async function executeSingleReadAnyOp(op: ReadAnyOp): Promise { + const { action, params } = op; + + try { + switch (action) { + case "tagBooks": { + const assignments = params.assignments as Array<{ bookId: string; tags: string[] }>; + if (!Array.isArray(assignments)) return "tagBooks 错误: assignments 参数必须是数组"; + const results: string[] = []; + for (const { bookId, tags } of assignments) { + const book = await getBook(bookId); + if (!book) { results.push(`✗ ${bookId}: 书籍未找到`); continue; } + const merged = [...new Set([...(book.tags || []), ...tags])]; + await updateBook(bookId, { tags: merged }); + results.push(`✓ 《${book.meta.title || bookId}》标签已更新: ${merged.join(", ")}`); + } + emitLibraryChanged(); + return results.join("\n"); + } + case "manageBookTags": { + const act = params.action as string; + if (act === "create") { + const tagsToCreate: string[] = Array.isArray(params.tags) ? params.tags : (params.tag ? [params.tag as string] : []); + const existingTags = (await loadFromFS("library-tags")) || []; + const newTags = tagsToCreate.filter((t) => !existingTags.includes(t)); + if (newTags.length === 0) return "所有标签已存在"; + const allTags = [...existingTags, ...newTags].sort(); + debouncedSave("library-tags", allTags); + emitLibraryChanged(); + return `✓ 已创建标签: ${newTags.join(", ")}(现有 ${allTags.length} 个标签)`; + } + if (act === "rename") { + const oldTag = params.tag as string; + const newTag = params.newTag as string; + if (!oldTag || !newTag) return "rename 需要 tag 和 newTag 参数"; + const books = await getBooks(); + let count = 0; + for (const book of books) { + if (book.tags?.includes(oldTag)) { + const updated = book.tags.map((t) => (t === oldTag ? newTag : t)); + await updateBook(book.id, { tags: [...new Set(updated)] }); + count++; + } + } + emitLibraryChanged(); + return `✓ 已将标签 "${oldTag}" 重命名为 "${newTag}",影响 ${count} 本书`; + } + if (act === "delete") { + const tagsToDelete: string[] = Array.isArray(params.tags) ? params.tags : (params.tag ? [params.tag as string] : []); + const books = await getBooks(); + let count = 0; + for (const book of books) { + if (tagsToDelete.some((t) => book.tags?.includes(t))) { + const updated = (book.tags || []).filter((t) => !tagsToDelete.includes(t)); + await updateBook(book.id, { tags: updated }); + count++; + } + } + emitLibraryChanged(tagsToDelete); + return `✓ 已删除标签: ${tagsToDelete.join(", ")},影响 ${count} 本书`; + } + if (act === "setBookTags") { + const bookId = params.bookId as string; + const tags = params.tags as string[]; + if (!bookId || !tags) return "setBookTags 需要 bookId 和 tags 参数"; + const book = await getBook(bookId); + if (!book) return `✗ 书籍 ${bookId} 未找到`; + await updateBook(bookId, { tags: [...new Set(tags)] }); + emitLibraryChanged(); + return `✓ 《${book.meta.title}》标签已设为: ${tags.join(", ")}`; + } + if (act === "removeFromBook") { + const bookId = params.bookId as string; + const tagsToRemove: string[] = Array.isArray(params.tags) ? params.tags : []; + if (!bookId || !tagsToRemove.length) return "removeFromBook 需要 bookId 和 tags 参数"; + const book = await getBook(bookId); + if (!book) return `✗ 书籍 ${bookId} 未找到`; + const updated = (book.tags || []).filter((t) => !tagsToRemove.includes(t)); + await updateBook(bookId, { tags: updated }); + emitLibraryChanged(); + return `✓ 已从《${book.meta.title}》移除标签: ${tagsToRemove.join(", ")}`; + } + return `未知的 manageBookTags 操作: ${act}`; + } + case "manageGroup": { + const act = params.action as string; + if (act === "create") { + const name = params.name as string; + if (!name) return "create 需要 name 参数"; + const groups = await getGroups(); + if (groups.find((g) => g.name === name)) return `分组"${name}"已存在`; + await insertGroup({ name }); + return `✓ 已创建分组: ${name}`; + } + if (act === "rename") { + const oldName = params.oldName as string; + const newName = params.newName as string; + if (!oldName || !newName) return "rename 需要 oldName 和 newName 参数"; + const groups = await getGroups(); + const target = groups.find((g) => g.name === oldName); + if (!target) return `✗ 未找到分组"${oldName}"`; + await updateGroupDb(target.id, { name: newName }); + return `✓ 已将分组"${oldName}"重命名为"${newName}"`; + } + if (act === "delete") { + const name = params.name as string; + if (!name) return "delete 需要 name 参数"; + const groups = await getGroups(); + const target = groups.find((g) => g.name === name); + if (!target) return `✗ 未找到分组"${name}"`; + await deleteGroupDb(target.id); + return `✓ 已删除分组"${name}"`; + } + if (act === "moveBooks") { + const groupName = params.groupName as string; + const bookIds = params.bookIds as string[]; + if (!groupName || !bookIds?.length) return "moveBooks 需要 groupName 和 bookIds 参数"; + const groups = await getGroups(); + const targetGroup = groups.find((g) => g.name === groupName); + if (!targetGroup) return `✗ 未找到分组"${groupName}",请先用 create-group 创建`; + let count = 0; + for (const bookId of bookIds) { + const book = await getBook(bookId); + if (!book) continue; + await updateBook(bookId, { groupId: targetGroup.id }); + count++; + } + emitLibraryChanged(); + return `✓ 已将${count}本书移入分组"${groupName}"`; + } + return `已知的 manageGroup 操作: ${act}`; + } + case "searchBook": { + const bookId = params.bookId as string; + const query = params.query as string; + if (!bookId || !query) return "searchBook 需要 bookId 和 query 参数"; + try { + const results = await ragSearch({ query, bookId, topK: 8, mode: "hybrid", threshold: 0.3 }); + if (results.length === 0) return `在《${(await getBook(bookId))?.meta.title || bookId}》中未找到与"${query}"相关的内容`; + const book = await getBook(bookId); + const title = book?.meta.title || bookId; + const parts = results.map((r, i) => + `[${i + 1}] 第${r.chunk.chapterIndex}章 ${r.chunk.chapterTitle || ""} (相关度: ${Math.round(r.score * 100)}%)\n${r.chunk.content}` + ); + return `在《${title}》中搜索"${query}",找到 ${results.length} 条:\n${parts.join("\n\n")}`; + } catch (err) { + return `搜索失败: ${err instanceof Error ? err.message : String(err)}`; + } + } + case "bookmark": { + const act = params.action as string; + if (act === "create") { + const bookId = params.bookId as string; + const label = (params.label as string) || ""; + if (!bookId) return "bookmark create 需要 bookId 参数"; + const id = `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + await insertBookmark({ id, bookId, cfi: "", label, createdAt: Date.now() }); + emitLibraryChanged(); + return `✓ 已添加书签: ${label || id}`; + } + if (act === "delete") { + const id = params.id as string; + if (!id) return "bookmark delete 需要 id 参数"; + await deleteBookmark(id); + emitLibraryChanged(); + return `✓ 已删除书签 ${id}`; + } + return `已知的 bookmark 操作: ${act}`; + } + case "listData": { + const type = params.type as string; + if (type === "bookmarks") { + const bookId = params.bookId as string; + if (!bookId) return "list bookmarks 需要 bookId 参数"; + const bookmarks = await getBookmarks(bookId); + if (bookmarks.length === 0) return "该书暂无书签"; + return `书签列表 (${bookmarks.length}):\n${bookmarks.map((b, i) => `[${i + 1}] ${b.label || "(无标签)"} | cfi: ${b.cfi || "(无)"}`).join("\n")}`; + } + if (type === "highlights") { + const bookId = params.bookId as string; + if (!bookId) return "list highlights 需要 bookId 参数"; + const highlights = await getAllHighlights(); + const filtered = highlights.filter((h) => h.bookId === bookId).slice(0, 30); + if (filtered.length === 0) return "该书暂无高亮标注"; + return `高亮标注 (${filtered.length}):\n${filtered.map((h, i) => `[${i + 1}] ${h.text.slice(0, 120)}${h.text.length > 120 ? "…" : ""} | 颜色: ${h.color || "yellow"} | ${h.note ? `笔记: ${h.note.slice(0, 60)}` : ""}`).join("\n")}`; + } + if (type === "highlightStats") { + const stats = await getHighlightStats(); + return `标注统计:总计 ${stats.totalHighlights} 条高亮,${stats.highlightsWithNotes} 条带笔记,涉及 ${stats.totalBooks} 本书,最近 ${stats.recentCount} 条`; + } + if (type === "notes") { + const bookId = params.bookId as string; + if (!bookId) return "list notes 需要 bookId 参数"; + const notes = await getNotes(bookId); + if (notes.length === 0) return "该书暂无笔记"; + return `笔记列表 (${notes.length}):\n${notes.slice(0, 20).map((n, i) => `[${i + 1}] ${n.title}: ${n.content.slice(0, 150)}${n.content.length > 150 ? "…" : ""}`).join("\n")}`; + } + if (type === "skills") { + const skills = await getSkills(); + if (skills.length === 0) return "暂无可用技能"; + return `可用技能 (${skills.length}):\n${skills.map((s) => `- ${s.name}: ${s.description || "(无描述)"}`).join("\n")}`; + } + if (type === "threads") { + const bookId = params.bookId as string; + if (!bookId) return "list threads 需要 bookId 参数"; + const threads = await getThreads(bookId); + if (threads.length === 0) return "该书暂无对话"; + return `对话列表 (${threads.length}):\n${threads.map((t, i) => `[${i + 1}] ${t.title || "无标题"} | ${t.messages?.length || 0} 条消息 | ${new Date(t.createdAt).toLocaleDateString("zh-CN")}`).join("\n")}`; + } + return `已知的 listData 类型: ${type}`; + } + case "note": { + const act = params.action as string; + if (act === "create") { + const bookId = params.bookId as string; + const title = (params.title as string) || ""; + const content = (params.content as string) || ""; + if (!bookId) return "note create 需要 bookId 参数"; + const id = `note-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + await insertNote({ id, bookId, title, content, tags: [], createdAt: Date.now(), updatedAt: Date.now() }); + emitLibraryChanged(); + return `✓ 已创建笔记: ${title}`; + } + return `已知的 note 操作: ${act}`; + } + default: + return `已知操作类型: ${action}`; + } + } catch (err) { + return `操作执行失败: ${err instanceof Error ? err.message : String(err)}`; + } +} + +/** 批量执行 ReadAny 操作 */ +export async function executeReadAnyOpsDetailed( + ops: ReadAnyOp[], + currentBook: Book | null = null, +): Promise> { + const results: Array<{ op: ReadAnyOp; result: string }> = []; + for (const rawOp of ops) { + const op = await resolveReadAnyOpBookReferences(rawOp, currentBook); + const result = await executeSingleReadAnyOp(op); + results.push({ op, result }); + } + return results; +} + +export async function executeReadAnyOps( + ops: ReadAnyOp[], + currentBook: Book | null = null, +): Promise { + const detailed = await executeReadAnyOpsDetailed(ops, currentBook); + return detailed.map(({ op, result }) => `[${op.action}] ${result}`); +} + +/** 自动分类引擎:根据书籍元数据自动生成标签和分组 */ +export async function autoClassifyAndTag(): Promise<{ createdTags: string[]; tagResults: string[] }> { + const books = await getBooks(); + const existingTags = (await loadFromFS("library-tags")) || []; + const existingGroups = await getGroups(); + const allTags = new Set(existingTags.map((t) => t.toLowerCase())); + + // 关键词到标签的映射(优先复用已有标签) + const keywordTagMap: Record = { + fiction: "小说", novel: "小说", "小说": "小说", "文学": "文学", literature: "文学", + science: "科学", fiction_sci: "科幻", scifi: "科幻", "科幻": "科幻", + philosophy: "哲学", "哲学": "哲学", thought: "哲学", + economics: "经济学", economy: "经济学", "经济学": "经济学", "经济": "经济学", + history: "历史", "历史": "历史", "史学": "历史", + politics: "政治", political: "政治", "政治": "政治", + psychology: "心理学", "心理": "心理学", "心理学": "心理学", + sociology: "社会学", "社会": "社会学", + biography: "传记", "传记": "传记", memoir: "传记", + poetry: "诗歌", "诗歌": "诗歌", poem: "诗歌", + essay: "散文", "散文": "散文", "随笔": "散文", + japan: "日本文学", japanese: "日本文学", "日本": "日本文学", + china: "中国文学", chinese: "中国文学", "中国": "中国文学", + french: "法国文学", france: "法国文学", "法国": "法国文学", + english: "英国文学", british: "英国文学", "英国": "英国文学", + american: "美国文学", america: "美国文学", "美国": "美国文学", + german: "德国文学", germany: "德国文学", "德国": "德国文学", + russian: "俄罗斯文学", russia: "俄罗斯文学", "俄罗斯": "俄罗斯文学", + technology: "技术", tech: "技术", "技术": "技术", "科技": "技术", + religion: "宗教", "宗教": "宗教", + art: "艺术", "艺术": "艺术", "美术": "艺术", + music: "音乐", "音乐": "音乐", + law: "法律", legal: "法律", "法律": "法律", + medicine: "医学", medical: "医学", "医学": "医学", + business: "商业", "商业": "商业", management: "管理", + education: "教育", "教育": "教育", + travel: "旅行", "旅行": "旅行", "游记": "旅行", + cooking: "美食", food: "美食", "美食": "美食", "烹饪": "美食", + comics: "漫画", manga: "漫画", "漫画": "漫画", + mystery: "推理", detective: "推理", "推理": "推理", "悬疑": "推理", + romance: "爱情", love: "爱情", "爱情": "爱情", + fantasy: "奇幻", "奇幻": "奇幻", "魔幻": "奇幻", + horror: "恐怖", "恐怖": "恐怖", thriller: "恐怖", + classic: "经典", classical: "经典", + }; + + function classifyBook(book: Awaited>[number]): string[] { + const candidates = new Map(); // tag → score + const text = [ + book.meta.title || "", + book.meta.author || "", + book.meta.description || "", + (book.meta.subjects || []).join(" "), + book.meta.language || "", + ].join(" ").toLowerCase(); + + for (const [keyword, tag] of Object.entries(keywordTagMap)) { + if (text.includes(keyword.toLowerCase())) { + candidates.set(tag, (candidates.get(tag) || 0) + 1); + } + } + + // 按得分排序,取 top 2 + return Array.from(candidates.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 2) + .map(([tag]) => tag); + } + + // 标签到分组的映射(最优先匹配的标签决定所属分组) + const tagToGroup: Record = { + "日语学习": "日语学习", "N1": "日语学习", "N2": "日语学习", "N3": "日语学习", + "哲学": "哲学", "精神分析": "哲学", + "文学": "文学", "小说": "文学", "日本文学": "文学", "中国文学": "文学", "推理悬疑": "文学", "推理": "文学", "悬疑": "文学", + "政治历史": "政治历史", "政治": "政治历史", "历史": "政治历史", "毛泽东": "政治历史", + "科普": "科普", "科学": "科普", + "数学": "数学", + "经济学": "经济学", "经济": "经济学", + "心理学": "心理学", + "技术": "技术", + "艺术": "艺术", + }; + + function inferGroupFromTags(tags: string[]): string | null { + for (const t of tags) { + const group = tagToGroup[t]; + if (group) return group; + } + return null; + } + + // 收集需要创建的新标签和新分组 + const newTags: string[] = []; + const assignments: Array<{ bookId: string; tags: string[] }> = []; + const groupMoves: Array<{ bookId: string; groupName: string }> = []; + const groupNames = new Set(existingGroups.map((g) => g.name)); + + for (const book of books) { + const existingForBook = book.tags || []; + const hasGroup = existingGroups.some((g) => g.id === book.groupId); + + const suggested = classifyBook(book); + const needed = suggested.filter((t) => !existingForBook.includes(t)).slice(0, 2 - existingForBook.length); + + if (needed.length > 0) { + assignments.push({ bookId: book.id, tags: needed }); + for (const t of needed) { + if (!allTags.has(t.toLowerCase()) && !newTags.includes(t)) { + newTags.push(t); + } + } + } + + // 推断分组 + const allTagsForBook = [...existingForBook, ...needed]; + const inferredGroup = inferGroupFromTags(allTagsForBook); + if (inferredGroup && !hasGroup) { + groupMoves.push({ bookId: book.id, groupName: inferredGroup }); + } + } + + const results: string[] = []; + + // 先创建缺失的分组 + if (groupMoves.length > 0) { + for (const gm of groupMoves) { + if (!groupNames.has(gm.groupName)) { + await insertGroup({ name: gm.groupName }); + groupNames.add(gm.groupName); + results.push(`✓ 已创建分组: ${gm.groupName}`); + } + } + } + + // 再创建新标签 + if (newTags.length > 0) { + const allExisting = await loadFromFS("library-tags") || []; + const updated = [...new Set([...allExisting, ...newTags])].sort(); + debouncedSave("library-tags", updated); + results.push(`✓ 已创建标签: ${newTags.join(", ")}`); + } + + // 执行标签分配 + for (const { bookId, tags } of assignments) { + const book = await getBook(bookId); + if (!book) continue; + const merged = [...new Set([...(book.tags || []), ...tags])]; + await updateBook(bookId, { tags: merged }); + results.push(`✓ 《${book.meta.title}》→ 标签 ${merged.join(", ")}`); + } + + // 执行移动分组 + for (const { bookId, groupName } of groupMoves) { + const group = existingGroups.find((g) => g.name === groupName) || + (await getGroups()).find((g) => g.name === groupName); + if (!group) continue; + await updateBook(bookId, { groupId: group.id }); + } + if (groupMoves.length > 0) { + results.push(`✓ 已将${groupMoves.length}本书移入对应分组`); + } + + if (assignments.length > 0 || groupMoves.length > 0) emitLibraryChanged(); + + return { + createdTags: newTags, + tagResults: results, + }; +} + +export function buildContextToolEvents(options: { + requestedFullChapter: boolean; + chapterContext: ChapterContext | null; + bookMemory: BookMemory | null; +}): AgentStreamEvent[] { + const events: AgentStreamEvent[] = []; + + if (options.bookMemory) { + events.push({ + type: "tool_call", + name: "readBookMemory", + args: { bookId: options.bookMemory.bookId }, + }); + events.push({ + type: "tool_result", + name: "readBookMemory", + result: { + summary: options.bookMemory.summary || "", + focus: options.bookMemory.focus, + openQuestions: options.bookMemory.openQuestions, + recentQuestions: options.bookMemory.recentQuestions.slice(-5), + totalMessages: options.bookMemory.totalMessages, + lastChapterTitle: options.bookMemory.lastChapterTitle, + lastChapterIndex: options.bookMemory.lastChapterIndex, + }, + }); + } + + if (options.requestedFullChapter && options.chapterContext) { + events.push({ + type: "tool_call", + name: "getCurrentChapter", + args: { + chapterIndex: options.chapterContext.chapterIndex, + requestedFullChapter: true, + }, + }); + events.push({ + type: "tool_result", + name: "getCurrentChapter", + result: { + chapterTitle: options.chapterContext.chapterTitle, + chapterIndex: options.chapterContext.chapterIndex, + source: options.chapterContext.source, + totalTokens: options.chapterContext.totalTokens, + characterCount: options.chapterContext.content.length, + segmentCount: options.chapterContext.chunks.length, + }, + }); + } + + return events; +} + +function compareChunkOrder(a: { id: string }, b: { id: string }): number { + return getChunkIndexFromId(a.id) - getChunkIndexFromId(b.id) || a.id.localeCompare(b.id); +} + +function getChunkIndexFromId(id: string): number { + const match = id.match(/-(\d+)$/); + return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER; +} + +function joinChunkContent(parts: string[]): string { + let result = ""; + for (const rawPart of parts) { + const part = rawPart.trim(); + if (!part) continue; + if (!result) { + result = part; + continue; + } + result = appendWithoutOverlap(result, part); + } + return result; +} + +function appendWithoutOverlap(existing: string, next: string): string { + const maxOverlap = Math.min(existing.length, next.length, 4000); + for (let length = maxOverlap; length >= 80; length--) { + if (existing.endsWith(next.slice(0, length))) { + return `${existing}${next.slice(length)}`; + } + } + return `${existing}\n\n${next}`; +} + +export function buildReadAnyToolContinuationPrompt(options: { + basePrompt: string; + previousResponse: string; + results: Array<{ op: ReadAnyOp; result: string }>; +}): string { + const toolResults = options.results + .map( + ({ op, result }, index) => + `## ReadAny tool ${index + 1}: ${op.action}\nArguments: ${JSON.stringify(op.params)}\nResult:\n${result}`, + ) + .join("\n\n"); + + return [ + options.basePrompt, + "", + "# ReadAny tool continuation", + "You requested ReadAny library tools in the previous assistant turn. They have now executed.", + "Continue the same task using the results below. Do not stop after announcing a search or tool call.", + "Answer the user's original question directly. If more ReadAny tools are truly necessary, output another readany-ops block at the end.", + "", + "## Previous assistant turn", + options.previousResponse, + "", + "# ReadAny tool results", + toolResults, + ].join("\n"); +} + +export async function* streamClaudeCodeAgent( + options: { + thread: Thread; + book: Book | null; + semanticContext: SemanticContext | null; + isVectorized: boolean; + deepThinking?: boolean; + spoilerFree?: boolean; + signal?: AbortSignal; + }, + userInput: string, + history: ProcessedMessage[] = [], +): AsyncGenerator { + const platform = getPlatformService(); + if (!platform.runClaudeCodeChat) { + yield { + type: "error", + error: "Claude Code local mode is only available in the ReadAny desktop app.", + }; + return; + } + + const readingContext = getReadingContextSnapshot(); + const selectionQuotes = getSelectionQuotes(readingContext); + const requestedFullChapter = shouldAttachFullChapter(userInput, selectionQuotes); + const chapterContext = await loadChapterContext({ + book: options.book, + userInput, + spoilerFree: options.spoilerFree, + requestedFullChapter, + }); + const bookMemory = await loadBookMemory(options.book); + const preExecutedContext = await preExecuteReadAnyTools({ + userInput, + book: options.book, + }); + for (const event of buildContextToolEvents({ + requestedFullChapter, + chapterContext, + bookMemory, + })) { + yield event; + } + + const systemPrompt = buildClaudeCodeSystemPrompt({ + book: options.book, + userLanguage: i18n.language || options.book?.meta.language || "zh-CN", + spoilerFree: options.spoilerFree, + }); + const prompt = buildClaudeCodeUserPrompt({ + userInput, + history, + book: options.book, + readingContext, + semanticContext: options.semanticContext, + chapterContext, + bookMemory, + preExecutedContext, + spoilerFree: options.spoilerFree, + }); + + const pendingErrors: string[] = []; + const executedOpKeys = new Set(); + let executedReadAnyOps = 0; + let nextPrompt = prompt; + const maxReadAnyRounds = 3; + + for (let round = 0; round < maxReadAnyRounds && !options.signal?.aborted; round++) { + const parser = createClaudeCodeStreamParser(); + const queue: AgentStreamEvent[] = []; + let wake: (() => void) | null = null; + let done = false; + let roundResponseText = ""; + + const notify = () => { + wake?.(); + wake = null; + }; + + const push = (events: AgentStreamEvent[]) => { + for (const event of events) { + if (event.type === "token") roundResponseText += event.content; + } + queue.push(...events); + notify(); + }; + + const runPromise = platform + .runClaudeCodeChat( + { + requestId: `cc-${Date.now()}-${round}-${Math.random().toString(36).slice(2, 8)}`, + prompt: nextPrompt, + systemPrompt, + effort: options.deepThinking ? "max" : "medium", + tools: ["WebSearch", "WebFetch"], + }, + { + signal: options.signal, + onStdoutLine: (line) => push(parser.parseLine(line)), + onStderr: (_content) => { + // Claude Code can write diagnostics to stderr while continuing normally. + }, + }, + ) + .catch((error) => { + pendingErrors.push(error instanceof Error ? error.message : String(error)); + }) + .finally(() => { + done = true; + notify(); + }); + + while (!done || queue.length > 0) { + while (queue.length > 0) { + const event = queue.shift(); + if (event) yield event; + } + if (done) break; + await Promise.race([ + runPromise, + new Promise((resolve) => { + wake = resolve; + }), + ]); + } + + if (options.signal?.aborted || pendingErrors.length > 0) break; + + const newOps = parseReadAnyOps(roundResponseText).filter((op) => { + const key = JSON.stringify(op); + if (executedOpKeys.has(key)) return false; + executedOpKeys.add(key); + return true; + }); + if (newOps.length === 0) break; + + const results: Array<{ op: ReadAnyOp; result: string }> = []; + for (const op of newOps) { + yield { type: "tool_call", name: op.action, args: op.params }; + const [result] = await executeReadAnyOpsDetailed([op], options.book); + results.push(result); + yield { type: "tool_result", name: result.op.action, result: result.result }; + } + executedReadAnyOps += results.length; + + if (round + 1 >= maxReadAnyRounds) break; + + yield { + type: "reasoning", + content: "ReadAny tools completed. Continuing with the tool results.", + stepType: "analyzing", + }; + nextPrompt = buildReadAnyToolContinuationPrompt({ + basePrompt: prompt, + previousResponse: roundResponseText, + results, + }); + } + + // 自动分类引擎:如果用户要求整理书库但模型没有输出操作指令,自动执行 + if ( + executedReadAnyOps === 0 && + preExecutedContext.intent === "library_organize" && + !options.signal?.aborted + ) { + yield { type: "token", content: "\n\n" }; + const autoResult = await autoClassifyAndTag(); + for (const r of autoResult.tagResults) { + yield { type: "token", content: `📋 ${r}\n` }; + } + } + + const finalError = pendingErrors[0]; + if (finalError && !options.signal?.aborted) { + yield { type: "error", error: finalError }; + } +} + +async function loadChapterContext(options: { + book: Book | null; + userInput: string; + spoilerFree?: boolean; + requestedFullChapter?: boolean; +}): Promise { + if (!options.book || options.spoilerFree) return null; + + const readingContext = getReadingContextSnapshot(); + const chapterIndex = readingContext?.currentChapter.index; + const chapterTitle = readingContext?.currentChapter.title; + const quotes = getSelectionQuotes(readingContext); + + // 如果没有显式请求且输入不涉及章节分析,跳过加载 + if (!(options.requestedFullChapter ?? shouldAttachFullChapter(options.userInput, quotes))) { + return null; + } + + // 尝试加载指定章节 + const tryLoadChapter = async (idx: number): Promise => { + if (idx < 0) return null; + try { + const chunks = await getChunks(options.book!.id); + const context = buildChapterContextFromChunks(chunks, idx); + if (context) return context; + } catch { + // 继续尝试文件提取 + } + + try { + const platform = getPlatformService(); + const extractedChapter = await platform.extractBookChapter?.(options.book!.filePath, idx); + return extractedChapter ? buildChapterContextFromExtractedChapter(extractedChapter) : null; + } catch { + return null; + } + }; + + // 优先用 chapterIndex + if (chapterIndex !== undefined && chapterIndex >= 0) { + const result = await tryLoadChapter(chapterIndex); + if (result) return result; + } + + // 回退 1:如果有章节标题,尝试在所有 chunks 中按标题匹配 + if (chapterTitle && chapterTitle !== "Unknown") { + try { + const chunks = await getChunks(options.book.id); + const titleMatch = chunks.find( + (c) => + c.chapterTitle === chapterTitle || + c.chapterTitle.toLowerCase().includes(chapterTitle.toLowerCase()) || + chapterTitle.toLowerCase().includes(c.chapterTitle.toLowerCase()), + ); + if (titleMatch) { + const context = buildChapterContextFromChunks(chunks, titleMatch.chapterIndex); + if (context) return context; + } + } catch { + // 继续 + } + } + + // 回退 2:作为最后手段,尝试加载第一章 + if (chapterIndex === undefined || chapterIndex < 0) { + const firstChapter = await tryLoadChapter(0); + if (firstChapter) return firstChapter; + } + + return null; +} + +async function loadBookMemory(book: Book | null): Promise { + if (!book) return null; + try { + return await getBookMemory(book.id); + } catch { + return null; + } +} + +function getSelectionQuotes( + readingContext: ReturnType, +): AttachedQuote[] { + return readingContext?.selection + ? [ + { + id: "selection", + text: readingContext.selection.text, + source: readingContext.selection.chapterTitle, + }, + ] + : []; +} + +function buildClaudeCodeSystemPrompt(options: { + book: Book | null; + userLanguage: string; + spoilerFree?: boolean; +}): string { + const language = options.userLanguage || "zh-CN"; + const bookInfo = options.book + ? `当前书籍: 《${options.book.meta.title || "未知"}》${options.book.meta.author ? `,作者 ${options.book.meta.author}` : ""}` + : "当前没有打开书籍。"; + + return [ + "# 角色", + `你是 ReadAny 的 AI 阅读助手,运行在 Claude Code + DeepSeek 后端上。你的职责是帮助用户理解书籍内容、管理书库、分析阅读数据。${options.book ? "用户当前正在阅读一本书,书籍信息已注入到本轮对话的上下文中。" : ""}`, + "", + "# 你的能力", + "你有四类能力:", + "1. **分析能力**:基于注入的书籍内容进行分析、总结、解释", + "2. **网络搜索**:使用 WebSearch / WebFetch 查外部资料", + "3. **书库操作**:通过输出操作指令码来执行书库操作(见下方指令集)", + "", + "# 操作指令格式", + "在回复末尾输出 ```readany-ops 代码块,每行一条指令。系统自动解析并执行,不征求确认。", + "", + "## 书库整理", + "| 指令 | 说明 |", + "|------|------|", + "| `create-group: 名` | 创建新分组 |", + "| `rename-group: 旧名=新名` | 重命名分组 |", + "| `delete-group: 名` | 删除分组 |", + "| `move-to-group: 分组名=bookId1, bookId2` | 书籍移入分组 |", + "| `create-tag: 名` | 创建全局标签 |", + "| `rename-tag: 旧名=新名` | 重命名标签 |", + "| `set-book-tags: bookId=标签1, 标签2` | 替换某书标签(每书≤2个) |", + "", + "## 搜索与查询", + "| 指令 | 说明 |", + "|------|------|", + "| `search-book: bookId=关键词` | RAG语义搜索(需已向量化) |", + "| `list-highlights: bookId` | 列出某书高亮标注 |", + "| `highlight-stats` | 全局标注统计 |", + "| `list-notes: bookId` | 列出某书笔记 |", + "| `list-bookmarks: bookId` | 列出某书书签 |", + "| `list-threads: bookId` | 列出某书对话记录 |", + "", + "## 编辑与操作", + "| 指令 | 说明 |", + "|------|------|", + "| `add-bookmark: bookId=标题` | 添加书签 |", + "| `remove-bookmark: bookmarkId` | 删除书签 |", + "| `add-note: bookId=标题\|内容` | 添加笔记(\|分隔标题和内容) |", + "| `list-skills` | 列出可用技能 |", + "", + "## 规则", + "- 整理书库时优先用分组归类,标签做细分", + "- 分析回复+操作指令放在同一条消息中,指令块放末尾", + "- user要搜书内内容时用 search-book,并且必须使用 Current book 中提供的 Book ID,不要使用书名代替 ID", + "- 用户没说要保留但含义相近的旧分组,用 rename-group 而非 create-group", + "- 用户要求整理书库又没指定维度时,按主题/学科分类", + "", + `- 回复语言:${language}`, + "- 书籍内容基于注入文本,不编造", + options.spoilerFree + ? "- **防剧透模式已开启**:不使用当前阅读位置之后的内容" + : "", + bookInfo, + ] + .filter(Boolean) + .join("\n"); +} + +function buildClaudeCodeUserPrompt(options: { + userInput: string; + history: ProcessedMessage[]; + book: Book | null; + readingContext: ReturnType; + semanticContext: SemanticContext | null; + chapterContext: ChapterContext | null; + bookMemory: BookMemory | null; + preExecutedContext?: PreExecutedContext; + spoilerFree?: boolean; +}): string { + const sections: string[] = []; + const selectionQuotes = getSelectionQuotes(options.readingContext); + const requestedFullChapter = shouldAttachFullChapter(options.userInput, selectionQuotes); + const preExec = options.preExecutedContext; + + if (options.history.length > 0) { + sections.push( + [ + "# Recent conversation", + ...options.history.slice(-8).map((message) => { + const role = message.role === "user" ? "User" : "Assistant"; + return `## ${role}\n${message.content}`; + }), + ].join("\n\n"), + ); + } + + if (options.book) { + const vectorizedStatus = options.book.isVectorized + ? "已向量化 — 支持语义搜索、摘要、实体提取等全部内容分析功能" + : "未向量化 — 内容分析功能受限。建议用户在 ReadAny 中对此书进行向量化以解锁 RAG 搜索、摘要、实体提取等功能"; + sections.push( + [ + "# Current book", + `Book ID: ${options.book.id}`, + `Title: ${options.book.meta.title || "Unknown"}`, + options.book.meta.author ? `Author: ${options.book.meta.author}` : "", + `Progress: ${Math.round((options.book.progress || 0) * 100)}%`, + `Vectorization: ${vectorizedStatus}`, + ] + .filter(Boolean) + .join("\n"), + ); + } + + const bookMemory = renderBookMemoryForPrompt(options.bookMemory); + if (bookMemory) { + sections.push(bookMemory); + } + + if (options.readingContext) { + const context = options.readingContext; + sections.push( + [ + "# Current reading position", + `Chapter: ${context.currentChapter.title || "Unknown"} (index ${context.currentChapter.index})`, + `Progress: ${Math.round((context.currentPosition.percentage || 0) * 100)}%`, + context.surroundingText ? `Visible text:\n${context.surroundingText}` : "", + context.selection + ? `Selected text (user focus):\n${context.selection.text}\nSource: ${ + context.selection.chapterTitle || context.currentChapter.title + }` + : "", + ] + .filter(Boolean) + .join("\n\n"), + ); + } else if (options.semanticContext?.surroundingText) { + sections.push(`# Current visible text\n${options.semanticContext.surroundingText}`); + } + + if (options.chapterContext) { + sections.push( + [ + "# Full current chapter", + `Chapter: ${options.chapterContext.chapterTitle} (index ${options.chapterContext.chapterIndex}, about ${options.chapterContext.totalTokens} tokens)`, + options.chapterContext.source === "chunks" + ? "The following text was reconstructed from the local chunks table. For chapter summaries or chapter analysis, base the answer primarily on this text:" + : "The following text was extracted directly from the local book file because no chunks are available. For chapter summaries or chapter analysis, base the answer primarily on this text:", + options.chapterContext.content, + ].join("\n\n"), + ); + } else if (requestedFullChapter && options.book) { + sections.push( + [ + "# Full chapter context status", + options.spoilerFree + ? "Spoiler-free mode is enabled, so the full chapter was not injected. Use only visible text, selected text, and the current reading position." + : "No full-chapter chunks are available for this request. If the user asked for a full chapter summary, state that the answer is limited to visible or selected text.", + ].join("\n"), + ); + } + + // 注入预执行的书库数据 + if (preExec) { + const preParts: string[] = []; + if (preExec.librarySummary) { + preParts.push(`## 书库数据\n${preExec.librarySummary}`); + } + if (preExec.statsSummary) { + preParts.push(`## 阅读统计\n${preExec.statsSummary}`); + } + if (preExec.notesSummary) { + preParts.push(`## 标注与笔记\n${preExec.notesSummary}`); + } + if (preExec.classifyData) { + preParts.push(`## 书籍分类数据\n${preExec.classifyData}`); + } + if (preExec.searchResult) { + preParts.push(`## 搜索结果\n${preExec.searchResult}`); + } + if (preExec.listResult) { + preParts.push(`## 查询结果\n${preExec.listResult}`); + } + if (preParts.length > 0) { + sections.push(`# ReadAny Pre-executed Context\n\n${preParts.join("\n\n")}`); + } + } + + sections.push( + `# User question\n${options.userInput || "Please analyze the selected text and reading context above."}`, + ); + + return sections.join("\n\n---\n\n"); +} diff --git a/packages/core/src/ai/streaming.ts b/packages/core/src/ai/streaming.ts index 4f2fdc69..63995be6 100644 --- a/packages/core/src/ai/streaming.ts +++ b/packages/core/src/ai/streaming.ts @@ -1,13 +1,13 @@ +import i18n from "i18next"; /** - * AI Streaming service — handles streaming chat completions - * Uses LangGraph reading agent for unified model support with tool calling. - * Supports OpenAI-compatible, Anthropic Claude, and Google Gemini providers. + * AI Streaming service - preserves the public chat API while routing desktop + * reading chat through the local Claude Code adapter. */ import type { AIConfig, Book, SemanticContext, Skill, Thread } from "../types"; -import { streamReadingAgent } from "./agents/reading-agent"; +import type { AgentStreamEvent } from "./agents/reading-agent"; +import { streamClaudeCodeAgent } from "./claude-code"; import { processMessages } from "./message-pipeline"; import type { ToolDefinition } from "./tools/tool-types"; -import i18n from "i18next"; export interface StreamingOptions { thread: Thread; @@ -82,16 +82,14 @@ export class StreamingChat { const toolCalls: Array<{ name: string; args: Record; result?: unknown }> = []; - const stream = streamReadingAgent( + const stream = streamClaudeCodeAgent( { - aiConfig: options.aiConfig, + thread: options.thread, book: options.book, semanticContext: options.semanticContext, - enabledSkills: options.enabledSkills, isVectorized: options.isVectorized, deepThinking: options.deepThinking, spoilerFree: options.spoilerFree, - getAvailableTools: options.getAvailableTools, signal, }, userInput, @@ -100,12 +98,12 @@ export class StreamingChat { // Helper to race iterator next() against abort signal const raceNext = async ( - iterator: AsyncIterator, - ): Promise> => { + iterator: AsyncIterator, + ): Promise> => { if (signal.aborted) { return { done: true, value: undefined }; } - const abortPromise = new Promise>((resolve) => { + const abortPromise = new Promise>((resolve) => { const onAbort = () => { signal.removeEventListener("abort", onAbort); resolve({ done: true, value: undefined }); @@ -119,7 +117,7 @@ export class StreamingChat { let eventResult = await raceNext(iterator); while (!eventResult.done) { - const event = eventResult.value as any; + const event = eventResult.value; if (signal.aborted) { options.onAbort?.(fullText, toolCalls.length > 0 ? toolCalls : undefined); @@ -137,13 +135,14 @@ export class StreamingChat { toolCalls.push({ name: event.name, args: event.args }); break; - case "tool_result": + case "tool_result": { options.onToolResult?.(event.name, event.result); const existingTc = [...toolCalls] .reverse() .find((tc) => tc.name === event.name && !tc.result); if (existingTc) existingTc.result = event.result; break; + } case "reasoning": options.onReasoning?.(event.content, event.stepType); diff --git a/packages/core/src/db/__tests__/chapter-translation-queries.test.ts b/packages/core/src/db/__tests__/chapter-translation-queries.test.ts new file mode 100644 index 00000000..2fbda0cc --- /dev/null +++ b/packages/core/src/db/__tests__/chapter-translation-queries.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockExecute = vi.fn(); +const mockSelect = vi.fn(); +const mockDb = { execute: mockExecute, select: mockSelect, close: vi.fn() }; + +const coreMocks = vi.hoisted(() => ({ + getDB: vi.fn(), + getDeviceId: vi.fn(), + nextSyncVersion: vi.fn(), + nextUpdatedAt: vi.fn(), + parseJSON: vi.fn((value: string, fallback: unknown) => { + try { + return JSON.parse(value); + } catch { + return fallback; + } + }), +})); + +vi.mock("../db-core", () => coreMocks); + +const { + buildChapterTranslationId, + computeChapterSourceHash, + getChapterTranslation, + updateChapterTranslationVisibility, + upsertChapterTranslation, +} = await import("../chapter-translation-queries"); + +describe("chapter-translation-queries", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(Date, "now").mockReturnValue(5000); + coreMocks.getDB.mockResolvedValue(mockDb); + coreMocks.getDeviceId.mockResolvedValue("device-1"); + coreMocks.nextSyncVersion.mockResolvedValue(7); + coreMocks.nextUpdatedAt.mockResolvedValue(6000); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("builds stable ids and source hashes for the same chapter text", () => { + expect(buildChapterTranslationId("book-1", 3, "AUTO", "zh-CN")).toBe( + "book-1:3:AUTO:zh-CN", + ); + expect( + computeChapterSourceHash([ + { paragraphId: "p1", originalText: "Hello" }, + { paragraphId: "p2", originalText: "World" }, + ]), + ).toBe( + computeChapterSourceHash([ + { paragraphId: "p1", originalText: "Hello" }, + { paragraphId: "p2", originalText: "World" }, + ]), + ); + }); + + it("upserts a full chapter translation with sync tracking", async () => { + mockExecute.mockResolvedValue(undefined); + + await upsertChapterTranslation({ + bookId: "book-1", + sectionIndex: 3, + sourceLang: "AUTO", + targetLang: "zh-CN", + provider: "ai", + model: "deepseek-v4-pro[1m]", + sourceHash: "hash-1", + paragraphs: [ + { paragraphId: "p1", originalText: "Hello", translatedText: "Hello zh" }, + { paragraphId: "p2", originalText: "World", translatedText: "World zh" }, + ], + originalVisible: true, + translationVisible: true, + }); + + expect(coreMocks.nextSyncVersion).toHaveBeenCalledWith(mockDb, "chapter_translations"); + expect(coreMocks.getDeviceId).toHaveBeenCalled(); + const [sql, params] = mockExecute.mock.calls[mockExecute.mock.calls.length - 1]!; + expect(sql).toContain("INSERT INTO chapter_translations"); + expect(params[0]).toBe("book-1:3:AUTO:zh-CN"); + expect(params[1]).toBe("book-1"); + expect(params[2]).toBe(3); + expect(params[5]).toBe("ai"); + expect(params[6]).toBe("deepseek-v4-pro[1m]"); + expect(params[7]).toBe("hash-1"); + expect(JSON.parse(params[8] as string)).toHaveLength(2); + expect(params).toContain(7); + expect(params).toContain("device-1"); + }); + + it("returns null when the persisted source hash does not match current text", async () => { + mockSelect.mockResolvedValue([ + { + id: "book-1:3:AUTO:zh-CN", + book_id: "book-1", + section_index: 3, + source_lang: "AUTO", + target_lang: "zh-CN", + provider: "ai", + model: null, + source_hash: "old-hash", + paragraphs: "[]", + original_visible: 1, + translation_visible: 1, + created_at: 1000, + updated_at: 1000, + }, + ]); + + await expect( + getChapterTranslation("book-1", 3, "AUTO", "zh-CN", "new-hash"), + ).resolves.toBeNull(); + }); + + it("maps a matching chapter translation row", async () => { + mockSelect.mockResolvedValue([ + { + id: "book-1:3:AUTO:zh-CN", + book_id: "book-1", + section_index: 3, + source_lang: "AUTO", + target_lang: "zh-CN", + provider: "ai", + model: "deepseek-v4-pro[1m]", + source_hash: "hash-1", + paragraphs: JSON.stringify([ + { paragraphId: "p1", originalText: "Hello", translatedText: "Hello zh" }, + ]), + original_visible: 0, + translation_visible: 1, + created_at: 1000, + updated_at: 2000, + }, + ]); + + const result = await getChapterTranslation("book-1", 3, "AUTO", "zh-CN", "hash-1"); + + expect(result).toEqual( + expect.objectContaining({ + id: "book-1:3:AUTO:zh-CN", + bookId: "book-1", + sectionIndex: 3, + originalVisible: false, + translationVisible: true, + updatedAt: 2000, + }), + ); + expect(result?.paragraphs[0].translatedText).toBe("Hello zh"); + }); + + it("updates visibility with sync tracking", async () => { + mockExecute.mockResolvedValue(undefined); + + await updateChapterTranslationVisibility("book-1", 3, "AUTO", "zh-CN", { + originalVisible: false, + translationVisible: true, + }); + + expect(coreMocks.nextUpdatedAt).toHaveBeenCalledWith( + mockDb, + "chapter_translations", + "book-1:3:AUTO:zh-CN", + ); + const [sql, params] = mockExecute.mock.calls[mockExecute.mock.calls.length - 1]!; + expect(sql).toContain("UPDATE chapter_translations SET"); + expect(sql).toContain("original_visible = ?"); + expect(sql).toContain("translation_visible = ?"); + expect(params).toEqual([0, 1, 6000, 7, "device-1", "book-1:3:AUTO:zh-CN"]); + }); +}); diff --git a/packages/core/src/db/book-memory-queries.ts b/packages/core/src/db/book-memory-queries.ts new file mode 100644 index 00000000..038d361e --- /dev/null +++ b/packages/core/src/db/book-memory-queries.ts @@ -0,0 +1,135 @@ +import { + type BookMemory, + type BookMemoryExchange, + createEmptyBookMemory, + mergeBookMemoryExchange, +} from "../ai/book-memory"; +import { getDB, getDeviceId, nextSyncVersion, parseJSON } from "./db-core"; + +type BookMemoryRow = { + book_id: string; + summary: string; + focus: string | null; + open_questions: string | null; + recent_questions: string | null; + last_chapter_title: string | null; + last_chapter_index: number | null; + last_position_percent: number | null; + total_messages: number; + last_compacted_at: number; + compacted_message_count: number | null; + updated_at: number; +}; + +export async function getBookMemory(bookId: string): Promise { + const database = await getDB(); + await ensureBookMemoryTable(database); + const rows = await database.select( + "SELECT * FROM book_memories WHERE book_id = ?", + [bookId], + ); + if (rows.length === 0) return null; + return rowToBookMemory(rows[0]); +} + +export async function upsertBookMemory(memory: BookMemory): Promise { + const database = await getDB(); + await ensureBookMemoryTable(database); + const deviceId = await getDeviceId(); + const syncVersion = await nextSyncVersion(database, "book_memories"); + await database.execute( + `INSERT INTO book_memories ( + book_id, summary, focus, open_questions, recent_questions, + last_chapter_title, last_chapter_index, last_position_percent, + total_messages, last_compacted_at, compacted_message_count, + updated_at, sync_version, last_modified_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(book_id) DO UPDATE SET + summary = excluded.summary, + focus = excluded.focus, + open_questions = excluded.open_questions, + recent_questions = excluded.recent_questions, + last_chapter_title = excluded.last_chapter_title, + last_chapter_index = excluded.last_chapter_index, + last_position_percent = excluded.last_position_percent, + total_messages = excluded.total_messages, + last_compacted_at = excluded.last_compacted_at, + compacted_message_count = excluded.compacted_message_count, + updated_at = excluded.updated_at, + sync_version = excluded.sync_version, + last_modified_by = excluded.last_modified_by`, + [ + memory.bookId, + memory.summary, + JSON.stringify(memory.focus), + JSON.stringify(memory.openQuestions), + JSON.stringify(memory.recentQuestions), + memory.lastChapterTitle || null, + memory.lastChapterIndex ?? null, + memory.lastPositionPercent ?? null, + memory.totalMessages, + memory.lastCompactedAt, + memory.compactedMessageCount, + memory.updatedAt, + syncVersion, + deviceId, + ], + ); +} + +export async function updateBookMemoryAfterExchange( + bookId: string, + exchange: BookMemoryExchange, +): Promise { + const current = (await getBookMemory(bookId)) ?? createEmptyBookMemory(bookId); + const merged = mergeBookMemoryExchange(current, exchange); + const next = { ...merged, bookId }; + await upsertBookMemory(next); + return next; +} + +async function ensureBookMemoryTable(database: Awaited>): Promise { + await database.execute(` + CREATE TABLE IF NOT EXISTS book_memories ( + book_id TEXT PRIMARY KEY, + summary TEXT NOT NULL DEFAULT '', + focus TEXT NOT NULL DEFAULT '[]', + open_questions TEXT NOT NULL DEFAULT '[]', + recent_questions TEXT NOT NULL DEFAULT '[]', + last_chapter_title TEXT, + last_chapter_index INTEGER, + last_position_percent REAL, + total_messages INTEGER NOT NULL DEFAULT 0, + last_compacted_at INTEGER NOT NULL DEFAULT 0, + compacted_message_count INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0, + sync_version INTEGER DEFAULT 0, + last_modified_by TEXT, + FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE + ) + `); + try { + await database.execute( + "ALTER TABLE book_memories ADD COLUMN compacted_message_count INTEGER NOT NULL DEFAULT 0", + ); + } catch { + // Column already exists. + } +} + +function rowToBookMemory(row: BookMemoryRow): BookMemory { + return { + bookId: row.book_id, + summary: row.summary || "", + focus: parseJSON(row.focus, []), + openQuestions: parseJSON(row.open_questions, []), + recentQuestions: parseJSON(row.recent_questions, []), + lastChapterTitle: row.last_chapter_title || undefined, + lastChapterIndex: row.last_chapter_index ?? undefined, + lastPositionPercent: row.last_position_percent ?? undefined, + totalMessages: row.total_messages || 0, + lastCompactedAt: row.last_compacted_at || 0, + compactedMessageCount: row.compacted_message_count ?? 0, + updatedAt: row.updated_at || 0, + }; +} diff --git a/packages/core/src/db/chapter-translation-queries.ts b/packages/core/src/db/chapter-translation-queries.ts new file mode 100644 index 00000000..5380f8d8 --- /dev/null +++ b/packages/core/src/db/chapter-translation-queries.ts @@ -0,0 +1,292 @@ +import { getDB, getDeviceId, nextSyncVersion, nextUpdatedAt, parseJSON } from "./db-core"; + +export interface ChapterTranslationParagraphRecord { + paragraphId: string; + originalText: string; + translatedText: string; +} + +export interface ChapterTranslationRecord { + id: string; + bookId: string; + sectionIndex: number; + sourceLang: string; + targetLang: string; + provider: string; + model?: string; + sourceHash: string; + paragraphs: ChapterTranslationParagraphRecord[]; + originalVisible: boolean; + translationVisible: boolean; + createdAt: number; + updatedAt: number; +} + +export interface UpsertChapterTranslationInput { + bookId: string; + sectionIndex: number; + sourceLang: string; + targetLang: string; + provider: string; + model?: string; + sourceHash: string; + paragraphs: ChapterTranslationParagraphRecord[]; + originalVisible?: boolean; + translationVisible?: boolean; +} + +export interface ImportChapterTranslationInput extends ChapterTranslationRecord {} + +type ChapterTranslationRow = { + id: string; + book_id: string; + section_index: number; + source_lang: string; + target_lang: string; + provider: string | null; + model: string | null; + source_hash: string; + paragraphs: string; + original_visible: number; + translation_visible: number; + created_at: number; + updated_at: number; +}; + +export function buildChapterTranslationId( + bookId: string, + sectionIndex: number, + sourceLang: string, + targetLang: string, +): string { + return `${bookId}:${sectionIndex}:${sourceLang}:${targetLang}`; +} + +export function computeChapterSourceHash( + paragraphs: Array>, +): string { + let hash = 2166136261; + for (const paragraph of paragraphs) { + const text = `${paragraph.paragraphId}\u0000${paragraph.originalText}\u0001`; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + } + return (hash >>> 0).toString(36); +} + +export async function getChapterTranslation( + bookId: string, + sectionIndex: number, + sourceLang: string, + targetLang: string, + currentSourceHash?: string, +): Promise { + const database = await getDB(); + await ensureChapterTranslationsTable(database); + const id = buildChapterTranslationId(bookId, sectionIndex, sourceLang, targetLang); + const rows = await database.select( + "SELECT * FROM chapter_translations WHERE id = ?", + [id], + ); + if (rows.length === 0) return null; + const record = rowToChapterTranslation(rows[0]); + if (currentSourceHash && record.sourceHash !== currentSourceHash) return null; + return record; +} + +export async function upsertChapterTranslation( + input: UpsertChapterTranslationInput, +): Promise { + const database = await getDB(); + await ensureChapterTranslationsTable(database); + const now = Date.now(); + const id = buildChapterTranslationId( + input.bookId, + input.sectionIndex, + input.sourceLang, + input.targetLang, + ); + const syncVersion = await nextSyncVersion(database, "chapter_translations"); + const deviceId = await getDeviceId(); + const originalVisible = input.originalVisible ?? true; + const translationVisible = input.translationVisible ?? true; + + await database.execute( + `INSERT INTO chapter_translations ( + id, book_id, section_index, source_lang, target_lang, + provider, model, source_hash, paragraphs, + original_visible, translation_visible, created_at, updated_at, + sync_version, last_modified_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + provider = excluded.provider, + model = excluded.model, + source_hash = excluded.source_hash, + paragraphs = excluded.paragraphs, + original_visible = excluded.original_visible, + translation_visible = excluded.translation_visible, + updated_at = excluded.updated_at, + sync_version = excluded.sync_version, + last_modified_by = excluded.last_modified_by`, + [ + id, + input.bookId, + input.sectionIndex, + input.sourceLang, + input.targetLang, + input.provider, + input.model || null, + input.sourceHash, + JSON.stringify(input.paragraphs), + originalVisible ? 1 : 0, + translationVisible ? 1 : 0, + now, + now, + syncVersion, + deviceId, + ], + ); + + return { + id, + bookId: input.bookId, + sectionIndex: input.sectionIndex, + sourceLang: input.sourceLang, + targetLang: input.targetLang, + provider: input.provider, + model: input.model, + sourceHash: input.sourceHash, + paragraphs: input.paragraphs, + originalVisible, + translationVisible, + createdAt: now, + updatedAt: now, + }; +} + +export async function updateChapterTranslationVisibility( + bookId: string, + sectionIndex: number, + sourceLang: string, + targetLang: string, + visibility: { originalVisible: boolean; translationVisible: boolean }, +): Promise { + const database = await getDB(); + await ensureChapterTranslationsTable(database); + const id = buildChapterTranslationId(bookId, sectionIndex, sourceLang, targetLang); + const updatedAt = await nextUpdatedAt(database, "chapter_translations", id); + const syncVersion = await nextSyncVersion(database, "chapter_translations"); + const deviceId = await getDeviceId(); + await database.execute( + `UPDATE chapter_translations SET + original_visible = ?, + translation_visible = ?, + updated_at = ?, + sync_version = ?, + last_modified_by = ? + WHERE id = ?`, + [ + visibility.originalVisible ? 1 : 0, + visibility.translationVisible ? 1 : 0, + updatedAt, + syncVersion, + deviceId, + id, + ], + ); +} + +export async function importChapterTranslationRecord( + input: ImportChapterTranslationInput, +): Promise { + const database = await getDB(); + await ensureChapterTranslationsTable(database); + await database.execute( + `INSERT INTO chapter_translations ( + id, book_id, section_index, source_lang, target_lang, + provider, model, source_hash, paragraphs, + original_visible, translation_visible, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + provider = excluded.provider, + model = excluded.model, + source_hash = excluded.source_hash, + paragraphs = excluded.paragraphs, + original_visible = excluded.original_visible, + translation_visible = excluded.translation_visible, + created_at = excluded.created_at, + updated_at = excluded.updated_at`, + [ + input.id, + input.bookId, + input.sectionIndex, + input.sourceLang, + input.targetLang, + input.provider, + input.model || null, + input.sourceHash, + JSON.stringify(input.paragraphs), + input.originalVisible ? 1 : 0, + input.translationVisible ? 1 : 0, + input.createdAt, + input.updatedAt, + ], + ); +} + +export async function deleteChapterTranslationsForSection( + bookId: string, + sectionIndex: number, +): Promise { + const database = await getDB(); + await ensureChapterTranslationsTable(database); + await database.execute( + "DELETE FROM chapter_translations WHERE book_id = ? AND section_index = ?", + [bookId, sectionIndex], + ); +} + +async function ensureChapterTranslationsTable( + database: Awaited>, +): Promise { + await database.execute(` + CREATE TABLE IF NOT EXISTS chapter_translations ( + id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + section_index INTEGER NOT NULL, + source_lang TEXT NOT NULL, + target_lang TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT '', + model TEXT, + source_hash TEXT NOT NULL, + paragraphs TEXT NOT NULL DEFAULT '[]', + original_visible INTEGER NOT NULL DEFAULT 1, + translation_visible INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + sync_version INTEGER DEFAULT 0, + last_modified_by TEXT, + FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE + ) + `); +} + +function rowToChapterTranslation(row: ChapterTranslationRow): ChapterTranslationRecord { + return { + id: row.id, + bookId: row.book_id, + sectionIndex: row.section_index, + sourceLang: row.source_lang, + targetLang: row.target_lang, + provider: row.provider || "", + model: row.model || undefined, + sourceHash: row.source_hash, + paragraphs: parseJSON(row.paragraphs, []), + originalVisible: row.original_visible !== 0, + translationVisible: row.translation_visible !== 0, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} diff --git a/packages/core/src/db/database.ts b/packages/core/src/db/database.ts index 6fbeed91..2b352a58 100644 --- a/packages/core/src/db/database.ts +++ b/packages/core/src/db/database.ts @@ -90,6 +90,28 @@ export { insertMessage, } from "./message-queries"; +export { + getBookMemory, + upsertBookMemory, + updateBookMemoryAfterExchange, +} from "./book-memory-queries"; + +export { + buildChapterTranslationId, + computeChapterSourceHash, + deleteChapterTranslationsForSection, + getChapterTranslation, + importChapterTranslationRecord, + updateChapterTranslationVisibility, + upsertChapterTranslation, +} from "./chapter-translation-queries"; +export type { + ChapterTranslationParagraphRecord, + ChapterTranslationRecord, + ImportChapterTranslationInput, + UpsertChapterTranslationInput, +} from "./chapter-translation-queries"; + export { getAllReadingSessions, getReadingSessions, diff --git a/packages/core/src/db/db-core.ts b/packages/core/src/db/db-core.ts index 9d995883..4352f8cc 100644 --- a/packages/core/src/db/db-core.ts +++ b/packages/core/src/db/db-core.ts @@ -110,6 +110,8 @@ export async function cleanupOrphanedSyncRows(databaseArg?: IDatabase): Promise< "DELETE FROM notes WHERE book_id NOT IN (SELECT id FROM books)", "DELETE FROM bookmarks WHERE book_id NOT IN (SELECT id FROM books)", "DELETE FROM reading_sessions WHERE book_id NOT IN (SELECT id FROM books)", + "DELETE FROM book_memories WHERE book_id NOT IN (SELECT id FROM books)", + "DELETE FROM chapter_translations WHERE book_id NOT IN (SELECT id FROM books)", "DELETE FROM book_tags WHERE book_id NOT IN (SELECT id FROM books) OR tag_id NOT IN (SELECT id FROM tags)", "UPDATE books SET group_id = NULL WHERE group_id IS NOT NULL AND group_id NOT IN (SELECT id FROM book_groups)", "DELETE FROM messages WHERE thread_id NOT IN (SELECT id FROM threads)", @@ -461,6 +463,47 @@ export async function initDatabase(): Promise { ) `); + await database.execute(` + CREATE TABLE IF NOT EXISTS book_memories ( + book_id TEXT PRIMARY KEY, + summary TEXT NOT NULL DEFAULT '', + focus TEXT NOT NULL DEFAULT '[]', + open_questions TEXT NOT NULL DEFAULT '[]', + recent_questions TEXT NOT NULL DEFAULT '[]', + last_chapter_title TEXT, + last_chapter_index INTEGER, + last_position_percent REAL, + total_messages INTEGER NOT NULL DEFAULT 0, + last_compacted_at INTEGER NOT NULL DEFAULT 0, + compacted_message_count INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0, + sync_version INTEGER DEFAULT 0, + last_modified_by TEXT, + FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE + ) + `); + + await database.execute(` + CREATE TABLE IF NOT EXISTS chapter_translations ( + id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + section_index INTEGER NOT NULL, + source_lang TEXT NOT NULL, + target_lang TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT '', + model TEXT, + source_hash TEXT NOT NULL, + paragraphs TEXT NOT NULL DEFAULT '[]', + original_visible INTEGER NOT NULL DEFAULT 1, + translation_visible INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + sync_version INTEGER DEFAULT 0, + last_modified_by TEXT, + FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE + ) + `); + await database.execute(` CREATE TABLE IF NOT EXISTS skills ( id TEXT PRIMARY KEY, @@ -500,6 +543,12 @@ export async function initDatabase(): Promise { await database.execute( "CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id)", ); + await database.execute( + "CREATE INDEX IF NOT EXISTS idx_book_memories_updated ON book_memories(updated_at)", + ); + await database.execute( + "CREATE INDEX IF NOT EXISTS idx_chapter_translations_book ON chapter_translations(book_id)", + ); await database.execute( "CREATE INDEX IF NOT EXISTS idx_reading_sessions_book ON reading_sessions(book_id)", ); @@ -589,6 +638,8 @@ export async function initDatabase(): Promise { "reading_sessions", "threads", "messages", + "book_memories", + "chapter_translations", "skills", ]; for (const table of syncTables) { diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts index d4a11882..9c7678f7 100644 --- a/packages/core/src/db/index.ts +++ b/packages/core/src/db/index.ts @@ -61,6 +61,17 @@ export { // Message queries getMessages, insertMessage, + // Book memory queries + getBookMemory, + upsertBookMemory, + updateBookMemoryAfterExchange, + buildChapterTranslationId, + computeChapterSourceHash, + deleteChapterTranslationsForSection, + getChapterTranslation, + importChapterTranslationRecord, + updateChapterTranslationVisibility, + upsertChapterTranslation, // Reading session queries getAllReadingSessions, getReadingSessions, @@ -81,3 +92,9 @@ export { } from "./database"; export type { HighlightWithBook } from "./database"; +export type { + ChapterTranslationParagraphRecord, + ChapterTranslationRecord, + ImportChapterTranslationInput, + UpsertChapterTranslationInput, +} from "./database"; diff --git a/packages/core/src/db/migrations.ts b/packages/core/src/db/migrations.ts index 8a780a3c..d4e2bfdd 100644 --- a/packages/core/src/db/migrations.ts +++ b/packages/core/src/db/migrations.ts @@ -107,6 +107,31 @@ const migrations: Migration[] = [ "ALTER TABLE feedback ADD COLUMN comment_count INTEGER NOT NULL DEFAULT 0", ], }, + { + version: 12, + description: "Persist full chapter translations", + up: [ + `CREATE TABLE IF NOT EXISTS chapter_translations ( + id TEXT PRIMARY KEY, + book_id TEXT NOT NULL, + section_index INTEGER NOT NULL, + source_lang TEXT NOT NULL, + target_lang TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT '', + model TEXT, + source_hash TEXT NOT NULL, + paragraphs TEXT NOT NULL DEFAULT '[]', + original_visible INTEGER NOT NULL DEFAULT 1, + translation_visible INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + sync_version INTEGER DEFAULT 0, + last_modified_by TEXT, + FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE + )`, + "CREATE INDEX IF NOT EXISTS idx_chapter_translations_book ON chapter_translations(book_id)", + ], + }, ]; /** Run pending migrations */ diff --git a/packages/core/src/hooks/use-streaming-chat.ts b/packages/core/src/hooks/use-streaming-chat.ts index d457760d..7cfe7db5 100644 --- a/packages/core/src/hooks/use-streaming-chat.ts +++ b/packages/core/src/hooks/use-streaming-chat.ts @@ -1,8 +1,9 @@ import { useCallback, useRef, useState } from "react"; +import { getReadingContextSnapshot } from "../ai/reading-context-service"; import { getBuiltinSkills } from "../ai/skills/builtin-skills"; import { StreamingChat, createMessageId } from "../ai/streaming"; import { getAvailableTools } from "../ai/tools"; -import { getSkills as getDbSkills } from "../db/database"; +import { getSkills as getDbSkills, updateBookMemoryAfterExchange } from "../db/database"; import i18n from "../i18n"; import { useChatStore } from "../stores/chat-store"; import { useSettingsStore } from "../stores/settings-store"; @@ -146,10 +147,10 @@ export function useStreamingChat(options?: StreamingChatOptions) { let aiPrompt = content.trim(); if (quotes && quotes.length > 0) { - const quotesText = quotes.map((q) => `> ${q.text.slice(0, 300)}`).join("\n\n"); + const quotesText = quotes.map((q) => `> ${q.text}`).join("\n\n"); aiPrompt = content.trim() - ? `关于以下文本:\n${quotesText}\n\n${content.trim()}` - : `关于以下文本:\n${quotesText}\n\n请帮我分析这段文本。`; + ? `\u5173\u4e8e\u4ee5\u4e0b\u6587\u672c\uff1a\n${quotesText}\n\n${content.trim()}` + : `\u5173\u4e8e\u4ee5\u4e0b\u6587\u672c\uff1a\n${quotesText}\n\n\u8bf7\u5e2e\u6211\u5206\u6790\u8fd9\u6bb5\u6587\u672c\u3002`; } const userMessageId = createMessageId(); @@ -313,6 +314,12 @@ export function useStreamingChat(options?: StreamingChatOptions) { currentStep: "idle", }); setStreaming(false); + void persistBookMemory({ + bookId, + userInput: aiPrompt, + assistantText: textContent, + quotes, + }); }, onError: async (err) => { setError(err); @@ -601,3 +608,25 @@ export function useStreamingChat(options?: StreamingChatOptions) { stopStream, }; } + +async function persistBookMemory(options: { + bookId?: string; + userInput: string; + assistantText: string; + quotes?: AttachedQuote[]; +}): Promise { + if (!options.bookId) return; + try { + const readingContext = getReadingContextSnapshot(); + await updateBookMemoryAfterExchange(options.bookId, { + userInput: options.userInput, + assistantText: options.assistantText, + selectedQuotes: options.quotes, + chapterTitle: readingContext?.currentChapter.title, + chapterIndex: readingContext?.currentChapter.index, + positionPercent: readingContext?.currentPosition.percentage, + }); + } catch (err) { + console.warn("[book-memory] Failed to persist book memory:", err); + } +} diff --git a/packages/core/src/hooks/useChapterTranslation.ts b/packages/core/src/hooks/useChapterTranslation.ts index 21a35808..e8f8cff9 100644 --- a/packages/core/src/hooks/useChapterTranslation.ts +++ b/packages/core/src/hooks/useChapterTranslation.ts @@ -8,6 +8,12 @@ */ import { useCallback, useEffect, useRef, useState } from "react"; +import { + computeChapterSourceHash, + getChapterTranslation, + updateChapterTranslationVisibility, + upsertChapterTranslation, +} from "../db"; import { useSettingsStore } from "../stores/settings-store"; import { getFromCache } from "../translation/cache"; import { @@ -84,11 +90,13 @@ export function useChapterTranslation(options: UseChapterTranslationOptions) { const getCurrentCfiRef = useRef(getCurrentCfi); const goToCfiRef = useRef(goToCfi); const visibilityRef = useRef({ originalVisible: true, translationVisible: true }); + const activeTargetLangRef = useRef(translationConfigOverride?.targetLang); const translationConfigFromStore = useSettingsStore((s) => s.translationConfig); const aiConfigFromStore = useSettingsStore((s) => s.aiConfig); const translationConfig = translationConfigOverride || translationConfigFromStore; const aiConfig = aiConfigOverride || aiConfigFromStore; + activeTargetLangRef.current ??= translationConfig.targetLang; getParagraphsRef.current = getParagraphs; injectTranslationsRef.current = injectTranslations; @@ -135,6 +143,26 @@ export function useChapterTranslation(options: UseChapterTranslationOptions) { setState({ status: "error", message: "No text to translate" }); return; } + const sourceHash = computeSourceHashFromParagraphs(paragraphs); + activeTargetLangRef.current = config.targetLang; + + const persisted = await getChapterTranslation( + bookId, + sectionIndex, + "AUTO", + config.targetLang, + sourceHash, + ); + if (persisted) { + const visibility = { + originalVisible: persisted.originalVisible, + translationVisible: persisted.translationVisible, + }; + visibilityRef.current = visibility; + await injectTranslations(persisted.paragraphs, visibility); + setState({ status: "complete", ...visibility }); + return; + } const abortController = new AbortController(); abortRef.current = abortController; @@ -145,7 +173,7 @@ export function useChapterTranslation(options: UseChapterTranslationOptions) { progress: { totalChars, translatedChars: 0 }, }); - await translateChapter({ + const results = await translateChapter({ paragraphs, sourceLang: "AUTO", targetLang: config.targetLang, @@ -159,10 +187,25 @@ export function useChapterTranslation(options: UseChapterTranslationOptions) { signal: abortController.signal, }); - // Mark chapter fully cached - markChapterFullyCached(bookId, sectionIndex, config.targetLang).catch((err) => - console.warn("[Translation] Failed to mark chapter cached:", err), - ); + if (!abortController.signal.aborted) { + const orderedResults = orderResultsByParagraphs(paragraphs, results); + await upsertChapterTranslation({ + bookId, + sectionIndex, + sourceLang: "AUTO", + targetLang: config.targetLang, + provider: config.provider.id, + model: config.provider.model, + sourceHash, + paragraphs: orderedResults, + ...visibilityRef.current, + }); + + // Keep the old marker for compatibility with legacy paragraph cache. + markChapterFullyCached(bookId, sectionIndex, config.targetLang).catch((err) => + console.warn("[Translation] Failed to mark chapter cached:", err), + ); + } setState({ status: "complete", ...visibilityRef.current }); } catch (err) { @@ -213,9 +256,16 @@ export function useChapterTranslation(options: UseChapterTranslationOptions) { }; // Persist visibility preference updateChapterTranslationSettings(bookId, sectionIndex, visibilityRef.current).catch(() => {}); + updateChapterTranslationVisibility( + bookId, + sectionIndex, + "AUTO", + activeTargetLangRef.current || translationConfig.targetLang, + visibilityRef.current, + ).catch(() => {}); return { ...prev, ...visibilityRef.current }; }); - }, [applyVisibility, bookId, sectionIndex]); + }, [applyVisibility, bookId, sectionIndex, translationConfig.targetLang]); // ---- Toggle Translation Visibility ---------------------------------------- const toggleTranslationVisible = useCallback(() => { @@ -230,9 +280,16 @@ export function useChapterTranslation(options: UseChapterTranslationOptions) { }; // Persist visibility preference updateChapterTranslationSettings(bookId, sectionIndex, visibilityRef.current).catch(() => {}); + updateChapterTranslationVisibility( + bookId, + sectionIndex, + "AUTO", + activeTargetLangRef.current || translationConfig.targetLang, + visibilityRef.current, + ).catch(() => {}); return { ...prev, ...visibilityRef.current }; }); - }, [applyVisibility, bookId, sectionIndex]); + }, [applyVisibility, bookId, sectionIndex, translationConfig.targetLang]); // ---- Reset (e.g. on chapter change) --------------------------------------- const reset = useCallback(async () => { @@ -251,6 +308,38 @@ export function useChapterTranslation(options: UseChapterTranslationOptions) { let cancelled = false; async function restoreCachedTranslations() { try { + const paragraphs = await getParagraphsRef.current(); + if (cancelled || !paragraphs || paragraphs.length === 0) return; + const sourceHash = computeSourceHashFromParagraphs(paragraphs); + + const persisted = await getChapterTranslation( + bookId, + sectionIndex, + "AUTO", + translationConfig.targetLang, + sourceHash, + ); + if (persisted && !cancelled) { + const visibility = { + originalVisible: persisted.originalVisible, + translationVisible: persisted.translationVisible, + }; + visibilityRef.current = visibility; + activeTargetLangRef.current = persisted.targetLang; + + const cfiBeforeInject = getCurrentCfiRef.current?.(); + await injectTranslationsRef.current(persisted.paragraphs, visibility); + if (cancelled) return; + + if (cfiBeforeInject && goToCfiRef.current) { + await goToCfiRef.current(cfiBeforeInject); + } + if (cancelled) return; + + setState({ status: "complete", ...visibility }); + return; + } + const cached = await isChapterFullyCached( bookId, sectionIndex, @@ -266,8 +355,6 @@ export function useChapterTranslation(options: UseChapterTranslationOptions) { }; visibilityRef.current = visibility; - const paragraphs = await getParagraphsRef.current(); - if (cancelled) return; const providerId = translationConfig.provider.id; const results: ChapterTranslationResult[] = []; @@ -295,6 +382,19 @@ export function useChapterTranslation(options: UseChapterTranslationOptions) { await injectTranslationsRef.current(results, visibility); if (cancelled) return; + await upsertChapterTranslation({ + bookId, + sectionIndex, + sourceLang: "AUTO", + targetLang: translationConfig.targetLang, + provider: providerId, + model: translationConfig.provider.model, + sourceHash, + paragraphs: orderResultsByParagraphs(paragraphs, results), + ...visibility, + }); + if (cancelled) return; + // Restore position after translation content changes layout. if (cfiBeforeInject && goToCfiRef.current) { await goToCfiRef.current(cfiBeforeInject); @@ -323,6 +423,7 @@ export function useChapterTranslation(options: UseChapterTranslationOptions) { sectionIndex, translationConfig.targetLang, translationConfig.provider.id, + translationConfig.provider.model, ]); return { @@ -334,3 +435,22 @@ export function useChapterTranslation(options: UseChapterTranslationOptions) { reset, }; } + +function computeSourceHashFromParagraphs(paragraphs: ChapterParagraph[]): string { + return computeChapterSourceHash( + paragraphs.map((paragraph) => ({ + paragraphId: paragraph.id, + originalText: paragraph.text, + })), + ); +} + +function orderResultsByParagraphs( + paragraphs: ChapterParagraph[], + results: ChapterTranslationResult[], +): ChapterTranslationResult[] { + const byId = new Map(results.map((result) => [result.paragraphId, result])); + return paragraphs + .map((paragraph) => byId.get(paragraph.id)) + .filter((result): result is ChapterTranslationResult => !!result); +} diff --git a/packages/core/src/i18n/locales/en/chat.json b/packages/core/src/i18n/locales/en/chat.json index cbf05dfa..126d8a76 100644 --- a/packages/core/src/i18n/locales/en/chat.json +++ b/packages/core/src/i18n/locales/en/chat.json @@ -92,7 +92,12 @@ "searchAllNotes": "Searching all notes", "getReadingStats": "Getting reading stats", "getSkills": "Querying skills", - "mindmap": "Generating mindmap" + "mindmap": "Generating mindmap", + "readBookMemory": "Reading book memory", + "searchBook": "Searching book content", + "listData": "Reading library data", + "WebSearch": "Searching the web", + "WebFetch": "Fetching web page" }, "mindmap": { "title": "Mindmap", diff --git a/packages/core/src/i18n/locales/zh/chat.json b/packages/core/src/i18n/locales/zh/chat.json index 461f6539..21cec200 100644 --- a/packages/core/src/i18n/locales/zh/chat.json +++ b/packages/core/src/i18n/locales/zh/chat.json @@ -92,7 +92,12 @@ "searchAllNotes": "搜索所有笔记", "getReadingStats": "获取阅读统计", "getSkills": "查询技能", - "mindmap": "生成思维导图" + "mindmap": "生成思维导图", + "readBookMemory": "读取本书记忆", + "searchBook": "搜索书籍全文", + "listData": "读取书库数据", + "WebSearch": "搜索网页", + "WebFetch": "读取网页" }, "mindmap": { "title": "思维导图", diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index b5fed7ca..1d52fd84 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -2,6 +2,9 @@ export type { IPlatformService, IDatabase, IWebSocket, + ClaudeCodeChatHandlers, + ClaudeCodeChatRequest, + ExtractedBookChapter, FetchOptions, FilePickerOptions, WebSocketOptions, diff --git a/packages/core/src/services/platform.ts b/packages/core/src/services/platform.ts index 87e0d833..9130d9ed 100644 --- a/packages/core/src/services/platform.ts +++ b/packages/core/src/services/platform.ts @@ -50,6 +50,32 @@ export interface IWebSocket { onError(handler: (error: unknown) => void): void; } +export interface ClaudeCodeChatRequest { + requestId: string; + prompt: string; + systemPrompt: string; + effort?: "low" | "medium" | "high" | "xhigh" | "max"; + model?: string; + tools?: string[]; + disallowedTools?: string[]; +} + +export interface ClaudeCodeChatHandlers { + signal?: AbortSignal; + onStdoutLine: (line: string) => void; + onStderr?: (content: string) => void; +} + +export interface ExtractedBookChapter { + index: number; + title: string; + content: string; + segments?: Array<{ + text: string; + cfi: string; + }>; +} + export interface IPlatformService { // ---- Platform info ---- readonly platformType: "desktop" | "mobile" | "web"; @@ -87,6 +113,15 @@ export interface IPlatformService { fetch(url: string, options?: FetchOptions): Promise; createWebSocket(url: string, options?: WebSocketOptions): Promise; + // ---- Claude Code (desktop only) ---- + runClaudeCodeChat?( + request: ClaudeCodeChatRequest, + handlers: ClaudeCodeChatHandlers, + ): Promise; + abortClaudeCodeChat?(requestId: string): Promise; + checkClaudeCode?(): Promise<{ available: boolean; version?: string; error?: string }>; + extractBookChapter?(filePath: string, chapterIndex: number): Promise; + // ---- App info ---- getAppVersion(): Promise; diff --git a/packages/core/src/sync/__tests__/simple-sync.integration.test.ts b/packages/core/src/sync/__tests__/simple-sync.integration.test.ts index baa98d06..9cb2cef0 100644 --- a/packages/core/src/sync/__tests__/simple-sync.integration.test.ts +++ b/packages/core/src/sync/__tests__/simple-sync.integration.test.ts @@ -76,6 +76,21 @@ const TABLE_COLUMNS: Record = { bookmarks: ["id", "book_id", "cfi", "label", "chapter_title", "created_at", "updated_at"], threads: ["id", "book_id", "title", "created_at", "updated_at"], messages: ["id", "thread_id", "role", "content", "created_at"], + chapter_translations: [ + "id", + "book_id", + "section_index", + "source_lang", + "target_lang", + "provider", + "model", + "source_hash", + "paragraphs", + "original_visible", + "translation_visible", + "created_at", + "updated_at", + ], skills: ["id", "name", "description", "created_at", "updated_at"], tags: ["id", "name", "updated_at"], book_tags: ["id", "book_id", "tag_id", "updated_at"], @@ -248,7 +263,14 @@ class FakeSyncDb { private assertForeignKeys(table: string, row: Row): void { if ( - ["highlights", "notes", "bookmarks", "book_tags", "reading_sessions"].includes(table) && + [ + "highlights", + "notes", + "bookmarks", + "book_tags", + "reading_sessions", + "chapter_translations", + ].includes(table) && row.book_id && !this.tables.get("books")?.has(String(row.book_id)) ) { @@ -390,6 +412,27 @@ async function syncDevice( return runSimpleSync(backend); } +function chapterTranslationRow(overrides: Row = {}): Row { + return { + id: "book-1:0:AUTO:zh-CN", + book_id: "book-1", + section_index: 0, + source_lang: "AUTO", + target_lang: "zh-CN", + provider: "ai", + model: "deepseek-v4-pro[1m]", + source_hash: "hash-1", + paragraphs: JSON.stringify([ + { paragraphId: "p1", originalText: "Hello", translatedText: "你好" }, + ]), + original_visible: 1, + translation_visible: 1, + created_at: 1000, + updated_at: 1000, + ...overrides, + }; +} + describe("simple sync convergence", () => { let now = 1000; @@ -563,4 +606,29 @@ describe("simple sync convergence", () => { ).tables, ).toHaveProperty("books"); }); + + it("syncs chapter translations through the legacy device payload for existing clients", async () => { + const backend = new MemoryBackend(); + const deviceA = new FakeSyncDb(); + const deviceB = new FakeSyncDb(); + + deviceA.insert("books", bookRow()); + deviceA.insert("chapter_translations", chapterTranslationRow()); + + now = 1100; + await syncDevice("device-a", deviceA, backend); + + const uploaded = backend.jsonFiles.get("/readany/sync/device-device-a.json") as { + tables: Record; + }; + expect(uploaded.tables.chapter_translations.records[0]).toHaveProperty("paragraphs"); + + now = 1200; + await syncDevice("device-b", deviceB, backend); + + expect(deviceB.get("chapter_translations", "book-1:0:AUTO:zh-CN")).toEqual( + chapterTranslationRow(), + ); + }); + }); diff --git a/packages/core/src/sync/__tests__/sync-files.test.ts b/packages/core/src/sync/__tests__/sync-files.test.ts index e953bab6..371dfd2d 100644 --- a/packages/core/src/sync/__tests__/sync-files.test.ts +++ b/packages/core/src/sync/__tests__/sync-files.test.ts @@ -17,9 +17,12 @@ vi.mock("../sync-adapter", () => ({ })); const mockSelect = vi.fn(); +const mockExecute = vi.fn(); const mockSetBookSyncStatus = vi.fn(); +const mockImportChapterTranslationRecord = vi.fn(); vi.mock("../../db/database", () => ({ - getDB: vi.fn(async () => ({ select: mockSelect })), + getDB: vi.fn(async () => ({ select: mockSelect, execute: mockExecute })), + importChapterTranslationRecord: mockImportChapterTranslationRecord, setBookSyncStatus: mockSetBookSyncStatus, })); @@ -47,6 +50,8 @@ describe("sync-files", () => { beforeEach(() => { vi.clearAllMocks(); mockSelect.mockResolvedValue([]); + mockExecute.mockResolvedValue(undefined); + mockImportChapterTranslationRecord.mockResolvedValue(undefined); mockAdapter.listFiles.mockResolvedValue([]); }); @@ -248,6 +253,148 @@ describe("sync-files", () => { ); }); + it("uploads persisted chapter translations as visible JSON files", async () => { + mockSelect.mockImplementation(async (sql: string) => { + if (sql.includes("FROM books WHERE deleted_at IS NULL")) { + return [ + { + id: "book-1", + file_path: null, + file_hash: "h1", + cover_url: null, + title: "Test Book", + }, + ]; + } + if (sql.includes("FROM chapter_translations")) { + return [ + { + id: "book-1:0:AUTO:zh-CN", + book_id: "book-1", + book_title: "Test Book", + section_index: 0, + source_lang: "AUTO", + target_lang: "zh-CN", + provider: "ai", + model: "deepseek-v4-pro[1m]", + source_hash: "hash-1", + paragraphs: JSON.stringify([ + { paragraphId: "p1", originalText: "Hello", translatedText: "Hello zh" }, + ]), + original_visible: 1, + translation_visible: 1, + created_at: 1000, + updated_at: 1000, + }, + ]; + } + return []; + }); + + const backend = createMockBackend({ + listDir: vi.fn().mockResolvedValue([]), + }); + + const result = await syncFiles(backend); + + expect(result.filesUploaded).toBe(1); + expect(backend.put).toHaveBeenCalledWith( + `${REMOTE_BOOKS_ROOT}/Test Book-book-1/translations/00000-AUTO-zh-CN-hash-1.json`, + expect.any(Uint8Array), + ); + }); + + it("downloads remote chapter translation JSON into the local database", async () => { + mockSelect.mockImplementation(async (sql: string) => { + if (sql.includes("FROM books WHERE deleted_at IS NULL")) { + return [ + { + id: "book-1", + file_path: null, + file_hash: "h1", + cover_url: null, + title: "Test Book", + }, + ]; + } + if (sql.includes("FROM chapter_translations")) { + return [ + { + id: "book-1:0:AUTO:zh-CN", + book_id: "book-1", + book_title: "Test Book", + section_index: 0, + source_lang: "AUTO", + target_lang: "zh-CN", + provider: "ai", + model: "deepseek-v4-pro[1m]", + source_hash: "hash-1", + paragraphs: "[]", + original_visible: 1, + translation_visible: 1, + created_at: 1000, + updated_at: 1000, + }, + ]; + } + return []; + }); + + const remotePath = `${REMOTE_BOOKS_ROOT}/Test Book-book-1/translations/00000-AUTO-zh-CN-hash-1.json`; + const backend = createMockBackend({ + listDir: vi.fn().mockImplementation(async (path: string) => { + if (path === `${REMOTE_BOOKS_ROOT}/Test Book-book-1/translations`) { + return [ + { + name: "00000-AUTO-zh-CN-hash-1.json", + path: remotePath, + size: 100, + lastModified: 2000, + isDirectory: false, + }, + ]; + } + return []; + }), + get: vi.fn().mockResolvedValue( + new TextEncoder().encode( + JSON.stringify({ + schemaVersion: 1, + id: "book-1:0:AUTO:zh-CN", + bookId: "book-1", + sectionIndex: 0, + sourceLang: "AUTO", + targetLang: "zh-CN", + provider: "ai", + model: "deepseek-v4-pro[1m]", + sourceHash: "hash-1", + paragraphs: [ + { paragraphId: "p1", originalText: "Hello", translatedText: "Hello zh" }, + ], + originalVisible: true, + translationVisible: true, + createdAt: 1000, + updatedAt: 1000, + }), + ), + ), + }); + + const result = await syncFiles(backend); + + expect(result.filesDownloaded).toBe(1); + expect(backend.get).toHaveBeenCalledWith(remotePath); + expect(mockImportChapterTranslationRecord).toHaveBeenCalledWith( + expect.objectContaining({ + id: "book-1:0:AUTO:zh-CN", + bookId: "book-1", + paragraphs: [ + { paragraphId: "p1", originalText: "Hello", translatedText: "Hello zh" }, + ], + }), + ); + }); + it("renames the book folder when the title has changed", async () => { mockSelect.mockResolvedValue([ { diff --git a/packages/core/src/sync/chapter-translation-files.ts b/packages/core/src/sync/chapter-translation-files.ts new file mode 100644 index 00000000..055cf01d --- /dev/null +++ b/packages/core/src/sync/chapter-translation-files.ts @@ -0,0 +1,224 @@ +import { + type ChapterTranslationParagraphRecord, + type ChapterTranslationRecord, + importChapterTranslationRecord, +} from "../db/database"; +import type { ISyncBackend, RemoteFile } from "./sync-backend"; +import { buildBookRemoteTranslationsDir, buildChapterTranslationRemoteFile } from "./sync-naming"; +import { parallelLimit } from "./sync-transfer"; + +const TRANSLATION_FILE_SCHEMA_VERSION = 1; +const TRANSLATION_SYNC_CONCURRENCY = 3; + +type ChapterTranslationFileRow = { + id: string; + book_id: string; + book_title: string | null; + section_index: number; + source_lang: string; + target_lang: string; + provider: string | null; + model: string | null; + source_hash: string; + paragraphs: string | null; + original_visible: number; + translation_visible: number; + created_at: number; + updated_at: number; +}; + +type ChapterTranslationFilePayload = { + schemaVersion: number; + id: string; + bookId: string; + sectionIndex: number; + sourceLang: string; + targetLang: string; + provider: string; + model?: string; + sourceHash: string; + paragraphs: ChapterTranslationParagraphRecord[]; + originalVisible: boolean; + translationVisible: boolean; + createdAt: number; + updatedAt: number; +}; + +export type ChapterTranslationFileSyncResult = { + uploaded: number; + downloaded: number; + uploadFailed: number; + downloadFailed: number; +}; + +function parseParagraphs(value: string | null): ChapterTranslationParagraphRecord[] { + if (!value) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function rowToPayload(row: ChapterTranslationFileRow): ChapterTranslationFilePayload { + return { + schemaVersion: TRANSLATION_FILE_SCHEMA_VERSION, + id: row.id, + bookId: row.book_id, + sectionIndex: row.section_index, + sourceLang: row.source_lang, + targetLang: row.target_lang, + provider: row.provider || "", + model: row.model || undefined, + sourceHash: row.source_hash, + paragraphs: parseParagraphs(row.paragraphs), + originalVisible: row.original_visible !== 0, + translationVisible: row.translation_visible !== 0, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function payloadToRecord(payload: ChapterTranslationFilePayload): ChapterTranslationRecord { + return { + id: payload.id, + bookId: payload.bookId, + sectionIndex: payload.sectionIndex, + sourceLang: payload.sourceLang, + targetLang: payload.targetLang, + provider: payload.provider, + model: payload.model, + sourceHash: payload.sourceHash, + paragraphs: payload.paragraphs, + originalVisible: payload.originalVisible, + translationVisible: payload.translationVisible, + createdAt: payload.createdAt, + updatedAt: payload.updatedAt, + }; +} + +function encodePayload(payload: ChapterTranslationFilePayload): Uint8Array { + return new TextEncoder().encode(`${JSON.stringify(payload)}\n`); +} + +function decodePayload(data: Uint8Array): ChapterTranslationFilePayload { + const raw = new TextDecoder().decode(data); + const parsed = JSON.parse(raw) as ChapterTranslationFilePayload; + if (parsed.schemaVersion !== TRANSLATION_FILE_SCHEMA_VERSION) { + throw new Error(`Unsupported chapter translation file schema: ${parsed.schemaVersion}`); + } + if (!Array.isArray(parsed.paragraphs)) { + throw new Error("Invalid chapter translation file: paragraphs must be an array"); + } + return parsed; +} + +function buildRemotePath(row: ChapterTranslationFileRow): string { + return buildChapterTranslationRemoteFile( + { id: row.book_id, title: row.book_title }, + { + sectionIndex: row.section_index, + sourceLang: row.source_lang, + targetLang: row.target_lang, + sourceHash: row.source_hash, + }, + ); +} + +function fileNameFromPath(path: string): string { + return path.substring(path.lastIndexOf("/") + 1); +} + +async function listRemoteTranslations( + backend: ISyncBackend, + row: ChapterTranslationFileRow, +): Promise> { + try { + const dir = buildBookRemoteTranslationsDir({ id: row.book_id, title: row.book_title }); + const files = await backend.listDir(dir); + return new Map(files.filter((file) => !file.isDirectory).map((file) => [file.name, file])); + } catch { + return new Map(); + } +} + +function isMeaningfulTranslationRow(row: ChapterTranslationFileRow): boolean { + return ( + typeof row.id === "string" && + typeof row.book_id === "string" && + typeof row.section_index === "number" && + typeof row.source_lang === "string" && + typeof row.target_lang === "string" && + typeof row.source_hash === "string" + ); +} + +export async function syncChapterTranslationFiles( + backend: ISyncBackend, + rows: ChapterTranslationFileRow[], + options: { + forceUploadAll?: boolean; + forceDownloadAll?: boolean; + disableUploads?: boolean; + } = {}, +): Promise { + const usableRows = rows.filter(isMeaningfulTranslationRow); + if (usableRows.length === 0) { + return { uploaded: 0, downloaded: 0, uploadFailed: 0, downloadFailed: 0 }; + } + + const remoteByBook = new Map>(); + for (const row of usableRows) { + if (!remoteByBook.has(row.book_id)) { + remoteByBook.set(row.book_id, await listRemoteTranslations(backend, row)); + } + } + + const uploadTasks: Array<() => Promise> = []; + const downloadTasks: Array<() => Promise> = []; + + for (const row of usableRows) { + const payload = rowToPayload(row); + const remotePath = buildRemotePath(row); + const remote = remoteByBook.get(row.book_id)?.get(fileNameFromPath(remotePath)); + const hasLocalParagraphs = payload.paragraphs.length > 0; + + if (!options.disableUploads && hasLocalParagraphs && (options.forceUploadAll || !remote)) { + uploadTasks.push(async () => { + try { + await backend.put(remotePath, encodePayload(payload)); + return true; + } catch (error) { + console.warn(`[Sync] Failed to upload chapter translation ${row.id}:`, error); + return false; + } + }); + continue; + } + + if (remote && (!hasLocalParagraphs || options.forceDownloadAll)) { + downloadTasks.push(async () => { + try { + const data = await backend.get(remotePath); + const remotePayload = decodePayload(data); + await importChapterTranslationRecord(payloadToRecord(remotePayload)); + return true; + } catch (error) { + console.warn(`[Sync] Failed to download chapter translation ${row.id}:`, error); + return false; + } + }); + } + } + + const uploadResults = await parallelLimit(uploadTasks, TRANSLATION_SYNC_CONCURRENCY); + const downloadResults = await parallelLimit(downloadTasks, TRANSLATION_SYNC_CONCURRENCY); + + return { + uploaded: uploadResults.filter(Boolean).length, + downloaded: downloadResults.filter(Boolean).length, + uploadFailed: uploadResults.filter((result) => !result).length, + downloadFailed: downloadResults.filter((result) => !result).length, + }; +} diff --git a/packages/core/src/sync/simple-sync.ts b/packages/core/src/sync/simple-sync.ts index 2155c49a..2f9d01ce 100644 --- a/packages/core/src/sync/simple-sync.ts +++ b/packages/core/src/sync/simple-sync.ts @@ -54,6 +54,8 @@ const SYNC_TABLES: SyncTableConfig[] = [ { name: "bookmarks", pk: "id", timestampCol: "updated_at" }, { name: "threads", pk: "id", timestampCol: "updated_at" }, { name: "messages", pk: "id", timestampCol: "created_at" }, + { name: "book_memories", pk: "book_id", timestampCol: "updated_at" }, + { name: "chapter_translations", pk: "id", timestampCol: "updated_at" }, { name: "skills", pk: "id", timestampCol: "updated_at" }, { name: "tags", pk: "id", timestampCol: "updated_at" }, { name: "book_tags", pk: "id", timestampCol: "updated_at" }, diff --git a/packages/core/src/sync/sync-files.ts b/packages/core/src/sync/sync-files.ts index ea38ff17..355a5498 100644 --- a/packages/core/src/sync/sync-files.ts +++ b/packages/core/src/sync/sync-files.ts @@ -21,6 +21,7 @@ import { parseBookFolderName, sanitizeBookTitleForFs, } from "./sync-naming"; +import { syncChapterTranslationFiles } from "./chapter-translation-files"; import { parallelLimit } from "./sync-transfer"; import { REMOTE_BOOKS_ROOT, @@ -75,6 +76,23 @@ type BookRow = { title: string; }; +type ChapterTranslationFileRow = { + id: string; + book_id: string; + book_title: string | null; + section_index: number; + source_lang: string; + target_lang: string; + provider: string | null; + model: string | null; + source_hash: string; + paragraphs: string | null; + original_visible: number; + translation_visible: number; + created_at: number; + updated_at: number; +}; + type BookInfo = { book: BookRow; fileExt: string; @@ -316,6 +334,19 @@ export async function syncFiles( ); } + const translationRows = await loadChapterTranslationRows(db); + if (translationRows.length > 0) { + const translationResult = await syncChapterTranslationFiles(backend, translationRows, { + forceUploadAll, + forceDownloadAll, + disableUploads, + }); + filesUploaded += translationResult.uploaded; + filesDownloaded += translationResult.downloaded; + filesUploadFailed += translationResult.uploadFailed; + filesDownloadFailed += translationResult.downloadFailed; + } + // --- Phase 3: orphan cleanup --- if (!disableRemoteDeletes) { await cleanupRemoteOrphans(backend, listings, currentBookIds); @@ -328,6 +359,38 @@ export async function syncFiles( /* ───────────────────────── helpers ───────────────────────── */ +async function loadChapterTranslationRows( + db: Awaited>, +): Promise { + try { + const rows = await db.select( + `SELECT + ct.id, + ct.book_id, + b.title AS book_title, + ct.section_index, + ct.source_lang, + ct.target_lang, + ct.provider, + ct.model, + ct.source_hash, + ct.paragraphs, + ct.original_visible, + ct.translation_visible, + ct.created_at, + ct.updated_at + FROM chapter_translations ct + JOIN books b ON b.id = ct.book_id + WHERE b.deleted_at IS NULL`, + [], + ); + return rows.filter((row) => typeof row.paragraphs === "string" || row.paragraphs === null); + } catch (error) { + console.warn("[Sync] Failed to load chapter translations for file sync:", error); + return []; + } +} + async function loadRemoteListings( backend: ISyncBackend, currentBookIds: Set, diff --git a/packages/core/src/sync/sync-naming.ts b/packages/core/src/sync/sync-naming.ts index 4dadf904..08cbb6d0 100644 --- a/packages/core/src/sync/sync-naming.ts +++ b/packages/core/src/sync/sync-naming.ts @@ -9,25 +9,29 @@ * Local storage stays UUID-flat (`books/{id}.{ext}`, `covers/{id}.{ext}`). */ -import { - COVER_EXTENSIONS, - REMOTE_BOOKS_ROOT, -} from "./sync-types"; +import { COVER_EXTENSIONS, REMOTE_BOOKS_ROOT } from "./sync-types"; const FALLBACK_TITLE = "未命名"; const MAX_TITLE_LEN = 64; // UUID v4 form: 8-4-4-4-12 hex chars (36 chars total). const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +function stripAsciiControlCharacters(value: string): string { + return Array.from(value) + .filter((char) => { + const code = char.charCodeAt(0); + return code >= 32 && code !== 127; + }) + .join(""); +} + /** * Strip filesystem / WebDAV-unsafe characters, collapse whitespace, * cap length, and fall back to a placeholder for empty input. */ export function sanitizeBookTitleForFs(title: string | null | undefined): string { if (!title) return FALLBACK_TITLE; - const cleaned = title - .replace(/[\/\\:*?"<>|]/g, "_") - .replace(/[\x00-\x1F\x7F]/g, "") + const cleaned = stripAsciiControlCharacters(title.replace(/[\/\\:*?"<>|]/g, "_")) .replace(/\s+/g, " ") .trim(); if (!cleaned) return FALLBACK_TITLE; @@ -45,15 +49,53 @@ export function buildBookFolderName(book: { id: string; title?: string | null }) } /** File path inside the book dir, e.g. {title}.epub. */ -export function buildBookRemoteFile(book: { id: string; title?: string | null }, ext: string): string { +export function buildBookRemoteFile( + book: { id: string; title?: string | null }, + ext: string, +): string { return `${buildBookRemoteDir(book)}/${sanitizeBookTitleForFs(book.title)}.${ext}`; } /** Cover path inside the book dir, e.g. {title}.jpg. */ -export function buildBookRemoteCover(book: { id: string; title?: string | null }, ext: string): string { +export function buildBookRemoteCover( + book: { id: string; title?: string | null }, + ext: string, +): string { return `${buildBookRemoteDir(book)}/${sanitizeBookTitleForFs(book.title)}.${ext}`; } +function sanitizeTranslationPathSegment(value: string | number): string { + const cleaned = stripAsciiControlCharacters(String(value).replace(/[\/\\:*?"<>|]/g, "_")) + .replace(/\s+/g, "_") + .trim(); + return cleaned || "unknown"; +} + +export function buildBookRemoteTranslationsDir(book: { + id: string; + title?: string | null; +}): string { + return `${buildBookRemoteDir(book)}/translations`; +} + +export function buildChapterTranslationRemoteFile( + book: { id: string; title?: string | null }, + translation: { + sectionIndex: number; + sourceLang: string; + targetLang: string; + sourceHash?: string; + }, +): string { + const section = String(translation.sectionIndex).padStart(5, "0"); + const sourceLang = sanitizeTranslationPathSegment(translation.sourceLang); + const targetLang = sanitizeTranslationPathSegment(translation.targetLang); + const hash = translation.sourceHash + ? `-${sanitizeTranslationPathSegment(translation.sourceHash)}` + : ""; + return `${buildBookRemoteTranslationsDir(book)}/${section}-${sourceLang}-${targetLang}${hash}.json`; +} + /** * Extract book.id from a folder name `{title}-{uuid}`. * Returns null when the trailing 36 chars do not match a UUID-v4-shaped string. diff --git a/packages/core/src/translation/__tests__/cache.test.ts b/packages/core/src/translation/__tests__/cache.test.ts new file mode 100644 index 00000000..433d6ad6 --- /dev/null +++ b/packages/core/src/translation/__tests__/cache.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const platformMocks = vi.hoisted(() => ({ + kv: new Map(), +})); + +vi.mock("../../services/platform", () => ({ + getPlatformService: vi.fn(() => ({ + kvGetItem: async (key: string) => platformMocks.kv.get(key) ?? null, + kvSetItem: async (key: string, value: string) => { + platformMocks.kv.set(key, value); + }, + kvRemoveItem: async (key: string) => { + platformMocks.kv.delete(key); + }, + kvGetAllKeys: async () => [...platformMocks.kv.keys()], + })), +})); + +const { clearTranslationCache, getFromCache, storeInCache } = await import("../cache"); + +describe("translation cache", () => { + beforeEach(() => { + platformMocks.kv = new Map(); + vi.restoreAllMocks(); + }); + + it("keeps cached translations after the old 7-day expiry window", async () => { + vi.spyOn(Date, "now").mockReturnValue(10 * 24 * 60 * 60 * 1000); + + await storeInCache("hello", "你好", "en", "zh", "deepl"); + + vi.spyOn(Date, "now").mockReturnValue(30 * 24 * 60 * 60 * 1000); + + await expect(getFromCache("hello", "en", "zh", "deepl")).resolves.toBe("你好"); + }); + + it("clears translation cache entries", async () => { + vi.spyOn(Date, "now").mockReturnValue(1234); + await storeInCache("hello", "你好", "en", "zh", "deepl"); + + const translationKey = [...platformMocks.kv.keys()].find((key) => + key.startsWith("readany_translation_cache_"), + ); + expect(translationKey).toBeTruthy(); + + await clearTranslationCache(); + + expect(platformMocks.kv.has(translationKey as string)).toBe(false); + }); +}); diff --git a/packages/core/src/translation/cache.ts b/packages/core/src/translation/cache.ts index 51d57831..232de2e9 100644 --- a/packages/core/src/translation/cache.ts +++ b/packages/core/src/translation/cache.ts @@ -45,11 +45,9 @@ export async function getFromCache( const cached = await platform.kvGetItem(key); if (cached) { const { translation, timestamp } = JSON.parse(cached); - // Cache expires after 7 days - if (Date.now() - timestamp < 7 * 24 * 60 * 60 * 1000) { + if (typeof timestamp === "number") { return translation; } - await platform.kvRemoveItem(key); } } catch (err) { console.warn("[Translation] Cache read error:", err); diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index aad33225..9006cb31 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -1,5 +1,13 @@ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const cssDataToString = async (data) => { + if (typeof data === "string") return data; + if (data instanceof Blob) return data.text(); + if (data instanceof ArrayBuffer) return new TextDecoder().decode(data); + if (ArrayBuffer.isView(data)) return new TextDecoder().decode(data); + return String(data ?? ""); +}; + const debounce = (f, wait, immediate) => { let timeout; return (...args) => { @@ -660,8 +668,8 @@ export class Paginator extends HTMLElement { if (detail.type !== "text/css") return; const w = innerWidth; const h = innerHeight; - detail.data = Promise.resolve(detail.data).then((data) => - data + detail.data = Promise.resolve(detail.data).then(async (data) => + (await cssDataToString(data)) // unprefix as most of the props are (only) supported unprefixed .replace(/(?<=[{\s;])-epub-/gi, "") // replace vw and vh as they cause problems with layout