Compare commits
No commits in common. "cba417526092f5e53f483141ba4be6b45f9d1236" and "d8991905d75565da31655d2287b21f2b849c172d" have entirely different histories.
cba4175260
...
d8991905d7
5 changed files with 9 additions and 261 deletions
|
|
@ -31,7 +31,6 @@ tauri-plugin-log = "2"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-fs = "2"
|
tauri-plugin-fs = "2"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use crate::config::{MigrationConfig, NotificationLevel, RobinConfig, SourceOs};
|
use crate::config::{MigrationConfig, NotificationLevel, RobinConfig, SourceOs};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::{Emitter, State};
|
use tauri::State;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: Mutex<RobinConfig>,
|
pub config: Mutex<RobinConfig>,
|
||||||
|
|
@ -79,50 +79,3 @@ pub fn panel_opened() {
|
||||||
pub fn panel_closed() {
|
pub fn panel_closed() {
|
||||||
crate::notify::set_panel_open(false);
|
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(())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
mod distro;
|
mod distro;
|
||||||
mod llm;
|
|
||||||
mod notify;
|
mod notify;
|
||||||
mod patterns;
|
mod patterns;
|
||||||
mod tray;
|
mod tray;
|
||||||
|
|
@ -77,7 +76,6 @@ pub fn run() {
|
||||||
commands::get_pending_events,
|
commands::get_pending_events,
|
||||||
commands::panel_opened,
|
commands::panel_opened,
|
||||||
commands::panel_closed,
|
commands::panel_closed,
|
||||||
commands::chat,
|
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running Robin");
|
.expect("error while running Robin");
|
||||||
|
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
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<OllamaChunkMessage>,
|
|
||||||
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<ChatMessage>,
|
|
||||||
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<u8> = 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::<OllamaChunk>(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::<OllamaChunk>("not valid json");
|
|
||||||
assert!(result.is_err(), "malformed JSON must fail to parse");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -26,11 +26,8 @@
|
||||||
class="chat-input"
|
class="chat-input"
|
||||||
placeholder="Ask Robin something..."
|
placeholder="Ask Robin something..."
|
||||||
@keydown.enter="send"
|
@keydown.enter="send"
|
||||||
:disabled="thinking"
|
|
||||||
/>
|
/>
|
||||||
<button class="send-btn" @click="send" :disabled="thinking">
|
<button class="send-btn" @click="send">→</button>
|
||||||
{{ thinking ? '…' : '→' }}
|
|
||||||
</button>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -45,12 +42,13 @@ interface RobinEvent { pattern_id: string; title: string; body: string; severity
|
||||||
|
|
||||||
const messages = ref<Message[]>([])
|
const messages = ref<Message[]>([])
|
||||||
const input = ref('')
|
const input = ref('')
|
||||||
const thinking = ref(false)
|
|
||||||
const messagesEl = ref<HTMLElement | null>(null)
|
const messagesEl = ref<HTMLElement | null>(null)
|
||||||
let unlisten: UnlistenFn | null = null
|
let unlisten: UnlistenFn | null = null
|
||||||
let activeCleanup: (() => void) | null = null
|
|
||||||
|
|
||||||
onMounted(async () => {
|
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 { await invoke('panel_opened') } catch {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -62,6 +60,7 @@ onMounted(async () => {
|
||||||
console.warn('Robin: failed to drain pending events:', err)
|
console.warn('Robin: failed to drain pending events:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always set up the live listener, even if drain failed
|
||||||
unlisten = await listen<RobinEvent>('robin:event', ({ payload }) => {
|
unlisten = await listen<RobinEvent>('robin:event', ({ payload }) => {
|
||||||
pushRobinEvent(payload)
|
pushRobinEvent(payload)
|
||||||
})
|
})
|
||||||
|
|
@ -70,7 +69,6 @@ onMounted(async () => {
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
unlisten?.()
|
unlisten?.()
|
||||||
invoke('panel_closed').catch(() => {})
|
invoke('panel_closed').catch(() => {})
|
||||||
activeCleanup?.()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function pushRobinEvent(e: RobinEvent) {
|
function pushRobinEvent(e: RobinEvent) {
|
||||||
|
|
@ -82,55 +80,13 @@ function pushRobinEvent(e: RobinEvent) {
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
const text = input.value.trim()
|
const text = input.value.trim()
|
||||||
if (!text || thinking.value) return
|
if (!text) return
|
||||||
input.value = ''
|
input.value = ''
|
||||||
thinking.value = true
|
|
||||||
|
|
||||||
messages.value.push({ role: 'user', content: text })
|
messages.value.push({ role: 'user', content: text })
|
||||||
messages.value.push({ role: 'robin', content: '' })
|
// M4: invoke('chat', { message: text }) and stream response
|
||||||
const robinIdx = messages.value.length - 1
|
messages.value.push({ role: 'robin', content: '(LLM chat not yet connected — M4)' })
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
messagesEl.value?.scrollTo({ top: messagesEl.value.scrollHeight, behavior: 'smooth' })
|
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<string>('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<string>('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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -198,6 +154,4 @@ async function send() {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.send-btn:hover { opacity: 0.85; }
|
.send-btn:hover { opacity: 0.85; }
|
||||||
.send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
.chat-input:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue