diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index a2f1f282..0d14aac0 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -523,7 +523,6 @@ App-level settings persisted at `~/.config/com.laputa.app/settings.json`: ```typescript interface Settings { - anthropic_key: string | null openai_key: string | null google_key: string | null github_token: string | null diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 92abba28..bbf86c01 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -21,7 +21,7 @@ When deciding where to persist a piece of data, ask: **"Would the user want this | Follows the vault | Stays with the installation | |-------------------|-----------------------------| | Type icon, type color | Editor zoom level | -| Pinned properties per type | API keys (Anthropic, OpenAI) | +| Pinned properties per type | API keys (OpenAI, Google) | | Sidebar label overrides | GitHub token | | Property display order | Window size / position | | Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting | @@ -31,7 +31,6 @@ When deciding where to persist a piece of data, ask: **"Would the user want this Examples: - ✅ Vault: `_pinned_properties` in a Type note (every device should show the same pinned properties) - ✅ Vault: `_icon: shapes` in a Type note (icon is part of the type's identity) -- ✅ App settings: `anthropic_key` (credential, not vault data) - ✅ App settings: `zoom: 1.3` (machine-specific preference) ### No hardcoded exceptions @@ -97,7 +96,6 @@ flowchart LR | Build | Vite | 7.3.1 | | Backend language | Rust (edition 2021) | 1.77.2 | | Frontmatter parsing | gray_matter | 0.2 | -| AI (in-app chat) | Anthropic Claude API (Haiku 3.5 default) | - | | AI (agent panel) | Claude CLI subprocess (streaming NDJSON) | - | | Search | Keyword (walkdir-based file scan) | - | | MCP | @modelcontextprotocol/sdk | 1.0 | @@ -116,14 +114,13 @@ flowchart TD NL["NoteList / PulseView\n(filtered list / activity)"] ED["Editor\n(BlockNote + diff + raw)"] IN["Inspector\n(metadata + relationships)"] - AIC["AIChatPanel\n(API-based chat)"] AIP["AiPanel\n(Claude CLI agent + tools)"] SP["SearchPanel\n(keyword search)"] ST["StatusBar\n(vault picker + sync + version)"] CP["CommandPalette\n(Cmd+K launcher)"] App --> WS & SB & NL & ED & SP & ST & CP - ED --> IN & AIC & AIP + ED --> IN & AIP end subgraph RB["Rust Backend"] @@ -138,7 +135,6 @@ flowchart TD end subgraph EXT["External Services"] - ANTH["Anthropic API\n(Claude chat)"] CCLI["Claude CLI\n(agent subprocess)"] MCP["MCP Server\n(ws://9710, 9711)"] GHAPI["GitHub API\n(OAuth, repos, clone)"] @@ -179,7 +175,7 @@ flowchart TD - **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse) and collapsible type-based section groups. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`. - **Note List / Pulse View** (200-500px, resizable): When a section group or filter is selected, shows filtered notes with snippets, modified dates, and status indicators. When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day. - **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, `useEditorSave`, `useRawMode`. Navigation history (Cmd+[/]) replaces tabs. -- **Inspector / AI Chat / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history), AI Chat panel (API-based), and AI Agent panel (Claude CLI subprocess with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). +- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (Claude CLI subprocess with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). Panels are separated by `ResizeHandle` components that support drag-to-resize. @@ -203,16 +199,6 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win ## AI System -Laputa has two AI interfaces with distinct architectures: - -### AI Chat (AIChatPanel) - -Simple chat mode — no tool execution, streaming text responses. - -1. **Frontend** (`AIChatPanel` + `useAIChat` hook) — UI and state management -2. **API Proxy** (Vite middleware in dev, Rust `ai_chat` command in Tauri) — routes to Anthropic -3. **Context picker** — selected notes sent as system context with token estimation - ### AI Agent (AiPanel) Full agent mode — spawns Claude CLI as a subprocess with tool access and MCP vault integration. @@ -263,7 +249,7 @@ When the agent writes or edits vault files, `useAiAgent` detects this from tool ### Context Building -Both AI modes use context from the active note and linked entries. The agent panel (`ai-context.ts`) builds a structured JSON snapshot: +The agent panel (`ai-context.ts`) builds a structured JSON snapshot from the active note and linked entries: ```json { @@ -277,19 +263,9 @@ Both AI modes use context from the active note and linked entries. The agent pan Token budget: 60% of 180k context limit (~108k tokens max). Active note gets priority, then linked notes, then truncation. -### Models (Chat mode) +### Authentication -| Model | ID | Use case | -|-------|----|----------| -| Haiku 3.5 | `claude-3-5-haiku-20241022` | Fast, cheap — default | -| Sonnet 4 | `claude-sonnet-4-20250514` | Balanced | -| Opus 4 | `claude-opus-4-20250514` | Most capable | - -### API Key Management - -- Stored in app settings (`~/.config/com.laputa.app/settings.json`) under `anthropic_key` -- Configurable via Settings panel (also supports `openai_key`, `google_key`) -- Claude CLI (agent mode) uses its own authentication — no API key needed +Claude CLI (agent mode) uses its own authentication — no API key configuration needed in Laputa. ## MCP Server @@ -589,7 +565,6 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `github/` | GitHub OAuth + API (`auth.rs`, `api.rs`, `clone.rs`) | | `search.rs` | Keyword search — walkdir-based vault file scan | | `claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing | -| `ai_chat.rs` | Direct Anthropic API client (non-streaming, for Tauri builds) | | `mcp.rs` | MCP server spawning + config registration | | `commands/` | Tauri command handlers (split into submodules) | | `settings.rs` | App settings persistence | @@ -674,7 +649,6 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | Command | Description | |---------|-------------| -| `ai_chat` | Direct Anthropic API call (non-streaming) | | `stream_claude_chat` | Claude CLI chat mode (streaming) | | `stream_claude_agent` | Claude CLI agent mode (streaming + tools) | | `check_claude_cli` | Check if Claude CLI is available | @@ -726,7 +700,6 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks: | `useTabManagement` | Navigation history, note switching | Note navigation lifecycle | | `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching | | `useTheme` | Editor theme CSS vars | Editor typography theme | -| `useAIChat` | `messages`, `isStreaming` | AI chat conversation | | `useAiAgent` | `messages`, `status`, tool actions | AI agent conversation | | `useAutoSync` | Sync interval, pull/push state | Git auto-sync | | `useUnifiedSearch` | Query, results, loading state | Keyword search | diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 148c5d7a..c0bdb214 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -53,7 +53,6 @@ laputa-app/ │ │ ├── RawEditorView.tsx # CodeMirror raw editor │ │ ├── Inspector.tsx # Fourth panel: metadata + relationships │ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties -│ │ ├── AIChatPanel.tsx # AI chat (API-based) │ │ ├── AiPanel.tsx # AI agent (Claude CLI subprocess) │ │ ├── AiMessage.tsx # Agent message display │ │ ├── AiActionCard.tsx # Agent tool action cards @@ -86,7 +85,6 @@ laputa-app/ │ │ ├── useNoteActions.ts # Composes creation + rename + frontmatter │ │ ├── useNoteCreation.ts # Note/type/daily-note creation │ │ ├── useNoteRename.ts # Note renaming + wikilink updates -│ │ ├── useAIChat.ts # AI chat state │ │ ├── useAiAgent.ts # AI agent state + tool tracking │ │ ├── useAiActivity.ts # MCP UI bridge listener │ │ ├── useAutoSync.ts # Auto git pull/push @@ -110,7 +108,7 @@ laputa-app/ │ │ ├── wikilinks.ts # Wikilink preprocessing pipeline │ │ ├── frontmatter.ts # TypeScript YAML parser │ │ ├── ai-agent.ts # Agent stream utilities -│ │ ├── ai-chat.ts # Chat API client + token estimation +│ │ ├── ai-chat.ts # Token estimation utilities │ │ ├── ai-context.ts # Context snapshot builder │ │ ├── noteListHelpers.ts # Sorting, filtering, date formatting │ │ ├── wikilink.ts # Wikilink resolution @@ -155,7 +153,6 @@ laputa-app/ │ │ ├── telemetry.rs # Sentry init + path scrubber │ │ ├── search.rs # Keyword search (walkdir-based) │ │ ├── claude_cli.rs # Claude CLI subprocess management -│ │ ├── ai_chat.rs # Direct Anthropic API client │ │ ├── mcp.rs # MCP server lifecycle + registration │ │ ├── settings.rs # App settings persistence │ │ ├── vault_config.rs # Per-vault UI config @@ -231,7 +228,6 @@ laputa-app/ | File | Why it matters | |------|---------------| | `src/components/AiPanel.tsx` | AI agent panel — Claude CLI with tool execution, reasoning, actions. | -| `src/components/AIChatPanel.tsx` | AI chat panel — API-based chat without tools. | | `src/hooks/useAiAgent.ts` | Agent state: messages, streaming, tool tracking, file detection. | | `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. | diff --git a/docs/IPAD-PROTOTYPE.md b/docs/IPAD-PROTOTYPE.md index d40de1a5..21c0751b 100644 --- a/docs/IPAD-PROTOTYPE.md +++ b/docs/IPAD-PROTOTYPE.md @@ -30,7 +30,7 @@ Laputa can be ported to iPad using Tauri v2 iOS (beta) with **minimal code chang |---------|---------|---------------------| | Git operations | No `git` binary on iOS | **Option B (Working Copy)** for prototype; **Option A (isomorphic-git)** for production | | GitHub clone/push/pull | Depends on git CLI | Same as above | -| Claude CLI streaming | No `claude` binary on iOS | Use Anthropic API directly (already available via `ai_chat`) | +| Claude CLI streaming | No `claude` binary on iOS | Use Anthropic API directly (requires new implementation) | | MCP server / WS bridge | Spawns Node.js child process | Skip for mobile; explore in-process MCP later | | macOS menu bar | Desktop-only API | Touch-native navigation (already handled by React) | | Updater plugin | Desktop-only | Use TestFlight for updates | diff --git a/docs/adr/0027-dual-ai-architecture.md b/docs/adr/0027-dual-ai-architecture.md index fe8a5151..42eb4228 100644 --- a/docs/adr/0027-dual-ai-architecture.md +++ b/docs/adr/0027-dual-ai-architecture.md @@ -2,7 +2,8 @@ type: ADR id: "0027" title: "Dual AI architecture (API chat + CLI agent)" -status: active +status: superseded +superseded_by: "0028" date: 2026-03-01 --- diff --git a/docs/adr/0028-cli-agent-only-no-api-key.md b/docs/adr/0028-cli-agent-only-no-api-key.md new file mode 100644 index 00000000..2b579be6 --- /dev/null +++ b/docs/adr/0028-cli-agent-only-no-api-key.md @@ -0,0 +1,40 @@ +--- +type: ADR +id: "0028" +title: "CLI agent only — no direct Anthropic API key" +status: active +date: 2026-03-29 +supersedes: "0027" +--- + +## Context + +ADR-0027 introduced a dual AI architecture: a lightweight API-based chat (AIChatPanel) using the Anthropic API directly, and a full CLI agent (AiPanel) spawning Claude CLI as a subprocess with MCP tool access. In practice, the API chat was never shipped to users — the CLI agent covered all use cases and provided a superior experience through tool access and MCP integration. Maintaining two codepaths added complexity, and requiring users to manage an Anthropic API key created friction. + +## Decision + +**Remove the direct Anthropic API integration entirely. AI is available exclusively via CLI agent subprocesses (Claude Code, and in the future Codex or other CLI agents).** No API key field in settings. The CLI agent authenticates via its own mechanism (e.g. `claude` CLI login). + +Removed: +- `AIChatPanel` component, `useAIChat` hook +- Rust `ai_chat` command and `ai_chat.rs` module +- `anthropic_key` field from Settings (Rust and TypeScript) +- Vite dev-server Anthropic API proxy (`aiChatProxyPlugin`, `aiAgentProxyPlugin`) + +Kept: +- `AiPanel` + `useAiAgent` — Claude CLI subprocess with MCP vault integration +- Shared utilities in `ai-chat.ts` (`trimHistory`, `formatMessageWithHistory`, `streamClaudeChat`, etc.) +- `Cmd+I` keyboard shortcut and menu item for toggling the AI panel + +## Options considered + +- **Option A** (chosen): Remove API chat, keep CLI agent only. Simplifies codebase, removes API key management, single codepath. +- **Option B**: Keep both but hide API chat behind feature flag. Adds dead code weight without benefit. +- **Option C**: Replace CLI agent with API chat + manual tool calling. Loses MCP integration and Claude CLI features. + +## Consequences + +- Users no longer need to obtain or manage an Anthropic API key +- Existing saved API keys are silently ignored (the field no longer exists in the Settings struct; serde skips unknown fields on deserialization) +- Future CLI agents (Codex, etc.) can plug into the same `AiPanel` architecture +- If a lightweight chat mode is needed later, it should be built as a CLI agent mode, not a separate API integration diff --git a/src-tauri/src/ai_chat.rs b/src-tauri/src/ai_chat.rs deleted file mode 100644 index a3903f61..00000000 --- a/src-tauri/src/ai_chat.rs +++ /dev/null @@ -1,386 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Deserialize)] -pub struct AiChatRequest { - pub model: Option, - pub messages: Vec, - pub system: Option, - pub max_tokens: Option, -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct AiMessage { - pub role: String, - pub content: String, -} - -#[derive(Debug, Serialize)] -pub struct AiChatResponse { - pub content: String, - pub model: String, - pub stop_reason: Option, -} - -#[derive(Debug, Deserialize)] -struct AnthropicResponse { - content: Vec, - model: String, - stop_reason: Option, -} - -#[derive(Debug, Deserialize)] -struct ContentBlock { - text: Option, -} - -#[derive(Debug, Serialize)] -struct AnthropicRequest { - model: String, - max_tokens: u32, - messages: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - system: Option, -} - -fn get_api_key() -> Result { - std::env::var("ANTHROPIC_API_KEY") - .map_err(|_| "ANTHROPIC_API_KEY environment variable not set".to_string()) -} - -fn build_request(req: &AiChatRequest) -> AnthropicRequest { - AnthropicRequest { - model: req - .model - .clone() - .unwrap_or_else(|| "claude-3-5-haiku-20241022".to_string()), - max_tokens: req.max_tokens.unwrap_or(4096), - messages: req.messages.clone(), - system: req.system.clone(), - } -} - -fn extract_response_text(resp: &AnthropicResponse) -> String { - resp.content - .iter() - .filter_map(|block| block.text.as_ref()) - .cloned() - .collect::>() - .join("") -} - -pub async fn send_chat(req: AiChatRequest) -> Result { - let api_key = get_api_key()?; - send_chat_with_base(req, "https://api.anthropic.com", &api_key).await -} - -async fn send_chat_with_base( - req: AiChatRequest, - api_base: &str, - api_key: &str, -) -> Result { - let anthropic_req = build_request(&req); - - let client = reqwest::Client::new(); - let response = client - .post(format!("{}/v1/messages", api_base)) - .header("x-api-key", api_key) - .header("anthropic-version", "2023-06-01") - .header("content-type", "application/json") - .json(&anthropic_req) - .send() - .await - .map_err(|e| format!("Request failed: {}", e))?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(format!("Anthropic API error ({}): {}", status, body)); - } - - let anthropic_resp: AnthropicResponse = response - .json() - .await - .map_err(|e| format!("Failed to parse response: {}", e))?; - - Ok(AiChatResponse { - content: extract_response_text(&anthropic_resp), - model: anthropic_resp.model, - stop_reason: anthropic_resp.stop_reason, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── Pure logic tests ───────────────────────────────────────────────────── - - #[test] - fn test_build_request_defaults() { - let req = AiChatRequest { - model: None, - messages: vec![AiMessage { - role: "user".to_string(), - content: "Hello".to_string(), - }], - system: None, - max_tokens: None, - }; - let built = build_request(&req); - assert_eq!(built.model, "claude-3-5-haiku-20241022"); - assert_eq!(built.max_tokens, 4096); - assert!(built.system.is_none()); - } - - #[test] - fn test_build_request_custom() { - let req = AiChatRequest { - model: Some("claude-sonnet-4-20250514".to_string()), - messages: vec![], - system: Some("You are helpful".to_string()), - max_tokens: Some(1024), - }; - let built = build_request(&req); - assert_eq!(built.model, "claude-sonnet-4-20250514"); - assert_eq!(built.max_tokens, 1024); - assert_eq!(built.system.unwrap(), "You are helpful"); - } - - #[test] - fn test_extract_response_text() { - let resp = AnthropicResponse { - content: vec![ - ContentBlock { - text: Some("Hello ".to_string()), - }, - ContentBlock { - text: Some("world".to_string()), - }, - ContentBlock { text: None }, - ], - model: "test".to_string(), - stop_reason: Some("end_turn".to_string()), - }; - assert_eq!(extract_response_text(&resp), "Hello world"); - } - - #[test] - fn test_extract_response_text_empty() { - let resp = AnthropicResponse { - content: vec![], - model: "test".to_string(), - stop_reason: None, - }; - assert_eq!(extract_response_text(&resp), ""); - } - - #[test] - fn test_extract_response_text_all_none() { - let resp = AnthropicResponse { - content: vec![ContentBlock { text: None }, ContentBlock { text: None }], - model: "test".to_string(), - stop_reason: None, - }; - assert_eq!(extract_response_text(&resp), ""); - } - - // Mutex to serialize env-var tests and avoid race conditions in parallel test runs - static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - #[test] - fn test_get_api_key_missing() { - let _guard = ENV_MUTEX.lock().unwrap(); - // Temporarily clear the env var - let prev = std::env::var("ANTHROPIC_API_KEY").ok(); - unsafe { - std::env::remove_var("ANTHROPIC_API_KEY"); - } - let result = get_api_key(); - // Restore - if let Some(val) = prev { - unsafe { - std::env::set_var("ANTHROPIC_API_KEY", val); - } - } - assert!(result.is_err()); - assert!(result - .unwrap_err() - .contains("ANTHROPIC_API_KEY environment variable not set")); - } - - #[test] - fn test_get_api_key_present() { - let _guard = ENV_MUTEX.lock().unwrap(); - let prev = std::env::var("ANTHROPIC_API_KEY").ok(); - unsafe { - std::env::set_var("ANTHROPIC_API_KEY", "sk-test-key-123"); - } - let result = get_api_key(); - if let Some(val) = prev { - unsafe { - std::env::set_var("ANTHROPIC_API_KEY", val); - } - } else { - unsafe { - std::env::remove_var("ANTHROPIC_API_KEY"); - } - } - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "sk-test-key-123"); - } - - // ── HTTP mock tests ────────────────────────────────────────────────────── - - #[tokio::test] - async fn test_send_chat_success() { - let mut server = mockito::Server::new_async().await; - let mock = server - .mock("POST", "/v1/messages") - .with_status(200) - .with_header("content-type", "application/json") - .with_body( - r#"{"id":"msg_01","type":"message","role":"assistant","content":[{"type":"text","text":"Hello there!"}],"model":"claude-3-5-haiku-20241022","stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":5}}"#, - ) - .create_async() - .await; - - let req = AiChatRequest { - model: None, - messages: vec![AiMessage { - role: "user".to_string(), - content: "Say hello".to_string(), - }], - system: None, - max_tokens: None, - }; - - let result = send_chat_with_base(req, &server.url(), "sk-test-key").await; - mock.assert_async().await; - assert!(result.is_ok()); - let resp = result.unwrap(); - assert_eq!(resp.content, "Hello there!"); - assert_eq!(resp.model, "claude-3-5-haiku-20241022"); - assert_eq!(resp.stop_reason, Some("end_turn".to_string())); - } - - #[tokio::test] - async fn test_send_chat_with_system_prompt() { - let mut server = mockito::Server::new_async().await; - let mock = server - .mock("POST", "/v1/messages") - .with_status(200) - .with_header("content-type", "application/json") - .with_body( - r#"{"id":"msg_02","type":"message","role":"assistant","content":[{"type":"text","text":"I am a helpful assistant."}],"model":"claude-sonnet-4-20250514","stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":20,"output_tokens":8}}"#, - ) - .create_async() - .await; - - let req = AiChatRequest { - model: Some("claude-sonnet-4-20250514".to_string()), - messages: vec![AiMessage { - role: "user".to_string(), - content: "Who are you?".to_string(), - }], - system: Some("You are a helpful assistant.".to_string()), - max_tokens: Some(512), - }; - - let result = send_chat_with_base(req, &server.url(), "sk-key").await; - mock.assert_async().await; - assert!(result.is_ok()); - let resp = result.unwrap(); - assert_eq!(resp.model, "claude-sonnet-4-20250514"); - assert_eq!(resp.content, "I am a helpful assistant."); - } - - #[tokio::test] - async fn test_send_chat_api_error() { - let mut server = mockito::Server::new_async().await; - let mock = server - .mock("POST", "/v1/messages") - .with_status(401) - .with_header("content-type", "application/json") - .with_body(r#"{"type":"error","error":{"type":"authentication_error","message":"Invalid API key"}}"#) - .create_async() - .await; - - let req = AiChatRequest { - model: None, - messages: vec![AiMessage { - role: "user".to_string(), - content: "Hello".to_string(), - }], - system: None, - max_tokens: None, - }; - - let result = send_chat_with_base(req, &server.url(), "bad-key").await; - mock.assert_async().await; - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - err.contains("Anthropic API error") && err.contains("401"), - "unexpected error: {}", - err - ); - } - - #[tokio::test] - async fn test_send_chat_rate_limit() { - let mut server = mockito::Server::new_async().await; - let mock = server - .mock("POST", "/v1/messages") - .with_status(429) - .with_header("content-type", "application/json") - .with_body(r#"{"type":"error","error":{"type":"rate_limit_error","message":"Rate limit exceeded"}}"#) - .create_async() - .await; - - let req = AiChatRequest { - model: None, - messages: vec![], - system: None, - max_tokens: None, - }; - - let result = send_chat_with_base(req, &server.url(), "sk-key").await; - mock.assert_async().await; - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - err.contains("Anthropic API error") && err.contains("429"), - "unexpected error: {}", - err - ); - } - - #[tokio::test] - async fn test_send_chat_multiple_content_blocks() { - let mut server = mockito::Server::new_async().await; - let mock = server - .mock("POST", "/v1/messages") - .with_status(200) - .with_header("content-type", "application/json") - .with_body( - r#"{"id":"msg_03","type":"message","role":"assistant","content":[{"type":"text","text":"Part one. "},{"type":"text","text":"Part two."}],"model":"claude-3-5-haiku-20241022","stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":5,"output_tokens":10}}"#, - ) - .create_async() - .await; - - let req = AiChatRequest { - model: None, - messages: vec![AiMessage { - role: "user".to_string(), - content: "Give me two parts".to_string(), - }], - system: None, - max_tokens: None, - }; - - let result = send_chat_with_base(req, &server.url(), "sk-key").await; - mock.assert_async().await; - assert!(result.is_ok()); - assert_eq!(result.unwrap().content, "Part one. Part two."); - } -} diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index ada1cf16..b20c0849 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -1,14 +1,8 @@ -use crate::ai_chat::{AiChatRequest, AiChatResponse}; #[cfg(desktop)] use crate::claude_cli::ClaudeStreamEvent; use crate::claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus}; -// ── AI / Claude commands (desktop) ────────────────────────────────────────── - -#[tauri::command] -pub async fn ai_chat(request: AiChatRequest) -> Result { - crate::ai_chat::send_chat(request).await -} +// ── Claude CLI commands (desktop) ─────────────────────────────────────────── #[cfg(desktop)] #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 425b8aee..6760e357 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,3 @@ -pub mod ai_chat; pub mod claude_cli; mod commands; pub mod frontmatter; @@ -138,7 +137,6 @@ pub fn run() { commands::get_conflict_mode, commands::git_resolve_conflict, commands::git_commit_conflict_resolution, - commands::ai_chat, commands::check_claude_cli, commands::stream_claude_chat, commands::stream_claude_agent, diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 9160170e..9a002163 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -293,7 +293,7 @@ fn build_note_menu(app: &App) -> MenuResult { .id(EDIT_TOGGLE_RAW_EDITOR) .accelerator("CmdOrCtrl+\\") .build(app)?; - let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Chat") + let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Panel") .id(VIEW_TOGGLE_AI_CHAT) .accelerator("CmdOrCtrl+I") .build(app)?; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 3e59598a..55508bf4 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -4,7 +4,6 @@ use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Settings { - pub anthropic_key: Option, pub openai_key: Option, pub google_key: Option, pub github_token: Option, @@ -40,10 +39,6 @@ fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> { // Trim whitespace and convert empty strings to None let cleaned = Settings { - anthropic_key: settings - .anthropic_key - .map(|k| k.trim().to_string()) - .filter(|k| !k.is_empty()), openai_key: settings .openai_key .map(|k| k.trim().to_string()) @@ -132,7 +127,6 @@ mod tests { #[test] fn test_default_settings_all_none() { let s = Settings::default(); - assert!(s.anthropic_key.is_none()); assert!(s.openai_key.is_none()); assert!(s.google_key.is_none()); assert!(s.github_token.is_none()); @@ -148,7 +142,6 @@ mod tests { #[test] fn test_settings_json_roundtrip() { let settings = Settings { - anthropic_key: Some("sk-ant-test123".to_string()), openai_key: None, google_key: Some("AIza-test".to_string()), github_token: Some("gho_xyz789".to_string()), @@ -161,7 +154,6 @@ mod tests { }; let json = serde_json::to_string(&settings).unwrap(); let parsed: Settings = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.anthropic_key, settings.anthropic_key); assert_eq!(parsed.google_key, settings.google_key); assert_eq!(parsed.github_token, settings.github_token); assert_eq!(parsed.github_username, settings.github_username); @@ -176,13 +168,12 @@ mod tests { let dir = tempfile::TempDir::new().unwrap(); let path = dir.path().join("nonexistent.json"); let result = get_settings_at(&path).unwrap(); - assert!(result.anthropic_key.is_none()); + assert!(result.openai_key.is_none()); } #[test] fn test_save_and_load_preserves_values() { let loaded = save_and_reload(Settings { - anthropic_key: Some("sk-ant-key".to_string()), openai_key: Some("sk-openai".to_string()), google_key: None, github_token: Some("gho_token123".to_string()), @@ -190,7 +181,6 @@ mod tests { auto_pull_interval_minutes: Some(10), ..Default::default() }); - assert_eq!(loaded.anthropic_key.as_deref(), Some("sk-ant-key")); assert_eq!(loaded.openai_key.as_deref(), Some("sk-openai")); assert_eq!(loaded.github_token.as_deref(), Some("gho_token123")); assert_eq!(loaded.github_username.as_deref(), Some("lucaong")); @@ -200,12 +190,10 @@ mod tests { #[test] fn test_save_trims_whitespace() { let loaded = save_and_reload(Settings { - anthropic_key: Some(" sk-ant-test ".to_string()), github_token: Some(" gho_abc ".to_string()), github_username: Some(" lucaong ".to_string()), ..Default::default() }); - assert_eq!(loaded.anthropic_key.as_deref(), Some("sk-ant-test")); assert_eq!(loaded.github_token.as_deref(), Some("gho_abc")); assert_eq!(loaded.github_username.as_deref(), Some("lucaong")); } @@ -213,12 +201,10 @@ mod tests { #[test] fn test_save_filters_empty_and_whitespace_only() { let loaded = save_and_reload(Settings { - anthropic_key: Some("".to_string()), openai_key: Some(" ".to_string()), github_username: Some("".to_string()), ..Default::default() }); - assert!(loaded.anthropic_key.is_none()); assert!(loaded.openai_key.is_none()); assert!(loaded.github_username.is_none()); } @@ -231,14 +217,14 @@ mod tests { save_settings_at( &path, Settings { - anthropic_key: Some("key".to_string()), + openai_key: Some("key".to_string()), ..Default::default() }, ) .unwrap(); assert!(path.exists()); assert_eq!( - get_settings_at(&path).unwrap().anthropic_key.as_deref(), + get_settings_at(&path).unwrap().openai_key.as_deref(), Some("key") ); } @@ -273,9 +259,9 @@ mod tests { let dir = tempfile::TempDir::new().unwrap(); let path = dir.path().join("settings.json"); // Simulate old settings.json without telemetry fields - fs::write(&path, r#"{"anthropic_key":"sk-test"}"#).unwrap(); + fs::write(&path, r#"{"openai_key":"sk-test"}"#).unwrap(); let loaded = get_settings_at(&path).unwrap(); - assert_eq!(loaded.anthropic_key.as_deref(), Some("sk-test")); + assert_eq!(loaded.openai_key.as_deref(), Some("sk-test")); assert!(loaded.telemetry_consent.is_none()); assert!(loaded.crash_reporting_enabled.is_none()); assert!(loaded.analytics_enabled.is_none()); diff --git a/src/App.test.tsx b/src/App.test.tsx index 80770340..7a57e46f 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -67,7 +67,7 @@ const mockCommandResults: Record = { get_modified_files: [], get_note_content: mockAllContent['/vault/project/test.md'] || '', get_file_history: [], - get_settings: { anthropic_key: null, openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, update_channel: null }, + get_settings: { openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, update_channel: null }, git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }, save_settings: null, check_vault_exists: true, diff --git a/src/components/AIChatPanel.tsx b/src/components/AIChatPanel.tsx deleted file mode 100644 index 847eecdc..00000000 --- a/src/components/AIChatPanel.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import { useState, useRef, useEffect, useMemo } from 'react' -import type { VaultEntry } from '../types' -import { - X, Plus, PaperPlaneRight, Copy, ArrowClockwise, - TextIndent, Sparkle, MagnifyingGlass, Minus, -} from '@phosphor-icons/react' -import { - type ChatMessage, - buildSystemPrompt, -} from '../utils/ai-chat' -import { useAIChat } from '../hooks/useAIChat' -import { MarkdownContent } from './MarkdownContent' - -// --- Sub-components --- - -interface AIChatPanelProps { - entry: VaultEntry | null - entries?: VaultEntry[] - onClose: () => void - onNavigateWikilink?: (target: string) => void -} - -function TypingIndicator() { - return ( -
-
- - - -
-
- ) -} - -function ContextPill({ note, onRemove }: { note: VaultEntry; onRemove: () => void }) { - return ( - - {note.title} - - - ) -} - -function ContextSearchDropdown({ - entries, contextPaths, onAdd, onClose, -}: { - entries: VaultEntry[]; contextPaths: Set - onAdd: (entry: VaultEntry) => void; onClose: () => void -}) { - const [query, setQuery] = useState('') - const inputRef = useRef(null) - useEffect(() => { inputRef.current?.focus() }, []) - - const filtered = useMemo(() => { - const q = query.toLowerCase() - return entries - .filter(e => !contextPaths.has(e.path)) - .filter(e => !q || e.title.toLowerCase().includes(q) || (e.isA ?? '').toLowerCase().includes(q)) - .slice(0, 8) - }, [entries, contextPaths, query]) - - return ( -
-
- - setQuery(e.target.value)} - onKeyDown={e => e.key === 'Escape' && onClose()} placeholder="Search notes..." - className="flex-1 border-none bg-transparent text-foreground outline-none" - style={{ fontSize: 12, padding: '2px 0' }} /> -
-
- {filtered.map(entry => ( - - ))} - {filtered.length === 0 && ( -
No matching notes
- )} -
-
- ) -} - -function AssistantMessage({ msg, onRetry, onNavigateWikilink }: { msg: ChatMessage; onRetry: () => void; onNavigateWikilink?: (target: string) => void }) { - return ( -
- -
- - - -
-
- ) -} - -function StreamingContent({ content, onNavigateWikilink }: { content: string; onNavigateWikilink?: (target: string) => void }) { - return ( -
- -
- ) -} - -function UserBubble({ content }: { content: string }) { - return ( -
-
{content}
-
- ) -} - -function formatTokens(n: number): string { - return n < 1000 ? String(n) : `${(n / 1000).toFixed(1)}k` -} - -const QUICK_ACTIONS = [ - { label: 'Summarize', message: 'Summarize this note' }, - { label: 'Expand', message: 'Expand this note with more detail' }, - { label: 'Fix grammar', message: 'Fix grammar and improve readability' }, -] - -// --- Context management hook --- - -function useContextNotes(entry: VaultEntry | null) { - const [contextNotes, setContextNotes] = useState([]) - - useEffect(() => { - if (entry) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- sync context when active note changes - setContextNotes(prev => prev.some(n => n.path === entry.path) ? prev : [entry, ...prev]) - } - }, [entry?.path]) // eslint-disable-line react-hooks/exhaustive-deps - - const addNote = (note: VaultEntry) => { - setContextNotes(prev => prev.some(n => n.path === note.path) ? prev : [...prev, note]) - } - const removeNote = (path: string) => { - setContextNotes(prev => prev.filter(n => n.path !== path)) - } - const paths = useMemo(() => new Set(contextNotes.map(n => n.path)), [contextNotes]) - - return { contextNotes, addNote, removeNote, paths } -} - -// --- Main component --- - -export function AIChatPanel({ entry, entries = [], onClose, onNavigateWikilink }: AIChatPanelProps) { - const [input, setInput] = useState('') - const [showSearch, setShowSearch] = useState(false) - const messagesEndRef = useRef(null) - - const ctx = useContextNotes(entry) - const chat = useAIChat(ctx.contextNotes) - - const contextInfo = useMemo( - () => buildSystemPrompt(ctx.contextNotes), - [ctx.contextNotes], - ) - - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [chat.messages, chat.isStreaming, chat.streamingContent]) - - const handleSend = () => { chat.sendMessage(input); setInput('') } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } - } - - return ( - - ) -} - -// --- Extracted layout sections --- - -function PanelHeader({ onClear, onClose }: { onClear: () => void; onClose: () => void }) { - return ( -
- - AI Chat - - -
- ) -} - -function ContextBar({ - notes, entries, contextPaths, tokenCount, truncated, - showSearch, onToggleSearch, onAdd, onRemove, onCloseSearch, -}: { - notes: VaultEntry[]; entries: VaultEntry[]; contextPaths: Set - tokenCount: number; truncated: boolean - showSearch: boolean; onToggleSearch: () => void - onAdd: (note: VaultEntry) => void; onRemove: (path: string) => void; onCloseSearch: () => void -}) { - return ( -
- {notes.map(note => ( - onRemove(note.path)} /> - ))} - - {notes.length > 0 && ( - - ~{formatTokens(tokenCount)} tokens{truncated && ' (truncated)'} - - )} - {showSearch && ( - - )} -
- ) -} - -function MessageList({ - messages, isStreaming, streamingContent, onRetry, messagesEndRef, onNavigateWikilink, -}: { - messages: ChatMessage[]; isStreaming: boolean; streamingContent: string - onRetry: (idx: number) => void; messagesEndRef: React.RefObject - onNavigateWikilink?: (target: string) => void -}) { - return ( -
- {messages.length === 0 && !isStreaming && ( -
- -

Ask anything about your notes

-

Powered by Claude CLI

-
- )} - {messages.map((msg, idx) => ( -
- {msg.role === 'user' - ? - : onRetry(idx)} onNavigateWikilink={onNavigateWikilink} />} -
- ))} - {isStreaming && streamingContent && } - {isStreaming && !streamingContent && } -
-
- ) -} - -function QuickActionsBar({ - actions, disabled, onAction, -}: { - actions: { label: string; message: string }[]; disabled: boolean; onAction: (msg: string) => void -}) { - return ( -
- {actions.map(a => ( - - ))} -
- ) -} - -function InputArea({ - input, onInputChange, onKeyDown, onSend, disabled, -}: { - input: string; onInputChange: (v: string) => void - onKeyDown: (e: React.KeyboardEvent) => void; onSend: () => void; disabled: boolean -}) { - return ( -
-
-