diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0aeb6d2..4e0262e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,6 +31,7 @@ tauri-plugin-log = "2" tauri-plugin-notification = "2" tauri-plugin-shell = "2" tauri-plugin-fs = "2" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } [dev-dependencies] tempfile = "3" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cf96e71..42ad589 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,6 +1,6 @@ use crate::config::{MigrationConfig, NotificationLevel, RobinConfig, SourceOs}; use std::sync::Mutex; -use tauri::State; +use tauri::{Emitter, State}; pub struct AppState { pub config: Mutex, @@ -79,3 +79,50 @@ pub fn panel_opened() { pub fn panel_closed() { crate::notify::set_panel_open(false); } + +#[tauri::command] +pub async fn chat( + message: String, + state: State<'_, AppState>, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + let (base_url, model, source_os, distro) = { + let cfg = state.config.lock().map_err(|e| e.to_string())?; + let base_url = cfg.ollama.base_url.clone(); + let model = cfg.ollama.model.clone(); + let (source_os, distro) = match cfg.migration.as_ref() { + Some(m) => { + let os = match m.source_os { + crate::config::SourceOs::Macos => "macOS", + crate::config::SourceOs::Windows => "Windows", + crate::config::SourceOs::Linux => "Linux", + crate::config::SourceOs::Unknown => "an unknown OS", + }; + (os.to_string(), m.distro.clone()) + } + None => ("an unknown OS".to_string(), "Linux".to_string()), + }; + (base_url, model, source_os, distro) + }; + + let system_prompt = crate::llm::build_system_prompt(&source_os, &distro); + let messages = vec![ + crate::llm::ChatMessage { + role: "system".into(), + content: system_prompt, + }, + crate::llm::ChatMessage { + role: "user".into(), + content: message, + }, + ]; + + tauri::async_runtime::spawn(async move { + if let Err(e) = crate::llm::chat_stream(&base_url, &model, messages, &app_handle).await { + log::error!("chat stream error: {e}"); + let _ = app_handle.emit("robin:chat-error", e.to_string()); + } + }); + + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5da7576..086382f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod commands; mod config; mod distro; +mod llm; mod notify; mod patterns; mod tray; @@ -76,6 +77,7 @@ pub fn run() { commands::get_pending_events, commands::panel_opened, commands::panel_closed, + commands::chat, ]) .run(tauri::generate_context!()) .expect("error while running Robin"); diff --git a/src-tauri/src/llm.rs b/src-tauri/src/llm.rs new file mode 100644 index 0000000..a1b8f20 --- /dev/null +++ b/src-tauri/src/llm.rs @@ -0,0 +1,156 @@ +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter}; + +#[derive(Debug, Clone, Serialize)] +pub struct ChatMessage { + pub role: String, + pub content: String, +} + +#[derive(Debug, Deserialize)] +struct OllamaChunk { + message: Option, + done: bool, +} + +#[derive(Debug, Deserialize)] +struct OllamaChunkMessage { + content: String, +} + +pub fn build_system_prompt(source_os: &str, distro: &str) -> String { + format!( + "You are Robin, a friendly Linux migration companion built into the user's desktop. \ + The user migrated from {source_os} and is currently using {distro}. \ + Help them with Linux questions. When concepts differ from their previous OS, \ + explain the Linux equivalent clearly. Be concise, practical, and warm. \ + Use plain language and avoid unexplained jargon. \ + Never make the user feel bad for not knowing something." + ) +} + +pub async fn chat_stream( + base_url: &str, + model: &str, + messages: Vec, + app: &AppHandle, +) -> anyhow::Result<()> { + let client = reqwest::Client::new(); + let url = format!("{base_url}/api/chat"); + let body = serde_json::json!({ + "model": model, + "messages": messages, + "stream": true + }); + + let mut response = client + .post(&url) + .json(&body) + .send() + .await + .context("failed to reach Ollama — is it running at the configured URL?")?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + anyhow::bail!("Ollama returned {status}: {text}"); + } + + let mut buffer: Vec = Vec::new(); + + loop { + match response.chunk().await { + Ok(Some(bytes)) => { + buffer.extend_from_slice(&bytes); + while let Some(nl) = buffer.iter().position(|&b| b == b'\n') { + let line_bytes = buffer[..nl].to_vec(); + buffer.drain(..=nl); + let line = String::from_utf8_lossy(&line_bytes); + let line = line.trim(); + if line.is_empty() { + continue; + } + match serde_json::from_str::(line) { + Ok(chunk) if chunk.done => { + if let Err(e) = app.emit("robin:chat-done", ()) { + log::warn!("llm: failed to emit chat-done: {e}"); + } + return Ok(()); + } + Ok(chunk) => { + if let Some(msg) = chunk.message { + if !msg.content.is_empty() { + if let Err(e) = app.emit("robin:chat-token", msg.content) { + log::warn!("llm: failed to emit chat-token: {e}"); + } + } + } + } + Err(e) => { + log::warn!("llm: failed to parse Ollama chunk: {e} — raw: {line}"); + } + } + } + } + Ok(None) => break, + Err(e) => { + return Err(e).context("stream read error"); + } + } + } + + // Stream ended without a done:true line + if let Err(e) = app.emit("robin:chat-done", ()) { + log::warn!("llm: failed to emit chat-done: {e}"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prompt_includes_source_os_and_distro() { + let prompt = build_system_prompt("macOS", "cachyos"); + assert!(prompt.contains("macOS"), "prompt must mention source OS"); + assert!(prompt.contains("cachyos"), "prompt must mention distro"); + } + + #[test] + fn prompt_includes_robin_name() { + let prompt = build_system_prompt("Windows", "linuxmint"); + assert!(prompt.contains("Robin"), "prompt must identify Robin as the assistant"); + } + + #[test] + fn parse_token_chunk() { + let json = r#"{"model":"llama3.2","created_at":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"Hello"},"done":false}"#; + let chunk: OllamaChunk = serde_json::from_str(json).unwrap(); + assert!(!chunk.done); + assert_eq!(chunk.message.unwrap().content, "Hello"); + } + + #[test] + fn parse_done_chunk() { + let json = r#"{"model":"llama3.2","created_at":"2024-01-01T00:00:00Z","done":true,"done_reason":"stop"}"#; + let chunk: OllamaChunk = serde_json::from_str(json).unwrap(); + assert!(chunk.done); + assert!(chunk.message.is_none()); + } + + #[test] + fn parse_empty_content_chunk_deserializes() { + let json = r#"{"model":"llama3.2","created_at":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":""},"done":false}"#; + let chunk: OllamaChunk = serde_json::from_str(json).unwrap(); + assert!(!chunk.done); + assert_eq!(chunk.message.unwrap().content, ""); + } + + #[test] + fn malformed_json_fails_to_parse() { + let result = serde_json::from_str::("not valid json"); + assert!(result.is_err(), "malformed JSON must fail to parse"); + } +} diff --git a/src/components/ChatPanel.vue b/src/components/ChatPanel.vue index 193c636..5ca174b 100644 --- a/src/components/ChatPanel.vue +++ b/src/components/ChatPanel.vue @@ -26,8 +26,11 @@ class="chat-input" placeholder="Ask Robin something..." @keydown.enter="send" + :disabled="thinking" /> - + @@ -42,13 +45,12 @@ interface RobinEvent { pattern_id: string; title: string; body: string; severity const messages = ref([]) const input = ref('') +const thinking = ref(false) const messagesEl = ref(null) let unlisten: UnlistenFn | null = null +let activeCleanup: (() => void) | null = null onMounted(async () => { - // Signal backend: stop queuing events into PENDING while panel is open. - // Must happen before drain so new events that arrive during mount are not - // double-delivered on the next open cycle. try { await invoke('panel_opened') } catch {} try { @@ -60,7 +62,6 @@ onMounted(async () => { console.warn('Robin: failed to drain pending events:', err) } - // Always set up the live listener, even if drain failed unlisten = await listen('robin:event', ({ payload }) => { pushRobinEvent(payload) }) @@ -69,6 +70,7 @@ onMounted(async () => { onUnmounted(() => { unlisten?.() invoke('panel_closed').catch(() => {}) + activeCleanup?.() }) function pushRobinEvent(e: RobinEvent) { @@ -80,13 +82,55 @@ function pushRobinEvent(e: RobinEvent) { async function send() { const text = input.value.trim() - if (!text) return + if (!text || thinking.value) return input.value = '' + thinking.value = true + messages.value.push({ role: 'user', content: text }) - // M4: invoke('chat', { message: text }) and stream response - messages.value.push({ role: 'robin', content: '(LLM chat not yet connected — M4)' }) + messages.value.push({ role: 'robin', content: '' }) + const robinIdx = messages.value.length - 1 + await nextTick() messagesEl.value?.scrollTo({ top: messagesEl.value.scrollHeight, behavior: 'smooth' }) + + let unlistenToken: UnlistenFn | null = null + let unlistenDone: UnlistenFn | null = null + let unlistenError: UnlistenFn | null = null + + function cleanup() { + unlistenToken?.() + unlistenDone?.() + unlistenError?.() + thinking.value = false + activeCleanup = null + } + activeCleanup = cleanup + + unlistenToken = await listen('robin:chat-token', ({ payload }) => { + messages.value[robinIdx].content += payload + nextTick(() => { + messagesEl.value?.scrollTo({ top: messagesEl.value.scrollHeight, behavior: 'smooth' }) + }) + }) + + unlistenDone = await listen('robin:chat-done', () => cleanup()) + + unlistenError = await listen('robin:chat-error', ({ payload }) => { + if (!messages.value[robinIdx].content) { + messages.value[robinIdx].content = + `Robin couldn't reach Ollama. Make sure it's running at the configured URL. (${payload})` + } + cleanup() + }) + + try { + await invoke('chat', { message: text }) + } catch (err) { + if (!messages.value[robinIdx].content) { + messages.value[robinIdx].content = `Something went wrong starting the chat. (${err})` + } + cleanup() + } } @@ -154,4 +198,6 @@ async function send() { cursor: pointer; } .send-btn:hover { opacity: 0.85; } +.send-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.chat-input:disabled { opacity: 0.6; cursor: not-allowed; }