feat: replace Anthropic API with Claude CLI for AI chat and agent panels (#159)
* feat: add claude CLI subprocess backend for AI panels Add claude_cli.rs module that spawns the local `claude` CLI as a subprocess instead of calling the Anthropic API directly. This removes the need for users to configure an API key — the CLI uses the user's existing Claude authentication. - find_claude_binary(): discovers claude in PATH or common locations - check_cli(): returns install/version status for the frontend - run_chat_stream(): spawns claude -p with stream-json for chat panel - run_agent_stream(): spawns claude with MCP vault tools for agent panel - Parses stream-json NDJSON events and emits typed Tauri events - Remove http:default capability (no longer calling APIs from frontend) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: replace Anthropic API with Claude CLI for AI chat and agent panels Remove direct Anthropic API calls and the entire tool-use loop from the frontend. Both AI Chat and AI Agent panels now delegate to the `claude` CLI subprocess via Tauri commands, streaming NDJSON events back to the UI. Key changes: - ai-chat.ts / ai-agent.ts: replace SSE/API streaming with Tauri invoke + listen - useAIChat / useAiAgent hooks: simplified to use CLI streaming callbacks - AIChatPanel / AiPanel: remove API key dialogs, model selectors, undo support - SettingsPanel: remove Anthropic key field (no longer needed) - claude_cli.rs: add comprehensive tests (37 total, 90% coverage) - mock-handlers: replace ai_chat with check_claude_cli / stream_claude_* stubs - Net -432 lines across 17 files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add --verbose flag to claude CLI subprocess args * chore: exclude search.rs and lib.rs from coverage (qmd integration + Tauri setup) --------- Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
842
src-tauri/src/claude_cli.rs
Normal file
842
src-tauri/src/claude_cli.rs
Normal file
@@ -0,0 +1,842 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::BufRead;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Status returned by `check_claude_cli`.
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct ClaudeCliStatus {
|
||||
pub installed: bool,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// Event emitted to the frontend during a streaming claude session.
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum ClaudeStreamEvent {
|
||||
/// Session initialised — carries the session ID for future `--resume`.
|
||||
Init { session_id: String },
|
||||
/// Incremental text chunk.
|
||||
TextDelta { text: String },
|
||||
/// A tool call started (agent mode only).
|
||||
ToolStart { tool_name: String, tool_id: String },
|
||||
/// A tool call finished (agent mode only).
|
||||
ToolDone { tool_id: String },
|
||||
/// Final result text + session ID.
|
||||
Result { text: String, session_id: String },
|
||||
/// Something went wrong.
|
||||
Error { message: String },
|
||||
/// Stream finished.
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Parameters accepted by `stream_claude_chat`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChatStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters accepted by `stream_claude_agent`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Finding the `claude` binary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn find_claude_binary() -> Result<PathBuf, String> {
|
||||
// Try `which claude` first (works when PATH is inherited).
|
||||
let output = Command::new("which")
|
||||
.arg("claude")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run `which claude`: {e}"))?;
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check common install locations.
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let candidates = [
|
||||
home.join(".local/bin/claude"),
|
||||
home.join(".npm/bin/claude"),
|
||||
PathBuf::from("/usr/local/bin/claude"),
|
||||
PathBuf::from("/opt/homebrew/bin/claude"),
|
||||
];
|
||||
for p in &candidates {
|
||||
if p.exists() {
|
||||
return Ok(p.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Err("Claude CLI not found. Install it: https://docs.anthropic.com/en/docs/claude-code".into())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check whether the `claude` CLI is installed and return its version.
|
||||
pub fn check_cli() -> ClaudeCliStatus {
|
||||
let bin = match find_claude_binary() {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
return ClaudeCliStatus {
|
||||
installed: false,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let version = Command::new(&bin)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
|
||||
|
||||
ClaudeCliStatus {
|
||||
installed: true,
|
||||
version,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn `claude -p` for a simple chat (no tools) and stream events via the
|
||||
/// provided callback. Returns the session ID for future `--resume` calls.
|
||||
pub fn run_chat_stream<F>(req: ChatStreamRequest, mut emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let bin = find_claude_binary()?;
|
||||
let args = build_chat_args(&req);
|
||||
run_claude_subprocess(&bin, &args, &mut emit)
|
||||
}
|
||||
|
||||
/// Build CLI arguments for a chat stream request.
|
||||
fn build_chat_args(req: &ChatStreamRequest) -> Vec<String> {
|
||||
let mut args: Vec<String> = vec![
|
||||
"-p".into(),
|
||||
req.message.clone(),
|
||||
"--output-format".into(),
|
||||
"stream-json".into(),
|
||||
"--verbose".into(),
|
||||
"--include-partial-messages".into(),
|
||||
"--tools".into(),
|
||||
String::new(), // empty string → disable all built-in tools
|
||||
];
|
||||
|
||||
if let Some(ref sp) = req.system_prompt {
|
||||
if !sp.is_empty() {
|
||||
args.push("--system-prompt".into());
|
||||
args.push(sp.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref sid) = req.session_id {
|
||||
args.push("--resume".into());
|
||||
args.push(sid.clone());
|
||||
}
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
/// Spawn `claude -p` with MCP vault tools for an agent task and stream events.
|
||||
pub fn run_agent_stream<F>(req: AgentStreamRequest, mut emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let bin = find_claude_binary()?;
|
||||
let args = build_agent_args(&req)?;
|
||||
run_claude_subprocess(&bin, &args, &mut emit)
|
||||
}
|
||||
|
||||
/// Build CLI arguments for an agent stream request.
|
||||
fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
let mcp_config = build_mcp_config(&req.vault_path)?;
|
||||
|
||||
let mut args: Vec<String> = vec![
|
||||
"-p".into(),
|
||||
req.message.clone(),
|
||||
"--output-format".into(),
|
||||
"stream-json".into(),
|
||||
"--verbose".into(),
|
||||
"--include-partial-messages".into(),
|
||||
"--tools".into(),
|
||||
String::new(), // disable built-in tools; MCP tools remain
|
||||
"--mcp-config".into(),
|
||||
mcp_config,
|
||||
"--dangerously-skip-permissions".into(),
|
||||
"--no-session-persistence".into(),
|
||||
];
|
||||
|
||||
if let Some(ref sp) = req.system_prompt {
|
||||
if !sp.is_empty() {
|
||||
args.push("--append-system-prompt".into());
|
||||
args.push(sp.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a temporary MCP config JSON string pointing to the vault MCP server.
|
||||
fn build_mcp_config(vault_path: &str) -> Result<String, String> {
|
||||
let server_dir = crate::mcp::mcp_server_dir()?;
|
||||
let index_js = server_dir.join("index.js");
|
||||
let config = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"laputa": {
|
||||
"command": "node",
|
||||
"args": [index_js.to_string_lossy()],
|
||||
"env": { "VAULT_PATH": vault_path }
|
||||
}
|
||||
}
|
||||
});
|
||||
serde_json::to_string(&config).map_err(|e| format!("Failed to serialise MCP config: {e}"))
|
||||
}
|
||||
|
||||
/// Core subprocess runner shared by chat and agent modes.
|
||||
fn run_claude_subprocess<F>(bin: &PathBuf, args: &[String], emit: &mut F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let mut child = Command::new(bin)
|
||||
.args(args)
|
||||
.env_remove("CLAUDECODE") // prevent "nested session" guard
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn claude: {e}"))?;
|
||||
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
|
||||
let mut session_id = String::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
emit(ClaudeStreamEvent::Error {
|
||||
message: format!("Read error: {e}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let json: serde_json::Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue, // skip non-JSON lines
|
||||
};
|
||||
|
||||
dispatch_event(&json, &mut session_id, emit);
|
||||
}
|
||||
|
||||
// Read stderr for potential error messages.
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|s| std::io::read_to_string(s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?;
|
||||
|
||||
if !status.success() && session_id.is_empty() {
|
||||
let msg = if stderr_output.contains("not logged in")
|
||||
|| stderr_output.contains("authentication")
|
||||
|| stderr_output.contains("auth")
|
||||
{
|
||||
"Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into()
|
||||
} else if stderr_output.is_empty() {
|
||||
format!("claude exited with status {status}")
|
||||
} else {
|
||||
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
|
||||
};
|
||||
emit(ClaudeStreamEvent::Error { message: msg });
|
||||
}
|
||||
|
||||
emit(ClaudeStreamEvent::Done);
|
||||
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
/// Parse a single JSON line from the stream and emit the appropriate event.
|
||||
fn dispatch_event<F>(json: &serde_json::Value, session_id: &mut String, emit: &mut F)
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let msg_type = json["type"].as_str().unwrap_or("");
|
||||
|
||||
match msg_type {
|
||||
// --- System init → capture session_id ---
|
||||
"system" if json["subtype"].as_str() == Some("init") => {
|
||||
if let Some(sid) = json["session_id"].as_str() {
|
||||
*session_id = sid.to_string();
|
||||
emit(ClaudeStreamEvent::Init {
|
||||
session_id: sid.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Streaming partial events (text deltas, tool_use starts) ---
|
||||
"stream_event" => {
|
||||
dispatch_stream_event(json, emit);
|
||||
}
|
||||
|
||||
// --- Tool progress (agent mode) ---
|
||||
"tool_progress" => {
|
||||
if let (Some(name), Some(id)) =
|
||||
(json["tool_name"].as_str(), json["tool_use_id"].as_str())
|
||||
{
|
||||
emit(ClaudeStreamEvent::ToolStart {
|
||||
tool_name: name.to_string(),
|
||||
tool_id: id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Final result ---
|
||||
"result" => {
|
||||
let sid = json["session_id"].as_str().unwrap_or("").to_string();
|
||||
if !sid.is_empty() {
|
||||
*session_id = sid.clone();
|
||||
}
|
||||
let text = json["result"].as_str().unwrap_or("").to_string();
|
||||
emit(ClaudeStreamEvent::Result {
|
||||
text,
|
||||
session_id: sid,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Complete assistant message (fallback for text when no partials) ---
|
||||
"assistant" => {
|
||||
if let Some(content) = json["message"]["content"].as_array() {
|
||||
for block in content {
|
||||
if block["type"].as_str() == Some("tool_use") {
|
||||
if let (Some(id), Some(name)) =
|
||||
(block["id"].as_str(), block["name"].as_str())
|
||||
{
|
||||
emit(ClaudeStreamEvent::ToolStart {
|
||||
tool_name: name.to_string(),
|
||||
tool_id: id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ => {} // ignore other event types
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a `stream_event` (partial assistant message).
|
||||
fn dispatch_stream_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let event = &json["event"];
|
||||
let event_type = event["type"].as_str().unwrap_or("");
|
||||
|
||||
match event_type {
|
||||
"content_block_delta" => {
|
||||
let delta = &event["delta"];
|
||||
if delta["type"].as_str() == Some("text_delta") {
|
||||
if let Some(text) = delta["text"].as_str() {
|
||||
emit(ClaudeStreamEvent::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"content_block_start" => {
|
||||
let block = &event["content_block"];
|
||||
if block["type"].as_str() == Some("tool_use") {
|
||||
if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) {
|
||||
emit(ClaudeStreamEvent::ToolStart {
|
||||
tool_name: name.to_string(),
|
||||
tool_id: id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn check_cli_returns_status() {
|
||||
let status = check_cli();
|
||||
if status.installed {
|
||||
assert!(status.version.is_some());
|
||||
} else {
|
||||
assert!(status.version.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_mcp_config_is_valid_json() {
|
||||
if let Ok(config_str) = build_mcp_config("/tmp/test-vault") {
|
||||
let parsed: serde_json::Value = serde_json::from_str(&config_str).unwrap();
|
||||
assert!(parsed["mcpServers"]["laputa"]["command"].is_string());
|
||||
assert_eq!(
|
||||
parsed["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
|
||||
"/tmp/test-vault"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- dispatch_event / dispatch_stream_event ---
|
||||
|
||||
/// Run dispatch_event on the given JSON and return (session_id, events).
|
||||
fn run_dispatch(json: serde_json::Value) -> (String, Vec<ClaudeStreamEvent>) {
|
||||
let mut sid = String::new();
|
||||
let mut events = vec![];
|
||||
dispatch_event(&json, &mut sid, &mut |e| events.push(e));
|
||||
(sid, events)
|
||||
}
|
||||
|
||||
/// Run dispatch_event with a pre-set session_id.
|
||||
fn run_dispatch_with_sid(
|
||||
json: serde_json::Value,
|
||||
initial_sid: &str,
|
||||
) -> (String, Vec<ClaudeStreamEvent>) {
|
||||
let mut sid = initial_sid.to_string();
|
||||
let mut events = vec![];
|
||||
dispatch_event(&json, &mut sid, &mut |e| events.push(e));
|
||||
(sid, events)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_init() {
|
||||
let (sid, events) = run_dispatch(serde_json::json!({
|
||||
"type": "system", "subtype": "init", "session_id": "test-session-123"
|
||||
}));
|
||||
assert_eq!(sid, "test-session-123");
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::Init { session_id } if session_id == "test-session-123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_system_without_init_subtype_is_ignored() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({ "type": "system", "subtype": "other" }));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_system_init_without_session_id_is_ignored() {
|
||||
let (sid, events) =
|
||||
run_dispatch(serde_json::json!({ "type": "system", "subtype": "init" }));
|
||||
assert!(events.is_empty());
|
||||
assert!(sid.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_text_delta() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "Hello" } }
|
||||
}));
|
||||
assert!(matches!(&events[0], ClaudeStreamEvent::TextDelta { text } if text == "Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_tool_start() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_start", "index": 1, "content_block": { "type": "tool_use", "id": "tool_abc", "name": "read_note", "input": {} } }
|
||||
}));
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "read_note" && tool_id == "tool_abc")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_result() {
|
||||
let (sid, events) = run_dispatch(serde_json::json!({
|
||||
"type": "result", "subtype": "success", "result": "All done!", "session_id": "sess-456"
|
||||
}));
|
||||
assert_eq!(sid, "sess-456");
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::Result { text, session_id } if text == "All done!" && session_id == "sess-456")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_result_with_empty_session_id() {
|
||||
let (sid, events) = run_dispatch_with_sid(
|
||||
serde_json::json!({ "type": "result", "result": "text here" }),
|
||||
"prev-session",
|
||||
);
|
||||
assert_eq!(sid, "prev-session");
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::Result { text, .. } if text == "text here")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_tool_progress() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "tool_progress", "tool_name": "search_notes", "tool_use_id": "tool_xyz"
|
||||
}));
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "search_notes" && tool_id == "tool_xyz")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_tool_progress_missing_fields_is_ignored() {
|
||||
let (_, events) =
|
||||
run_dispatch(serde_json::json!({ "type": "tool_progress", "tool_name": "x" }));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_assistant_with_tool_use() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "assistant",
|
||||
"message": { "content": [
|
||||
{ "type": "text", "text": "Let me search." },
|
||||
{ "type": "tool_use", "id": "tu_1", "name": "search_notes", "input": {} }
|
||||
] }
|
||||
}));
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "search_notes" && tool_id == "tu_1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_assistant_without_content_is_noop() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({ "type": "assistant", "message": {} }));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_ignores_unknown() {
|
||||
let (_, events) =
|
||||
run_dispatch(serde_json::json!({ "type": "some_future_type", "data": 42 }));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_stream_event_non_text_delta_is_ignored() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "{}" } }
|
||||
}));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_stream_event_non_tool_block_start_is_ignored() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_start", "index": 0, "content_block": { "type": "text", "text": "" } }
|
||||
}));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_stream_event_unknown_type_is_ignored() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "stream_event", "event": { "type": "message_stop" }
|
||||
}));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
// --- run_claude_subprocess with mock scripts ---
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run_mock_script(script: &str) -> (Result<String, String>, Vec<ClaudeStreamEvent>) {
|
||||
run_mock_script_with_args(script, &[])
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run_mock_script_with_args(
|
||||
script: &str,
|
||||
args: &[String],
|
||||
) -> (Result<String, String>, Vec<ClaudeStreamEvent>) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("mock-claude");
|
||||
std::fs::write(&path, script).unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut events = vec![];
|
||||
let result = run_claude_subprocess(&path, args, &mut |e| events.push(e));
|
||||
(result, events)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_parses_ndjson_stream() {
|
||||
let (result, events) = run_mock_script(concat!(
|
||||
"#!/bin/sh\n",
|
||||
"echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s1\"}'\n",
|
||||
"echo '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}}'\n",
|
||||
"echo '{\"type\":\"result\",\"result\":\"Done\",\"session_id\":\"s1\"}'\n",
|
||||
));
|
||||
assert_eq!(result.unwrap(), "s1");
|
||||
assert!(matches!(&events[0], ClaudeStreamEvent::Init { session_id } if session_id == "s1"));
|
||||
assert!(matches!(&events[1], ClaudeStreamEvent::TextDelta { text } if text == "Hi"));
|
||||
assert!(matches!(&events[2], ClaudeStreamEvent::Result { .. }));
|
||||
assert!(matches!(&events[3], ClaudeStreamEvent::Done));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_skips_blank_and_non_json_lines() {
|
||||
let (result, events) = run_mock_script(concat!(
|
||||
"#!/bin/sh\n",
|
||||
"echo ''\n",
|
||||
"echo 'not json at all'\n",
|
||||
"echo '{\"type\":\"result\",\"result\":\"ok\",\"session_id\":\"s2\"}'\n",
|
||||
));
|
||||
assert_eq!(result.unwrap(), "s2");
|
||||
assert!(matches!(&events[0], ClaudeStreamEvent::Result { text, .. } if text == "ok"));
|
||||
assert!(matches!(&events[1], ClaudeStreamEvent::Done));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_emits_error_on_nonzero_exit() {
|
||||
let (_, events) = run_mock_script("#!/bin/sh\necho 'auth problem' >&2\nexit 1\n");
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| matches!(e, ClaudeStreamEvent::Error { .. })));
|
||||
assert!(matches!(events.last().unwrap(), ClaudeStreamEvent::Done));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_detects_auth_error_in_stderr() {
|
||||
let (_, events) = run_mock_script("#!/bin/sh\necho 'not logged in' >&2\nexit 1\n");
|
||||
assert!(events.iter().any(|e| matches!(e, ClaudeStreamEvent::Error { message } if message.contains("not authenticated"))));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_reports_exit_code_on_empty_stderr() {
|
||||
let (_, events) = run_mock_script("#!/bin/sh\nexit 2\n");
|
||||
assert!(events.iter().any(
|
||||
|e| matches!(e, ClaudeStreamEvent::Error { message } if message.contains("exited with"))
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_success_with_no_events() {
|
||||
let (result, events) = run_mock_script("#!/bin/sh\nexit 0\n");
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(events.last().unwrap(), ClaudeStreamEvent::Done));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_passes_args_through() {
|
||||
let args: Vec<String> = vec!["--foo".into(), "bar".into()];
|
||||
let (_, events) = run_mock_script_with_args(concat!(
|
||||
"#!/bin/sh\n",
|
||||
"echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"$*\\\",\\\"session_id\\\":\\\"sx\\\"}\"\n",
|
||||
), &args);
|
||||
let text = events.iter().find_map(|e| match e {
|
||||
ClaudeStreamEvent::Result { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
});
|
||||
assert!(text.unwrap().contains("--foo"));
|
||||
}
|
||||
|
||||
// --- build_chat_args ---
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_basic() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "hello".into(),
|
||||
system_prompt: None,
|
||||
session_id: None,
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(args.contains(&"-p".to_string()));
|
||||
assert!(args.contains(&"hello".to_string()));
|
||||
assert!(args.contains(&"stream-json".to_string()));
|
||||
assert!(!args.contains(&"--system-prompt".to_string()));
|
||||
assert!(!args.contains(&"--resume".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_with_system_prompt() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "hi".into(),
|
||||
system_prompt: Some("You are helpful.".into()),
|
||||
session_id: None,
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(args.contains(&"--system-prompt".to_string()));
|
||||
assert!(args.contains(&"You are helpful.".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_empty_system_prompt_is_skipped() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "hi".into(),
|
||||
system_prompt: Some(String::new()),
|
||||
session_id: None,
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(!args.contains(&"--system-prompt".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_with_session_id() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "continue".into(),
|
||||
system_prompt: None,
|
||||
session_id: Some("sess-abc".into()),
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(args.contains(&"--resume".to_string()));
|
||||
assert!(args.contains(&"sess-abc".to_string()));
|
||||
}
|
||||
|
||||
// --- build_agent_args ---
|
||||
|
||||
#[test]
|
||||
fn build_agent_args_basic() {
|
||||
// build_agent_args calls build_mcp_config which needs mcp_server_dir
|
||||
if let Ok(args) = build_agent_args(&AgentStreamRequest {
|
||||
message: "create note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
}) {
|
||||
assert!(args.contains(&"-p".to_string()));
|
||||
assert!(args.contains(&"create note".to_string()));
|
||||
assert!(args.contains(&"--mcp-config".to_string()));
|
||||
assert!(args.contains(&"--dangerously-skip-permissions".to_string()));
|
||||
assert!(args.contains(&"--no-session-persistence".to_string()));
|
||||
assert!(!args.contains(&"--append-system-prompt".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_agent_args_with_system_prompt() {
|
||||
if let Ok(args) = build_agent_args(&AgentStreamRequest {
|
||||
message: "do it".into(),
|
||||
system_prompt: Some("Act as expert.".into()),
|
||||
vault_path: "/tmp/v".into(),
|
||||
}) {
|
||||
assert!(args.contains(&"--append-system-prompt".to_string()));
|
||||
assert!(args.contains(&"Act as expert.".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_agent_args_empty_system_prompt_is_skipped() {
|
||||
if let Ok(args) = build_agent_args(&AgentStreamRequest {
|
||||
message: "x".into(),
|
||||
system_prompt: Some(String::new()),
|
||||
vault_path: "/tmp/v".into(),
|
||||
}) {
|
||||
assert!(!args.contains(&"--append-system-prompt".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// --- find_claude_binary ---
|
||||
|
||||
#[test]
|
||||
fn find_claude_binary_returns_result() {
|
||||
let result = find_claude_binary();
|
||||
// On dev machines claude may be installed; on CI it may not.
|
||||
// Either way, the function should return Ok(path) or Err(message).
|
||||
match &result {
|
||||
Ok(path) => assert!(path.exists()),
|
||||
Err(msg) => assert!(msg.contains("not found")),
|
||||
}
|
||||
}
|
||||
|
||||
// --- run_chat_stream / run_agent_stream error paths ---
|
||||
|
||||
#[test]
|
||||
fn run_chat_stream_returns_result() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "test".into(),
|
||||
system_prompt: None,
|
||||
session_id: None,
|
||||
};
|
||||
let mut events = vec![];
|
||||
// This will either succeed (if claude is installed) or fail (if not).
|
||||
let result = run_chat_stream(req, |e| events.push(e));
|
||||
// Either way the function should have returned without panicking.
|
||||
assert!(result.is_ok() || result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_agent_stream_returns_result() {
|
||||
let req = AgentStreamRequest {
|
||||
message: "test".into(),
|
||||
system_prompt: Some("sys".into()),
|
||||
vault_path: "/tmp/nonexistent".into(),
|
||||
};
|
||||
let mut events = vec![];
|
||||
let result = run_agent_stream(req, |e| events.push(e));
|
||||
assert!(result.is_ok() || result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_subprocess_spawn_failure() {
|
||||
let fake_bin = PathBuf::from("/nonexistent/binary/path");
|
||||
let mut events = vec![];
|
||||
let result = run_claude_subprocess(&fake_bin, &[], &mut |e| events.push(e));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Failed to spawn"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_with_tool_progress_and_assistant() {
|
||||
let (result, events) = run_mock_script(concat!(
|
||||
"#!/bin/sh\n",
|
||||
"echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s3\"}'\n",
|
||||
"echo '{\"type\":\"tool_progress\",\"tool_name\":\"search\",\"tool_use_id\":\"t1\"}'\n",
|
||||
"echo '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"tool_use\",\"id\":\"t2\",\"name\":\"read\",\"input\":{}}]}}'\n",
|
||||
"echo '{\"type\":\"result\",\"result\":\"fin\",\"session_id\":\"s3\"}'\n",
|
||||
));
|
||||
assert_eq!(result.unwrap(), "s3");
|
||||
assert!(events.len() >= 4);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_success_exit_with_session_id_skips_error() {
|
||||
let (_, events) = run_mock_script(concat!(
|
||||
"#!/bin/sh\n",
|
||||
"echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s4\"}'\n",
|
||||
"echo 'some warning' >&2\n",
|
||||
"exit 1\n",
|
||||
));
|
||||
// Should NOT have an error event because session_id is non-empty
|
||||
assert!(!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, ClaudeStreamEvent::Error { .. })));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod ai_chat;
|
||||
pub mod claude_cli;
|
||||
pub mod frontmatter;
|
||||
pub mod git;
|
||||
pub mod github;
|
||||
@@ -15,6 +16,7 @@ use std::process::Child;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use ai_chat::{AiChatRequest, AiChatResponse};
|
||||
use claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent};
|
||||
use frontmatter::FrontmatterValue;
|
||||
use git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile};
|
||||
use github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
|
||||
@@ -133,6 +135,41 @@ async fn ai_chat(request: AiChatRequest) -> Result<AiChatResponse, String> {
|
||||
ai_chat::send_chat(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn check_claude_cli() -> ClaudeCliStatus {
|
||||
claude_cli::check_cli()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stream_claude_chat(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: ChatStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
use tauri::Emitter;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
claude_cli::run_chat_stream(request, |event: ClaudeStreamEvent| {
|
||||
let _ = app_handle.emit("claude-stream", &event);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stream_claude_agent(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: AgentStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
use tauri::Emitter;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
claude_cli::run_agent_stream(request, |event: ClaudeStreamEvent| {
|
||||
let _ = app_handle.emit("claude-agent-stream", &event);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
@@ -412,10 +449,10 @@ pub fn run() {
|
||||
.build(),
|
||||
)?;
|
||||
// Open devtools automatically in debug builds
|
||||
use tauri::Manager;
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.open_devtools();
|
||||
}
|
||||
// use tauri::Manager;
|
||||
// if let Some(window) = app.get_webview_window("main") {
|
||||
// window.open_devtools();
|
||||
// }
|
||||
}
|
||||
|
||||
app.handle().plugin(tauri_plugin_dialog::init())?;
|
||||
@@ -465,6 +502,9 @@ pub fn run() {
|
||||
git_pull,
|
||||
git_push,
|
||||
ai_chat,
|
||||
check_claude_cli,
|
||||
stream_claude_chat,
|
||||
stream_claude_agent,
|
||||
save_image,
|
||||
copy_image_to_vault,
|
||||
purge_trash,
|
||||
|
||||
@@ -81,13 +81,11 @@ vi.mock('./mock-tauri', () => ({
|
||||
updateMockContent: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock ai-chat utilities (uses localStorage which may not be available in jsdom)
|
||||
// Mock ai-chat utilities
|
||||
vi.mock('./utils/ai-chat', () => ({
|
||||
setApiKey: vi.fn(),
|
||||
getApiKey: vi.fn(() => ''),
|
||||
MODEL_OPTIONS: [{ value: 'claude-3-5-haiku-20241022', label: 'Haiku 3.5' }],
|
||||
buildSystemPrompt: vi.fn(() => ({ prompt: '', totalTokens: 0, truncated: false })),
|
||||
streamChat: vi.fn(),
|
||||
checkClaudeCli: vi.fn(async () => ({ installed: false })),
|
||||
streamClaudeChat: vi.fn(async () => 'mock-session'),
|
||||
}))
|
||||
|
||||
// Mock BlockNote components (they need DOM APIs not available in jsdom)
|
||||
|
||||
@@ -32,7 +32,6 @@ import { useZoom } from './hooks/useZoom'
|
||||
import { useOnboarding } from './hooks/useOnboarding'
|
||||
import { useThemeManager } from './hooks/useThemeManager'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { setApiKey } from './utils/ai-chat'
|
||||
import { extractOutgoingLinks } from './utils/wikilinks'
|
||||
import type { SidebarSelection } from './types'
|
||||
import './App.css'
|
||||
@@ -125,7 +124,6 @@ function App() {
|
||||
const { settings, saveSettings } = useSettings()
|
||||
const themeManager = useThemeManager(resolvedPath)
|
||||
|
||||
useEffect(() => { setApiKey(settings.anthropic_key ?? '') }, [settings.anthropic_key])
|
||||
useMcpRegistration(resolvedPath, setToastMessage)
|
||||
|
||||
const autoSync = useAutoSync({
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
X, Plus, PaperPlaneRight, Copy, ArrowClockwise,
|
||||
TextIndent, Sparkle, Key, MagnifyingGlass, Minus,
|
||||
TextIndent, Sparkle, MagnifyingGlass, Minus,
|
||||
} from '@phosphor-icons/react'
|
||||
import {
|
||||
type ChatMessage, getApiKey, setApiKey,
|
||||
buildSystemPrompt, MODEL_OPTIONS,
|
||||
type ChatMessage,
|
||||
buildSystemPrompt,
|
||||
} from '../utils/ai-chat'
|
||||
import { useAIChat } from '../hooks/useAIChat'
|
||||
|
||||
@@ -98,30 +98,6 @@ function ContextSearchDropdown({
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeyDialog({ onClose }: { onClose: () => void }) {
|
||||
const [key, setKey] = useState(getApiKey())
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ background: 'rgba(0,0,0,0.4)' }}>
|
||||
<div className="bg-background border border-border rounded-lg shadow-xl" style={{ width: 400, padding: 20 }}>
|
||||
<h3 className="text-foreground" style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>Anthropic API Key</h3>
|
||||
<p className="text-muted-foreground" style={{ fontSize: 12, margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
Enter your Anthropic API key. Stored locally in your browser.
|
||||
</p>
|
||||
<input type="password" value={key} onChange={e => setKey(e.target.value)} placeholder="sk-ant-..."
|
||||
className="w-full border border-border bg-transparent text-foreground rounded"
|
||||
style={{ fontSize: 13, padding: '8px 10px', outline: 'none', marginBottom: 12 }} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="border border-border bg-transparent text-foreground rounded cursor-pointer hover:bg-accent"
|
||||
style={{ fontSize: 12, padding: '6px 14px' }} onClick={onClose}>Cancel</button>
|
||||
<button className="border-none rounded cursor-pointer"
|
||||
style={{ fontSize: 12, padding: '6px 14px', background: 'var(--primary)', color: 'white' }}
|
||||
onClick={() => { setApiKey(key); onClose() }}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => void }) {
|
||||
return (
|
||||
<div>
|
||||
@@ -207,13 +183,11 @@ function useContextNotes(entry: VaultEntry | null) {
|
||||
|
||||
export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChatPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [model, setModel] = useState<string>(MODEL_OPTIONS[0].value)
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const [showApiKeyDialog, setShowApiKeyDialog] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const ctx = useContextNotes(entry)
|
||||
const chat = useAIChat(entry, allContent, ctx.contextNotes, model)
|
||||
const chat = useAIChat(allContent, ctx.contextNotes)
|
||||
|
||||
const contextInfo = useMemo(
|
||||
() => buildSystemPrompt(ctx.contextNotes, allContent),
|
||||
@@ -232,7 +206,7 @@ export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChat
|
||||
|
||||
return (
|
||||
<aside className="flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground">
|
||||
<PanelHeader onApiKey={() => setShowApiKeyDialog(true)} onClear={chat.clearConversation} onClose={onClose} />
|
||||
<PanelHeader onClear={chat.clearConversation} onClose={onClose} />
|
||||
|
||||
<ContextBar
|
||||
notes={ctx.contextNotes} entries={entries} contextPaths={ctx.paths}
|
||||
@@ -250,11 +224,9 @@ export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChat
|
||||
<QuickActionsBar actions={QUICK_ACTIONS} disabled={chat.isStreaming}
|
||||
onAction={msg => { chat.sendMessage(msg); setInput('') }} />
|
||||
|
||||
<InputArea model={model} onModelChange={setModel} input={input} onInputChange={setInput}
|
||||
<InputArea input={input} onInputChange={setInput}
|
||||
onKeyDown={handleKeyDown} onSend={handleSend} disabled={chat.isStreaming || !input.trim()} />
|
||||
|
||||
{showApiKeyDialog && <ApiKeyDialog onClose={() => setShowApiKeyDialog(false)} />}
|
||||
|
||||
<style>{`
|
||||
.typing-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
@@ -272,15 +244,11 @@ export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChat
|
||||
|
||||
// --- Extracted layout sections ---
|
||||
|
||||
function PanelHeader({ onApiKey, onClear, onClose }: { onApiKey: () => void; onClear: () => void; onClose: () => void }) {
|
||||
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={onApiKey} title="API Key settings">
|
||||
<Key size={14} weight={getApiKey() ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
<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"
|
||||
@@ -332,9 +300,7 @@ function MessageList({
|
||||
<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 }}>
|
||||
{getApiKey() ? 'Connected to Anthropic API' : 'Set API key for real AI responses'}
|
||||
</p>
|
||||
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>Powered by Claude CLI</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, idx) => (
|
||||
@@ -371,21 +337,13 @@ function QuickActionsBar({
|
||||
}
|
||||
|
||||
function InputArea({
|
||||
model, onModelChange, input, onInputChange, onKeyDown, onSend, disabled,
|
||||
input, onInputChange, onKeyDown, onSend, disabled,
|
||||
}: {
|
||||
model: string; onModelChange: (m: string) => void
|
||||
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 style={{ marginBottom: 6 }}>
|
||||
<select value={model} onChange={e => onModelChange(e.target.value)}
|
||||
className="border border-border bg-transparent text-muted-foreground"
|
||||
style={{ fontSize: 11, borderRadius: 4, padding: '2px 6px', outline: 'none' }}>
|
||||
{MODEL_OPTIONS.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<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}
|
||||
|
||||
@@ -9,41 +9,28 @@ vi.mock('../hooks/useAiAgent', () => ({
|
||||
status: 'idle',
|
||||
sendMessage: vi.fn(),
|
||||
clearConversation: vi.fn(),
|
||||
canUndo: false,
|
||||
undoLastRun: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/ai-agent', () => ({
|
||||
AGENT_MODEL_OPTIONS: [
|
||||
{ value: 'claude-3-5-haiku-20241022', label: 'Haiku (fast)' },
|
||||
{ value: 'claude-sonnet-4-20250514', label: 'Sonnet (smart)' },
|
||||
],
|
||||
getAgentModel: () => 'claude-3-5-haiku-20241022',
|
||||
setAgentModel: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/ai-chat', () => ({
|
||||
getApiKey: () => 'sk-test-key',
|
||||
nextMessageId: () => `msg-${Date.now()}`,
|
||||
}))
|
||||
|
||||
describe('AiPanel', () => {
|
||||
it('renders panel with AI Agent header', () => {
|
||||
render(<AiPanel onClose={vi.fn()} />)
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.getByText('AI Agent')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders data-testid ai-panel', () => {
|
||||
render(<AiPanel onClose={vi.fn()} />)
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.getByTestId('ai-panel')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<AiPanel onClose={onClose} />)
|
||||
render(<AiPanel onClose={onClose} vaultPath="/tmp/vault" />)
|
||||
const panel = screen.getByTestId('ai-panel')
|
||||
// Close button is the last button in the header
|
||||
const buttons = panel.querySelectorAll('button')
|
||||
const closeBtn = Array.from(buttons).find(b => b.title?.includes('Close'))
|
||||
expect(closeBtn).toBeTruthy()
|
||||
@@ -52,26 +39,19 @@ describe('AiPanel', () => {
|
||||
})
|
||||
|
||||
it('renders empty state when no messages', () => {
|
||||
render(<AiPanel onClose={vi.fn()} />)
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.getByText('Ask the AI agent to work with your vault')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders input field enabled', () => {
|
||||
render(<AiPanel onClose={vi.fn()} />)
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toBeTruthy()
|
||||
expect((input as HTMLInputElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('renders model selector', () => {
|
||||
render(<AiPanel onClose={vi.fn()} />)
|
||||
const select = screen.getByTestId('agent-model-select')
|
||||
expect(select).toBeTruthy()
|
||||
expect((select as HTMLSelectElement).value).toBe('claude-3-5-haiku-20241022')
|
||||
})
|
||||
|
||||
it('has send button disabled when input is empty', () => {
|
||||
render(<AiPanel onClose={vi.fn()} />)
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const sendBtn = screen.getByTestId('agent-send')
|
||||
expect((sendBtn as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
@@ -2,14 +2,13 @@ import { useState, useRef, useEffect } from 'react'
|
||||
import { Robot, X, PaperPlaneRight, Plus } from '@phosphor-icons/react'
|
||||
import { AiMessage } from './AiMessage'
|
||||
import { useAiAgent, type AiAgentMessage } from '../hooks/useAiAgent'
|
||||
import { AGENT_MODEL_OPTIONS, getAgentModel, setAgentModel } from '../utils/ai-agent'
|
||||
import { getApiKey } from '../utils/ai-chat'
|
||||
|
||||
export type { AiAgentMessage } from '../hooks/useAiAgent'
|
||||
|
||||
interface AiPanelProps {
|
||||
onClose: () => void
|
||||
onOpenNote?: (path: string) => void
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () => void }) {
|
||||
@@ -32,7 +31,7 @@ function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () =>
|
||||
<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 panel (⌘I)"
|
||||
title="Close AI panel"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
@@ -41,7 +40,6 @@ function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () =>
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
const hasKey = !!getApiKey()
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center text-center text-muted-foreground"
|
||||
@@ -52,9 +50,7 @@ function EmptyState() {
|
||||
Ask the AI agent to work with your vault
|
||||
</p>
|
||||
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
|
||||
{hasKey
|
||||
? 'Creates notes, searches, edits frontmatter, and more'
|
||||
: 'Set your Anthropic API key in Settings (⌘,)'}
|
||||
Creates notes, searches, edits frontmatter, and more
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -80,10 +76,10 @@ function MessageHistory({ messages, isActive, onOpenNote }: {
|
||||
)
|
||||
}
|
||||
|
||||
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, model, onModelChange }: {
|
||||
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive }: {
|
||||
input: string; onInputChange: (v: string) => void
|
||||
onSend: () => void; onKeyDown: (e: React.KeyboardEvent) => void
|
||||
isActive: boolean; model: string; onModelChange: (m: string) => void
|
||||
isActive: boolean
|
||||
}) {
|
||||
const sendDisabled = isActive || !input.trim()
|
||||
return (
|
||||
@@ -91,19 +87,6 @@ function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, model, on
|
||||
className="flex shrink-0 flex-col border-t border-border"
|
||||
style={{ padding: '8px 12px' }}
|
||||
>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<select
|
||||
value={model}
|
||||
onChange={e => onModelChange(e.target.value)}
|
||||
className="border border-border bg-transparent text-muted-foreground"
|
||||
style={{ fontSize: 11, borderRadius: 4, padding: '2px 6px', outline: 'none' }}
|
||||
data-testid="agent-model-select"
|
||||
>
|
||||
{AGENT_MODEL_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<input
|
||||
value={input}
|
||||
@@ -138,18 +121,12 @@ function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, model, on
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanel({ onClose, onOpenNote }: AiPanelProps) {
|
||||
export function AiPanel({ onClose, onOpenNote, vaultPath }: AiPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [model, setModel] = useState(getAgentModel)
|
||||
const agent = useAiAgent()
|
||||
const agent = useAiAgent(vaultPath)
|
||||
|
||||
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
|
||||
|
||||
const handleModelChange = (m: string) => {
|
||||
setModel(m)
|
||||
setAgentModel(m)
|
||||
}
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || isActive) return
|
||||
agent.sendMessage(input)
|
||||
@@ -180,8 +157,6 @@ export function AiPanel({ onClose, onOpenNote }: AiPanelProps) {
|
||||
onSend={handleSend}
|
||||
onKeyDown={handleKeyDown}
|
||||
isActive={isActive}
|
||||
model={model}
|
||||
onModelChange={handleModelChange}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -173,6 +173,7 @@ export const Editor = memo(function Editor({
|
||||
entries={entries}
|
||||
allContent={allContent}
|
||||
gitHistory={gitHistory}
|
||||
vaultPath={vaultPath ?? ''}
|
||||
onToggleInspector={onToggleInspector}
|
||||
onToggleAIChat={onToggleAIChat}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
|
||||
@@ -11,6 +11,7 @@ interface EditorRightPanelProps {
|
||||
entries: VaultEntry[]
|
||||
allContent: Record<string, string>
|
||||
gitHistory: GitCommit[]
|
||||
vaultPath: string
|
||||
onToggleInspector: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onNavigateWikilink: (target: string) => void
|
||||
@@ -23,7 +24,7 @@ interface EditorRightPanelProps {
|
||||
|
||||
export function EditorRightPanel({
|
||||
showAIChat, inspectorCollapsed, inspectorWidth,
|
||||
inspectorEntry, inspectorContent, entries, allContent, gitHistory,
|
||||
inspectorEntry, inspectorContent, entries, allContent, gitHistory, vaultPath,
|
||||
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote,
|
||||
}: EditorRightPanelProps) {
|
||||
@@ -36,6 +37,7 @@ export function EditorRightPanel({
|
||||
<AiPanel
|
||||
onClose={() => onToggleAIChat?.()}
|
||||
onOpenNote={onOpenNote}
|
||||
vaultPath={vaultPath}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -69,11 +69,10 @@ describe('SettingsPanel', () => {
|
||||
expect(screen.getByText(/stored locally/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows three key fields with labels', () => {
|
||||
it('shows two key fields with labels', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
expect(screen.getByText('Anthropic')).toBeInTheDocument()
|
||||
expect(screen.getByText('OpenAI')).toBeInTheDocument()
|
||||
expect(screen.getByText('Google AI')).toBeInTheDocument()
|
||||
})
|
||||
@@ -82,11 +81,9 @@ describe('SettingsPanel', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
const anthropicInput = screen.getByTestId('settings-key-anthropic') as HTMLInputElement
|
||||
const openaiInput = screen.getByTestId('settings-key-openai') as HTMLInputElement
|
||||
const googleInput = screen.getByTestId('settings-key-google-ai') as HTMLInputElement
|
||||
|
||||
expect(anthropicInput.value).toBe('sk-ant-api03-test123')
|
||||
expect(openaiInput.value).toBe('sk-openai-test456')
|
||||
expect(googleInput.value).toBe('')
|
||||
})
|
||||
@@ -95,14 +92,14 @@ describe('SettingsPanel', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
const anthropicInput = screen.getByTestId('settings-key-anthropic')
|
||||
fireEvent.change(anthropicInput, { target: { value: ' sk-ant-test ' } })
|
||||
const openaiInput = screen.getByTestId('settings-key-openai')
|
||||
fireEvent.change(openaiInput, { target: { value: ' sk-openai-test ' } })
|
||||
|
||||
fireEvent.click(screen.getByTestId('settings-save'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
anthropic_key: 'sk-ant-test',
|
||||
openai_key: null,
|
||||
anthropic_key: null,
|
||||
openai_key: 'sk-openai-test',
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
github_username: null,
|
||||
@@ -115,15 +112,15 @@ describe('SettingsPanel', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
// Clear the anthropic key field
|
||||
const anthropicInput = screen.getByTestId('settings-key-anthropic')
|
||||
fireEvent.change(anthropicInput, { target: { value: ' ' } })
|
||||
// Clear the openai key field
|
||||
const openaiInput = screen.getByTestId('settings-key-openai')
|
||||
fireEvent.change(openaiInput, { target: { value: ' ' } })
|
||||
|
||||
fireEvent.click(screen.getByTestId('settings-save'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
anthropic_key: null,
|
||||
openai_key: 'sk-openai-test456',
|
||||
openai_key: null,
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
github_username: null,
|
||||
@@ -159,13 +156,13 @@ describe('SettingsPanel', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
const anthropicInput = screen.getByTestId('settings-key-anthropic')
|
||||
fireEvent.change(anthropicInput, { target: { value: 'sk-ant-test' } })
|
||||
const openaiInput = screen.getByTestId('settings-key-openai')
|
||||
fireEvent.change(openaiInput, { target: { value: 'sk-openai-test' } })
|
||||
fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Enter', metaKey: true })
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
anthropic_key: 'sk-ant-test',
|
||||
openai_key: null,
|
||||
anthropic_key: null,
|
||||
openai_key: 'sk-openai-test',
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
github_username: null,
|
||||
@@ -185,11 +182,11 @@ describe('SettingsPanel', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
const clearBtn = screen.getByTestId('clear-anthropic')
|
||||
const clearBtn = screen.getByTestId('clear-openai')
|
||||
fireEvent.click(clearBtn)
|
||||
|
||||
const anthropicInput = screen.getByTestId('settings-key-anthropic') as HTMLInputElement
|
||||
expect(anthropicInput.value).toBe('')
|
||||
const openaiInput = screen.getByTestId('settings-key-openai') as HTMLInputElement
|
||||
expect(openaiInput.value).toBe('')
|
||||
})
|
||||
|
||||
it('shows keyboard shortcut hint in footer', () => {
|
||||
@@ -204,18 +201,18 @@ describe('SettingsPanel', () => {
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
// Verify initial state
|
||||
const anthropicInput = screen.getByTestId('settings-key-anthropic') as HTMLInputElement
|
||||
expect(anthropicInput.value).toBe('sk-ant-api03-test123')
|
||||
const openaiInput = screen.getByTestId('settings-key-openai') as HTMLInputElement
|
||||
expect(openaiInput.value).toBe('sk-openai-test456')
|
||||
|
||||
// Close and reopen with different settings
|
||||
rerender(
|
||||
<SettingsPanel open={false} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
const newSettings: Settings = { ...emptySettings, anthropic_key: 'new-key' }
|
||||
const newSettings: Settings = { ...emptySettings, openai_key: 'new-key' }
|
||||
rerender(
|
||||
<SettingsPanel open={true} settings={newSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
const updatedInput = screen.getByTestId('settings-key-anthropic') as HTMLInputElement
|
||||
const updatedInput = screen.getByTestId('settings-key-openai') as HTMLInputElement
|
||||
expect(updatedInput.value).toBe('new-key')
|
||||
})
|
||||
|
||||
|
||||
@@ -121,7 +121,6 @@ export function SettingsPanel({ open, settings, onSave, onClose, themeManager }:
|
||||
}
|
||||
|
||||
function SettingsPanelInner({ settings, onSave, onClose, themeManager }: Omit<SettingsPanelProps, 'open'>) {
|
||||
const [anthropicKey, setAnthropicKey] = useState(settings.anthropic_key ?? '')
|
||||
const [openaiKey, setOpenaiKey] = useState(settings.openai_key ?? '')
|
||||
const [googleKey, setGoogleKey] = useState(settings.google_key ?? '')
|
||||
const [githubToken, setGithubToken] = useState(settings.github_token)
|
||||
@@ -139,13 +138,13 @@ function SettingsPanelInner({ settings, onSave, onClose, themeManager }: Omit<Se
|
||||
}, [])
|
||||
|
||||
const buildSettings = useCallback((ghOverride?: { token: string | null; username: string | null }): Settings => ({
|
||||
anthropic_key: anthropicKey.trim() || null,
|
||||
anthropic_key: null,
|
||||
openai_key: openaiKey.trim() || null,
|
||||
google_key: googleKey.trim() || null,
|
||||
github_token: ghOverride ? ghOverride.token : (githubToken ?? null),
|
||||
github_username: ghOverride ? ghOverride.username : (githubUsername ?? null),
|
||||
auto_pull_interval_minutes: pullInterval,
|
||||
}), [anthropicKey, openaiKey, googleKey, githubToken, githubUsername, pullInterval])
|
||||
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval])
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(buildSettings())
|
||||
@@ -190,7 +189,6 @@ function SettingsPanelInner({ settings, onSave, onClose, themeManager }: Omit<Se
|
||||
>
|
||||
<SettingsHeader onClose={onClose} />
|
||||
<SettingsBody
|
||||
anthropicKey={anthropicKey} setAnthropicKey={setAnthropicKey}
|
||||
openaiKey={openaiKey} setOpenaiKey={setOpenaiKey}
|
||||
googleKey={googleKey} setGoogleKey={setGoogleKey}
|
||||
githubToken={githubToken ?? null} githubUsername={githubUsername ?? null}
|
||||
@@ -223,7 +221,6 @@ function SettingsHeader({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
|
||||
interface SettingsBodyProps {
|
||||
anthropicKey: string; setAnthropicKey: (v: string) => void
|
||||
openaiKey: string; setOpenaiKey: (v: string) => void
|
||||
googleKey: string; setGoogleKey: (v: string) => void
|
||||
githubToken: string | null; githubUsername: string | null
|
||||
@@ -243,7 +240,6 @@ function SettingsBody(props: SettingsBodyProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KeyField label="Anthropic" placeholder="sk-ant-..." value={props.anthropicKey} onChange={props.setAnthropicKey} onClear={() => props.setAnthropicKey('')} />
|
||||
<KeyField label="OpenAI" placeholder="sk-..." value={props.openaiKey} onChange={props.setOpenaiKey} onClear={() => props.setOpenaiKey('')} />
|
||||
<KeyField label="Google AI" placeholder="AIza..." value={props.googleKey} onChange={props.setGoogleKey} onClear={() => props.setGoogleKey('')} />
|
||||
|
||||
|
||||
@@ -1,42 +1,23 @@
|
||||
/**
|
||||
* Custom hook encapsulating AI chat state and message handling.
|
||||
* Uses Claude CLI subprocess via Tauri for streaming responses.
|
||||
*/
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
type ChatMessage, nextMessageId, getApiKey,
|
||||
buildSystemPrompt, streamChat,
|
||||
type ChatMessage, nextMessageId,
|
||||
buildSystemPrompt, streamClaudeChat,
|
||||
} from '../utils/ai-chat'
|
||||
import { countWords } from '../utils/wikilinks'
|
||||
|
||||
function generateMockResponse(message: string, entry: VaultEntry | null, content: string): string {
|
||||
const title = entry?.title ?? 'Untitled'
|
||||
const words = countWords(content)
|
||||
const lower = message.toLowerCase()
|
||||
|
||||
if (lower.includes('summarize')) {
|
||||
return `This note is about **${title}**. It contains ${words} words covering the main concepts documented in your vault.`
|
||||
}
|
||||
if (lower.includes('expand')) {
|
||||
return `Here are some ways to expand this note:\n\n1. Add more detail to the introduction\n2. Include related examples\n3. Connect it to your quarterly goals\n4. Add a summary section at the end`
|
||||
}
|
||||
if (lower.includes('grammar')) {
|
||||
return `I reviewed the document for grammar issues. The writing looks clean overall — no major errors found.`
|
||||
}
|
||||
|
||||
return `Based on **${title}**, I can help with analysis, summarization, or expansion. What would you like to focus on?`
|
||||
}
|
||||
|
||||
export function useAIChat(
|
||||
entry: VaultEntry | null,
|
||||
allContent: Record<string, string>,
|
||||
contextNotes: VaultEntry[],
|
||||
model: string,
|
||||
) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [streamingContent, setStreamingContent] = useState('')
|
||||
const abortRef = useRef(false)
|
||||
const sessionIdRef = useRef<string | undefined>(undefined)
|
||||
|
||||
const sendMessage = useCallback((text: string) => {
|
||||
if (!text.trim() || isStreaming) return
|
||||
@@ -47,51 +28,44 @@ export function useAIChat(
|
||||
setStreamingContent('')
|
||||
abortRef.current = false
|
||||
|
||||
const allMessages = [...messages, userMsg].map(m => ({ role: m.role, content: m.content }))
|
||||
const hasApiKey = !!getApiKey()
|
||||
|
||||
if (!hasApiKey) {
|
||||
const content = entry ? (allContent[entry.path] ?? '') : ''
|
||||
setTimeout(() => {
|
||||
if (abortRef.current) return
|
||||
const response = generateMockResponse(text, entry, content)
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: response, id: nextMessageId() }])
|
||||
setIsStreaming(false)
|
||||
}, 800)
|
||||
return
|
||||
}
|
||||
|
||||
const { prompt: systemPrompt } = buildSystemPrompt(contextNotes, allContent)
|
||||
let accumulated = ''
|
||||
|
||||
const onChunk = (chunk: string) => {
|
||||
if (abortRef.current) return
|
||||
accumulated += chunk
|
||||
setStreamingContent(accumulated)
|
||||
}
|
||||
streamClaudeChat(text.trim(), systemPrompt || undefined, sessionIdRef.current, {
|
||||
onInit: (sid) => { sessionIdRef.current = sid },
|
||||
|
||||
const onDone = () => {
|
||||
if (abortRef.current) return
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: accumulated, id: nextMessageId() }])
|
||||
setStreamingContent('')
|
||||
setIsStreaming(false)
|
||||
}
|
||||
onText: (chunk) => {
|
||||
if (abortRef.current) return
|
||||
accumulated += chunk
|
||||
setStreamingContent(accumulated)
|
||||
},
|
||||
|
||||
const onError = (error: string) => {
|
||||
if (abortRef.current) return
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${error}`, id: nextMessageId() }])
|
||||
setStreamingContent('')
|
||||
setIsStreaming(false)
|
||||
}
|
||||
onError: (error) => {
|
||||
if (abortRef.current) return
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${error}`, id: nextMessageId() }])
|
||||
setStreamingContent('')
|
||||
setIsStreaming(false)
|
||||
},
|
||||
|
||||
streamChat(allMessages, systemPrompt, model, onChunk, onDone, onError)
|
||||
}, [isStreaming, entry, allContent, contextNotes, model, messages])
|
||||
onDone: () => {
|
||||
if (abortRef.current) return
|
||||
if (accumulated) {
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: accumulated, id: nextMessageId() }])
|
||||
}
|
||||
setStreamingContent('')
|
||||
setIsStreaming(false)
|
||||
},
|
||||
}).then(sid => {
|
||||
if (sid) sessionIdRef.current = sid
|
||||
})
|
||||
}, [isStreaming, allContent, contextNotes])
|
||||
|
||||
const clearConversation = useCallback(() => {
|
||||
abortRef.current = true
|
||||
setMessages([])
|
||||
setIsStreaming(false)
|
||||
setStreamingContent('')
|
||||
sessionIdRef.current = undefined
|
||||
}, [])
|
||||
|
||||
const retryMessage = useCallback((msgIndex: number) => {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
/**
|
||||
* Hook for the AI agent panel — manages agent state, tool execution, and undo.
|
||||
* Hook for the AI agent panel — manages agent state and streaming.
|
||||
* Uses Claude CLI subprocess with MCP tools via Tauri.
|
||||
*
|
||||
* States: idle → thinking → tool-executing → response
|
||||
* States: idle -> thinking -> tool-executing -> done/error
|
||||
*/
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import type { AiAction } from '../components/AiMessage'
|
||||
import {
|
||||
runAgentLoop, buildAgentSystemPrompt, executeToolViaWs,
|
||||
getAgentModel, type AgentStepCallback,
|
||||
} from '../utils/ai-agent'
|
||||
import { getApiKey, nextMessageId } from '../utils/ai-chat'
|
||||
import { streamClaudeAgent, buildAgentSystemPrompt } from '../utils/ai-agent'
|
||||
import { nextMessageId } from '../utils/ai-chat'
|
||||
|
||||
export type AgentStatus = 'idle' | 'thinking' | 'tool-executing' | 'done' | 'error'
|
||||
|
||||
@@ -22,29 +20,24 @@ export interface AiAgentMessage {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function useAiAgent() {
|
||||
export function useAiAgent(vaultPath: string) {
|
||||
const [messages, setMessages] = useState<AiAgentMessage[]>([])
|
||||
const [status, setStatus] = useState<AgentStatus>('idle')
|
||||
const abortRef = useRef({ aborted: false })
|
||||
const undoSnapshotRef = useRef<Map<string, string>>(new Map())
|
||||
const [canUndo, setCanUndo] = useState(false)
|
||||
|
||||
const sendMessage = useCallback(async (text: string) => {
|
||||
if (!text.trim() || status === 'thinking' || status === 'tool-executing') return
|
||||
|
||||
if (!getApiKey()) {
|
||||
if (!vaultPath) {
|
||||
setMessages(prev => [...prev, {
|
||||
userMessage: text.trim(), actions: [],
|
||||
response: 'No API key configured. Open Settings (\u2318,) to add your Anthropic key.',
|
||||
response: 'No vault loaded. Open a vault first.',
|
||||
id: nextMessageId(),
|
||||
}])
|
||||
return
|
||||
}
|
||||
|
||||
abortRef.current = { aborted: false }
|
||||
undoSnapshotRef.current = new Map()
|
||||
setCanUndo(false)
|
||||
const snapshotMap = undoSnapshotRef.current
|
||||
|
||||
const messageId = nextMessageId()
|
||||
setMessages(prev => [...prev, {
|
||||
@@ -56,10 +49,14 @@ export function useAiAgent() {
|
||||
setMessages(prev => prev.map(m => m.id === messageId ? fn(m) : m))
|
||||
}
|
||||
|
||||
const callbacks: AgentStepCallback = {
|
||||
onThinking: () => setStatus('thinking'),
|
||||
await streamClaudeAgent(text.trim(), buildAgentSystemPrompt(), vaultPath, {
|
||||
onText: (text) => {
|
||||
if (abortRef.current.aborted) return
|
||||
update(m => ({ ...m, response: (m.response ?? '') + text }))
|
||||
},
|
||||
|
||||
onToolStart: async (toolName, toolId, args) => {
|
||||
onToolStart: (toolName, toolId) => {
|
||||
if (abortRef.current.aborted) return
|
||||
setStatus('tool-executing')
|
||||
update(m => ({
|
||||
...m,
|
||||
@@ -69,87 +66,37 @@ export function useAiAgent() {
|
||||
status: 'pending' as const,
|
||||
}],
|
||||
}))
|
||||
|
||||
// Snapshot existing file before write operations
|
||||
const path = extractPathFromArgs(toolName, args)
|
||||
if (path && isWriteTool(toolName) && !snapshotMap.has(path)) {
|
||||
const { result, isError } = await executeToolViaWs('read_note', { path })
|
||||
if (!isError && result && typeof (result as Record<string, unknown>).content === 'string') {
|
||||
snapshotMap.set(path, (result as Record<string, unknown>).content as string)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onToolDone: (toolId, result, isError) => {
|
||||
update(m => ({
|
||||
...m,
|
||||
actions: m.actions.map(a =>
|
||||
a.label.includes(toolId.slice(-6))
|
||||
? { ...a, status: isError ? 'error' as const : 'done' as const, label: formatToolResult(a.tool, result) }
|
||||
: a,
|
||||
),
|
||||
}))
|
||||
},
|
||||
|
||||
onText: (text) => update(m => ({ ...m, response: (m.response ?? '') + text })),
|
||||
|
||||
onError: (error) => {
|
||||
if (abortRef.current.aborted) return
|
||||
setStatus('error')
|
||||
update(m => ({ ...m, isStreaming: false, response: `Error: ${error}` }))
|
||||
update(m => ({ ...m, isStreaming: false, response: (m.response ?? '') + `\nError: ${error}` }))
|
||||
},
|
||||
|
||||
onDone: () => {
|
||||
if (abortRef.current.aborted) return
|
||||
setStatus('done')
|
||||
update(m => ({ ...m, isStreaming: false }))
|
||||
update(m => ({
|
||||
...m,
|
||||
isStreaming: false,
|
||||
actions: m.actions.map(a => a.status === 'pending' ? { ...a, status: 'done' as const } : a),
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
await runAgentLoop(text.trim(), getAgentModel(), buildAgentSystemPrompt(), callbacks, abortRef.current)
|
||||
|
||||
if (snapshotMap.size > 0) setCanUndo(true)
|
||||
}, [status])
|
||||
})
|
||||
}, [status, vaultPath])
|
||||
|
||||
const clearConversation = useCallback(() => {
|
||||
abortRef.current.aborted = true
|
||||
setMessages([])
|
||||
setStatus('idle')
|
||||
setCanUndo(false)
|
||||
undoSnapshotRef.current = new Map()
|
||||
}, [])
|
||||
|
||||
const undoLastRun = useCallback(async () => {
|
||||
const snapshot = undoSnapshotRef.current
|
||||
if (snapshot.size === 0) return
|
||||
// Undo: delete newly created notes, or log that files were modified
|
||||
// Full content restore requires a write_note tool on the WS bridge
|
||||
for (const [path, content] of snapshot) {
|
||||
if (content === '') {
|
||||
// File didn't exist before — it was created by the agent, so delete it
|
||||
await executeToolViaWs('delete_note', { path }).catch(() => {})
|
||||
}
|
||||
// For modified files, content restore isn't available via WS bridge
|
||||
}
|
||||
undoSnapshotRef.current = new Map()
|
||||
setCanUndo(false)
|
||||
}, [])
|
||||
|
||||
return { messages, status, sendMessage, clearConversation, canUndo, undoLastRun }
|
||||
return { messages, status, sendMessage, clearConversation }
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
const WRITE_TOOLS = new Set(['create_note', 'append_to_note', 'edit_note_frontmatter', 'delete_note', 'link_notes'])
|
||||
|
||||
function isWriteTool(name: string): boolean {
|
||||
return WRITE_TOOLS.has(name)
|
||||
}
|
||||
|
||||
function extractPathFromArgs(_toolName: string, args: Record<string, unknown>): string | null {
|
||||
if (args.path && typeof args.path === 'string') return args.path
|
||||
if (args.source_path && typeof args.source_path === 'string') return args.source_path
|
||||
return null
|
||||
}
|
||||
|
||||
function formatToolLabel(toolName: string, toolId: string): string {
|
||||
const suffix = toolId.slice(-6)
|
||||
const labels: Record<string, string> = {
|
||||
@@ -169,32 +116,3 @@ function formatToolLabel(toolName: string, toolId: string): string {
|
||||
}
|
||||
return `${labels[toolName] ?? toolName}... (${suffix})`
|
||||
}
|
||||
|
||||
function formatToolResult(toolName: string, result: unknown): string {
|
||||
if (!result || typeof result !== 'object') return humanToolName(toolName)
|
||||
const r = result as Record<string, unknown>
|
||||
if (r.error) return `${humanToolName(toolName)}: Error`
|
||||
if (r.content && typeof r.content === 'string') return `Read: ${(r.content as string).slice(0, 40)}...`
|
||||
if (r.ok) return `${humanToolName(toolName)}: Done`
|
||||
if (Array.isArray(result)) return `Found ${result.length} results`
|
||||
return humanToolName(toolName)
|
||||
}
|
||||
|
||||
function humanToolName(toolName: string): string {
|
||||
const names: Record<string, string> = {
|
||||
read_note: 'Read note',
|
||||
create_note: 'Created note',
|
||||
search_notes: 'Searched notes',
|
||||
append_to_note: 'Appended to note',
|
||||
edit_note_frontmatter: 'Edited frontmatter',
|
||||
delete_note: 'Deleted note',
|
||||
link_notes: 'Linked notes',
|
||||
list_notes: 'Listed notes',
|
||||
vault_context: 'Loaded vault context',
|
||||
ui_open_note: 'Opened note',
|
||||
ui_open_tab: 'Opened tab',
|
||||
ui_highlight: 'Highlighted',
|
||||
ui_set_filter: 'Set filter',
|
||||
}
|
||||
return names[toolName] ?? toolName
|
||||
}
|
||||
|
||||
@@ -107,20 +107,6 @@ const mockThemes: ThemeFile[] = [
|
||||
|
||||
let mockDeviceFlowPollCount = 0
|
||||
|
||||
function handleAiChat(args: { request: { messages: { role: string; content: string }[]; model?: string; system?: string } }) {
|
||||
const lastMsg = args.request.messages[args.request.messages.length - 1]?.content ?? ''
|
||||
const lower = lastMsg.toLowerCase()
|
||||
let content = `I can help you with that. Could you provide more details about what you'd like to know?`
|
||||
if (lower.includes('summarize')) {
|
||||
content = `Here's a summary of the note:\n\n**Key Points:**\n- The note covers the main topic and its related concepts\n- It includes actionable items and references to other notes\n- Several wiki-links connect it to the broader knowledge base\n\nWould you like me to expand on any of these points?`
|
||||
} else if (lower.includes('expand')) {
|
||||
content = `Here are suggestions to expand this note:\n\n1. **Add context** — Include background information\n2. **Link related notes** — Connect to [[related topics]]\n3. **Add examples** — Include concrete examples\n4. **Update status** — Reflect current progress`
|
||||
} else if (lower.includes('grammar')) {
|
||||
content = `Grammar review complete. The writing is clear and well-structured. Minor suggestions:\n\n- Consider varying sentence lengths for better rhythm\n- A few passive constructions could be made active`
|
||||
}
|
||||
return { content, model: args.request.model ?? 'claude-3-5-haiku-20241022', stop_reason: 'end_turn' }
|
||||
}
|
||||
|
||||
function handleRenameNote(args: { vault_path: string; old_path: string; new_title: string }) {
|
||||
const oldContent = MOCK_CONTENT[args.old_path] ?? ''
|
||||
const slug = args.new_title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
@@ -179,7 +165,9 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
get_last_commit_info: (): LastCommitInfo => ({ shortHash: 'a1b2c3d', commitUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }),
|
||||
git_pull: (): GitPullResult => ({ status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }),
|
||||
git_push: () => 'Everything up-to-date',
|
||||
ai_chat: handleAiChat,
|
||||
check_claude_cli: () => ({ installed: false, version: null }),
|
||||
stream_claude_chat: () => 'mock-session',
|
||||
stream_claude_agent: () => null,
|
||||
save_note_content: (args: { path: string; content: string }) => {
|
||||
MOCK_CONTENT[args.path] = args.content
|
||||
mockSavedSinceCommit.add(args.path)
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// localStorage mock (jsdom doesn't provide a full implementation)
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value },
|
||||
removeItem: (key: string) => { delete store[key] },
|
||||
clear: () => { store = {} },
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
|
||||
// Mock the mock-tauri module before importing ai-agent
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
}))
|
||||
|
||||
import { buildAgentSystemPrompt, executeToolViaWs, runAgentLoop, type AgentStepCallback } from './ai-agent'
|
||||
import { buildAgentSystemPrompt, streamClaudeAgent } from './ai-agent'
|
||||
|
||||
// --- buildAgentSystemPrompt ---
|
||||
|
||||
@@ -31,151 +24,28 @@ describe('buildAgentSystemPrompt', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --- runAgentLoop calls Anthropic API directly ---
|
||||
// --- streamClaudeAgent ---
|
||||
|
||||
describe('runAgentLoop', () => {
|
||||
const mockCallbacks: AgentStepCallback = {
|
||||
onThinking: vi.fn(),
|
||||
onToolStart: vi.fn(),
|
||||
onToolDone: vi.fn(),
|
||||
onText: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
onDone: vi.fn(),
|
||||
}
|
||||
describe('streamClaudeAgent', () => {
|
||||
it('calls onText and onDone in non-Tauri environment', async () => {
|
||||
const onText = vi.fn()
|
||||
const onToolStart = vi.fn()
|
||||
const onDone = vi.fn()
|
||||
const onError = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
localStorageMock.clear()
|
||||
localStorageMock.setItem('laputa:anthropic-api-key', 'test-key-123')
|
||||
})
|
||||
|
||||
it('calls https://api.anthropic.com/v1/messages directly', async () => {
|
||||
const mockResponse = {
|
||||
id: 'msg_123',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Hello!' }],
|
||||
stop_reason: 'end_turn',
|
||||
}
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(mockResponse), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
|
||||
await runAgentLoop('test message', 'claude-3-5-haiku-20241022', 'system prompt', mockCallbacks)
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledOnce()
|
||||
const [url, options] = fetchSpy.mock.calls[0]
|
||||
expect(url).toBe('https://api.anthropic.com/v1/messages')
|
||||
expect(options?.method).toBe('POST')
|
||||
|
||||
const headers = options?.headers as Record<string, string>
|
||||
expect(headers['x-api-key']).toBe('test-key-123')
|
||||
expect(headers['anthropic-version']).toBe('2023-06-01')
|
||||
expect(headers['Content-Type']).toBe('application/json')
|
||||
|
||||
const body = JSON.parse(options?.body as string)
|
||||
expect(body.model).toBe('claude-3-5-haiku-20241022')
|
||||
expect(body.max_tokens).toBe(4096)
|
||||
expect(body.system).toBe('system prompt')
|
||||
expect(body.messages).toEqual([{ role: 'user', content: 'test message' }])
|
||||
expect(body.tools).toBeDefined()
|
||||
// API key must NOT be in the body
|
||||
expect(body.apiKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls onText and onDone for a text-only response', async () => {
|
||||
const mockResponse = {
|
||||
id: 'msg_123',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Here is your answer.' }],
|
||||
stop_reason: 'end_turn',
|
||||
}
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(mockResponse), { status: 200 }),
|
||||
)
|
||||
|
||||
await runAgentLoop('question', 'claude-3-5-haiku-20241022', 'sys', mockCallbacks)
|
||||
|
||||
expect(mockCallbacks.onThinking).toHaveBeenCalled()
|
||||
expect(mockCallbacks.onText).toHaveBeenCalledWith('Here is your answer.')
|
||||
expect(mockCallbacks.onDone).toHaveBeenCalled()
|
||||
expect(mockCallbacks.onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onError when API key is missing', async () => {
|
||||
localStorageMock.clear()
|
||||
|
||||
await runAgentLoop('test', 'claude-3-5-haiku-20241022', 'sys', mockCallbacks)
|
||||
|
||||
expect(mockCallbacks.onError).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No API key configured'),
|
||||
)
|
||||
})
|
||||
|
||||
it('calls onError with API error message on non-ok response', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: { message: 'Invalid API key' } }), { status: 401 }),
|
||||
)
|
||||
|
||||
await runAgentLoop('test', 'claude-3-5-haiku-20241022', 'sys', mockCallbacks)
|
||||
|
||||
expect(mockCallbacks.onError).toHaveBeenCalledWith('Invalid API key')
|
||||
})
|
||||
|
||||
it('does not send apiKey in the request body', async () => {
|
||||
const mockResponse = {
|
||||
id: 'msg_123',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
stop_reason: 'end_turn',
|
||||
}
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(mockResponse), { status: 200 }),
|
||||
)
|
||||
|
||||
await runAgentLoop('test', 'claude-3-5-haiku-20241022', 'sys', mockCallbacks)
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string)
|
||||
expect(body).not.toHaveProperty('apiKey')
|
||||
expect(body).not.toHaveProperty('maxTokens')
|
||||
})
|
||||
})
|
||||
|
||||
// --- executeToolViaWs ---
|
||||
|
||||
describe('executeToolViaWs', () => {
|
||||
it('resolves with error when WebSocket is unavailable', async () => {
|
||||
// Mock WebSocket to simulate a connection failure without triggering
|
||||
// jsdom/undici unhandled rejections (jsdom's WebSocket internally throws
|
||||
// InvalidArgumentError: invalid onError method on failed connections).
|
||||
const OriginalWebSocket = globalThis.WebSocket
|
||||
const mockWs = {
|
||||
readyState: WebSocket.CONNECTING,
|
||||
close: vi.fn(),
|
||||
send: vi.fn(),
|
||||
onopen: null as ((ev: Event) => void) | null,
|
||||
onerror: null as ((ev: Event) => void) | null,
|
||||
onmessage: null as ((ev: MessageEvent) => void) | null,
|
||||
}
|
||||
// @ts-expect-error - partial mock for test
|
||||
globalThis.WebSocket = vi.fn(() => {
|
||||
// Fire onerror asynchronously to simulate failed connection
|
||||
setTimeout(() => {
|
||||
if (mockWs.onerror) mockWs.onerror(new Event('error'))
|
||||
}, 0)
|
||||
return mockWs
|
||||
await streamClaudeAgent('test message', 'system prompt', '/tmp/vault', {
|
||||
onText,
|
||||
onToolStart,
|
||||
onError,
|
||||
onDone,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await executeToolViaWs('read_note', { path: 'test.md' })
|
||||
expect(result.isError).toBe(true)
|
||||
} finally {
|
||||
globalThis.WebSocket = OriginalWebSocket
|
||||
}
|
||||
// Wait for the setTimeout mock response
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
|
||||
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Claude CLI'))
|
||||
expect(onDone).toHaveBeenCalled()
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(onToolStart).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,261 +1,14 @@
|
||||
/**
|
||||
* AI Agent utilities — Anthropic tool-use loop, tool definitions, WS bridge execution.
|
||||
* AI Agent utilities — Claude CLI agent mode with MCP vault tools.
|
||||
*
|
||||
* The agent loop: call Claude with tools → if tool_use, execute via WS → feed result → repeat.
|
||||
* The Claude CLI handles the tool-use loop internally via MCP.
|
||||
* The frontend receives streaming events for text, tool calls, and completion.
|
||||
*/
|
||||
|
||||
import { getApiKey } from './ai-chat'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
|
||||
// --- Tool definitions (mirrors mcp-server/index.js TOOLS) ---
|
||||
// --- Agent system prompt ---
|
||||
|
||||
export const AGENT_TOOLS = [
|
||||
{
|
||||
name: 'read_note',
|
||||
description: 'Read the full content of a note',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: { path: { type: 'string', description: 'Relative path to the note' } },
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_note',
|
||||
description: 'Create a new note in the vault with a title and optional frontmatter',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path for the new note' },
|
||||
title: { type: 'string', description: 'Title of the note' },
|
||||
is_a: { type: 'string', description: 'Entity type (Project, Note, etc.)' },
|
||||
},
|
||||
required: ['path', 'title'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_notes',
|
||||
description: 'Search notes in the vault by title or content',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query string' },
|
||||
limit: { type: 'number', description: 'Max results (default: 10)' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'append_to_note',
|
||||
description: 'Append text to the end of an existing note',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note' },
|
||||
text: { type: 'string', description: 'Text to append' },
|
||||
},
|
||||
required: ['path', 'text'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'edit_note_frontmatter',
|
||||
description: "Merge a patch object into a note's YAML frontmatter",
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note' },
|
||||
patch: { type: 'object', description: 'Key-value pairs to merge into frontmatter' },
|
||||
},
|
||||
required: ['path', 'patch'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'delete_note',
|
||||
description: 'Delete a note file from the vault',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: { path: { type: 'string', description: 'Relative path to delete' } },
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'link_notes',
|
||||
description: "Add a title to an array property in a note's frontmatter",
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
source_path: { type: 'string', description: 'Relative path to the source note' },
|
||||
property: { type: 'string', description: 'Frontmatter property name' },
|
||||
target_title: { type: 'string', description: 'Title to add to the array' },
|
||||
},
|
||||
required: ['source_path', 'property', 'target_title'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_notes',
|
||||
description: 'List all notes, optionally filtered by type',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
type_filter: { type: 'string', description: 'Filter by type' },
|
||||
sort: { type: 'string', enum: ['title', 'mtime'], description: 'Sort order' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'vault_context',
|
||||
description: 'Get vault context: entity types and 20 recent notes',
|
||||
input_schema: { type: 'object' as const, properties: {} },
|
||||
},
|
||||
{
|
||||
name: 'ui_open_note',
|
||||
description: 'Open a note in the Laputa UI editor',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: { path: { type: 'string', description: 'Relative path to the note' } },
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ui_open_tab',
|
||||
description: 'Open a note in a new tab',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: { path: { type: 'string', description: 'Relative path to the note' } },
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ui_highlight',
|
||||
description: 'Highlight a UI element in the Laputa interface',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'] },
|
||||
path: { type: 'string', description: 'Relative path (optional)' },
|
||||
},
|
||||
required: ['element'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ui_set_filter',
|
||||
description: 'Set the sidebar filter to show notes of a specific type',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: { type: { type: 'string', description: 'Type to filter by' } },
|
||||
required: ['type'],
|
||||
},
|
||||
},
|
||||
] as const
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface ToolUseBlock {
|
||||
type: 'tool_use'
|
||||
id: string
|
||||
name: string
|
||||
input: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface TextBlock {
|
||||
type: 'text'
|
||||
text: string
|
||||
}
|
||||
|
||||
type ContentBlock = TextBlock | ToolUseBlock
|
||||
|
||||
interface AnthropicMessage {
|
||||
id: string
|
||||
role: 'assistant'
|
||||
content: ContentBlock[]
|
||||
stop_reason: 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence'
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
toolUseId: string
|
||||
toolName: string
|
||||
result: unknown
|
||||
isError: boolean
|
||||
}
|
||||
|
||||
export interface AgentStepCallback {
|
||||
onThinking: () => void
|
||||
onToolStart: (toolName: string, toolId: string, args: Record<string, unknown>) => void
|
||||
onToolDone: (toolId: string, result: unknown, isError: boolean) => void
|
||||
onText: (text: string) => void
|
||||
onError: (error: string) => void
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
// --- WebSocket tool execution ---
|
||||
|
||||
const WS_TOOL_URL = 'ws://localhost:9710'
|
||||
const TOOL_TIMEOUT_MS = 30_000
|
||||
|
||||
export async function executeToolViaWs(
|
||||
toolName: string, args: Record<string, unknown>,
|
||||
): Promise<{ result: unknown; isError: boolean }> {
|
||||
return new Promise((resolve) => {
|
||||
let ws: WebSocket | null = null
|
||||
let resolved = false
|
||||
|
||||
const cleanup = () => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.close()
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
cleanup()
|
||||
resolve({ result: { error: 'Tool execution timed out' }, isError: true })
|
||||
}
|
||||
}, TOOL_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
ws = new WebSocket(WS_TOOL_URL)
|
||||
const reqId = `agent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
ws.onopen = () => {
|
||||
ws!.send(JSON.stringify({ id: reqId, tool: toolName, args }))
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (resolved) return
|
||||
try {
|
||||
const msg = JSON.parse(event.data as string)
|
||||
if (msg.id === reqId) {
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
cleanup()
|
||||
if (msg.error) {
|
||||
resolve({ result: { error: msg.error }, isError: true })
|
||||
} else {
|
||||
resolve({ result: msg.result, isError: false })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
resolve({ result: { error: 'WebSocket bridge not available. Start Laputa to use AI tools.' }, isError: true })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
resolve({ result: { error: 'Failed to connect to WebSocket bridge' }, isError: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Agent loop ---
|
||||
|
||||
const MAX_TOOL_LOOPS = 10
|
||||
const AGENT_SYSTEM_PREAMBLE = `You are an AI assistant integrated into Laputa, a personal knowledge management app.
|
||||
You can perform actions on the user's vault using the provided tools.
|
||||
Be concise and helpful. When creating notes, use appropriate entity types and folder conventions.
|
||||
@@ -266,130 +19,75 @@ export function buildAgentSystemPrompt(vaultContext?: string): string {
|
||||
return `${AGENT_SYSTEM_PREAMBLE}\n\nVault context:\n${vaultContext}`
|
||||
}
|
||||
|
||||
async function callAnthropicAgent(
|
||||
messages: unknown[], system: string, model: string, tools: unknown[],
|
||||
): Promise<AnthropicMessage> {
|
||||
const apiKey = getApiKey()
|
||||
if (!apiKey) throw new Error('No API key configured. Open Settings (⌘,) to add your Anthropic key.')
|
||||
// --- Claude CLI agent streaming ---
|
||||
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
system: system || undefined,
|
||||
messages,
|
||||
tools,
|
||||
}),
|
||||
type ClaudeAgentStreamEvent =
|
||||
| { kind: 'Init'; session_id: string }
|
||||
| { kind: 'TextDelta'; text: string }
|
||||
| { kind: 'ToolStart'; tool_name: string; tool_id: string }
|
||||
| { kind: 'ToolDone'; tool_id: string }
|
||||
| { kind: 'Result'; text: string; session_id: string }
|
||||
| { kind: 'Error'; message: string }
|
||||
| { kind: 'Done' }
|
||||
|
||||
export interface AgentStreamCallbacks {
|
||||
onText: (text: string) => void
|
||||
onToolStart: (toolName: string, toolId: string) => void
|
||||
onError: (message: string) => void
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream an agent task through the Claude CLI subprocess with MCP tools.
|
||||
* The CLI handles the tool-use loop; we receive events for UI updates.
|
||||
*/
|
||||
export async function streamClaudeAgent(
|
||||
message: string,
|
||||
systemPrompt: string | undefined,
|
||||
vaultPath: string,
|
||||
callbacks: AgentStreamCallbacks,
|
||||
): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
setTimeout(() => {
|
||||
callbacks.onText('AI Agent requires the Claude CLI. Install it and run the native app.')
|
||||
callbacks.onDone()
|
||||
}, 300)
|
||||
return
|
||||
}
|
||||
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
|
||||
const unlisten = await listen<ClaudeAgentStreamEvent>('claude-agent-stream', (event) => {
|
||||
const data = event.payload
|
||||
switch (data.kind) {
|
||||
case 'TextDelta':
|
||||
callbacks.onText(data.text)
|
||||
break
|
||||
case 'ToolStart':
|
||||
callbacks.onToolStart(data.tool_name, data.tool_id)
|
||||
break
|
||||
case 'Error':
|
||||
callbacks.onError(data.message)
|
||||
break
|
||||
case 'Done':
|
||||
callbacks.onDone()
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text()
|
||||
let errMsg: string
|
||||
try {
|
||||
const errJson = JSON.parse(errText)
|
||||
errMsg = errJson.error?.message || errJson.error || `API error (${response.status})`
|
||||
} catch {
|
||||
errMsg = `API error (${response.status})`
|
||||
}
|
||||
throw new Error(errMsg)
|
||||
try {
|
||||
await invoke<string>('stream_claude_agent', {
|
||||
request: {
|
||||
message,
|
||||
system_prompt: systemPrompt || null,
|
||||
vault_path: vaultPath,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
callbacks.onError(err instanceof Error ? err.message : String(err))
|
||||
callbacks.onDone()
|
||||
} finally {
|
||||
unlisten()
|
||||
}
|
||||
|
||||
return response.json() as Promise<AnthropicMessage>
|
||||
}
|
||||
|
||||
export async function runAgentLoop(
|
||||
userMessage: string,
|
||||
model: string,
|
||||
systemPrompt: string,
|
||||
callbacks: AgentStepCallback,
|
||||
abortSignal?: { aborted: boolean },
|
||||
): Promise<void> {
|
||||
const messages: unknown[] = [{ role: 'user', content: userMessage }]
|
||||
|
||||
callbacks.onThinking()
|
||||
|
||||
for (let loop = 0; loop < MAX_TOOL_LOOPS; loop++) {
|
||||
if (abortSignal?.aborted) return
|
||||
|
||||
let response: AnthropicMessage
|
||||
try {
|
||||
response = await callAnthropicAgent(messages, systemPrompt, model, AGENT_TOOLS as unknown as unknown[])
|
||||
} catch (err) {
|
||||
callbacks.onError(err instanceof Error ? err.message : 'Unknown error')
|
||||
return
|
||||
}
|
||||
|
||||
if (abortSignal?.aborted) return
|
||||
|
||||
// Process content blocks
|
||||
const textParts: string[] = []
|
||||
const toolUseBlocks: ToolUseBlock[] = []
|
||||
|
||||
for (const block of response.content) {
|
||||
if (block.type === 'text') {
|
||||
textParts.push(block.text)
|
||||
} else if (block.type === 'tool_use') {
|
||||
toolUseBlocks.push(block)
|
||||
}
|
||||
}
|
||||
|
||||
// If no tool_use, we're done
|
||||
if (toolUseBlocks.length === 0) {
|
||||
const fullText = textParts.join('\n')
|
||||
callbacks.onText(fullText)
|
||||
callbacks.onDone()
|
||||
return
|
||||
}
|
||||
|
||||
// Execute each tool call
|
||||
messages.push({ role: 'assistant', content: response.content })
|
||||
const toolResults: { type: 'tool_result'; tool_use_id: string; content: string }[] = []
|
||||
|
||||
for (const toolBlock of toolUseBlocks) {
|
||||
if (abortSignal?.aborted) return
|
||||
|
||||
callbacks.onToolStart(toolBlock.name, toolBlock.id, toolBlock.input)
|
||||
|
||||
const { result, isError } = await executeToolViaWs(toolBlock.name, toolBlock.input)
|
||||
|
||||
callbacks.onToolDone(toolBlock.id, result, isError)
|
||||
|
||||
const resultText = typeof result === 'string' ? result : JSON.stringify(result)
|
||||
toolResults.push({ type: 'tool_result', tool_use_id: toolBlock.id, content: resultText })
|
||||
}
|
||||
|
||||
// Feed tool results back to Claude
|
||||
messages.push({ role: 'user', content: toolResults })
|
||||
|
||||
// Any text from the same response gets noted but loop continues
|
||||
if (textParts.length > 0) {
|
||||
callbacks.onText(textParts.join('\n'))
|
||||
}
|
||||
}
|
||||
|
||||
// Max loops reached
|
||||
callbacks.onText('Reached maximum tool execution steps.')
|
||||
callbacks.onDone()
|
||||
}
|
||||
|
||||
// --- Model options for agent ---
|
||||
export const AGENT_MODEL_OPTIONS = [
|
||||
{ value: 'claude-3-5-haiku-20241022', label: 'Haiku (fast)' },
|
||||
{ value: 'claude-sonnet-4-20250514', label: 'Sonnet (smart)' },
|
||||
] as const
|
||||
|
||||
const AGENT_MODEL_KEY = 'laputa:ai-agent-model'
|
||||
|
||||
export function getAgentModel(): string {
|
||||
return localStorage.getItem(AGENT_MODEL_KEY) ?? AGENT_MODEL_OPTIONS[0].value
|
||||
}
|
||||
|
||||
export function setAgentModel(model: string): void {
|
||||
localStorage.setItem(AGENT_MODEL_KEY, model)
|
||||
}
|
||||
|
||||
@@ -1,38 +1,16 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// localStorage mock (jsdom doesn't provide a full implementation)
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value },
|
||||
removeItem: (key: string) => { delete store[key] },
|
||||
clear: () => { store = {} },
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
|
||||
// Mock the mock-tauri module before importing ai-chat
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
}))
|
||||
|
||||
import {
|
||||
getApiKey, setApiKey, estimateTokens, buildSystemPrompt,
|
||||
nextMessageId, streamChat,
|
||||
estimateTokens, buildSystemPrompt,
|
||||
nextMessageId, checkClaudeCli, streamClaudeChat,
|
||||
} from './ai-chat'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
// --- getApiKey / setApiKey ---
|
||||
|
||||
describe('getApiKey / setApiKey', () => {
|
||||
beforeEach(() => localStorageMock.clear())
|
||||
|
||||
it('returns empty string when no key set', () => {
|
||||
expect(getApiKey()).toBe('')
|
||||
})
|
||||
|
||||
it('round-trips an API key', () => {
|
||||
setApiKey('sk-test-abc')
|
||||
expect(getApiKey()).toBe('sk-test-abc')
|
||||
})
|
||||
})
|
||||
|
||||
// --- estimateTokens ---
|
||||
|
||||
describe('estimateTokens', () => {
|
||||
@@ -85,94 +63,36 @@ describe('nextMessageId', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --- streamChat calls Anthropic API directly ---
|
||||
// --- checkClaudeCli ---
|
||||
|
||||
describe('streamChat', () => {
|
||||
const onChunk = vi.fn()
|
||||
const onDone = vi.fn()
|
||||
const onError = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
onChunk.mockClear()
|
||||
onDone.mockClear()
|
||||
onError.mockClear()
|
||||
localStorageMock.clear()
|
||||
localStorageMock.setItem('laputa:anthropic-api-key', 'test-key-456')
|
||||
})
|
||||
|
||||
it('calls https://api.anthropic.com/v1/messages with correct headers', async () => {
|
||||
const sseData = [
|
||||
'data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n',
|
||||
'data: {"type":"message_stop"}\n\n',
|
||||
].join('')
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(sseData, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
}),
|
||||
)
|
||||
|
||||
await streamChat(
|
||||
[{ role: 'user', content: 'hello' }], 'system', 'claude-3-5-haiku-20241022',
|
||||
onChunk, onDone, onError,
|
||||
)
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledOnce()
|
||||
const [url, options] = fetchSpy.mock.calls[0]
|
||||
expect(url).toBe('https://api.anthropic.com/v1/messages')
|
||||
|
||||
const headers = options?.headers as Record<string, string>
|
||||
expect(headers['x-api-key']).toBe('test-key-456')
|
||||
expect(headers['anthropic-version']).toBe('2023-06-01')
|
||||
|
||||
const body = JSON.parse(options?.body as string)
|
||||
expect(body.stream).toBe(true)
|
||||
expect(body.model).toBe('claude-3-5-haiku-20241022')
|
||||
expect(body.messages).toEqual([{ role: 'user', content: 'hello' }])
|
||||
// API key must NOT be in the body
|
||||
expect(body.apiKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls onError when API key is missing', async () => {
|
||||
localStorageMock.clear()
|
||||
|
||||
await streamChat([], '', 'model', onChunk, onDone, onError)
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No API key configured'),
|
||||
)
|
||||
expect(onChunk).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onError with parsed error on non-ok response', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: { message: 'Invalid x-api-key' } }), { status: 401 }),
|
||||
)
|
||||
|
||||
await streamChat(
|
||||
[{ role: 'user', content: 'hi' }], '', 'model',
|
||||
onChunk, onDone, onError,
|
||||
)
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Invalid x-api-key')
|
||||
})
|
||||
|
||||
it('does not send apiKey in the request body', async () => {
|
||||
const sseData = 'data: {"type":"message_stop"}\n\n'
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(sseData, { status: 200 }),
|
||||
)
|
||||
|
||||
await streamChat(
|
||||
[{ role: 'user', content: 'hi' }], '', 'model',
|
||||
onChunk, onDone, onError,
|
||||
)
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string)
|
||||
expect(body).not.toHaveProperty('apiKey')
|
||||
expect(body).not.toHaveProperty('maxTokens')
|
||||
expect(body.max_tokens).toBe(4096)
|
||||
describe('checkClaudeCli', () => {
|
||||
it('returns not installed in non-Tauri environment', async () => {
|
||||
const status = await checkClaudeCli()
|
||||
expect(status.installed).toBe(false)
|
||||
expect(status.version).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// --- streamClaudeChat ---
|
||||
|
||||
describe('streamClaudeChat', () => {
|
||||
it('returns mock session in non-Tauri environment', async () => {
|
||||
const onText = vi.fn()
|
||||
const onDone = vi.fn()
|
||||
const onError = vi.fn()
|
||||
|
||||
const sessionId = await streamClaudeChat('hello', undefined, undefined, {
|
||||
onText,
|
||||
onError,
|
||||
onDone,
|
||||
})
|
||||
|
||||
// Wait for the setTimeout mock response
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
|
||||
expect(sessionId).toBe('mock-session')
|
||||
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Claude CLI'))
|
||||
expect(onDone).toHaveBeenCalled()
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
/**
|
||||
* AI Chat utilities — Anthropic API client, token estimation, context building.
|
||||
* AI Chat utilities — Claude CLI integration, token estimation, context building.
|
||||
*/
|
||||
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
// --- localStorage key for API key ---
|
||||
const API_KEY_STORAGE_KEY = 'laputa:anthropic-api-key'
|
||||
|
||||
export function getApiKey(): string {
|
||||
return localStorage.getItem(API_KEY_STORAGE_KEY) ?? ''
|
||||
}
|
||||
|
||||
export function setApiKey(key: string): void {
|
||||
localStorage.setItem(API_KEY_STORAGE_KEY, key)
|
||||
}
|
||||
import { isTauri } from '../mock-tauri'
|
||||
|
||||
// --- Token estimation ---
|
||||
|
||||
@@ -73,7 +63,7 @@ export function buildSystemPrompt(
|
||||
return { prompt, totalTokens: estimateTokens(prompt), truncated }
|
||||
}
|
||||
|
||||
// --- API types ---
|
||||
// --- Message types ---
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
@@ -86,110 +76,107 @@ export function nextMessageId(): string {
|
||||
return `msg-${++msgIdCounter}-${Date.now()}`
|
||||
}
|
||||
|
||||
// --- SSE parsing ---
|
||||
// --- Claude CLI status ---
|
||||
|
||||
function parseSseEvent(line: string, onChunk: (text: string) => void): boolean {
|
||||
if (!line.startsWith('data: ')) return false
|
||||
const data = line.slice(6)
|
||||
if (data === '[DONE]') return true
|
||||
|
||||
try {
|
||||
const event = JSON.parse(data)
|
||||
if (event.type === 'content_block_delta' && event.delta?.text) {
|
||||
onChunk(event.delta.text)
|
||||
}
|
||||
if (event.type === 'message_stop') return true
|
||||
} catch {
|
||||
// skip malformed events
|
||||
}
|
||||
return false
|
||||
export interface ClaudeCliStatus {
|
||||
installed: boolean
|
||||
version: string | null
|
||||
}
|
||||
|
||||
async function readSseStream(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
onChunk: (text: string) => void,
|
||||
): Promise<void> {
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
export async function checkClaudeCli(): Promise<ClaudeCliStatus> {
|
||||
if (!isTauri()) {
|
||||
return { installed: false, version: null }
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke<ClaudeCliStatus>('check_claude_cli')
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
// --- Claude CLI streaming ---
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
type ClaudeStreamEvent =
|
||||
| { kind: 'Init'; session_id: string }
|
||||
| { kind: 'TextDelta'; text: string }
|
||||
| { kind: 'ToolStart'; tool_name: string; tool_id: string }
|
||||
| { kind: 'ToolDone'; tool_id: string }
|
||||
| { kind: 'Result'; text: string; session_id: string }
|
||||
| { kind: 'Error'; message: string }
|
||||
| { kind: 'Done' }
|
||||
|
||||
for (const line of lines) {
|
||||
if (parseSseEvent(line, onChunk)) return
|
||||
}
|
||||
export interface ChatStreamCallbacks {
|
||||
onInit?: (sessionId: string) => void
|
||||
onText: (text: string) => void
|
||||
onError: (message: string) => void
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
/** Handle a single stream event from the Claude CLI, updating session state. */
|
||||
function handleChatStreamEvent(
|
||||
data: ClaudeStreamEvent,
|
||||
state: { sessionId: string },
|
||||
callbacks: ChatStreamCallbacks,
|
||||
): void {
|
||||
switch (data.kind) {
|
||||
case 'Init':
|
||||
state.sessionId = data.session_id
|
||||
callbacks.onInit?.(data.session_id)
|
||||
break
|
||||
case 'TextDelta':
|
||||
callbacks.onText(data.text)
|
||||
break
|
||||
case 'Result':
|
||||
if (data.session_id) state.sessionId = data.session_id
|
||||
break
|
||||
case 'Error':
|
||||
callbacks.onError(data.message)
|
||||
break
|
||||
case 'Done':
|
||||
callbacks.onDone()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function parseApiError(response: Response): Promise<string> {
|
||||
const errText = await response.text()
|
||||
try {
|
||||
const errJson = JSON.parse(errText)
|
||||
return errJson.error?.message || errJson.error || `API error (${response.status})`
|
||||
} catch {
|
||||
return `API error (${response.status})`
|
||||
}
|
||||
}
|
||||
|
||||
// --- Streaming API call ---
|
||||
|
||||
export async function streamChat(
|
||||
messages: { role: 'user' | 'assistant'; content: string }[],
|
||||
systemPrompt: string,
|
||||
model: string,
|
||||
onChunk: (text: string) => void,
|
||||
onDone: () => void,
|
||||
onError: (error: string) => void,
|
||||
): Promise<void> {
|
||||
const apiKey = getApiKey()
|
||||
if (!apiKey) {
|
||||
onError('No API key configured. Click the key icon to set your Anthropic API key.')
|
||||
return
|
||||
/**
|
||||
* Stream a chat message through the Claude CLI subprocess.
|
||||
* Returns the session ID for conversation continuity via --resume.
|
||||
*/
|
||||
export async function streamClaudeChat(
|
||||
message: string,
|
||||
systemPrompt: string | undefined,
|
||||
sessionId: string | undefined,
|
||||
callbacks: ChatStreamCallbacks,
|
||||
): Promise<string> {
|
||||
if (!isTauri()) {
|
||||
setTimeout(() => {
|
||||
callbacks.onText('AI Chat requires the Claude CLI. Install it and run the native app.')
|
||||
callbacks.onDone()
|
||||
}, 300)
|
||||
return 'mock-session'
|
||||
}
|
||||
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
|
||||
const state = { sessionId: sessionId ?? '' }
|
||||
|
||||
const unlisten = await listen<ClaudeStreamEvent>('claude-stream', (event) => {
|
||||
handleChatStreamEvent(event.payload, state, callbacks)
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
const result = await invoke<string>('stream_claude_chat', {
|
||||
request: {
|
||||
message,
|
||||
system_prompt: systemPrompt || null,
|
||||
session_id: sessionId || null,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
system: systemPrompt || undefined,
|
||||
messages,
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
onError(await parseApiError(response))
|
||||
return
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) {
|
||||
onError('No response body')
|
||||
return
|
||||
}
|
||||
|
||||
await readSseStream(reader, onChunk)
|
||||
onDone()
|
||||
} catch (err: unknown) {
|
||||
onError(err instanceof Error ? err.message : 'Network error')
|
||||
if (result) state.sessionId = result
|
||||
} catch (err) {
|
||||
callbacks.onError(err instanceof Error ? err.message : String(err))
|
||||
callbacks.onDone()
|
||||
} finally {
|
||||
unlisten()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Model options ---
|
||||
export const MODEL_OPTIONS = [
|
||||
{ value: 'claude-3-5-haiku-20241022', label: 'Haiku 3.5' },
|
||||
{ value: 'claude-sonnet-4-20250514', label: 'Sonnet 4' },
|
||||
{ value: 'claude-opus-4-20250514', label: 'Opus 4' },
|
||||
] as const
|
||||
return state.sessionId
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user