Compare commits
27 commits
main
...
feat/m2-ll
| Author | SHA1 | Date | |
|---|---|---|---|
| cba4175260 | |||
| 0e3bfd24f1 | |||
| 9175b7a247 | |||
| 369216b354 | |||
| c149c598ae | |||
| 9c45015052 | |||
| d8991905d7 | |||
| db3694d9cf | |||
| 8195ad98b0 | |||
| 88924aa593 | |||
| c3958553a5 | |||
| 2706b2367d | |||
| c0f046bd7c | |||
| 3b7653d731 | |||
| 3c77969680 | |||
| 7eaf22c130 | |||
| db7d30d4c1 | |||
| e48536dfbe | |||
| d1bea47495 | |||
| 6acf085c0f | |||
| 1e733a062b | |||
| a94e3dbb66 | |||
| dc45c82aba | |||
| cee05b5d18 | |||
| c94fc58296 | |||
| 5216549d0a | |||
| c33d4cf07f |
24 changed files with 1449 additions and 54 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -29,3 +29,6 @@ src-tauri/target/
|
|||
# Secrets
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Visual companion brainstorm sessions
|
||||
.superpowers/
|
||||
|
|
|
|||
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
|||
"name": "robin",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"vue": "^3.5.34"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -398,6 +399,16 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"vue": "^3.5.34"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -24,8 +24,14 @@ 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"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
tauri_build::build()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@
|
|||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
"chat"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
"core:default",
|
||||
"notification:default",
|
||||
"shell:allow-open"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
65
src-tauri/patterns/macos-to-arch.toml
Normal file
65
src-tauri/patterns/macos-to-arch.toml
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
[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"
|
||||
56
src-tauri/patterns/windows-to-debian.toml
Normal file
56
src-tauri/patterns/windows-to-debian.toml
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
[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."
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
use crate::config::{RobinConfig, MigrationConfig, SourceOs};
|
||||
use tauri::State;
|
||||
use crate::config::{MigrationConfig, NotificationLevel, RobinConfig, SourceOs};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
pub struct AppState {
|
||||
pub config: Mutex<RobinConfig>,
|
||||
|
|
@ -8,14 +8,18 @@ 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)
|
||||
}
|
||||
|
|
@ -33,11 +37,92 @@ 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,
|
||||
distro: detected,
|
||||
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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use anyhow::{Context, Result};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SourceOs {
|
||||
Macos,
|
||||
|
|
@ -11,6 +11,24 @@ 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,
|
||||
|
|
@ -37,14 +55,15 @@ impl Default for OllamaConfig {
|
|||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DisplayConfig {
|
||||
pub show_notifications: bool,
|
||||
#[serde(default)]
|
||||
pub notification_level: NotificationLevel,
|
||||
pub quiet_mode: bool,
|
||||
}
|
||||
|
||||
impl Default for DisplayConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_notifications: true,
|
||||
notification_level: NotificationLevel::BadgeAndToast,
|
||||
quiet_mode: false,
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +74,8 @@ pub struct RobinConfig {
|
|||
pub migration: Option<MigrationConfig>,
|
||||
pub ollama: OllamaConfig,
|
||||
pub display: DisplayConfig,
|
||||
#[serde(default)]
|
||||
pub tier: Tier,
|
||||
}
|
||||
|
||||
impl Default for RobinConfig {
|
||||
|
|
@ -63,6 +84,7 @@ impl Default for RobinConfig {
|
|||
migration: None,
|
||||
ollama: OllamaConfig::default(),
|
||||
display: DisplayConfig::default(),
|
||||
tier: Tier::Free,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -80,8 +102,7 @@ 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<()> {
|
||||
|
|
@ -136,4 +157,36 @@ 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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
88
src-tauri/src/distro.rs
Normal file
88
src-tauri/src/distro.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
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::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::Manager;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
|
|
@ -21,13 +26,58 @@ pub fn run() {
|
|||
})
|
||||
.setup(|app| {
|
||||
tray::build_tray(&app.handle())?;
|
||||
watcher::spawn();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
|
|
|
|||
156
src-tauri/src/llm.rs
Normal file
156
src-tauri/src/llm.rs
Normal file
|
|
@ -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<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");
|
||||
}
|
||||
}
|
||||
|
|
@ -2,5 +2,5 @@
|
|||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
app_lib::run();
|
||||
}
|
||||
|
|
|
|||
95
src-tauri/src/notify.rs
Normal file
95
src-tauri/src/notify.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
289
src-tauri/src/patterns.rs
Normal file
289
src-tauri/src/patterns.rs
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
|
|
@ -34,7 +35,7 @@ pub fn build_tray<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn toggle_chat_panel<R: Runtime>(app: &AppHandle<R>) {
|
||||
pub 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();
|
||||
|
|
@ -44,3 +45,32 @@ 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
/// 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");
|
||||
}
|
||||
124
src-tauri/src/watcher/inotify.rs
Normal file
124
src-tauri/src/watcher/inotify.rs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
74
src-tauri/src/watcher/journald.rs
Normal file
74
src-tauri/src/watcher/journald.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
68
src-tauri/src/watcher/kmsg.rs
Normal file
68
src-tauri/src/watcher/kmsg.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
85
src-tauri/src/watcher/mod.rs
Normal file
85
src-tauri/src/watcher/mod.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,9 @@
|
|||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "rpm", "appimage"],
|
||||
"resources": {
|
||||
"patterns/*": "patterns/"
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
|
|
|||
|
|
@ -26,30 +26,111 @@
|
|||
class="chat-input"
|
||||
placeholder="Ask Robin something..."
|
||||
@keydown.enter="send"
|
||||
:disabled="thinking"
|
||||
/>
|
||||
<button class="send-btn" @click="send">→</button>
|
||||
<button class="send-btn" @click="send" :disabled="thinking">
|
||||
{{ thinking ? '…' : '→' }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
|
||||
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) 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<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>
|
||||
|
||||
|
|
@ -117,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; }
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue