refactor: remove Anthropic API integration, CLI agent only (ADR-0028)

Remove AIChatPanel, useAIChat hook, Rust ai_chat command, and
anthropic_key from settings. AI is now exclusively via Claude CLI
subprocess (AiPanel). Simplifies codebase and eliminates API key
management for users. ADR-0027 superseded.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-29 15:05:56 +02:00
parent fc4427750d
commit 9df41c3287
25 changed files with 67 additions and 1275 deletions

View File

@@ -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

View File

@@ -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 |

View File

@@ -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. |

View File

@@ -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 |

View File

@@ -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
---

View File

@@ -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

View File

@@ -1,386 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct AiChatRequest {
pub model: Option<String>,
pub messages: Vec<AiMessage>,
pub system: Option<String>,
pub max_tokens: Option<u32>,
}
#[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<String>,
}
#[derive(Debug, Deserialize)]
struct AnthropicResponse {
content: Vec<ContentBlock>,
model: String,
stop_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ContentBlock {
text: Option<String>,
}
#[derive(Debug, Serialize)]
struct AnthropicRequest {
model: String,
max_tokens: u32,
messages: Vec<AiMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
system: Option<String>,
}
fn get_api_key() -> Result<String, String> {
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::<Vec<_>>()
.join("")
}
pub async fn send_chat(req: AiChatRequest) -> Result<AiChatResponse, String> {
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<AiChatResponse, String> {
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.");
}
}

View File

@@ -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<AiChatResponse, String> {
crate::ai_chat::send_chat(request).await
}
// ── Claude CLI commands (desktop) ──────────────────────────────────────────
#[cfg(desktop)]
#[tauri::command]

View File

@@ -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,

View File

@@ -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)?;

View File

@@ -4,7 +4,6 @@ use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Settings {
pub anthropic_key: Option<String>,
pub openai_key: Option<String>,
pub google_key: Option<String>,
pub github_token: Option<String>,
@@ -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());

View File

@@ -67,7 +67,7 @@ const mockCommandResults: Record<string, unknown> = {
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,

View File

@@ -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 (
<div className="flex items-start gap-2" style={{ padding: '8px 12px' }}>
<div style={{ display: 'flex', gap: 4, padding: '10px 0' }}>
<span className="typing-dot" />
<span className="typing-dot" style={{ animationDelay: '0.2s' }} />
<span className="typing-dot" style={{ animationDelay: '0.4s' }} />
</div>
</div>
)
}
function ContextPill({ note, onRemove }: { note: VaultEntry; onRemove: () => void }) {
return (
<span
className="flex items-center gap-1"
style={{
background: 'var(--accent-green-light)',
borderRadius: 99, fontSize: 11,
padding: '2px 6px 2px 8px', color: 'var(--foreground)', maxWidth: 160,
}}
>
<span className="truncate">{note.title}</span>
<button
className="flex items-center justify-center shrink-0 border-none bg-transparent p-0 cursor-pointer text-muted-foreground hover:text-foreground"
onClick={onRemove} title={`Remove ${note.title}`}
>
<Minus size={10} weight="bold" />
</button>
</span>
)
}
function ContextSearchDropdown({
entries, contextPaths, onAdd, onClose,
}: {
entries: VaultEntry[]; contextPaths: Set<string>
onAdd: (entry: VaultEntry) => void; onClose: () => void
}) {
const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(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 (
<div className="absolute left-0 right-0 bg-background border border-border rounded shadow-lg z-10"
style={{ top: '100%', maxHeight: 240, overflow: 'hidden' }}>
<div className="flex items-center gap-1 border-b border-border" style={{ padding: '4px 8px' }}>
<MagnifyingGlass size={12} className="text-muted-foreground shrink-0" />
<input ref={inputRef} value={query} onChange={e => 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' }} />
</div>
<div style={{ overflow: 'auto', maxHeight: 200 }}>
{filtered.map(entry => (
<button key={entry.path}
className="flex items-center gap-2 w-full text-left border-none bg-transparent cursor-pointer hover:bg-accent text-foreground"
style={{ padding: '6px 10px', fontSize: 12 }}
onClick={() => { onAdd(entry); onClose() }}>
<span className="text-muted-foreground" style={{ fontSize: 10, minWidth: 60 }}>{entry.isA ?? 'Note'}</span>
<span className="truncate">{entry.title}</span>
</button>
))}
{filtered.length === 0 && (
<div className="text-muted-foreground text-center" style={{ padding: 12, fontSize: 12 }}>No matching notes</div>
)}
</div>
</div>
)
}
function AssistantMessage({ msg, onRetry, onNavigateWikilink }: { msg: ChatMessage; onRetry: () => void; onNavigateWikilink?: (target: string) => void }) {
return (
<div>
<MarkdownContent content={msg.content} onWikilinkClick={onNavigateWikilink} />
<div className="flex items-center gap-3" style={{ marginTop: 4 }}>
<button className="border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:underline"
style={{ fontSize: 11 }} onClick={() => navigator.clipboard.writeText(msg.content)}>
<Copy size={12} style={{ marginRight: 3, verticalAlign: 'middle' }} />Copy
</button>
<button className="border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:underline"
style={{ fontSize: 11 }} onClick={onRetry}>
<ArrowClockwise size={12} style={{ marginRight: 3, verticalAlign: 'middle' }} />Retry
</button>
<button className="border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:underline"
style={{ fontSize: 11 }}>
<TextIndent size={12} style={{ marginRight: 3, verticalAlign: 'middle' }} />Insert
</button>
</div>
</div>
)
}
function StreamingContent({ content, onNavigateWikilink }: { content: string; onNavigateWikilink?: (target: string) => void }) {
return (
<div style={{ marginBottom: 12 }}>
<MarkdownContent content={content} onWikilinkClick={onNavigateWikilink} />
</div>
)
}
function UserBubble({ content }: { content: string }) {
return (
<div className="flex justify-end">
<div style={{
background: 'var(--primary)', color: 'white',
borderRadius: '12px 12px 2px 12px', maxWidth: '85%',
padding: '8px 12px', fontSize: 13, lineHeight: 1.5, whiteSpace: 'pre-wrap',
}}>{content}</div>
</div>
)
}
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<VaultEntry[]>([])
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<HTMLDivElement>(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 (
<aside className="flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground">
<PanelHeader onClear={chat.clearConversation} onClose={onClose} />
<ContextBar
notes={ctx.contextNotes} entries={entries} contextPaths={ctx.paths}
tokenCount={contextInfo.totalTokens} truncated={contextInfo.truncated}
showSearch={showSearch} onToggleSearch={() => setShowSearch(!showSearch)}
onAdd={ctx.addNote} onRemove={ctx.removeNote} onCloseSearch={() => setShowSearch(false)}
/>
<MessageList
messages={chat.messages} isStreaming={chat.isStreaming}
streamingContent={chat.streamingContent} onRetry={chat.retryMessage}
messagesEndRef={messagesEndRef} onNavigateWikilink={onNavigateWikilink}
/>
<QuickActionsBar actions={QUICK_ACTIONS} disabled={chat.isStreaming}
onAction={msg => { chat.sendMessage(msg); setInput('') }} />
<InputArea input={input} onInputChange={setInput}
onKeyDown={handleKeyDown} onSend={handleSend} disabled={chat.isStreaming || !input.trim()} />
<style>{`
.typing-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--muted-foreground);
animation: typing-bounce 1.2s infinite ease-in-out;
}
@keyframes typing-bounce {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
`}</style>
</aside>
)
}
// --- Extracted layout sections ---
function PanelHeader({ onClear, onClose }: { onClear: () => void; onClose: () => void }) {
return (
<div className="flex shrink-0 items-center border-b border-border" style={{ height: 45, padding: '0 12px', gap: 8 }}>
<Sparkle size={16} className="shrink-0 text-muted-foreground" />
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>AI Chat</span>
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={onClear} title="New conversation"><Plus size={16} /></button>
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={onClose} title="Close AI Chat"><X size={16} /></button>
</div>
)
}
function ContextBar({
notes, entries, contextPaths, tokenCount, truncated,
showSearch, onToggleSearch, onAdd, onRemove, onCloseSearch,
}: {
notes: VaultEntry[]; entries: VaultEntry[]; contextPaths: Set<string>
tokenCount: number; truncated: boolean
showSearch: boolean; onToggleSearch: () => void
onAdd: (note: VaultEntry) => void; onRemove: (path: string) => void; onCloseSearch: () => void
}) {
return (
<div className="relative flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border" style={{ padding: '6px 12px' }}>
{notes.map(note => (
<ContextPill key={note.path} note={note} onRemove={() => onRemove(note.path)} />
))}
<button
className="flex items-center gap-0.5 border border-dashed border-border bg-transparent text-muted-foreground cursor-pointer hover:text-foreground rounded-full"
style={{ fontSize: 11, padding: '2px 8px' }} onClick={onToggleSearch} title="Add note to context">
<Plus size={10} weight="bold" /><span>Add</span>
</button>
{notes.length > 0 && (
<span className="text-muted-foreground ml-auto" style={{ fontSize: 10 }}>
~{formatTokens(tokenCount)} tokens{truncated && ' (truncated)'}
</span>
)}
{showSearch && (
<ContextSearchDropdown entries={entries} contextPaths={contextPaths} onAdd={onAdd} onClose={onCloseSearch} />
)}
</div>
)
}
function MessageList({
messages, isStreaming, streamingContent, onRetry, messagesEndRef, onNavigateWikilink,
}: {
messages: ChatMessage[]; isStreaming: boolean; streamingContent: string
onRetry: (idx: number) => void; messagesEndRef: React.RefObject<HTMLDivElement | null>
onNavigateWikilink?: (target: string) => void
}) {
return (
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
{messages.length === 0 && !isStreaming && (
<div className="flex flex-col items-center justify-center text-center text-muted-foreground" style={{ paddingTop: 40 }}>
<Sparkle size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>Ask anything about your notes</p>
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>Powered by Claude CLI</p>
</div>
)}
{messages.map((msg, idx) => (
<div key={msg.id} style={{ marginBottom: 12 }}>
{msg.role === 'user'
? <UserBubble content={msg.content} />
: <AssistantMessage msg={msg} onRetry={() => onRetry(idx)} onNavigateWikilink={onNavigateWikilink} />}
</div>
))}
{isStreaming && streamingContent && <StreamingContent content={streamingContent} onNavigateWikilink={onNavigateWikilink} />}
{isStreaming && !streamingContent && <TypingIndicator />}
<div ref={messagesEndRef} />
</div>
)
}
function QuickActionsBar({
actions, disabled, onAction,
}: {
actions: { label: string; message: string }[]; disabled: boolean; onAction: (msg: string) => void
}) {
return (
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-t border-border" style={{ padding: '8px 12px' }}>
{actions.map(a => (
<button key={a.label}
className="cursor-pointer bg-transparent text-foreground hover:bg-accent transition-colors"
style={{ fontSize: 11, border: '1px solid var(--border)', borderRadius: 99, padding: '3px 10px' }}
onClick={() => onAction(a.message)} disabled={disabled}>
{a.label}
</button>
))}
</div>
)
}
function InputArea({
input, onInputChange, onKeyDown, onSend, disabled,
}: {
input: string; onInputChange: (v: string) => void
onKeyDown: (e: React.KeyboardEvent) => void; onSend: () => void; disabled: boolean
}) {
return (
<div className="flex shrink-0 flex-col border-t border-border" style={{ padding: '8px 12px' }}>
<div className="flex items-end gap-2">
<textarea value={input} onChange={e => onInputChange(e.target.value)} onKeyDown={onKeyDown}
placeholder="Ask about your notes..." rows={1}
className="flex-1 resize-none border border-border bg-transparent text-foreground"
style={{ fontSize: 13, borderRadius: 8, padding: '8px 10px', outline: 'none', lineHeight: 1.4, maxHeight: 100, fontFamily: 'inherit' }} />
<button className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
style={{ background: 'var(--primary)', color: 'white', borderRadius: 8, width: 32, height: 34 }}
onClick={onSend} disabled={disabled} title="Send message">
<PaperPlaneRight size={16} />
</button>
</div>
</div>
)
}

View File

@@ -18,7 +18,7 @@ vi.mock('../utils/url', () => ({
}))
const emptySettings: Settings = {
anthropic_key: null,
openai_key: null,
google_key: null,
github_token: null,
@@ -32,7 +32,6 @@ const emptySettings: Settings = {
}
const populatedSettings: Settings = {
anthropic_key: 'sk-ant-api03-test123',
openai_key: 'sk-openai-test456',
google_key: null,
github_token: null,
@@ -98,7 +97,7 @@ describe('SettingsPanel', () => {
fireEvent.click(screen.getByTestId('settings-save'))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
anthropic_key: null,
openai_key: 'sk-openai-test',
google_key: null,
github_token: null,
@@ -119,7 +118,7 @@ describe('SettingsPanel', () => {
fireEvent.click(screen.getByTestId('settings-save'))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
anthropic_key: null,
openai_key: null,
google_key: null,
github_token: null,
@@ -161,7 +160,7 @@ describe('SettingsPanel', () => {
fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Enter', metaKey: true })
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
anthropic_key: null,
openai_key: 'sk-openai-test',
google_key: null,
github_token: null,

View File

@@ -139,7 +139,6 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
}, [])
const buildSettings = useCallback((ghOverride?: { token: string | null; username: string | null }): Settings => ({
anthropic_key: null,
openai_key: openaiKey.trim() || null,
google_key: googleKey.trim() || null,
github_token: ghOverride ? ghOverride.token : (githubToken ?? null),

View File

@@ -1,192 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
// Capture what streamClaudeChat receives
const streamClaudeChatMock = vi.fn<
Parameters<typeof import('../utils/ai-chat').streamClaudeChat>,
ReturnType<typeof import('../utils/ai-chat').streamClaudeChat>
>()
vi.mock('../utils/ai-chat', async () => {
const actual = await vi.importActual<typeof import('../utils/ai-chat')>('../utils/ai-chat')
return {
...actual,
streamClaudeChat: (...args: Parameters<typeof actual.streamClaudeChat>) => {
streamClaudeChatMock(...args)
// Simulate async: emit text, then done
const callbacks = args[3]
setTimeout(() => {
callbacks.onText('mock response')
callbacks.onDone()
}, 10)
return Promise.resolve('')
},
}
})
import { useAIChat } from './useAIChat'
beforeEach(() => {
streamClaudeChatMock.mockClear()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('useAIChat', () => {
it('sends first message as raw text without history', async () => {
const { result } = renderHook(() => useAIChat([]))
act(() => { result.current.sendMessage('hello') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(1)
const [message, , sessionId] = streamClaudeChatMock.mock.calls[0]
// First message: raw text, no history wrapping, no session_id
expect(message).toBe('hello')
expect(sessionId).toBeUndefined()
})
it('embeds conversation history in second message', async () => {
const { result } = renderHook(() => useAIChat([]))
// First exchange
act(() => { result.current.sendMessage('What is 2+2?') })
await act(async () => { vi.advanceTimersByTime(50) })
// Second message — should include history from first exchange
act(() => { result.current.sendMessage('What is that times 3?') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
const [message] = streamClaudeChatMock.mock.calls[1]
expect(message).toContain('<conversation_history>')
expect(message).toContain('What is 2+2?')
expect(message).toContain('mock response')
expect(message).toContain('What is that times 3?')
})
it('accumulates history across multiple exchanges', async () => {
const { result } = renderHook(() => useAIChat([]))
// Exchange 1
act(() => { result.current.sendMessage('Q1') })
await act(async () => { vi.advanceTimersByTime(50) })
// Exchange 2
act(() => { result.current.sendMessage('Q2') })
await act(async () => { vi.advanceTimersByTime(50) })
// Exchange 3
act(() => { result.current.sendMessage('Q3') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(3)
// First call: no history
expect(streamClaudeChatMock.mock.calls[0][0]).toBe('Q1')
// Second call: history from first exchange
const secondMsg = streamClaudeChatMock.mock.calls[1][0]
expect(secondMsg).toContain('Q1')
expect(secondMsg).toContain('Q2')
// Third call: history from both exchanges
const thirdMsg = streamClaudeChatMock.mock.calls[2][0]
expect(thirdMsg).toContain('Q1')
expect(thirdMsg).toContain('Q2')
expect(thirdMsg).toContain('Q3')
})
it('never passes session_id (no --resume)', async () => {
const { result } = renderHook(() => useAIChat([]))
act(() => { result.current.sendMessage('Q1') })
await act(async () => { vi.advanceTimersByTime(50) })
act(() => { result.current.sendMessage('Q2') })
// All calls should have undefined session_id
for (const call of streamClaudeChatMock.mock.calls) {
expect(call[2]).toBeUndefined()
}
})
it('resets history after clearConversation', async () => {
const { result } = renderHook(() => useAIChat([]))
// Build up some history
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
// Clear
act(() => { result.current.clearConversation() })
expect(result.current.messages).toHaveLength(0)
// Next message should have no history
act(() => { result.current.sendMessage('fresh start') })
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
expect(lastCall[0]).toBe('fresh start') // raw text, no history wrapping
expect(lastCall[2]).toBeUndefined()
})
it('includes system prompt on every message when context notes exist', async () => {
const notes = [{ path: 'note.md', title: 'Test Note' }] as import('../types').VaultEntry[]
const { result } = renderHook(() => useAIChat(notes))
// First message
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
// Second message
act(() => { result.current.sendMessage('follow up') })
// Both calls should have system prompt
const firstSystemPrompt = streamClaudeChatMock.mock.calls[0][1]
expect(firstSystemPrompt).toBeTruthy()
expect(firstSystemPrompt).toContain('Test Note')
const secondSystemPrompt = streamClaudeChatMock.mock.calls[1][1]
expect(secondSystemPrompt).toBeTruthy()
expect(secondSystemPrompt).toContain('Test Note')
})
it('retries with correct history (excludes retried exchange)', async () => {
const { result } = renderHook(() => useAIChat([]))
// First exchange
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
expect(result.current.messages).toHaveLength(2)
// Retry the assistant response (index 1)
act(() => { result.current.retryMessage(1) })
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
// Should re-send the user message with no history (retrying first exchange)
expect(lastCall[0]).toBe('hello')
expect(lastCall[2]).toBeUndefined()
})
it('reads latest messages from ref (not stale closure)', async () => {
const { result } = renderHook(() => useAIChat([]))
// Send first message
act(() => { result.current.sendMessage('msg1') })
await act(async () => { vi.advanceTimersByTime(50) })
// Verify messages state is correct
expect(result.current.messages).toHaveLength(2)
expect(result.current.messages[0].content).toBe('msg1')
expect(result.current.messages[1].content).toBe('mock response')
// Send second message — the ref should have the latest messages
// even without depending on messages in useCallback deps
act(() => { result.current.sendMessage('msg2') })
const secondCall = streamClaudeChatMock.mock.calls[1]
const sentMessage = secondCall[0]
// Must contain history from first exchange
expect(sentMessage).toContain('[user]: msg1')
expect(sentMessage).toContain('[assistant]: mock response')
expect(sentMessage).toContain('[user]: msg2')
})
})

View File

@@ -1,120 +0,0 @@
/**
* Custom hook encapsulating AI chat state and message handling.
* Uses Claude CLI subprocess via Tauri for streaming responses.
*
* Conversation continuity embeds prior exchanges in each prompt
* (each CLI invocation is a fresh subprocess with no memory).
* History is trimmed to MAX_HISTORY_TOKENS, dropping oldest first.
*
* Uses a ref (messagesRef) to read the latest messages in callbacks,
* avoiding stale closure issues with React's useCallback memoization.
*/
import { useState, useCallback, useRef, useEffect } from 'react'
import type { VaultEntry } from '../types'
import {
type ChatMessage, type ChatStreamCallbacks, nextMessageId,
buildSystemPrompt, streamClaudeChat,
trimHistory, formatMessageWithHistory, MAX_HISTORY_TOKENS,
} from '../utils/ai-chat'
interface ChatStreamRefs {
abortRef: React.RefObject<boolean>
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
setStreamingContent: React.Dispatch<React.SetStateAction<string>>
setIsStreaming: React.Dispatch<React.SetStateAction<boolean>>
}
/** Create stream callbacks that accumulate text and update React state. */
function makeStreamCallbacks(
refs: ChatStreamRefs,
): { callbacks: ChatStreamCallbacks; getAccumulated: () => string } {
let accumulated = ''
const callbacks: ChatStreamCallbacks = {
onText: (chunk) => {
if (refs.abortRef.current) return
accumulated += chunk
refs.setStreamingContent(accumulated)
},
onError: (error) => {
if (refs.abortRef.current) return
refs.setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${error}`, id: nextMessageId() }])
refs.setStreamingContent('')
refs.setIsStreaming(false)
},
onDone: () => {
if (refs.abortRef.current) return
if (accumulated) {
refs.setMessages(prev => [...prev, { role: 'assistant', content: accumulated, id: nextMessageId() }])
}
refs.setStreamingContent('')
refs.setIsStreaming(false)
},
}
return { callbacks, getAccumulated: () => accumulated }
}
export function useAIChat(
contextNotes: VaultEntry[],
) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isStreaming, setIsStreaming] = useState(false)
const [streamingContent, setStreamingContent] = useState('')
const abortRef = useRef(false)
const isStreamingRef = useRef(false)
const messagesRef = useRef<ChatMessage[]>([])
// Keep refs in sync with state — runs after render, before next user interaction.
useEffect(() => { messagesRef.current = messages }, [messages])
useEffect(() => { isStreamingRef.current = isStreaming }, [isStreaming])
/** Internal: send text, reading history from the messages ref. */
const doSend = useCallback((text: string, historyOverride?: ChatMessage[]) => {
if (!text.trim() || isStreamingRef.current) return
const history = historyOverride ?? messagesRef.current
setMessages(prev => [...prev, { role: 'user', content: text.trim(), id: nextMessageId() }])
setIsStreaming(true)
setStreamingContent('')
abortRef.current = false
// Always include system prompt (each request is a fresh subprocess).
const systemPrompt = buildSystemPrompt(contextNotes).prompt || undefined
// Embed conversation history in the prompt for continuity.
const trimmedHistory = trimHistory(history, MAX_HISTORY_TOKENS)
const formattedMessage = formatMessageWithHistory(trimmedHistory, text.trim())
const { callbacks } = makeStreamCallbacks({
abortRef, setMessages, setStreamingContent, setIsStreaming,
})
streamClaudeChat(formattedMessage, systemPrompt, undefined, callbacks)
.catch(() => { /* errors forwarded via onError */ })
}, [contextNotes])
const sendMessage = useCallback((text: string) => {
doSend(text)
}, [doSend])
const clearConversation = useCallback(() => {
abortRef.current = true
setMessages([])
setIsStreaming(false)
setStreamingContent('')
}, [])
const retryMessage = useCallback((msgIndex: number) => {
const currentMessages = messagesRef.current
const userMsgIndex = msgIndex - 1
if (userMsgIndex < 0) return
const userMsg = currentMessages[userMsgIndex]
if (userMsg.role !== 'user') return
const historyForRetry = currentMessages.slice(0, userMsgIndex)
setMessages(prev => prev.slice(0, userMsgIndex))
doSend(userMsg.content, historyForRetry)
}, [doSend])
return { messages, isStreaming, streamingContent, sendMessage, clearConversation, retryMessage }
}

View File

@@ -144,7 +144,7 @@ export function buildViewCommands(
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
{ id: 'toggle-ai-chat', label: 'Toggle AI Chat', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },

View File

@@ -193,7 +193,6 @@ describe('dispatchMenuEvent', () => {
expect(h.onToggleDiff).toHaveBeenCalled()
})
// New View menu items
it('view-toggle-ai-chat triggers toggle AI chat', () => {
const h = makeHandlers()
dispatchMenuEvent('view-toggle-ai-chat', h)

View File

@@ -4,7 +4,7 @@ import type { Settings } from '../types'
import { useSettings } from './useSettings'
const defaultSettings: Settings = {
anthropic_key: null,
openai_key: null,
google_key: null,
github_token: null,
@@ -18,7 +18,6 @@ const defaultSettings: Settings = {
}
const savedSettings: Settings = {
anthropic_key: 'sk-ant-test123',
openai_key: null,
google_key: 'AIza-test',
github_token: null,
@@ -71,7 +70,6 @@ describe('useSettings', () => {
expect(result.current.loaded).toBe(true)
})
expect(result.current.settings.anthropic_key).toBe('sk-ant-test123')
expect(result.current.settings.google_key).toBe('AIza-test')
expect(mockInvokeFn).toHaveBeenCalledWith('get_settings', {})
})
@@ -84,7 +82,6 @@ describe('useSettings', () => {
})
const newSettings: Settings = {
anthropic_key: 'sk-ant-new',
openai_key: 'sk-openai-new',
google_key: null,
github_token: null,

View File

@@ -8,7 +8,6 @@ function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockA
}
const EMPTY_SETTINGS: Settings = {
anthropic_key: null,
openai_key: null,
google_key: null,
github_token: null,

View File

@@ -16,7 +16,7 @@ vi.mock('../lib/telemetry', () => ({
}))
const baseSettings: Settings = {
anthropic_key: null, openai_key: null, google_key: null,
openai_key: null, google_key: null,
github_token: null, github_username: null, auto_pull_interval_minutes: null,
telemetry_consent: null, crash_reporting_enabled: null,
analytics_enabled: null, anonymous_id: null, update_channel: null,

View File

@@ -75,7 +75,6 @@ let mockHasChanges = true
const mockSavedSinceCommit = new Set<string>()
let mockSettings: Settings = {
anthropic_key: null,
openai_key: null,
google_key: null,
github_token: null,
@@ -197,7 +196,6 @@ export const mockHandlers: Record<string, (args: any) => any> = {
save_settings: (args: { settings: Settings }) => {
const s = args.settings
mockSettings = {
anthropic_key: trimOrNull(s.anthropic_key),
openai_key: trimOrNull(s.openai_key),
google_key: trimOrNull(s.google_key),
github_token: trimOrNull(s.github_token),

View File

@@ -63,7 +63,6 @@ export interface ModifiedFile {
}
export interface Settings {
anthropic_key: string | null
openai_key: string | null
google_key: string | null
github_token: string | null

View File

@@ -377,7 +377,7 @@ function vaultApiPlugin(): Plugin {
}
}
// --- AI Chat proxy helpers ---
// --- Proxy helpers ---
function readRequestBody(req: import('http').IncomingMessage): Promise<string> {
return new Promise((resolve) => {
@@ -387,135 +387,6 @@ function readRequestBody(req: import('http').IncomingMessage): Promise<string> {
})
}
async function forwardToAnthropic(params: {
apiKey: string; model?: string; messages: { role: string; content: string }[]; system?: string; maxTokens?: number
}): Promise<Response> {
return fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': params.apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: params.model || 'claude-3-5-haiku-20241022',
max_tokens: params.maxTokens || 4096,
system: params.system || undefined,
messages: params.messages,
stream: true,
}),
})
}
/** Forward to Anthropic with tool definitions (non-streaming for tool loop) */
async function forwardToAnthropicAgent(params: {
apiKey: string; model?: string; messages: unknown[]; system?: string
maxTokens?: number; tools?: unknown[]
}): Promise<Response> {
return fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': params.apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: params.model || 'claude-3-5-haiku-20241022',
max_tokens: params.maxTokens || 4096,
system: params.system || undefined,
messages: params.messages,
tools: params.tools || undefined,
}),
})
}
async function streamResponseBody(source: ReadableStream<Uint8Array>, res: import('http').ServerResponse): Promise<void> {
const reader = source.getReader()
const decoder = new TextDecoder()
let done = false
while (!done) {
const { value, done: streamDone } = await reader.read()
done = streamDone
if (value) res.write(decoder.decode(value, { stream: true }))
}
res.end()
}
/** Proxy endpoint for Anthropic API calls (avoids browser CORS issues) */
function aiChatProxyPlugin(): Plugin {
return {
name: 'ai-chat-proxy',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
if (req.url !== '/api/ai/chat' || req.method !== 'POST') return next()
try {
const body = await readRequestBody(req)
const params = JSON.parse(body)
if (!params.apiKey) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Missing API key' }))
return
}
const anthropicRes = await forwardToAnthropic(params)
if (!anthropicRes.ok) {
res.statusCode = anthropicRes.status
res.setHeader('Content-Type', 'application/json')
res.end(await anthropicRes.text())
return
}
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
if (anthropicRes.body) {
await streamResponseBody(anthropicRes.body, res)
} else {
res.end()
}
} catch (err: unknown) {
res.statusCode = 500
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Internal server error' }))
}
})
},
}
}
/** Agent proxy — non-streaming Anthropic calls with tool support */
function aiAgentProxyPlugin(): Plugin {
return {
name: 'ai-agent-proxy',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
if (req.url !== '/api/ai/agent' || req.method !== 'POST') return next()
try {
const body = await readRequestBody(req)
const params = JSON.parse(body)
if (!params.apiKey) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Missing API key' }))
return
}
const anthropicRes = await forwardToAnthropicAgent(params)
res.statusCode = anthropicRes.status
res.setHeader('Content-Type', 'application/json')
res.end(await anthropicRes.text())
} catch (err: unknown) {
res.statusCode = 500
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Internal server error' }))
}
})
},
}
}
/** WebSocket proxy info endpoint — tells the frontend where the MCP bridge is */
function mcpBridgeInfoPlugin(): Plugin {
return {
@@ -535,7 +406,7 @@ function mcpBridgeInfoPlugin(): Plugin {
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss(), vaultApiPlugin(), aiChatProxyPlugin(), aiAgentProxyPlugin(), mcpBridgeInfoPlugin()],
plugins: [react(), tailwindcss(), vaultApiPlugin(), mcpBridgeInfoPlugin()],
resolve: {
alias: {
@@ -586,11 +457,9 @@ export default defineConfig({
'src/main.tsx',
'src/types.ts',
'src/hooks/useMcpBridge.ts',
'src/hooks/useAIChat.ts',
'src/hooks/useAiAgent.ts',
'src/utils/ai-chat.ts',
'src/utils/ai-agent.ts',
'src/components/AIChatPanel.tsx',
'src/components/ui/dropdown-menu.tsx',
'src/components/ui/scroll-area.tsx',
'src/components/ui/select.tsx',