mirror of
https://github.com/delibae/claude-prism.git
synced 2026-08-28 05:14:59 +00:00
feat: add Ollama provider support
- Add Rust Ollama backend module with /api/tags status check, streaming /api/chat command, and cancellation support. - Add frontend provider selection (Claude / Ollama) in chat composer. - Persist Ollama URL/model in settings store and per-tab state. - Implement structured edit parser for <proposed-change> blocks emitted by Ollama models and convert them into proposed changes. - Add use-ollama-events hook to stream responses into the chat UI. - Update CSP to allow localhost Ollama connections. - Add OLLAMA.md documentation and mention in README. - Add/extend frontend tests; all 165 pass. ClaudePrism users can now chat with local Ollama models as an alternative to Claude Code, including offline file-edit suggestions.
This commit is contained in:
parent
1939030e9a
commit
30157821f7
18 changed files with 1566 additions and 147 deletions
49
OLLAMA.md
Normal file
49
OLLAMA.md
Normal file
|
|
@ -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 `<proposed-change>` 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
|
||||
<proposed-change file="relative/path.tex">
|
||||
<old>
|
||||
exact existing text
|
||||
</old>
|
||||
<new>
|
||||
replacement text
|
||||
</new>
|
||||
</proposed-change>
|
||||
```
|
||||
|
||||
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 <model>`.
|
||||
- **Edits not applied** — the old text in the `<old>` 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.
|
||||
|
|
@ -111,6 +111,9 @@ Chat with Claude directly in the editor. Select between Sonnet, Opus, Haiku mode
|
|||
<img src="./assets/demo/claudecommand.webp" alt="Claude AI Assistant & Slash Commands" width="600" />
|
||||
</p>
|
||||
|
||||
### 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`).
|
||||
|
||||
|
|
|
|||
21
apps/desktop/src-tauri/Cargo.lock
generated
21
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
477
apps/desktop/src-tauri/src/ollama.rs
Normal file
477
apps/desktop/src-tauri/src/ollama.rs
Normal file
|
|
@ -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<OllamaChatMessage>,
|
||||
stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
options: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OllamaChatResponse {
|
||||
#[serde(default)]
|
||||
message: Option<OllamaMessage>,
|
||||
#[serde(default)]
|
||||
done: bool,
|
||||
#[serde(default)]
|
||||
eval_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
prompt_eval_count: Option<u64>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
// ─── 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<Mutex<HashMap<String, JoinHandle<()>>>>,
|
||||
}
|
||||
|
||||
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",
|
||||
"<proposed-change file=\"relative/path.tex\">\n",
|
||||
"<old>\n",
|
||||
"exact existing text to replace\n",
|
||||
"</old>\n",
|
||||
"<new>\n",
|
||||
"replacement text\n",
|
||||
"</new>\n",
|
||||
"</proposed-change>\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<OllamaChatMessage>,
|
||||
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<OllamaStatus, String> {
|
||||
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<OllamaChatMessage>,
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
|
|
|
|||
86
apps/desktop/src/__tests__/lib/ollama-edit-parser.test.ts
Normal file
86
apps/desktop/src/__tests__/lib/ollama-edit-parser.test.ts
Normal file
|
|
@ -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.
|
||||
|
||||
<proposed-change file="main.tex">
|
||||
<old>
|
||||
\\section{Introduction}
|
||||
Hello.
|
||||
</old>
|
||||
<new>
|
||||
\\section{Introduction}
|
||||
Hello, world!
|
||||
</new>
|
||||
</proposed-change>
|
||||
`;
|
||||
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 = `
|
||||
<proposed-change file="a.tex">
|
||||
<old>alpha</old>
|
||||
<new>ALPHA</new>
|
||||
</proposed-change>
|
||||
<proposed-change file="b.tex">
|
||||
<old>beta</old>
|
||||
<new>BETA</new>
|
||||
</proposed-change>
|
||||
`;
|
||||
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 = `
|
||||
<proposed-change file="main.tex">
|
||||
<old>old text</old>
|
||||
<new>new text</new>
|
||||
`;
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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!");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ function resetStores() {
|
|||
error: null,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
provider: "claude",
|
||||
ollamaModel: "",
|
||||
draft: { input: "", pinnedContexts: [] },
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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<HTMLTextAreaElement>(null);
|
||||
|
||||
// Ollama model list state
|
||||
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
|
||||
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<HTMLDivElement>(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<PinnedContext[]>([]);
|
||||
|
|
@ -681,101 +731,202 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
|
|||
createPortal(
|
||||
<div
|
||||
ref={modelPickerRef}
|
||||
className="fixed w-64 rounded-lg border border-border bg-background shadow-lg"
|
||||
className="fixed w-72 rounded-lg border border-border bg-background shadow-lg"
|
||||
style={{
|
||||
left: pickerPos.left,
|
||||
bottom: pickerPos.bottom,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
{/* Models */}
|
||||
<div className="p-1">
|
||||
<div className="px-2 py-1 font-medium text-muted-foreground text-xs">
|
||||
Model
|
||||
</div>
|
||||
{[
|
||||
{
|
||||
id: "sonnet" as const,
|
||||
name: "Sonnet",
|
||||
desc: "Fast, efficient for most tasks",
|
||||
icon: <ZapIcon className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "opus" as const,
|
||||
name: "Opus",
|
||||
desc: "Most capable, complex reasoning",
|
||||
icon: <SparklesIcon className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "haiku" as const,
|
||||
name: "Haiku",
|
||||
desc: "Fastest, simple tasks",
|
||||
icon: <RabbitIcon className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "opusplan" as const,
|
||||
name: "OpusPlan",
|
||||
desc: "Opus for planning, Sonnet for execution",
|
||||
icon: <LayersIcon className="size-3.5" />,
|
||||
},
|
||||
].map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
|
||||
selectedModel === m.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted",
|
||||
)}
|
||||
onClick={() => setSelectedModel(m.id)}
|
||||
>
|
||||
{m.icon}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-xs">{m.name}</div>
|
||||
<div className="truncate text-muted-foreground text-xs">
|
||||
{m.desc}
|
||||
</div>
|
||||
</div>
|
||||
{selectedModel === m.id && (
|
||||
<CheckIcon className="size-3 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-border border-t" />
|
||||
|
||||
{/* Effort level */}
|
||||
<div className="p-2">
|
||||
<div className="mb-1.5 flex items-center justify-between px-1">
|
||||
<span className="font-medium text-muted-foreground text-xs">
|
||||
Effort
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{effortLevel === "low"
|
||||
? "Low"
|
||||
: effortLevel === "medium"
|
||||
? "Medium"
|
||||
: "High"}
|
||||
</span>
|
||||
{/* Provider switch */}
|
||||
<div className="border-border border-b p-2">
|
||||
<div className="mb-1.5 px-1 font-medium text-muted-foreground text-xs">
|
||||
Provider
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{(["low", "medium", "high"] as const).map((level) => (
|
||||
{(["claude", "ollama"] as AiProvider[]).map((p) => (
|
||||
<button
|
||||
key={level}
|
||||
key={p}
|
||||
onClick={() => setProvider(p)}
|
||||
className={cn(
|
||||
"flex-1 rounded-md py-1 text-center font-medium text-xs transition-colors",
|
||||
effortLevel === level
|
||||
provider === p
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||
)}
|
||||
onClick={() => setEffortLevel(level)}
|
||||
>
|
||||
{level === "low" ? "L" : level === "medium" ? "M" : "H"}
|
||||
{p === "claude" ? "Claude" : "Ollama"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{provider === "claude" ? (
|
||||
<>
|
||||
{/* Claude models */}
|
||||
<div className="p-1">
|
||||
<div className="px-2 py-1 font-medium text-muted-foreground text-xs">
|
||||
Model
|
||||
</div>
|
||||
{[
|
||||
{
|
||||
id: "sonnet" as const,
|
||||
name: "Sonnet",
|
||||
desc: "Fast, efficient for most tasks",
|
||||
icon: <ZapIcon className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "opus" as const,
|
||||
name: "Opus",
|
||||
desc: "Most capable, complex reasoning",
|
||||
icon: <SparklesIcon className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "haiku" as const,
|
||||
name: "Haiku",
|
||||
desc: "Fastest, simple tasks",
|
||||
icon: <RabbitIcon className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "opusplan" as const,
|
||||
name: "OpusPlan",
|
||||
desc: "Opus for planning, Sonnet for execution",
|
||||
icon: <LayersIcon className="size-3.5" />,
|
||||
},
|
||||
].map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
|
||||
selectedModel === m.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted",
|
||||
)}
|
||||
onClick={() => setSelectedModel(m.id)}
|
||||
>
|
||||
{m.icon}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-xs">{m.name}</div>
|
||||
<div className="truncate text-muted-foreground text-xs">
|
||||
{m.desc}
|
||||
</div>
|
||||
</div>
|
||||
{selectedModel === m.id && (
|
||||
<CheckIcon className="size-3 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-border border-t" />
|
||||
|
||||
{/* Effort level */}
|
||||
<div className="p-2">
|
||||
<div className="mb-1.5 flex items-center justify-between px-1">
|
||||
<span className="font-medium text-muted-foreground text-xs">
|
||||
Effort
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{effortLevel === "low"
|
||||
? "Low"
|
||||
: effortLevel === "medium"
|
||||
? "Medium"
|
||||
: "High"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{(["low", "medium", "high"] as const).map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
className={cn(
|
||||
"flex-1 rounded-md py-1 text-center font-medium text-xs transition-colors",
|
||||
effortLevel === level
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||
)}
|
||||
onClick={() => setEffortLevel(level)}
|
||||
>
|
||||
{level === "low" ? "L" : level === "medium" ? "M" : "H"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Ollama settings */}
|
||||
<div className="space-y-2 p-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ollama-url"
|
||||
className="mb-1 block px-1 font-medium text-muted-foreground text-xs"
|
||||
>
|
||||
Ollama URL
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
id="ollama-url"
|
||||
type="text"
|
||||
value={ollamaBaseUrl}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refreshOllamaModels()}
|
||||
disabled={ollamaStatus.loading}
|
||||
className="rounded-md bg-muted px-2 py-1 font-medium text-muted-foreground text-xs transition-colors hover:bg-muted/80 disabled:opacity-50"
|
||||
>
|
||||
{ollamaStatus.loading ? "..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between px-1">
|
||||
<span className="font-medium text-muted-foreground text-xs">
|
||||
Model
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{ollamaStatus.loading ? (
|
||||
<span className="text-muted-foreground">
|
||||
Checking…
|
||||
</span>
|
||||
) : ollamaStatus.available ? (
|
||||
<span className="text-green-600">Connected</span>
|
||||
) : (
|
||||
<span className="text-destructive">
|
||||
{ollamaStatus.error || "Unreachable"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{ollamaModels.length === 0 ? (
|
||||
<div className="rounded-md bg-muted px-2 py-1.5 text-muted-foreground text-xs">
|
||||
No models found. Pull a model with{" "}
|
||||
<code className="rounded bg-background px-1 py-0.5">
|
||||
ollama pull <model>
|
||||
</code>
|
||||
.
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={ollamaModel}
|
||||
onChange={(e) => setOllamaModel(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-1 text-xs outline-none focus:border-ring"
|
||||
>
|
||||
{ollamaModels.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
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"
|
||||
>
|
||||
<span>
|
||||
{selectedModel === "sonnet"
|
||||
? "Sonnet"
|
||||
: selectedModel === "opus"
|
||||
? "Opus"
|
||||
: selectedModel === "haiku"
|
||||
? "Haiku"
|
||||
: "OpusPlan"}
|
||||
{provider === "ollama"
|
||||
? ollamaModel || "Ollama"
|
||||
: selectedModel === "sonnet"
|
||||
? "Sonnet"
|
||||
: selectedModel === "opus"
|
||||
? "Opus"
|
||||
: selectedModel === "haiku"
|
||||
? "Haiku"
|
||||
: "OpusPlan"}
|
||||
</span>
|
||||
<span className="text-muted-foreground/60">
|
||||
{effortLevel === "low"
|
||||
? "L"
|
||||
: effortLevel === "medium"
|
||||
? "M"
|
||||
: "H"}
|
||||
{provider === "ollama"
|
||||
? "local"
|
||||
: effortLevel === "low"
|
||||
? "L"
|
||||
: effortLevel === "medium"
|
||||
? "M"
|
||||
: "H"}
|
||||
</span>
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ export const ChatMessages: FC = () => {
|
|||
>
|
||||
{displayMessages.length === 0 && !isStreaming && (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
||||
Ask Claude about your LaTeX document...
|
||||
Ask about your LaTeX document...
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
235
apps/desktop/src/hooks/use-ollama-events.ts
Normal file
235
apps/desktop/src/hooks/use-ollama-events.ts
Normal file
|
|
@ -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
|
||||
* `<proposed-change>` 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<string, string>());
|
||||
const listenersRef = useRef<UnlistenFn[]>([]);
|
||||
|
||||
// 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<OllamaOutputPayload>(
|
||||
"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<OllamaCompletePayload>(
|
||||
"ollama-complete",
|
||||
(event) => {
|
||||
if (!cancelled) handleComplete(event.payload);
|
||||
},
|
||||
);
|
||||
if (cancelled) {
|
||||
unlistenComplete();
|
||||
return;
|
||||
}
|
||||
listenersRef.current.push(unlistenComplete);
|
||||
|
||||
const unlistenError = await listen<OllamaErrorPayload>(
|
||||
"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 = [];
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
99
apps/desktop/src/lib/ollama-edit-parser.ts
Normal file
99
apps/desktop/src/lib/ollama-edit-parser.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
export interface OllamaProposedEdit {
|
||||
filePath: string;
|
||||
oldText: string;
|
||||
newText: string;
|
||||
}
|
||||
|
||||
const CHANGE_OPEN_RE = /<proposed-change\s+file\s*=\s*["']([^"']+)["']\s*>/;
|
||||
|
||||
/**
|
||||
* Parse `<proposed-change file="...">...<old>...<old/><new>...<new/><proposed-change/>`
|
||||
* 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("</proposed-change>", 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 + "</proposed-change>".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 = `</${tag}>`;
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<ClaudeChatState>()((set, get) => ({
|
|||
effortLevel: "medium",
|
||||
setEffortLevel: (level) => set({ effortLevel: level }),
|
||||
|
||||
provider: "claude",
|
||||
setProvider: (provider) =>
|
||||
set((state) => {
|
||||
const updates: Partial<ClaudeChatState> = { provider };
|
||||
return {
|
||||
...updates,
|
||||
...applyTabUpdate(state, state.activeTabId, {
|
||||
provider,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
|
||||
ollamaModel: "",
|
||||
setOllamaModel: (model) =>
|
||||
set((state) => {
|
||||
const updates: Partial<ClaudeChatState> = { 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<ClaudeChatState>()((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<ClaudeChatState>()((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<ClaudeChatState>()((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<ClaudeChatState>()((set, get) => ({
|
|||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
title: "New Chat",
|
||||
// Keep provider + ollama model on new session
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
|
@ -501,8 +607,9 @@ export const useClaudeChatStore = create<ClaudeChatState>()((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<ClaudeChatState>()((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<ClaudeChatState>()((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<ClaudeChatState>()((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<ClaudeChatState>()((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 }));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<SettingsState>()(
|
||||
|
|
@ -17,6 +24,12 @@ export const useSettingsStore = create<SettingsState>()(
|
|||
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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue