diff --git a/OLLAMA.md b/OLLAMA.md new file mode 100644 index 0000000..b53e583 --- /dev/null +++ b/OLLAMA.md @@ -0,0 +1,49 @@ +# Ollama Support in ClaudePrism + +ClaudePrism can use locally-hosted [Ollama](https://ollama.com/) models as an alternative to the Claude Code backend. This lets you chat about your LaTeX project and receive structured edit suggestions while keeping everything on your machine. + +## What works + +- **Streaming chat** with any Ollama model. +- **Structured file edits**: Ollama can emit `` blocks that ClaudePrism converts into proposed changes, just like Claude's Write/Edit tools. +- **Per-tab provider switching**: each conversation can independently use Claude or Ollama, or you can change the provider on the fly from the composer. + +## What does not work (yet) + +- Native Claude Code tool use (`Bash`, `Read`, `Write`, etc.) is **not available** through Ollama. +- Persistent sessions are **not** stored for Ollama; each prompt sends the current conversation history. +- Claude-specific slash commands and skills rely on the Claude Code CLI and are only available with the Claude provider. + +## Setup + +1. [Install Ollama](https://ollama.com/download) and start it locally. +2. Pull a model: + ```bash + ollama pull llama3 + ``` +3. Open ClaudePrism and switch the chat provider to **Ollama** from the composer model picker. +4. Confirm the Ollama URL (default: `http://localhost:11434`) and click **Refresh** to load your local models. +5. Select a model and start chatting. + +## Structured edits + +When you ask Ollama to modify a file, it can output edits in this format: + +```xml + + +exact existing text + + +replacement text + + +``` + +ClaudePrism parses these blocks after the response finishes and shows them in the **Proposed Changes** panel. You can accept or reject each change, just like edits from Claude. + +## Troubleshooting + +- **"Could not connect to Ollama"** — make sure the Ollama server is running and reachable at the configured URL. +- **"No models found"** — pull at least one model with `ollama pull `. +- **Edits not applied** — the old text in the `` block must closely match the file. The parser tolerates leading/trailing blank lines; for larger mismatches, try rephrasing your request or making the change manually. diff --git a/README.md b/README.md index 87b769f..db37baf 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,9 @@ Chat with Claude directly in the editor. Select between Sonnet, Opus, Haiku mode Claude AI Assistant & Slash Commands

+### Local Models via Ollama +Prefer to keep AI inference on your own machine? ClaudePrism also supports [Ollama](https://ollama.com/) for local chat and structured file edits. Switch between Claude and Ollama per conversation from the composer model picker. See [OLLAMA.md](./OLLAMA.md) for setup instructions. + ### History & Proposed Changes Every save creates a snapshot in a local Git repository (`.claudeprism/history.git/`). Label important checkpoints, browse diffs between any two snapshots, and restore previous versions. When Claude suggests edits, changes appear in a dedicated panel with visual diffs — accept or reject per chunk, or apply/undo all at once (`⌘Y` / `⌘N`). diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 36accd5..e10d622 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -502,13 +502,14 @@ dependencies = [ [[package]] name = "claude-prism-desktop" -version = "1.1.7" +version = "1.2.0" dependencies = [ "base64 0.22.1", "chrono", "dirs 5.0.1", "dotenvy", "flate2", + "futures-util", "git2", "hmac", "objc2", @@ -4074,6 +4075,7 @@ dependencies = [ "base64 0.22.1", "bytes", "futures-core", + "futures-util", "http 1.4.2", "http-body 1.0.1", "http-body-util", @@ -4093,12 +4095,14 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.4.2", "web-sys", "webpki-roots", ] @@ -4138,7 +4142,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -6520,6 +6524,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 8b659ef..7ca718d 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -23,7 +23,8 @@ serde_json = "1" serde_yaml = "0.9" tokio = { version = "1", features = ["full"] } dirs = "5" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } +futures-util = "0.3" hmac = "0.12" sha1 = "0.10" base64 = "0.22" diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d720552..c28ecde 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod claude; mod history; mod latex; +mod ollama; mod skills; mod slash_commands; mod uv; @@ -341,6 +342,7 @@ pub fn run() { .plugin(tauri_plugin_process::init()) .manage(claude::ClaudeProcessState::default()) .manage(latex::LatexCompilerState::default()) + .manage(ollama::OllamaState::default()) .manage(zotero::ZoteroOAuthState::default()) .setup(|app| { // Safety net: force-show the main window after a timeout if the @@ -385,6 +387,9 @@ pub fn run() { claude::set_claude_fast_mode, claude::list_claude_sessions, claude::load_session_history, + ollama::check_ollama_status, + ollama::send_ollama_message, + ollama::cancel_ollama_message, zotero::zotero_start_oauth, zotero::zotero_complete_oauth, zotero::zotero_cancel_oauth, diff --git a/apps/desktop/src-tauri/src/ollama.rs b/apps/desktop/src-tauri/src/ollama.rs new file mode 100644 index 0000000..6a2377d --- /dev/null +++ b/apps/desktop/src-tauri/src/ollama.rs @@ -0,0 +1,477 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use futures_util::StreamExt; +use reqwest::header::CONTENT_TYPE; +use serde::{Deserialize, Serialize}; +use tauri::{Emitter, WebviewWindow}; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; + +// ─── Request / Response Types ─── + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OllamaChatMessage { + pub role: String, + pub content: String, +} + +#[derive(Debug, Serialize)] +struct OllamaChatRequest { + model: String, + messages: Vec, + stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + options: Option>, +} + +#[derive(Debug, Deserialize)] +struct OllamaChatResponse { + #[serde(default)] + message: Option, + #[serde(default)] + done: bool, + #[serde(default)] + eval_count: Option, + #[serde(default)] + prompt_eval_count: Option, +} + +#[derive(Debug, Deserialize)] +struct OllamaMessage { + role: String, + content: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct OllamaModelEntry { + name: String, +} + +#[derive(Debug, Serialize)] +pub struct OllamaStatus { + pub available: bool, + pub models: Vec, + pub error: Option, +} + +// ─── Event Payloads ─── + +#[derive(Clone, serde::Serialize)] +struct OllamaOutputEvent { + tab_id: String, + data: String, +} + +#[derive(Clone, serde::Serialize)] +struct OllamaCompleteEvent { + tab_id: String, + success: bool, +} + +#[derive(Clone, serde::Serialize)] +struct OllamaErrorEvent { + tab_id: String, + data: String, +} + +// ─── Cancellation State ─── + +#[derive(Default, Clone)] +pub struct OllamaState { + /// Streaming task handles keyed by `window_label:tab_id`. + pub tasks: Arc>>>, +} + +fn process_key(window: &WebviewWindow, tab_id: &str) -> String { + format!("{}:{}", window.label(), tab_id) +} + +/// System prompt adapted from the Claude Code integration. +/// Includes instructions for the structured edit XML format. +fn system_prompt() -> String { + concat!( + "You are an AI assistant integrated into a LaTeX document editor (Prism). ", + "You are running as a local Ollama model. ", + "Follow these rules strictly:\n", + "1. PLANNING FIRST: Before making changes, briefly describe your plan. ", + "Break large tasks into small, incremental steps.\n", + "2. INCREMENTAL EDITS: Never rewrite an entire file unless asked. ", + "Prefer editing existing content over replacing it wholesale.\n", + "3. PRESERVE EXISTING CONTENT: Keep the existing preamble, packages, and structure intact. ", + "Only add or modify what is needed for the current step.\n", + "4. LaTeX BEST PRACTICES: Use proper sectioning (\\chapter, \\section, \\subsection), ", + "citations (\\cite), cross-references (\\label, \\ref), and BibTeX for bibliographies.\n", + "5. PYTHON: If a .venv/ exists in the project, it is already activated. ", + "Use `uv pip install` to add packages and `python` to run scripts.\n", + "6. STRUCTURED EDITS: When you need to modify a file, emit one or more blocks exactly like this:\n", + "\n", + "\n", + "exact existing text to replace\n", + "\n", + "\n", + "replacement text\n", + "\n", + "\n", + "The old text must match the file exactly (line endings may differ). ", + "Place edits after your explanatory text, not inside it." + ) + .to_string() +} + +/// Build the chat request body, prepending the system prompt. +fn build_request( + model: String, + mut messages: Vec, + stream: bool, +) -> OllamaChatRequest { + let system = OllamaChatMessage { + role: "system".to_string(), + content: system_prompt(), + }; + messages.insert(0, system); + OllamaChatRequest { + model, + messages, + stream, + options: None, + } +} + +/// Emit a text chunk shaped like a Claude assistant stream message. +fn emit_text_chunk(window: &WebviewWindow, tab_id: &str, text: &str) { + if text.is_empty() { + return; + } + let payload = serde_json::json!({ + "type": "assistant", + "message": { + "content": [{ "type": "text", "text": text }] + } + }); + let data = match serde_json::to_string(&payload) { + Ok(s) => s, + Err(e) => { + eprintln!("[ollama] failed to serialize chunk: {}", e); + return; + } + }; + let _ = window.emit( + "ollama-output", + OllamaOutputEvent { + tab_id: tab_id.to_string(), + data, + }, + ); +} + +/// Emit a final `result` message with token counts. +fn emit_result(window: &WebviewWindow, tab_id: &str, prompt_tokens: u64, eval_tokens: u64) { + let payload = serde_json::json!({ + "type": "result", + "duration_ms": 0, + "duration_api_ms": 0, + "usage": { + "input_tokens": prompt_tokens, + "output_tokens": eval_tokens, + } + }); + if let Ok(data) = serde_json::to_string(&payload) { + let _ = window.emit( + "ollama-output", + OllamaOutputEvent { + tab_id: tab_id.to_string(), + data, + }, + ); + } +} + +/// Emit an error event. +fn emit_error(window: &WebviewWindow, tab_id: &str, message: &str) { + eprintln!("[ollama] error for tab {}: {}", tab_id, message); + let _ = window.emit( + "ollama-error", + OllamaErrorEvent { + tab_id: tab_id.to_string(), + data: message.to_string(), + }, + ); +} + +/// Emit the completion event. +fn emit_complete(window: &WebviewWindow, tab_id: &str, success: bool) { + let _ = window.emit( + "ollama-complete", + OllamaCompleteEvent { + tab_id: tab_id.to_string(), + success, + }, + ); +} + +/// Stream the Ollama response and emit events. +async fn stream_ollama_response( + window: WebviewWindow, + tab_id: String, + base_url: String, + request_body: OllamaChatRequest, +) { + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(600)) + .build() + { + Ok(c) => c, + Err(e) => { + emit_error(&window, &tab_id, &format!("Failed to build HTTP client: {}", e)); + emit_complete(&window, &tab_id, false); + return; + } + }; + + let url = format!("{}/api/chat", base_url.trim_end_matches('/')); + let body_json = match serde_json::to_string(&request_body) { + Ok(j) => j, + Err(e) => { + emit_error(&window, &tab_id, &format!("Failed to serialize request: {}", e)); + emit_complete(&window, &tab_id, false); + return; + } + }; + + eprintln!("[ollama] POST {} model={}", url, request_body.model); + + let response = match client + .post(&url) + .header(CONTENT_TYPE, "application/json") + .body(body_json) + .send() + .await + { + Ok(resp) => resp, + Err(e) => { + let msg = if e.is_connect() { + format!( + "Could not connect to Ollama at {}. Is Ollama running?", + base_url + ) + } else { + format!("Ollama request failed: {}", e) + }; + emit_error(&window, &tab_id, &msg); + emit_complete(&window, &tab_id, false); + return; + } + }; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + emit_error( + &window, + &tab_id, + &format!("Ollama returned HTTP {}: {}", status, body), + ); + emit_complete(&window, &tab_id, false); + return; + } + + let mut prompt_tokens: u64 = 0; + let mut eval_tokens: u64 = 0; + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + + while let Some(chunk_result) = stream.next().await { + let chunk = match chunk_result { + Ok(c) => c, + Err(e) => { + emit_error(&window, &tab_id, &format!("Stream read error: {}", e)); + break; + } + }; + + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + // Ollama streams one JSON object per line (NDJSON). + while let Some(pos) = buffer.find('\n') { + let line = buffer[..pos].trim().to_string(); + buffer.replace_range(..pos + 1, ""); + if line.is_empty() { + continue; + } + + let parsed: OllamaChatResponse = match serde_json::from_str(&line) { + Ok(r) => r, + Err(e) => { + eprintln!("[ollama] failed to parse line: {} — error: {}", line, e); + continue; + } + }; + + if let Some(msg) = parsed.message { + emit_text_chunk(&window, &tab_id, &msg.content); + } + + if parsed.done { + if let Some(n) = parsed.prompt_eval_count { + prompt_tokens = n; + } + if let Some(n) = parsed.eval_count { + eval_tokens = n; + } + break; + } + } + } + + emit_result(&window, &tab_id, prompt_tokens, eval_tokens); + emit_complete(&window, &tab_id, true); +} + +// ─── Tauri Commands ─── + +#[tauri::command] +pub async fn check_ollama_status(base_url: String) -> Result { + let url = format!("{}/api/tags", base_url.trim_end_matches('/')); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| format!("Failed to build HTTP client: {}", e))?; + + let response = client.get(&url).send().await; + match response { + Ok(resp) if resp.status().is_success() => { + let body = resp + .text() + .await + .map_err(|e| format!("Failed to read Ollama response: {}", e))?; + let parsed: serde_json::Value = + serde_json::from_str(&body).map_err(|e| format!("Invalid JSON from Ollama: {}", e))?; + let models = parsed + .get("models") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|m| m.get("name").and_then(|n| n.as_str()).map(String::from)) + .collect() + }) + .unwrap_or_default(); + Ok(OllamaStatus { + available: true, + models, + error: None, + }) + } + Ok(resp) => Ok(OllamaStatus { + available: false, + models: Vec::new(), + error: Some(format!("Ollama returned HTTP {}", resp.status())), + }), + Err(e) => Ok(OllamaStatus { + available: false, + models: Vec::new(), + error: Some(format!("Could not reach Ollama: {}", e)), + }), + } +} + +#[tauri::command] +pub async fn send_ollama_message( + window: WebviewWindow, + state: tauri::State<'_, OllamaState>, + base_url: String, + model: String, + messages: Vec, + tab_id: String, + _project_path: String, +) -> Result<(), String> { + if model.trim().is_empty() { + return Err("No Ollama model selected".to_string()); + } + + let key = process_key(&window, &tab_id); + let request = build_request(model, messages, true); + let win = window.clone(); + + // Abort any existing stream for this tab. + { + let mut tasks = state.tasks.lock().await; + if let Some(handle) = tasks.remove(&key) { + handle.abort(); + } + } + + let handle = tokio::spawn(async move { + stream_ollama_response(win, tab_id, base_url, request).await; + }); + + { + let mut tasks = state.tasks.lock().await; + tasks.insert(key, handle); + } + + Ok(()) +} + +#[tauri::command] +pub async fn cancel_ollama_message( + window: WebviewWindow, + state: tauri::State<'_, OllamaState>, + tab_id: String, +) -> Result<(), String> { + let key = process_key(&window, &tab_id); + let mut tasks = state.tasks.lock().await; + if let Some(handle) = tasks.remove(&key) { + handle.abort(); + } + let _ = window.emit( + "ollama-complete", + OllamaCompleteEvent { + tab_id, + success: false, + }, + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_request_prepends_system_prompt() { + let request = build_request( + "llama3".to_string(), + vec![OllamaChatMessage { + role: "user".to_string(), + content: "hello".to_string(), + }], + true, + ); + assert_eq!(request.model, "llama3"); + assert!(request.stream); + assert_eq!(request.messages.len(), 2); + assert_eq!(request.messages[0].role, "system"); + assert!(request.messages[0].content.contains("Prism")); + assert!(request.messages[0].content.contains("proposed-change")); + assert_eq!(request.messages[1].role, "user"); + assert_eq!(request.messages[1].content, "hello"); + } + + #[test] + fn test_emit_text_chunk_serializes_claude_shape() { + // This test verifies the emitted JSON shape by calling the helper logic. + let text = "hi"; + let payload = serde_json::json!({ + "type": "assistant", + "message": { + "content": [{ "type": "text", "text": text }] + } + }); + let data = serde_json::to_string(&payload).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&data).unwrap(); + assert_eq!(parsed["type"], "assistant"); + assert_eq!(parsed["message"]["content"][0]["type"], "text"); + assert_eq!(parsed["message"]["content"][0]["text"], "hi"); + } +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 1f24def..551be52 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -31,7 +31,7 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; connect-src 'self' ipc: http://ipc.localhost https://api.zotero.org https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' asset: http://asset.localhost data: blob:; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'self' blob:", + "csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; connect-src 'self' ipc: http://ipc.localhost http://localhost:* https://api.zotero.org https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' asset: http://asset.localhost data: blob:; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'self' blob:", "dangerousDisableAssetCspModification": true } }, diff --git a/apps/desktop/src/__tests__/lib/ollama-edit-parser.test.ts b/apps/desktop/src/__tests__/lib/ollama-edit-parser.test.ts new file mode 100644 index 0000000..50a1ac0 --- /dev/null +++ b/apps/desktop/src/__tests__/lib/ollama-edit-parser.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { + parseOllamaProposedChanges, + applyOllamaEdit, +} from "@/lib/ollama-edit-parser"; + +describe("parseOllamaProposedChanges", () => { + it("returns an empty array when there are no edit blocks", () => { + expect(parseOllamaProposedChanges("Hello, world!")).toEqual([]); + }); + + it("parses a single proposed-change block", () => { + const text = ` +Some explanation. + + + +\\section{Introduction} +Hello. + + +\\section{Introduction} +Hello, world! + + +`; + const edits = parseOllamaProposedChanges(text); + expect(edits).toHaveLength(1); + expect(edits[0]).toEqual({ + filePath: "main.tex", + oldText: "\\section{Introduction}\nHello.", + newText: "\\section{Introduction}\nHello, world!", + }); + }); + + it("parses multiple proposed-change blocks", () => { + const text = ` + +alpha +ALPHA + + +beta +BETA + +`; + const edits = parseOllamaProposedChanges(text); + expect(edits).toHaveLength(2); + expect(edits[0].filePath).toBe("a.tex"); + expect(edits[1].filePath).toBe("b.tex"); + }); + + it("ignores partial blocks without a closing tag", () => { + const text = ` + +old text +new text +`; + expect(parseOllamaProposedChanges(text)).toEqual([]); + }); +}); + +describe("applyOllamaEdit", () => { + it("replaces exact old text", () => { + const result = applyOllamaEdit( + "\\section{Intro}\nHello.\n\\section{Body}", + "\\section{Intro}\nHello.\n", + "\\section{Intro}\nHello, world!\n", + ); + expect(result).toBe("\\section{Intro}\nHello, world!\n\\section{Body}"); + }); + + it("returns null when old text is not found", () => { + const result = applyOllamaEdit("some content", "missing text", "new text"); + expect(result).toBeNull(); + }); + + it("falls back to trimmed matching", () => { + const result = applyOllamaEdit( + "\\section{Intro}\nHello.\n", + "\n\\section{Intro}\nHello.\n", + "\\section{Intro}\nHello, world!", + ); + expect(result).toBe("\\section{Intro}\nHello, world!\n"); + }); +}); diff --git a/apps/desktop/src/__tests__/stores/claude-chat-send-prompt.test.ts b/apps/desktop/src/__tests__/stores/claude-chat-send-prompt.test.ts index accf88e..d48a29f 100644 --- a/apps/desktop/src/__tests__/stores/claude-chat-send-prompt.test.ts +++ b/apps/desktop/src/__tests__/stores/claude-chat-send-prompt.test.ts @@ -23,6 +23,14 @@ vi.mock("@/stores/history-store", () => ({ }, })); +vi.mock("@/stores/settings-store", () => ({ + useSettingsStore: { + getState: vi.fn(() => ({ + ollamaBaseUrl: "http://localhost:11434", + })), + }, +})); + import { useClaudeChatStore } from "@/stores/claude-chat-store"; function resetClaudeChatStore() { @@ -43,6 +51,8 @@ function resetClaudeChatStore() { error: null, totalInputTokens: 0, totalOutputTokens: 0, + provider: "claude", + ollamaModel: "", draft: { input: "", pinnedContexts: [] }, }, ], @@ -51,6 +61,8 @@ function resetClaudeChatStore() { pendingAttachments: [], selectedModel: "opus", effortLevel: "medium", + provider: "claude", + ollamaModel: "", _cancelledByUser: false, }); } @@ -168,4 +180,41 @@ describe("useClaudeChatStore.sendPrompt context assembly", () => { "[claude] Before Claude edit", ); }); + + it("invokes send_ollama_message when provider is Ollama", async () => { + useClaudeChatStore.setState({ + provider: "ollama", + ollamaModel: "llama3", + tabs: [ + { + ...useClaudeChatStore.getState().tabs[0], + provider: "ollama", + ollamaModel: "llama3", + }, + ], + }); + + await useClaudeChatStore.getState().sendPrompt("Explain this section"); + + expect(invoke).toHaveBeenCalledWith( + "send_ollama_message", + expect.objectContaining({ + baseUrl: "http://localhost:11434", + model: "llama3", + tabId: "tab-default", + projectPath: "/project", + }), + ); + + const messagesArg = (vi.mocked(invoke).mock.calls[0]?.[1] as any) + ?.messages as { role: string; content: string }[]; + expect(messagesArg).toHaveLength(1); + expect(messagesArg[0].role).toBe("user"); + expect(messagesArg[0].content).toContain("Explain this section"); + + expect(createSnapshotMock).toHaveBeenCalledWith( + "/project", + "[ollama] Before Ollama response", + ); + }); }); diff --git a/apps/desktop/src/__tests__/stores/claude-chat-store.test.ts b/apps/desktop/src/__tests__/stores/claude-chat-store.test.ts index b24ca29..fa83e33 100644 --- a/apps/desktop/src/__tests__/stores/claude-chat-store.test.ts +++ b/apps/desktop/src/__tests__/stores/claude-chat-store.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from "vitest"; -import { offsetToLineCol } from "@/stores/claude-chat-store"; +import { + offsetToLineCol, + useClaudeChatStore, +} from "@/stores/claude-chat-store"; describe("offsetToLineCol", () => { it("returns line 1, col 1 for offset 0 on empty string", () => { @@ -38,3 +41,68 @@ describe("offsetToLineCol", () => { expect(offsetToLineCol("\n\n", 2)).toEqual({ line: 3, col: 1 }); }); }); + +describe("useClaudeChatStore._appendStreamingText", () => { + it("creates a new assistant message when the last message is not assistant", () => { + useClaudeChatStore.setState({ + tabs: [ + { + id: "tab-1", + title: "Chat", + sessionId: null, + messages: [ + { + type: "user", + message: { content: [{ type: "text", text: "Hi" }] }, + }, + ], + isStreaming: true, + error: null, + totalInputTokens: 0, + totalOutputTokens: 0, + provider: "ollama", + ollamaModel: "llama3", + draft: { input: "", pinnedContexts: [] }, + }, + ], + activeTabId: "tab-1", + }); + + useClaudeChatStore.getState()._appendStreamingText("tab-1", "Hello"); + const messages = useClaudeChatStore.getState().tabs[0].messages; + expect(messages).toHaveLength(2); + expect(messages[1].type).toBe("assistant"); + expect(messages[1].message?.content?.[0].text).toBe("Hello"); + }); + + it("merges text into the existing assistant message", () => { + useClaudeChatStore.setState({ + tabs: [ + { + id: "tab-1", + title: "Chat", + sessionId: null, + messages: [ + { + type: "assistant", + message: { content: [{ type: "text", text: "Hello" }] }, + }, + ], + isStreaming: true, + error: null, + totalInputTokens: 0, + totalOutputTokens: 0, + provider: "ollama", + ollamaModel: "llama3", + draft: { input: "", pinnedContexts: [] }, + }, + ], + activeTabId: "tab-1", + }); + + useClaudeChatStore.getState()._appendStreamingText("tab-1", ", world!"); + const messages = useClaudeChatStore.getState().tabs[0].messages; + expect(messages).toHaveLength(1); + expect(messages[0].message?.content?.[0].text).toBe("Hello, world!"); + }); +}); diff --git a/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts b/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts index 6f743e1..73a7128 100644 --- a/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts +++ b/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts @@ -49,6 +49,8 @@ function resetStores() { error: null, totalInputTokens: 0, totalOutputTokens: 0, + provider: "claude", + ollamaModel: "", draft: { input: "", pinnedContexts: [] }, }, ], diff --git a/apps/desktop/src/components/claude-chat/chat-composer.tsx b/apps/desktop/src/components/claude-chat/chat-composer.tsx index c283062..1d19d2d 100644 --- a/apps/desktop/src/components/claude-chat/chat-composer.tsx +++ b/apps/desktop/src/components/claude-chat/chat-composer.tsx @@ -32,7 +32,9 @@ import { invoke } from "@tauri-apps/api/core"; import { useClaudeChatStore, offsetToLineCol, + type AiProvider, } from "@/stores/claude-chat-store"; +import { useSettingsStore } from "@/stores/settings-store"; import { useDocumentStore, type ProjectFile } from "@/stores/document-store"; import { getUniqueTargetName } from "@/lib/tauri/fs"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; @@ -74,10 +76,55 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { const setSelectedModel = useClaudeChatStore((s) => s.setSelectedModel); const effortLevel = useClaudeChatStore((s) => s.effortLevel); const setEffortLevel = useClaudeChatStore((s) => s.setEffortLevel); + const provider = useClaudeChatStore((s) => s.provider); + const setProvider = useClaudeChatStore((s) => s.setProvider); + const ollamaModel = useClaudeChatStore((s) => s.ollamaModel); + const setOllamaModel = useClaudeChatStore((s) => s.setOllamaModel); + const ollamaBaseUrl = useSettingsStore((s) => s.ollamaBaseUrl); + const setOllamaBaseUrl = useSettingsStore((s) => s.setOllamaBaseUrl); const activeTabId = useClaudeChatStore((s) => s.activeTabId); const [input, setInput] = useState(""); const textareaRef = useRef(null); + // Ollama model list state + const [ollamaModels, setOllamaModels] = useState([]); + const [ollamaStatus, setOllamaStatus] = useState<{ + available: boolean; + error: string | null; + loading: boolean; + }>({ available: false, error: null, loading: false }); + + const refreshOllamaModels = useCallback(async () => { + setOllamaStatus((prev) => ({ ...prev, loading: true })); + try { + const result = await invoke<{ + available: boolean; + models: string[]; + error?: string; + }>("check_ollama_status", { baseUrl: ollamaBaseUrl }); + setOllamaModels(result.models); + setOllamaStatus({ + available: result.available, + error: result.error ?? null, + loading: false, + }); + if ( + result.available && + result.models.length > 0 && + !result.models.includes(ollamaModel) + ) { + setOllamaModel(result.models[0]); + } + } catch (err) { + setOllamaModels([]); + setOllamaStatus({ + available: false, + error: err instanceof Error ? err.message : String(err), + loading: false, + }); + } + }, [ollamaBaseUrl, ollamaModel, setOllamaModel]); + // Model picker state const [modelPickerOpen, setModelPickerOpen] = useState(false); const modelPickerRef = useRef(null); @@ -87,7 +134,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { bottom: 0, }); - // Recalculate popup position when it opens + // Recalculate popup position and refresh Ollama models when the picker opens useLayoutEffect(() => { if (!modelPickerOpen || !modelButtonRef.current) return; const rect = modelButtonRef.current.getBoundingClientRect(); @@ -95,7 +142,10 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { left: rect.left, bottom: window.innerHeight - rect.top + 4, }); - }, [modelPickerOpen]); + if (provider === "ollama") { + refreshOllamaModels(); + } + }, [modelPickerOpen, provider, refreshOllamaModels]); // Pinned contexts — supports multiple files/selections const [pinnedContexts, setPinnedContexts] = useState([]); @@ -681,101 +731,202 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { createPortal(
- {/* Models */} -
-
- Model -
- {[ - { - id: "sonnet" as const, - name: "Sonnet", - desc: "Fast, efficient for most tasks", - icon: , - }, - { - id: "opus" as const, - name: "Opus", - desc: "Most capable, complex reasoning", - icon: , - }, - { - id: "haiku" as const, - name: "Haiku", - desc: "Fastest, simple tasks", - icon: , - }, - { - id: "opusplan" as const, - name: "OpusPlan", - desc: "Opus for planning, Sonnet for execution", - icon: , - }, - ].map((m) => ( - - ))} -
- -
- - {/* Effort level */} -
-
- - Effort - - - {effortLevel === "low" - ? "Low" - : effortLevel === "medium" - ? "Medium" - : "High"} - + {/* Provider switch */} +
+
+ Provider
- {(["low", "medium", "high"] as const).map((level) => ( + {(["claude", "ollama"] as AiProvider[]).map((p) => ( ))}
+ + {provider === "claude" ? ( + <> + {/* Claude models */} +
+
+ Model +
+ {[ + { + id: "sonnet" as const, + name: "Sonnet", + desc: "Fast, efficient for most tasks", + icon: , + }, + { + id: "opus" as const, + name: "Opus", + desc: "Most capable, complex reasoning", + icon: , + }, + { + id: "haiku" as const, + name: "Haiku", + desc: "Fastest, simple tasks", + icon: , + }, + { + id: "opusplan" as const, + name: "OpusPlan", + desc: "Opus for planning, Sonnet for execution", + icon: , + }, + ].map((m) => ( + + ))} +
+ +
+ + {/* Effort level */} +
+
+ + Effort + + + {effortLevel === "low" + ? "Low" + : effortLevel === "medium" + ? "Medium" + : "High"} + +
+
+ {(["low", "medium", "high"] as const).map((level) => ( + + ))} +
+
+ + ) : ( + <> + {/* Ollama settings */} +
+
+ +
+ setOllamaBaseUrl(e.target.value)} + className="flex-1 rounded-md border border-input bg-background px-2 py-1 text-xs outline-none focus:border-ring" + placeholder="http://localhost:11434" + /> + +
+
+ +
+
+ + Model + + + {ollamaStatus.loading ? ( + + Checking… + + ) : ollamaStatus.available ? ( + Connected + ) : ( + + {ollamaStatus.error || "Unreachable"} + + )} + +
+ {ollamaModels.length === 0 ? ( +
+ No models found. Pull a model with{" "} + + ollama pull <model> + + . +
+ ) : ( + + )} +
+
+ + )}
, document.body, )} @@ -904,20 +1055,24 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => { className="flex items-center gap-1.5 rounded-md px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground" > - {selectedModel === "sonnet" - ? "Sonnet" - : selectedModel === "opus" - ? "Opus" - : selectedModel === "haiku" - ? "Haiku" - : "OpusPlan"} + {provider === "ollama" + ? ollamaModel || "Ollama" + : selectedModel === "sonnet" + ? "Sonnet" + : selectedModel === "opus" + ? "Opus" + : selectedModel === "haiku" + ? "Haiku" + : "OpusPlan"} - {effortLevel === "low" - ? "L" - : effortLevel === "medium" - ? "M" - : "H"} + {provider === "ollama" + ? "local" + : effortLevel === "low" + ? "L" + : effortLevel === "medium" + ? "M" + : "H"} diff --git a/apps/desktop/src/components/claude-chat/chat-messages.tsx b/apps/desktop/src/components/claude-chat/chat-messages.tsx index 76c0c71..fa34fd8 100644 --- a/apps/desktop/src/components/claude-chat/chat-messages.tsx +++ b/apps/desktop/src/components/claude-chat/chat-messages.tsx @@ -152,7 +152,7 @@ export const ChatMessages: FC = () => { > {displayMessages.length === 0 && !isStreaming && (
- Ask Claude about your LaTeX document... + Ask about your LaTeX document...
)} diff --git a/apps/desktop/src/components/claude-chat/claude-chat-drawer.tsx b/apps/desktop/src/components/claude-chat/claude-chat-drawer.tsx index 0e86208..7d451e2 100644 --- a/apps/desktop/src/components/claude-chat/claude-chat-drawer.tsx +++ b/apps/desktop/src/components/claude-chat/claude-chat-drawer.tsx @@ -9,6 +9,7 @@ import { import { cn } from "@/lib/utils"; import { useClaudeChatStore } from "@/stores/claude-chat-store"; import { useClaudeEvents } from "@/hooks/use-claude-events"; +import { useOllamaEvents } from "@/hooks/use-ollama-events"; import { ChatMessages } from "./chat-messages"; import { ChatComposer } from "./chat-composer"; import { ChatTabBar } from "./chat-tab-bar"; @@ -17,8 +18,9 @@ const MIN_HEIGHT = 150; const DEFAULT_HEIGHT = 360; export function ClaudeChatDrawer() { - // Initialize event listeners for Claude streaming + // Initialize event listeners for Claude and Ollama streaming useClaudeEvents(); + useOllamaEvents(); const anyStreaming = useClaudeChatStore((s) => s.tabs.some((t) => t.isStreaming), diff --git a/apps/desktop/src/hooks/use-ollama-events.ts b/apps/desktop/src/hooks/use-ollama-events.ts new file mode 100644 index 0000000..a56398f --- /dev/null +++ b/apps/desktop/src/hooks/use-ollama-events.ts @@ -0,0 +1,235 @@ +import { useEffect, useRef } from "react"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { useClaudeChatStore } from "@/stores/claude-chat-store"; +import { useDocumentStore } from "@/stores/document-store"; +import { useHistoryStore } from "@/stores/history-store"; +import { useProposedChangesStore } from "@/stores/proposed-changes-store"; +import { + parseOllamaProposedChanges, + applyOllamaEdit, +} from "@/lib/ollama-edit-parser"; +import { createLogger } from "@/lib/debug/logger"; + +const log = createLogger("ollama-event"); + +interface OllamaOutputPayload { + tab_id: string; + data: string; +} + +interface OllamaCompletePayload { + tab_id: string; + success: boolean; +} + +interface OllamaErrorPayload { + tab_id: string; + data: string; +} + +/** + * Hook that manages Tauri event listeners for Ollama streaming output. + * + * Ollama responses are plain text, so we accumulate them per tab and convert + * each chunk into a Claude-shaped assistant message so the existing chat UI + * can render it. After the stream completes, we parse the full response for + * `` blocks and register them as proposed changes. + */ +export function useOllamaEvents() { + // Per-tab mutable state stored in refs so long-lived listeners read latest. + const accumulatedTextRef = useRef(new Map()); + const listenersRef = useRef([]); + + // Reset accumulator whenever a tab starts streaming + const tabs = useClaudeChatStore((s) => s.tabs); + useEffect(() => { + for (const tab of tabs) { + if (tab.isStreaming) { + accumulatedTextRef.current.set(tab.id, ""); + } else { + // Keep the accumulated text for a moment so the complete handler + // can still read it; it will clean up after itself. + } + } + }, [tabs]); + + useEffect(() => { + function appendTextChunk(tabId: string, text: string) { + if (!text) return; + const current = accumulatedTextRef.current.get(tabId) ?? ""; + accumulatedTextRef.current.set(tabId, current + text); + useClaudeChatStore.getState()._appendStreamingText(tabId, text); + } + + async function registerProposedChanges( + tabId: string, + responseText: string, + ) { + const docState = useDocumentStore.getState(); + const projectRoot = docState.projectRoot; + if (!projectRoot) return; + + const edits = parseOllamaProposedChanges(responseText); + if (edits.length === 0) return; + + const warnings: string[] = []; + for (const edit of edits) { + const file = docState.files.find( + (f) => f.relativePath === edit.filePath, + ); + if (!file) { + warnings.push(`Could not find file: ${edit.filePath}`); + continue; + } + const currentContent = file.content ?? ""; + const newContent = applyOllamaEdit( + currentContent, + edit.oldText, + edit.newText, + ); + if (newContent === null) { + warnings.push( + `Could not locate the specified text in ${edit.filePath}`, + ); + continue; + } + useProposedChangesStore.getState().addChange({ + id: `ollama-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + filePath: file.relativePath, + absolutePath: file.absolutePath, + oldContent: currentContent, + newContent, + toolName: "OllamaEdit", + }); + } + + if (warnings.length > 0) { + const chatStore = useClaudeChatStore.getState(); + chatStore._appendMessage(tabId, { + type: "assistant", + message: { + content: [ + { + type: "text", + text: + "_Some edits could not be applied:_\n\n" + + warnings.map((w) => `- ${w}`).join("\n"), + }, + ], + }, + }); + } + } + + async function handleComplete(payload: OllamaCompletePayload) { + const { tab_id: tabId, success } = payload; + const chatStore = useClaudeChatStore.getState(); + const tab = chatStore.tabs.find((t) => t.id === tabId); + if (!tab?.isStreaming) { + log.warn(`[${tabId}] ignoring duplicate ollama-complete event`); + return; + } + + log.info(`[${tabId}] ollama complete success=${success}`); + + if (!success && !tab.error && !chatStore._cancelledByUser) { + chatStore._setError( + tabId, + "Ollama response failed. Check that Ollama is running and the model is available.", + ); + } + + // Parse any structured edits from the full response. + const responseText = accumulatedTextRef.current.get(tabId) ?? ""; + if (success && responseText) { + await registerProposedChanges(tabId, responseText); + } + + accumulatedTextRef.current.delete(tabId); + chatStore._setStreaming(tabId, false); + + // Snapshot after Ollama response. + const projectPath = useDocumentStore.getState().projectRoot; + if (projectPath) { + try { + await useHistoryStore + .getState() + .createSnapshot(projectPath, "[ollama] After Ollama response"); + } catch { + /* snapshot failure should not break the flow */ + } + } + + await useDocumentStore.getState().refreshFiles(); + } + + let cancelled = false; + (async () => { + const unlistenOutput = await listen( + "ollama-output", + (event) => { + if (cancelled) return; + const { tab_id: tabId, data } = event.payload; + let msg; + try { + msg = JSON.parse(data); + } catch { + return; + } + + const type = msg?.type; + if (type === "assistant" && Array.isArray(msg?.message?.content)) { + for (const block of msg.message.content) { + if (block?.type === "text" && typeof block.text === "string") { + appendTextChunk(tabId, block.text); + } + } + } else if (type === "result") { + // Result metadata — append to the chat store for token accounting. + useClaudeChatStore.getState()._appendMessage(tabId, msg); + } + }, + ); + if (cancelled) { + unlistenOutput(); + return; + } + listenersRef.current.push(unlistenOutput); + + const unlistenComplete = await listen( + "ollama-complete", + (event) => { + if (!cancelled) handleComplete(event.payload); + }, + ); + if (cancelled) { + unlistenComplete(); + return; + } + listenersRef.current.push(unlistenComplete); + + const unlistenError = await listen( + "ollama-error", + (event) => { + if (cancelled) return; + const { tab_id: tabId, data } = event.payload; + log.error(`[${tabId}] ollama-error: ${data}`); + useClaudeChatStore.getState()._setError(tabId, data); + }, + ); + if (cancelled) { + unlistenError(); + return; + } + listenersRef.current.push(unlistenError); + })(); + + return () => { + cancelled = true; + for (const unlisten of listenersRef.current) { + unlisten(); + } + listenersRef.current = []; + }; + }, []); +} diff --git a/apps/desktop/src/lib/ollama-edit-parser.ts b/apps/desktop/src/lib/ollama-edit-parser.ts new file mode 100644 index 0000000..8033c2d --- /dev/null +++ b/apps/desktop/src/lib/ollama-edit-parser.ts @@ -0,0 +1,99 @@ +export interface OllamaProposedEdit { + filePath: string; + oldText: string; + newText: string; +} + +const CHANGE_OPEN_RE = //; + +/** + * Parse `.........` + * blocks from an Ollama response. + * + * This is intentionally tolerant: it searches linearly and does not require + * well-formed XML beyond the expected tags. + */ +export function parseOllamaProposedChanges(text: string): OllamaProposedEdit[] { + const edits: OllamaProposedEdit[] = []; + let searchFrom = 0; + + while (true) { + const startMatch = findNextBlockStart(text, searchFrom); + if (!startMatch) break; + const openEnd = startMatch.end; + const filePath = startMatch.filePath; + + const closeIdx = text.indexOf("", openEnd); + if (closeIdx === -1) { + // No closing tag — ignore this partial block. + break; + } + + const block = text.slice(openEnd, closeIdx); + const oldText = extractTag(block, "old"); + const newText = extractTag(block, "new"); + + if (oldText !== null && newText !== null) { + edits.push({ filePath, oldText, newText }); + } + + searchFrom = closeIdx + "".length; + } + + return edits; +} + +interface BlockStart { + end: number; + filePath: string; +} + +function findNextBlockStart(text: string, from: number): BlockStart | null { + const regex = new RegExp(CHANGE_OPEN_RE.source, "g"); + regex.lastIndex = from; + const match = regex.exec(text); + if (!match) return null; + return { + end: match.index + match[0].length, + filePath: match[1], + }; +} + +function extractTag(block: string, tag: string): string | null { + const open = `<${tag}>`; + const close = ``; + const start = block.indexOf(open); + if (start === -1) return null; + const contentStart = start + open.length; + const end = block.indexOf(close, contentStart); + if (end === -1) return null; + let content = block.slice(contentStart, end); + // Models commonly insert a leading/trailing newline after/before XML tags. + if (content.startsWith("\n")) content = content.slice(1); + if (content.endsWith("\n")) content = content.slice(0, -1); + return content; +} + +/** + * Try to apply `oldText` → `newText` to `fileContent`. + * Falls back to a trimmed match if an exact match fails. + * + * Returns the updated content, or `null` if the old text could not be located. + */ +export function applyOllamaEdit( + fileContent: string, + oldText: string, + newText: string, +): string | null { + if (fileContent.includes(oldText)) { + return fileContent.replace(oldText, newText); + } + + // Trim-only fallback: handles models that add leading/trailing blank lines. + const trimmed = oldText.trim(); + if (trimmed && fileContent.includes(trimmed)) { + return fileContent.replace(trimmed, newText); + } + + return null; +} diff --git a/apps/desktop/src/stores/claude-chat-store.ts b/apps/desktop/src/stores/claude-chat-store.ts index bdcd818..d1d2d82 100644 --- a/apps/desktop/src/stores/claude-chat-store.ts +++ b/apps/desktop/src/stores/claude-chat-store.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { invoke } from "@tauri-apps/api/core"; import { useDocumentStore } from "./document-store"; import { useHistoryStore } from "./history-store"; +import { useSettingsStore } from "./settings-store"; import { createLogger } from "@/lib/debug/logger"; const log = createLogger("claude"); @@ -67,6 +68,8 @@ export interface TabDraft { }[]; } +export type AiProvider = "claude" | "ollama"; + export interface TabState { id: string; title: string; @@ -76,6 +79,8 @@ export interface TabState { error: string | null; totalInputTokens: number; totalOutputTokens: number; + provider: AiProvider; + ollamaModel: string; draft: TabDraft; } @@ -87,9 +92,15 @@ const TAB_FIELDS = [ "error", "totalInputTokens", "totalOutputTokens", + "provider", + "ollamaModel", ] as const; -function makeDefaultTab(id: string): TabState { +function makeDefaultTab( + id: string, + provider: AiProvider = "claude", + ollamaModel = "", +): TabState { return { id, title: "New Chat", @@ -99,6 +110,8 @@ function makeDefaultTab(id: string): TabState { error: null, totalInputTokens: 0, totalOutputTokens: 0, + provider, + ollamaModel, draft: { input: "", pinnedContexts: [] }, }; } @@ -108,6 +121,41 @@ function nextTabId(): string { return `tab-${++tabCounter}`; } +// ─── Ollama helpers ─── + +interface OllamaMessage { + role: "system" | "user" | "assistant"; + content: string; +} + +/** Build a clean message history for Ollama from the tab's current messages. */ +function buildOllamaMessages(messages: ClaudeStreamMessage[]): OllamaMessage[] { + const out: OllamaMessage[] = []; + for (const msg of messages) { + if (msg.type === "system") continue; + if (msg.type === "result") continue; + if (msg.type === "user" && msg.message?.content) { + const text = msg.message.content + .filter((b) => b.type === "text") + .map((b) => b.text) + .join("\n"); + if (text) { + out.push({ role: "user", content: text }); + } + } + if (msg.type === "assistant" && msg.message?.content) { + const text = msg.message.content + .filter((b) => b.type === "text") + .map((b) => b.text) + .join(""); + if (text) { + out.push({ role: "assistant", content: text }); + } + } + } + return out; +} + /** * Update a specific tab in `tabs[]` and, if that tab is the active tab, * also project the changed fields to top-level state for consumer compatibility. @@ -181,6 +229,14 @@ interface ClaudeChatState { effortLevel: "low" | "medium" | "high"; setEffortLevel: (level: "low" | "medium" | "high") => void; + /** Active AI provider for the current tab */ + provider: AiProvider; + setProvider: (provider: AiProvider) => void; + + /** Selected Ollama model for the current tab */ + ollamaModel: string; + setOllamaModel: (model: string) => void; + // Actions sendPrompt: ( userPrompt: string, @@ -202,6 +258,7 @@ interface ClaudeChatState { // Internal actions (called by event hook, routed by tabId) _appendMessage: (tabId: string, msg: ClaudeStreamMessage) => void; + _appendStreamingText: (tabId: string, text: string) => void; _setSessionId: (tabId: string, id: string) => void; _setStreaming: (tabId: string, streaming: boolean) => void; _setError: (tabId: string, error: string | null) => void; @@ -230,6 +287,30 @@ export const useClaudeChatStore = create()((set, get) => ({ effortLevel: "medium", setEffortLevel: (level) => set({ effortLevel: level }), + provider: "claude", + setProvider: (provider) => + set((state) => { + const updates: Partial = { provider }; + return { + ...updates, + ...applyTabUpdate(state, state.activeTabId, { + provider, + }), + }; + }), + + ollamaModel: "", + setOllamaModel: (model) => + set((state) => { + const updates: Partial = { ollamaModel: model }; + return { + ...updates, + ...applyTabUpdate(state, state.activeTabId, { + ollamaModel: model, + }), + }; + }), + pendingInitialPrompt: null, setPendingInitialPrompt: (prompt) => set({ pendingInitialPrompt: prompt }), consumePendingInitialPrompt: () => { @@ -266,10 +347,12 @@ export const useClaudeChatStore = create()((set, get) => ({ // Guard: prevent sending from a tab that's already streaming if (activeTab?.isStreaming) return; - const { sessionId, selectedModel, effortLevel } = state; + const { sessionId, selectedModel, effortLevel, provider, ollamaModel } = + state; const sendStart = performance.now(); log.info("sendPrompt start", { + provider, sessionId: !!sessionId, hasContext: !!contextOverride, tab: activeTabId, @@ -333,72 +416,90 @@ export const useClaudeChatStore = create()((set, get) => ({ }; }); - // Flush unsaved edits to disk so Claude reads the latest content + // Flush unsaved edits to disk so the AI reads the latest content if (docState.files.some((f) => f.isDirty)) { log.debug("saving dirty files..."); await docState.saveAllFiles(); log.debug("saveAllFiles done"); } - // Snapshot before Claude edit + // Snapshot before AI edit if (projectPath) { try { log.debug("creating snapshot..."); + const snapshotLabel = + provider === "ollama" + ? "[ollama] Before Ollama response" + : "[claude] Before Claude edit"; await useHistoryStore .getState() - .createSnapshot(projectPath, "[claude] Before Claude edit"); + .createSnapshot(projectPath, snapshotLabel); log.debug("snapshot done"); } catch { - /* snapshot failure should not block Claude */ + /* snapshot failure should not block the AI flow */ } } - // Build prompt with full context for Claude - let prompt = userPrompt; - if (activeFile) { - const selRange = docState.selectionRange; - const selectedText = - selRange && activeFile.content - ? activeFile.content.slice(selRange.start, selRange.end) - : null; - let ctx = `[Currently open file: ${activeFile.relativePath}]`; - if (contextOverride) { - ctx += `\n[Selection: ${contextOverride.label}]`; - ctx += `\n[Selected text:\n${contextOverride.selectedText}\n]`; - } else if (selectedText && selRange) { - const content = activeFile.content ?? ""; - const startLC = offsetToLineCol(content, selRange.start); - const endLC = offsetToLineCol(content, selRange.end); - ctx += `\n[Selection: @${activeFile.relativePath}:${startLC.line}:${startLC.col}-${endLC.line}:${endLC.col}]`; - ctx += `\n[Selected text:\n${selectedText}\n]`; - } - prompt = `${ctx}\n\n${userPrompt}`; - } - log.info("invoking CLI", { - promptLength: prompt.length, - mode: sessionId ? "resume" : "new", - }); - try { - if (sessionId) { - // Resume existing session - await invoke("resume_claude_code", { - projectPath, - sessionId, - prompt, + if (provider === "ollama") { + // Build full message history for Ollama + const currentMessages = + get().tabs.find((t) => t.id === activeTabId)?.messages ?? []; + const ollamaMessages = buildOllamaMessages(currentMessages); + const settings = useSettingsStore.getState(); + + await invoke("send_ollama_message", { + baseUrl: settings.ollamaBaseUrl, + model: ollamaModel, + messages: ollamaMessages, tabId: activeTabId, - model: selectedModel, - effortLevel, + projectPath, }); } else { - // New session - await invoke("execute_claude_code", { - projectPath, - prompt, - tabId: activeTabId, - model: selectedModel, - effortLevel, + // Build prompt with full context for Claude + let prompt = userPrompt; + if (activeFile) { + const selRange = docState.selectionRange; + const selectedText = + selRange && activeFile.content + ? activeFile.content.slice(selRange.start, selRange.end) + : null; + let ctx = `[Currently open file: ${activeFile.relativePath}]`; + if (contextOverride) { + ctx += `\n[Selection: ${contextOverride.label}]`; + ctx += `\n[Selected text:\n${contextOverride.selectedText}\n]`; + } else if (selectedText && selRange) { + const content = activeFile.content ?? ""; + const startLC = offsetToLineCol(content, selRange.start); + const endLC = offsetToLineCol(content, selRange.end); + ctx += `\n[Selection: @${activeFile.relativePath}:${startLC.line}:${startLC.col}-${endLC.line}:${endLC.col}]`; + ctx += `\n[Selected text:\n${selectedText}\n]`; + } + prompt = `${ctx}\n\n${userPrompt}`; + } + log.info("invoking Claude CLI", { + promptLength: prompt.length, + mode: sessionId ? "resume" : "new", }); + + if (sessionId) { + await invoke("resume_claude_code", { + projectPath, + sessionId, + prompt, + tabId: activeTabId, + model: selectedModel, + effortLevel, + }); + } else { + await invoke("execute_claude_code", { + projectPath, + prompt, + tabId: activeTabId, + model: selectedModel, + effortLevel, + }); + } } log.info( `sendPrompt complete in ${(performance.now() - sendStart).toFixed(0)}ms`, @@ -418,10 +519,14 @@ export const useClaudeChatStore = create()((set, get) => ({ }, cancelExecution: async () => { - const { activeTabId } = get(); + const { activeTabId, provider } = get(); set({ _cancelledByUser: true }); try { - await invoke("cancel_claude_execution", { tabId: activeTabId }); + if (provider === "ollama") { + await invoke("cancel_ollama_message", { tabId: activeTabId }); + } else { + await invoke("cancel_claude_execution", { tabId: activeTabId }); + } } catch { // ignore } @@ -452,6 +557,7 @@ export const useClaudeChatStore = create()((set, get) => ({ totalInputTokens: 0, totalOutputTokens: 0, title: "New Chat", + // Keep provider + ollama model on new session }), ); }, @@ -501,8 +607,9 @@ export const useClaudeChatStore = create()((set, get) => ({ createTab: () => { log.debug("Creating new tab"); + const state = get(); const id = nextTabId(); - const newTab = makeDefaultTab(id); + const newTab = makeDefaultTab(id, state.provider, state.ollamaModel); set((s) => ({ tabs: [...s.tabs, newTab], activeTabId: id, @@ -513,6 +620,8 @@ export const useClaudeChatStore = create()((set, get) => ({ error: newTab.error, totalInputTokens: newTab.totalInputTokens, totalOutputTokens: newTab.totalOutputTokens, + provider: newTab.provider, + ollamaModel: newTab.ollamaModel, })); return id; }, @@ -544,6 +653,8 @@ export const useClaudeChatStore = create()((set, get) => ({ error: newActive.error, totalInputTokens: newActive.totalInputTokens, totalOutputTokens: newActive.totalOutputTokens, + provider: newActive.provider, + ollamaModel: newActive.ollamaModel, }); } else { set({ tabs: newTabs }); @@ -565,6 +676,8 @@ export const useClaudeChatStore = create()((set, get) => ({ error: targetTab.error, totalInputTokens: targetTab.totalInputTokens, totalOutputTokens: targetTab.totalOutputTokens, + provider: targetTab.provider, + ollamaModel: targetTab.ollamaModel, }); }, @@ -597,6 +710,51 @@ export const useClaudeChatStore = create()((set, get) => ({ }); }, + _appendStreamingText: (tabId: string, text: string) => { + if (!text) return; + set((state) => { + const tab = state.tabs.find((t) => t.id === tabId); + if (!tab) return {}; + + const lastMsg = tab.messages[tab.messages.length - 1]; + if ( + lastMsg?.type === "assistant" && + Array.isArray(lastMsg.message?.content) + ) { + const content = lastMsg.message.content; + const lastBlock = content[content.length - 1]; + if (lastBlock?.type === "text") { + const updatedContent = [...content]; + updatedContent[updatedContent.length - 1] = { + ...lastBlock, + text: (lastBlock.text ?? "") + text, + }; + return applyTabUpdate(state, tabId, { + messages: [ + ...tab.messages.slice(0, -1), + { + ...lastMsg, + message: { ...lastMsg.message, content: updatedContent }, + }, + ], + }); + } + } + + return applyTabUpdate(state, tabId, { + messages: [ + ...tab.messages, + { + type: "assistant", + message: { + content: [{ type: "text", text }], + }, + } as ClaudeStreamMessage, + ], + }); + }); + }, + _setSessionId: (tabId: string, id: string) => { set((state) => applyTabUpdate(state, tabId, { sessionId: id })); }, diff --git a/apps/desktop/src/stores/settings-store.ts b/apps/desktop/src/stores/settings-store.ts index c5bffd1..6669b98 100644 --- a/apps/desktop/src/stores/settings-store.ts +++ b/apps/desktop/src/stores/settings-store.ts @@ -2,12 +2,19 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; type CompilerBackend = "tectonic" | "texlive"; +type AiProvider = "claude" | "ollama"; interface SettingsState { compilerBackend: CompilerBackend; setCompilerBackend: (backend: CompilerBackend) => void; vimMode: boolean; setVimMode: (enabled: boolean) => void; + aiProvider: AiProvider; + setAiProvider: (provider: AiProvider) => void; + ollamaBaseUrl: string; + setOllamaBaseUrl: (url: string) => void; + ollamaModel: string; + setOllamaModel: (model: string) => void; } export const useSettingsStore = create()( @@ -17,6 +24,12 @@ export const useSettingsStore = create()( setCompilerBackend: (backend) => set({ compilerBackend: backend }), vimMode: false, setVimMode: (enabled) => set({ vimMode: enabled }), + aiProvider: "claude", + setAiProvider: (provider) => set({ aiProvider: provider }), + ollamaBaseUrl: "http://localhost:11434", + setOllamaBaseUrl: (url) => set({ ollamaBaseUrl: url }), + ollamaModel: "", + setOllamaModel: (model) => set({ ollamaModel: model }), }), { name: "claude-prism-settings",