Compare commits

..

No commits in common. "feat/m2-llm-chat" and "main" have entirely different histories.

24 changed files with 54 additions and 1449 deletions

3
.gitignore vendored
View file

@ -29,6 +29,3 @@ src-tauri/target/
# Secrets
.env
.env.local
# Visual companion brainstorm sessions
.superpowers/

11
package-lock.json generated
View file

@ -8,7 +8,6 @@
"name": "robin",
"version": "0.0.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0",
"vue": "^3.5.34"
},
"devDependencies": {
@ -399,16 +398,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tauri-apps/api": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",

View file

@ -9,7 +9,6 @@
"preview": "vite preview"
},
"dependencies": {
"@tauri-apps/api": "^2.11.0",
"vue": "^3.5.34"
},
"devDependencies": {

View file

@ -24,14 +24,8 @@ log = "0.4"
anyhow = "1.0"
tokio = { version = "1", features = ["full"] }
toml = "0.8"
notify = "8"
dirs = "6"
tauri = { version = "2.11.2", features = ["tray-icon"] }
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"

View file

@ -3,11 +3,9 @@
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"chat"
"main"
],
"permissions": [
"core:default",
"notification:default",
"shell:allow-open"
"core:default"
]
}

View file

@ -1,65 +0,0 @@
[meta]
source_os = "macos"
target_distro_family = "arch"
[log_paths]
steam = "~/.local/share/Steam/logs/content_log.txt"
proton = "~/.local/share/Steam/logs/proton_log.txt"
retroarch = "~/.config/retroarch/retroarch.log"
lutris = "~/.cache/lutris/logs/lutris.log"
[[patterns]]
id = "aur-build-failure"
sources = ["journald"]
match_text = "error: failed to build"
severity = "warn"
title = "AUR package build failed"
body = "A package failed to compile from source. This usually means a missing dependency or a broken AUR package."
[[patterns]]
id = "aur-pgp-key"
sources = ["journald"]
match_text = "unknown public key"
severity = "warn"
title = "Missing PGP key for AUR package"
body = "The package signature can't be verified. Run: gpg --recv-keys <keyid>"
[[patterns]]
id = "proton-runtime-missing"
sources = ["applog:proton"]
match_text = "wine: cannot find"
severity = "warn"
title = "Proton runtime issue"
body = "Steam Proton couldn't find a required file. Try: right-click the game -> Properties -> Local Files -> Verify game files."
[[patterns]]
id = "steam-disk-write"
sources = ["applog:steam"]
match_text = "ERROR: failed to write"
severity = "warn"
title = "Steam disk write error"
body = "Steam can't write to its library folder. Check that you own the directory: ls -la ~/.local/share/Steam"
[[patterns]]
id = "retroarch-shader-fail"
sources = ["applog:retroarch"]
match_text = "Failed to compile shader"
severity = "info"
title = "RetroArch shader failed to compile"
body = "A graphical shader couldn't load. Try switching to a different shader preset in Settings -> Video -> Shaders."
[[patterns]]
id = "kernel-driver-firmware"
sources = ["kmsg"]
match_text = "firmware: failed to load"
severity = "warn"
title = "Missing firmware for hardware"
body = "Your system is missing a firmware file for a hardware component. On Arch, try: sudo pacman -S linux-firmware"
[[patterns]]
id = "missing-codec"
sources = ["journald"]
match_text = "GStreamer: Failed to find plugin"
severity = "info"
title = "Missing media codec"
body = "A media codec isn't installed. On Arch: sudo pacman -S gst-plugins-good gst-plugins-bad gst-libav"

View file

@ -1,56 +0,0 @@
[meta]
source_os = "windows"
target_distro_family = "debian"
[log_paths]
steam = "~/.local/share/Steam/logs/content_log.txt"
proton = "~/.local/share/Steam/logs/proton_log.txt"
retroarch = "~/.config/retroarch/retroarch.log"
[[patterns]]
id = "apt-lock"
sources = ["journald"]
match_text = "Could not get lock /var/lib/dpkg/lock"
severity = "warn"
title = "Package manager is locked"
body = "Another process is using apt. Wait a moment and try again. If it's stuck: sudo rm /var/lib/dpkg/lock-frontend"
[[patterns]]
id = "apt-unmet-dependency"
sources = ["journald"]
match_text = "Unmet dependencies"
severity = "warn"
title = "Package dependency conflict"
body = "apt can't resolve a dependency. Try: sudo apt --fix-broken install"
[[patterns]]
id = "proton-runtime-missing"
sources = ["applog:proton"]
match_text = "wine: cannot find"
severity = "warn"
title = "Proton runtime issue"
body = "Steam Proton couldn't find a required file. Right-click the game -> Properties -> Local Files -> Verify game files."
[[patterns]]
id = "kernel-driver-firmware"
sources = ["kmsg"]
match_text = "firmware: failed to load"
severity = "warn"
title = "Missing firmware for hardware"
body = "Your system is missing a firmware file. On Debian/Ubuntu/Mint: sudo apt install firmware-linux firmware-linux-nonfree"
[[patterns]]
id = "missing-codec"
sources = ["journald"]
match_text = "GStreamer: Failed to find plugin"
severity = "info"
title = "Missing media codec"
body = "A media codec isn't installed. Try: sudo apt install ubuntu-restricted-extras"
[[patterns]]
id = "snap-confinement"
sources = ["journald"]
match_text = "snap: cannot use strict"
severity = "info"
title = "Snap package confinement issue"
body = "A Snap package is having permission trouble. Try running it with --devmode, or look for a Flatpak or apt alternative."

View file

@ -1,6 +1,6 @@
use crate::config::{MigrationConfig, NotificationLevel, RobinConfig, SourceOs};
use crate::config::{RobinConfig, MigrationConfig, SourceOs};
use tauri::State;
use std::sync::Mutex;
use tauri::{Emitter, State};
pub struct AppState {
pub config: Mutex<RobinConfig>,
@ -8,18 +8,14 @@ pub struct AppState {
#[tauri::command]
pub fn get_config(state: State<'_, AppState>) -> Result<RobinConfig, String> {
state
.config
.lock()
state.config.lock()
.map(|c| c.clone())
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn needs_onboarding(state: State<'_, AppState>) -> bool {
state
.config
.lock()
state.config.lock()
.map(|c| c.needs_onboarding())
.unwrap_or(true)
}
@ -37,92 +33,11 @@ pub fn complete_onboarding(
_ => SourceOs::Unknown,
};
let detected = if distro == "unknown" || distro.is_empty() {
crate::distro::detect()
} else {
distro
};
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.migration = Some(MigrationConfig {
source_os: source,
distro: detected,
distro,
fluency_level: 0,
});
config.save().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_notification_level(level: String, state: State<'_, AppState>) -> Result<(), String> {
let parsed = match level.as_str() {
"off" => NotificationLevel::Off,
"badge_only" => NotificationLevel::BadgeOnly,
"badge_and_toast" => NotificationLevel::BadgeAndToast,
_ => return Err(format!("unknown notification level: {level}")),
};
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.display.notification_level = parsed;
config.save().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_pending_events() -> Vec<crate::patterns::MatchedEvent> {
crate::notify::take_pending()
}
#[tauri::command]
pub fn panel_opened() {
crate::notify::set_panel_open(true);
}
#[tauri::command]
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(())
}

View file

@ -2,7 +2,7 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceOs {
Macos,
@ -11,24 +11,6 @@ pub enum SourceOs {
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "snake_case")]
pub enum Tier {
#[default]
Free,
Paid,
Premium,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "snake_case")]
pub enum NotificationLevel {
Off,
BadgeOnly,
#[default]
BadgeAndToast,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationConfig {
pub source_os: SourceOs,
@ -55,15 +37,14 @@ impl Default for OllamaConfig {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DisplayConfig {
#[serde(default)]
pub notification_level: NotificationLevel,
pub show_notifications: bool,
pub quiet_mode: bool,
}
impl Default for DisplayConfig {
fn default() -> Self {
Self {
notification_level: NotificationLevel::BadgeAndToast,
show_notifications: true,
quiet_mode: false,
}
}
@ -74,8 +55,6 @@ pub struct RobinConfig {
pub migration: Option<MigrationConfig>,
pub ollama: OllamaConfig,
pub display: DisplayConfig,
#[serde(default)]
pub tier: Tier,
}
impl Default for RobinConfig {
@ -84,7 +63,6 @@ impl Default for RobinConfig {
migration: None,
ollama: OllamaConfig::default(),
display: DisplayConfig::default(),
tier: Tier::Free,
}
}
}
@ -102,7 +80,8 @@ impl RobinConfig {
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
toml::from_str(&content).with_context(|| format!("failed to parse {}", path.display()))
toml::from_str(&content)
.with_context(|| format!("failed to parse {}", path.display()))
}
pub fn save(&self) -> Result<()> {
@ -157,36 +136,4 @@ mod tests {
let deserialized: RobinConfig = toml::from_str(&serialized).unwrap();
assert!(!deserialized.needs_onboarding());
}
#[test]
fn notification_level_default_is_badge_and_toast() {
let config = RobinConfig::default();
assert!(matches!(
config.display.notification_level,
NotificationLevel::BadgeAndToast
));
}
#[test]
fn tier_default_is_free() {
let config = RobinConfig::default();
assert!(matches!(config.tier, Tier::Free));
}
#[test]
fn notification_level_roundtrips_toml() {
let config = RobinConfig {
display: DisplayConfig {
notification_level: NotificationLevel::BadgeOnly,
quiet_mode: false,
},
..Default::default()
};
let toml = toml::to_string_pretty(&config).unwrap();
let back: RobinConfig = toml::from_str(&toml).unwrap();
assert!(matches!(
back.display.notification_level,
NotificationLevel::BadgeOnly
));
}
}

View file

@ -1,88 +0,0 @@
pub fn detect() -> String {
std::fs::read_to_string("/etc/os-release")
.map(|s| parse_id(&s))
.unwrap_or_else(|_| "unknown".to_string())
}
pub fn distro_family(id: &str) -> &'static str {
match id {
"arch" | "cachyos" | "endeavouros" | "manjaro" | "garuda" => "arch",
"debian" | "ubuntu" | "linuxmint" | "mint" | "pop" | "elementary" | "kali" => "debian",
"fedora" | "rhel" | "centos" | "rocky" | "alma" => "fedora",
"opensuse" | "opensuse-tumbleweed" | "opensuse-leap" => "opensuse",
_ => "unknown",
}
}
fn parse_id(content: &str) -> String {
content
.lines()
.find_map(|line| {
let (key, val) = line.split_once('=')?;
if key.trim() == "ID" {
let raw = val.trim();
let unquoted = raw
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.or_else(|| raw.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
.unwrap_or(raw);
Some(unquoted.to_lowercase())
} else {
None
}
})
.unwrap_or_else(|| "unknown".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_arch_id() {
let content = "ID=arch\nID_LIKE=\nPRETTY_NAME=Arch Linux\n";
assert_eq!(parse_id(content), "arch");
}
#[test]
fn parses_cachyos_id() {
let content = "ID=cachyos\nID_LIKE=arch\nPRETTY_NAME=CachyOS\n";
assert_eq!(parse_id(content), "cachyos");
}
#[test]
fn parses_linuxmint_id() {
let content = "ID=linuxmint\nID_LIKE=ubuntu\nPRETTY_NAME=Linux Mint 22\n";
assert_eq!(parse_id(content), "linuxmint");
}
#[test]
fn distro_family_arch_based() {
assert_eq!(distro_family("cachyos"), "arch");
assert_eq!(distro_family("arch"), "arch");
assert_eq!(distro_family("manjaro"), "arch");
}
#[test]
fn distro_family_debian_based() {
assert_eq!(distro_family("linuxmint"), "debian");
assert_eq!(distro_family("ubuntu"), "debian");
assert_eq!(distro_family("debian"), "debian");
}
#[test]
fn distro_family_unknown() {
assert_eq!(distro_family("slackware"), "unknown");
}
#[test]
fn parses_single_quoted_id() {
let content = "ID='arch'\nPRETTY_NAME='Arch Linux'\n";
assert_eq!(parse_id(content), "arch");
}
#[test]
fn distro_family_mint_legacy() {
assert_eq!(distro_family("mint"), "debian");
}
}

View file

@ -1,16 +1,11 @@
mod commands;
mod config;
mod distro;
mod llm;
mod notify;
mod patterns;
mod tray;
mod watcher;
use commands::AppState;
use config::RobinConfig;
use std::sync::{Arc, Mutex};
use tauri::Manager;
use std::sync::Mutex;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@ -26,58 +21,13 @@ pub fn run() {
})
.setup(|app| {
tray::build_tray(&app.handle())?;
let state = app.state::<AppState>();
let cfg = state
.config
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
let pattern_file = if let Some(ref migration) = cfg.migration {
let family = distro::distro_family(&migration.distro);
let source = match migration.source_os {
config::SourceOs::Macos => "macos",
config::SourceOs::Windows => "windows",
config::SourceOs::Linux => "linux",
config::SourceOs::Unknown => "unknown",
};
patterns::load(source, family).ok()
} else {
None
};
let log_paths = pattern_file
.as_ref()
.map(|pf| pf.log_paths.clone())
.unwrap_or_default();
let rx = watcher::spawn(log_paths);
let pf = Arc::new(pattern_file);
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let mut rx = rx;
while let Some(event) = rx.recv().await {
if let Some(ref pf) = *pf {
if let Some(matched) = patterns::classify(&event, pf) {
notify::dispatch(&app_handle, matched);
}
}
}
});
watcher::spawn();
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::get_config,
commands::needs_onboarding,
commands::complete_onboarding,
commands::update_notification_level,
commands::get_pending_events,
commands::panel_opened,
commands::panel_closed,
commands::chat,
])
.run(tauri::generate_context!())
.expect("error while running Robin");

View file

@ -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");
}
}

View file

@ -1,95 +0,0 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Runtime};
use tauri_plugin_notification::NotificationExt;
use crate::commands::AppState;
use crate::config::NotificationLevel;
use crate::patterns::MatchedEvent;
static PENDING: Mutex<Vec<MatchedEvent>> = Mutex::new(vec![]);
// True while ChatPanel is mounted and listening for live events.
// When true, dispatch skips PENDING so events are not shown twice on re-open.
static PANEL_OPEN: AtomicBool = AtomicBool::new(false);
pub fn set_panel_open(open: bool) {
PANEL_OPEN.store(open, Ordering::Relaxed);
}
pub fn dispatch<R: Runtime>(app: &AppHandle<R>, event: MatchedEvent) {
let level = app
.state::<AppState>()
.config
.lock()
.map(|c| c.display.notification_level.clone())
.unwrap_or_default();
if !PANEL_OPEN.load(Ordering::Relaxed) {
match PENDING.lock() {
Ok(mut pending) => pending.push(event.clone()),
Err(e) => log::warn!("notify: pending queue lock poisoned — event dropped: {e}"),
}
}
match level {
NotificationLevel::Off => {}
NotificationLevel::BadgeOnly => {
crate::tray::badge_on(app);
}
NotificationLevel::BadgeAndToast => {
crate::tray::badge_on(app);
send_toast(app, &event);
}
}
let _ = app.emit("robin:event", &event);
}
fn send_toast<R: Runtime>(app: &AppHandle<R>, event: &MatchedEvent) {
let _ = app
.notification()
.builder()
.title(&event.title)
.body(&event.body)
.show();
}
pub fn take_pending() -> Vec<MatchedEvent> {
PENDING
.lock()
.map(|mut v| std::mem::take(&mut *v))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_event(id: &str) -> MatchedEvent {
MatchedEvent {
pattern_id: id.into(),
title: "Title".into(),
body: "Body".into(),
severity: "warn".into(),
timestamp: 0,
}
}
#[test]
fn take_pending_drains_queue() {
// Ensure clean state — drain any events from other tests sharing the static
let _ = take_pending();
if let Ok(mut q) = PENDING.lock() {
q.push(make_event("a"));
q.push(make_event("b"));
}
let first = take_pending();
assert_eq!(first.len(), 2);
assert_eq!(first[0].pattern_id, "a");
let second = take_pending();
assert!(second.is_empty(), "queue must be empty after drain");
}
}

View file

@ -1,289 +0,0 @@
use crate::watcher::{EventSource, SystemEvent};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize)]
pub struct PatternMeta {
pub source_os: String,
pub target_distro_family: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Pattern {
pub id: String,
pub sources: Vec<String>,
pub match_text: String,
pub severity: String,
pub title: String,
pub body: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PatternFile {
pub meta: PatternMeta,
#[serde(default)]
pub log_paths: HashMap<String, String>,
#[serde(default)]
pub patterns: Vec<Pattern>,
}
#[derive(Debug, Clone, Serialize)]
pub struct MatchedEvent {
pub pattern_id: String,
pub title: String,
pub body: String,
pub severity: String,
pub timestamp: u64,
}
/// Load the pattern file for a source OS and distro family.
///
/// Tries candidates in order: dev-relative path, src-tauri-relative path,
/// system path. The Tauri resource directory is the authoritative location
/// at runtime; passing a base path is handled in Task 10's caller via candidate list.
pub fn load(source_os: &str, distro_family: &str) -> Result<PatternFile> {
let filename = format!("{source_os}-to-{distro_family}.toml");
let candidates = [
format!("patterns/{filename}"),
format!("src-tauri/patterns/{filename}"),
format!("/usr/share/robin/patterns/{filename}"),
];
for path in &candidates {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let pf: PatternFile = match toml::from_str(&content) {
Ok(p) => p,
Err(e) => {
log::warn!("patterns: failed to parse {path}: {e}");
continue;
}
};
for p in &pf.patterns {
anyhow::ensure!(
!p.match_text.is_empty(),
"pattern '{}' has empty match_text in {path}",
p.id
);
anyhow::ensure!(
!p.sources.is_empty(),
"pattern '{}' has empty sources list in {path}",
p.id
);
}
return Ok(pf);
}
anyhow::bail!("pattern file not found: {filename}")
}
#[must_use]
pub fn classify(event: &SystemEvent, pf: &PatternFile) -> Option<MatchedEvent> {
for pattern in &pf.patterns {
if !event.raw_line.contains(&pattern.match_text) {
continue;
}
let source_matches = pattern.sources.iter().any(|s| {
if s == "any" {
return true;
}
match &event.source {
EventSource::Journald => s == "journald",
EventSource::Kmsg => s == "kmsg",
EventSource::AppLog { app } => s == &format!("applog:{app}"),
}
});
if source_matches {
return Some(MatchedEvent {
pattern_id: pattern.id.clone(),
title: pattern.title.clone(),
body: pattern.body.clone(),
severity: pattern.severity.clone(),
timestamp: event.timestamp,
});
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::watcher::{EventSource, SystemEvent};
const FIXTURE: &str = r#"
[meta]
source_os = "macos"
target_distro_family = "arch"
[log_paths]
steam = "~/.local/share/Steam/logs/content_log.txt"
[[patterns]]
id = "aur-build-failure"
sources = ["journald"]
match_text = "error: failed to build"
severity = "warn"
title = "AUR package build failed"
body = "A package failed to compile from source."
[[patterns]]
id = "proton-runtime"
sources = ["applog:steam"]
match_text = "wine: cannot find"
severity = "warn"
title = "Proton runtime issue"
body = "Steam Proton couldn't find a required file."
[[patterns]]
id = "any-source-pattern"
sources = ["any"]
match_text = "kernel panic"
severity = "crit"
title = "Kernel panic"
body = "The kernel encountered a fatal error."
"#;
fn make_event(source: EventSource, raw_line: &str) -> SystemEvent {
SystemEvent {
source,
raw_line: raw_line.into(),
timestamp: 0,
}
}
#[test]
fn loads_pattern_file_from_toml() {
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
assert_eq!(pf.meta.source_os, "macos");
assert_eq!(pf.patterns.len(), 3);
assert_eq!(
pf.log_paths.get("steam").unwrap(),
"~/.local/share/Steam/logs/content_log.txt"
);
}
#[test]
fn classify_matches_journald_event() {
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
let event = make_event(EventSource::Journald, ":: error: failed to build (foo-git)");
let matched = classify(&event, &pf).unwrap();
assert_eq!(matched.pattern_id, "aur-build-failure");
assert_eq!(matched.title, "AUR package build failed");
}
#[test]
fn classify_no_match_returns_none() {
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
let event = make_event(
EventSource::Journald,
"systemd: Started NetworkManager.service",
);
assert!(classify(&event, &pf).is_none());
}
#[test]
fn classify_applog_matches_correct_source() {
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
let event = make_event(
EventSource::AppLog {
app: "steam".into(),
},
"wine: cannot find /run/media/user/game.exe",
);
let matched = classify(&event, &pf).unwrap();
assert_eq!(matched.pattern_id, "proton-runtime");
}
#[test]
fn classify_does_not_match_wrong_source() {
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
// proton pattern is applog:steam — journald event must not match
let event = make_event(EventSource::Journald, "wine: cannot find something");
assert!(classify(&event, &pf).is_none());
}
#[test]
fn classify_applog_does_not_match_different_app() {
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
let event = make_event(
EventSource::AppLog {
app: "retroarch".into(),
},
"wine: cannot find libvulkan.so",
);
assert!(classify(&event, &pf).is_none());
}
#[test]
fn classify_any_source_matches_kmsg() {
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
let event = make_event(EventSource::Kmsg, "kernel panic - not syncing: VFS");
let matched = classify(&event, &pf).unwrap();
assert_eq!(matched.pattern_id, "any-source-pattern");
}
#[test]
fn classify_any_source_matches_journald() {
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
let event = make_event(EventSource::Journald, "kernel panic - not syncing: VFS");
let matched = classify(&event, &pf).unwrap();
assert_eq!(matched.pattern_id, "any-source-pattern");
}
#[test]
fn load_rejects_empty_match_text() {
let bad = r#"
[meta]
source_os = "macos"
target_distro_family = "arch"
[[patterns]]
id = "bad"
sources = ["journald"]
match_text = ""
severity = "warn"
title = "Bad"
body = "Bad pattern."
"#;
let pf: PatternFile = toml::from_str(bad).unwrap();
// Validate manually since load() reads from filesystem; test the invariant directly
let result: anyhow::Result<()> = (|| {
for p in &pf.patterns {
anyhow::ensure!(!p.match_text.is_empty(), "empty match_text in '{}'", p.id);
}
Ok(())
})();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty match_text"));
}
#[test]
fn load_rejects_empty_sources() {
let bad = r#"
[meta]
source_os = "macos"
target_distro_family = "arch"
[[patterns]]
id = "bad"
sources = []
match_text = "something"
severity = "warn"
title = "Bad"
body = "Bad pattern."
"#;
let pf: PatternFile = toml::from_str(bad).unwrap();
let result: anyhow::Result<()> = (|| {
for p in &pf.patterns {
anyhow::ensure!(!p.sources.is_empty(), "empty sources in '{}'", p.id);
}
Ok(())
})();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty sources"));
}
}

View file

@ -1,4 +1,3 @@
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
@ -35,7 +34,7 @@ pub fn build_tray<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
Ok(())
}
pub fn toggle_chat_panel<R: Runtime>(app: &AppHandle<R>) {
fn toggle_chat_panel<R: Runtime>(app: &AppHandle<R>) {
if let Some(window) = app.get_webview_window("chat") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
@ -45,32 +44,3 @@ pub fn toggle_chat_panel<R: Runtime>(app: &AppHandle<R>) {
}
}
}
static BADGE_ACTIVE: AtomicBool = AtomicBool::new(false);
pub fn badge_on<R: Runtime>(app: &AppHandle<R>) {
if BADGE_ACTIVE.swap(true, Ordering::Relaxed) {
return; // already badged
}
if let Some(tray) = app.tray_by_id("robin-tray") {
if let Some(icon) = app.default_window_icon().cloned() {
let _ = tray.set_icon(Some(icon));
}
let _ = tray.set_tooltip(Some("Robin — something to show you"));
}
}
pub fn badge_off<R: Runtime>(app: &AppHandle<R>) {
BADGE_ACTIVE.store(false, Ordering::Relaxed);
if let Some(tray) = app.tray_by_id("robin-tray") {
if let Some(icon) = app.default_window_icon().cloned() {
let _ = tray.set_icon(Some(icon));
}
let _ = tray.set_tooltip(Some("Robin"));
}
}
pub fn clear_badge_and_open<R: Runtime>(app: &AppHandle<R>) {
badge_off(app);
toggle_chat_panel(app);
}

32
src-tauri/src/watcher.rs Normal file
View file

@ -0,0 +1,32 @@
/// System event watcher — M1 implementation.
///
/// M0 stub: defines the types and the spawn interface so the rest of the app
/// can wire up event handling now. Actual journald/dmesg reading lands in M1.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventSeverity {
Info,
Warn,
Crit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemEvent {
pub severity: EventSeverity,
pub source: String,
pub message: String,
pub timestamp: u64,
}
/// Starts the background watcher task.
/// M0: no-op placeholder — returns immediately.
/// M1: spawns a tokio task reading journald + dmesg and emitting events.
pub fn spawn() {
// TODO(M1): spawn tokio::task reading journald via sd-journal crate
// TODO(M1): spawn dmesg poller for kernel messages
// TODO(M1): emit SystemEvent via tauri app_handle.emit()
log::info!("watcher: stub — no-op until M1");
}

View file

@ -1,124 +0,0 @@
use super::{now_unix, EventSource, SystemEvent};
use notify::{recommended_watcher, RecursiveMode, Watcher};
use std::collections::HashMap;
use std::io::{Read, Seek};
use tokio::sync::mpsc;
pub async fn watch(log_paths: HashMap<String, String>, tx: mpsc::Sender<SystemEvent>) {
if log_paths.is_empty() {
return;
}
let expanded: HashMap<String, String> = log_paths
.iter()
.map(|(k, v)| (k.clone(), expand_tilde(v)))
.collect();
tokio::task::spawn_blocking(move || {
let (notify_tx, notify_rx) = std::sync::mpsc::channel();
let mut watcher = match recommended_watcher(notify_tx) {
Ok(w) => w,
Err(e) => {
log::error!("inotify watcher init failed: {e}");
return;
}
};
// positions: path -> (app_name, byte_offset)
let mut positions: HashMap<String, (String, u64)> = HashMap::new();
for (app_name, path) in &expanded {
let pb = std::path::Path::new(path);
if pb.exists() {
let len = std::fs::metadata(pb).map(|m| m.len()).unwrap_or(0);
positions.insert(path.clone(), (app_name.clone(), len));
watcher.watch(pb, RecursiveMode::NonRecursive).ok();
}
}
for result in notify_rx {
if let Ok(event) = result {
for path in event.paths {
let path_str = path.to_string_lossy().to_string();
if let Some((app_name, pos)) = positions.get_mut(&path_str) {
let (lines, new_pos) = read_new_lines(&path_str, *pos);
*pos = new_pos;
for line in lines {
tx.blocking_send(SystemEvent {
source: EventSource::AppLog {
app: app_name.clone(),
},
raw_line: line,
timestamp: now_unix(),
})
.ok();
}
}
}
}
}
})
.await
.ok();
}
pub fn expand_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/") {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".into());
format!("{home}/{rest}")
} else {
path.to_string()
}
}
pub fn read_new_lines(path: &str, from_byte: u64) -> (Vec<String>, u64) {
let mut file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return (vec![], from_byte),
};
if file.seek(std::io::SeekFrom::Start(from_byte)).is_err() {
return (vec![], from_byte);
}
let mut raw = Vec::new();
let bytes_read = file.read_to_end(&mut raw).unwrap_or(0);
let content = String::from_utf8_lossy(&raw).into_owned();
let new_pos = from_byte + bytes_read as u64;
let lines: Vec<String> = content
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect();
(lines, new_pos)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_tilde_home() {
let home = std::env::var("HOME").unwrap_or("/home/user".into());
let expanded = expand_tilde("~/.config/retroarch/retroarch.log");
assert!(expanded.starts_with(&home));
assert!(expanded.ends_with(".config/retroarch/retroarch.log"));
}
#[test]
fn expand_tilde_no_tilde() {
let path = "/absolute/path/to/file.log";
assert_eq!(expand_tilde(path), path);
}
#[tokio::test]
async fn reads_new_lines_from_file() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.log");
let mut f = std::fs::File::create(&path).unwrap();
write!(f, "line one\n").unwrap();
let (lines, pos) = read_new_lines(path.to_str().unwrap(), 0);
assert_eq!(lines, vec!["line one".to_string()]);
assert!(pos > 0);
}
}

View file

@ -1,74 +0,0 @@
use super::{now_unix, EventSource, SystemEvent};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::mpsc;
pub async fn watch(tx: mpsc::Sender<SystemEvent>) {
let mut child = match Command::new("journalctl")
.args(["--follow", "--output=json", "--lines=0"])
.stdout(std::process::Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
log::error!("journald watcher: failed to spawn journalctl: {e}");
return;
}
};
let stdout = match child.stdout.take() {
Some(s) => s,
None => return,
};
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
if let Some(msg) = extract_message(&line) {
let _ = tx
.send(SystemEvent {
source: EventSource::Journald,
raw_line: msg,
timestamp: now_unix(),
})
.await;
}
}
log::warn!("journald watcher: journalctl exited — no new journal events will be observed");
let _ = child.wait().await;
}
fn extract_message(line: &str) -> Option<String> {
let json: serde_json::Value = serde_json::from_str(line).ok()?;
let msg = json.get("MESSAGE")?.as_str()?;
if msg.is_empty() {
return None;
}
Some(msg.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_message_from_journald_json() {
let line = r#"{"MESSAGE":"AUR build failed for foo","PRIORITY":"3","_COMM":"makepkg"}"#;
assert_eq!(
extract_message(line),
Some("AUR build failed for foo".to_string())
);
}
#[test]
fn extract_message_missing_returns_none() {
let line = r#"{"PRIORITY":"6","_COMM":"systemd"}"#;
assert_eq!(extract_message(line), None);
}
#[test]
fn extract_message_empty_skipped() {
let line = r#"{"MESSAGE":"","PRIORITY":"6"}"#;
assert_eq!(extract_message(line), None);
}
}

View file

@ -1,68 +0,0 @@
use super::{now_unix, EventSource, SystemEvent};
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::mpsc;
pub async fn watch(tx: mpsc::Sender<SystemEvent>) {
let file = match File::open("/dev/kmsg").await {
Ok(f) => f,
Err(e) => {
log::warn!("kmsg watcher: cannot open /dev/kmsg: {e}");
return;
}
};
let mut lines = BufReader::new(file).lines();
while let Ok(Some(line)) = lines.next_line().await {
if let Some(msg) = parse_kmsg(&line) {
let _ = tx
.send(SystemEvent {
source: EventSource::Kmsg,
raw_line: msg,
timestamp: now_unix(),
})
.await;
}
}
}
fn parse_kmsg(line: &str) -> Option<String> {
let msg = if let Some(pos) = line.find(';') {
&line[pos + 1..]
} else {
line
};
if msg.is_empty() {
return None;
}
Some(msg.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_kmsg_line_with_semicolon() {
let line = "6,1234,56789,-;usb 1-1: new full-speed USB device number 2";
assert_eq!(
parse_kmsg(line),
Some("usb 1-1: new full-speed USB device number 2".to_string())
);
}
#[test]
fn parses_kmsg_line_without_semicolon() {
let line = "plain message without header";
assert_eq!(
parse_kmsg(line),
Some("plain message without header".to_string())
);
}
#[test]
fn skips_empty_message_after_semicolon() {
let line = "6,1234,56789,-;";
assert_eq!(parse_kmsg(line), None);
}
}

View file

@ -1,85 +0,0 @@
pub mod inotify;
pub mod journald;
pub mod kmsg;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::mpsc;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventSeverity {
Info,
Warn,
Crit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum EventSource {
Journald,
Kmsg,
AppLog { app: String },
}
#[derive(Debug, Clone, Serialize)]
pub struct SystemEvent {
pub source: EventSource,
pub raw_line: String,
pub timestamp: u64,
}
pub fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Spawns all watcher tasks. Returns the receiver end of the event channel.
/// `log_paths` comes from the loaded PatternFile.
pub fn spawn(log_paths: HashMap<String, String>) -> mpsc::Receiver<SystemEvent> {
let (tx, rx) = mpsc::channel::<SystemEvent>(256);
let tx_j = tx.clone();
tauri::async_runtime::spawn(async move {
journald::watch(tx_j).await;
});
let tx_k = tx.clone();
tauri::async_runtime::spawn(async move {
kmsg::watch(tx_k).await;
});
tauri::async_runtime::spawn(async move {
inotify::watch(log_paths, tx).await;
});
rx
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_source_can_be_cloned() {
let s = EventSource::AppLog {
app: "steam".into(),
};
let _ = s.clone();
assert!(matches!(s, EventSource::AppLog { .. }));
}
#[test]
fn system_event_constructed() {
let e = SystemEvent {
source: EventSource::Journald,
raw_line: "test line".into(),
timestamp: now_unix(),
};
assert_eq!(e.raw_line, "test line");
assert!(e.timestamp > 0);
}
}

View file

@ -38,9 +38,6 @@
"bundle": {
"active": true,
"targets": ["deb", "rpm", "appimage"],
"resources": {
"patterns/*": "patterns/"
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",

View file

@ -26,111 +26,30 @@
class="chat-input"
placeholder="Ask Robin something..."
@keydown.enter="send"
:disabled="thinking"
/>
<button class="send-btn" @click="send" :disabled="thinking">
{{ thinking ? '…' : '→' }}
</button>
<button class="send-btn" @click="send"></button>
</footer>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { ref, nextTick } from 'vue'
interface Message { role: 'user' | 'robin'; content: string }
interface RobinEvent { pattern_id: string; title: string; body: string; severity: string; timestamp: number }
const messages = ref<Message[]>([])
const input = ref('')
const thinking = ref(false)
const messagesEl = ref<HTMLElement | null>(null)
let unlisten: UnlistenFn | null = null
let activeCleanup: (() => void) | null = null
onMounted(async () => {
try { await invoke('panel_opened') } catch {}
try {
const pending = await invoke<RobinEvent[]>('get_pending_events')
for (const e of pending) {
pushRobinEvent(e)
}
} catch (err) {
console.warn('Robin: failed to drain pending events:', err)
}
unlisten = await listen<RobinEvent>('robin:event', ({ payload }) => {
pushRobinEvent(payload)
})
})
onUnmounted(() => {
unlisten?.()
invoke('panel_closed').catch(() => {})
activeCleanup?.()
})
function pushRobinEvent(e: RobinEvent) {
messages.value.push({ role: 'robin', content: `**${e.title}**\n\n${e.body}` })
nextTick(() => {
messagesEl.value?.scrollTo({ top: messagesEl.value.scrollHeight, behavior: 'smooth' })
})
}
async function send() {
const text = input.value.trim()
if (!text || thinking.value) return
if (!text) return
input.value = ''
thinking.value = true
messages.value.push({ role: 'user', content: text })
messages.value.push({ role: 'robin', content: '' })
const robinIdx = messages.value.length - 1
// M4: invoke('chat', { message: text }) and stream response
messages.value.push({ role: 'robin', content: '(LLM chat not yet connected — M4)' })
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<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>
@ -198,6 +117,4 @@ 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; }
</style>