1use std::net::SocketAddr;
15use std::sync::{Arc, Mutex};
16
17use anyhow::Result;
18use serde::{Deserialize, Serialize};
19use tauri::{Manager};
20use wactorz_agents::{LlmConfig, LlmProvider, MainActor};
21use wactorz_core::{ActorConfig, ActorSystem, EventPublisher, Supervisor, SupervisorStrategy};
22use wactorz_interfaces::ws::WsEnvelope;
23use wactorz_interfaces::{RestServer, RuntimeConfig, WsBridge};
24use wactorz_mqtt::{MqttClient, MqttConfig};
25const DEFAULT_PORT: u16 = 8888;
26
27#[derive(Clone, Serialize, Deserialize)]
30#[serde(default)]
31pub struct AppConfig {
32 pub api_port: u16,
33 pub llm_provider: String,
34 pub llm_model: String,
35 pub llm_api_key: String,
36 pub mqtt_host: String,
37 pub mqtt_port: u16,
38 pub mqtt_ws_port: u16,
39 pub ha_url: String,
40 pub ha_token: String,
41 pub static_dir: String,
42}
43
44impl Default for AppConfig {
45 fn default() -> Self {
46 Self {
47 api_port: env_u16("API_PORT", DEFAULT_PORT),
48 llm_provider: env_str("LLM_PROVIDER", "anthropic"),
49 llm_model: env_str("LLM_MODEL", "claude-sonnet-4-6"),
50 llm_api_key: env_str("LLM_API_KEY", ""),
51 mqtt_host: env_str("MQTT_HOST", "localhost"),
52 mqtt_port: env_u16("MQTT_PORT", 1883),
53 mqtt_ws_port: env_u16("MQTT_WS_PORT", 9001),
54 ha_url: env_str("HA_URL", ""),
55 ha_token: env_str("HA_TOKEN", ""),
56 static_dir: env_str("STATIC_DIR", "static/app"),
57 }
58 }
59}
60
61fn env_str(key: &str, default: &str) -> String {
62 std::env::var(key).unwrap_or_else(|_| default.to_owned())
63}
64
65fn env_u16(key: &str, default: u16) -> u16 {
66 std::env::var(key)
67 .ok()
68 .and_then(|v| v.parse().ok())
69 .unwrap_or(default)
70}
71
72fn config_path(app: &tauri::AppHandle) -> std::path::PathBuf {
73 app.path()
74 .app_config_dir()
75 .unwrap_or_else(|_| std::path::PathBuf::from("."))
76 .join("config.json")
77}
78
79fn load_config(app: &tauri::AppHandle) -> AppConfig {
81 let path = config_path(app);
82 if let Ok(bytes) = std::fs::read(&path)
83 && let Ok(saved) = serde_json::from_slice::<AppConfig>(&bytes) {
84 tracing::info!("Loaded config from {}", path.display());
85 return saved;
86 }
87 tracing::info!(
88 "Using default/env config (no saved config at {})",
89 path.display()
90 );
91 AppConfig::default()
92}
93
94struct ConfigState(Mutex<AppConfig>);
97struct BadgeState(Mutex<u32>);
98
99#[tauri::command]
103fn notify(app: tauri::AppHandle, title: String, body: String) {
104 use tauri_plugin_notification::NotificationExt;
105 let _ = app.notification().builder().title(title).body(body).show();
106}
107
108#[tauri::command]
110fn add_unread(app: tauri::AppHandle, badge: tauri::State<BadgeState>) {
111 let count = {
112 let mut n = badge.0.lock().unwrap();
113 *n += 1;
114 *n
115 };
116 update_tray_tooltip(&app, count);
117}
118
119#[tauri::command]
121fn clear_unread(app: tauri::AppHandle, badge: tauri::State<BadgeState>) {
122 *badge.0.lock().unwrap() = 0;
123 update_tray_tooltip(&app, 0);
124}
125
126fn update_tray_tooltip(app: &tauri::AppHandle, count: u32) {
127 if let Some(tray) = app.tray_by_id("main") {
128 let tip = if count == 0 {
129 "Wactorz".to_string()
130 } else {
131 format!("Wactorz · {count} unread")
132 };
133 let _ = tray.set_tooltip(Some(&tip));
134 }
135}
136
137#[tauri::command]
138fn get_api_port(state: tauri::State<ConfigState>) -> u16 {
139 state.0.lock().unwrap().api_port
140}
141
142#[tauri::command]
143fn get_config(state: tauri::State<ConfigState>) -> AppConfig {
144 state.0.lock().unwrap().clone()
145}
146
147#[tauri::command]
148fn save_config(
149 config: AppConfig,
150 state: tauri::State<ConfigState>,
151 app: tauri::AppHandle,
152) -> Result<(), String> {
153 let path = config_path(&app);
154 if let Some(parent) = path.parent() {
155 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
156 }
157 let json = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
158 std::fs::write(&path, json).map_err(|e| e.to_string())?;
159 *state.0.lock().unwrap() = config;
160 tracing::info!("Config saved to {}", path.display());
161 Ok(())
162}
163fn build_tray(app: &tauri::App) -> tauri::Result<()> {
166 use tauri::{
167 menu::{MenuBuilder, MenuItemBuilder},
168 tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
169 };
170
171 let show_hide = MenuItemBuilder::with_id("show_hide", "Show / Hide").build(app)?;
172 let quit = MenuItemBuilder::with_id("quit", "Quit Wactorz").build(app)?;
173 let menu = MenuBuilder::new(app)
174 .items(&[&show_hide, &quit])
175 .build()?;
176
177 TrayIconBuilder::with_id("main")
178 .icon(app.default_window_icon().unwrap().clone())
179 .tooltip("Wactorz")
180 .menu(&menu)
181 .on_menu_event(|app, event| match event.id().as_ref() {
182 "show_hide" => toggle_window(app),
183 "quit" => app.exit(0),
184 _ => {}
185 })
186 .on_tray_icon_event(|tray, event| {
187 if let TrayIconEvent::Click {
188 button: MouseButton::Left,
189 button_state: MouseButtonState::Up,
190 ..
191 } = event
192 {
193 toggle_window(tray.app_handle());
194 }
195 })
196 .build(app)?;
197
198 Ok(())
199}
200
201fn toggle_window(app: &tauri::AppHandle) {
202 if let Some(win) = app.get_webview_window("main") {
203 if win.is_visible().unwrap_or(false) {
204 let _ = win.hide();
205 } else {
206 let _ = win.unminimize();
207 let _ = win.show();
208 let _ = win.set_focus();
209 }
210 }
211}
212
213async fn start_backend(cfg: AppConfig) -> Result<()> {
216 let (publisher, mut pub_rx) = EventPublisher::channel();
217 let system = ActorSystem::with_publisher(publisher.clone());
218
219 let mqtt_config = MqttConfig {
220 host: cfg.mqtt_host.clone(),
221 port: cfg.mqtt_port,
222 client_id: "wactorz-desktop".into(),
223 ..Default::default()
224 };
225 let (mqtt_client, mut event_loop) = MqttClient::new(mqtt_config)?;
226 let mqtt_client = Arc::new(mqtt_client);
227
228 let (ws_tx, _) = tokio::sync::broadcast::channel::<WsEnvelope>(100);
229 let ws_tx_mqtt = ws_tx.clone();
230
231 let reg_route = system.registry.clone();
232 let reg_qa = system.registry.clone();
233 let reg_wik = system.registry.clone();
234 let reg_switch = system.registry.clone();
235
236 tokio::spawn(async move {
237 MqttClient::run_event_loop(&mut event_loop, move |evt| {
238 if let wactorz_mqtt::MqttEvent::Incoming { topic, payload } = evt
239 && let Ok(json_val) = serde_json::from_slice::<serde_json::Value>(&payload) {
240 let _ = ws_tx_mqtt.send(WsEnvelope {
241 topic: topic.clone(),
242 payload: json_val.clone(),
243 });
244
245 if topic == wactorz_mqtt::topics::SYSTEM_LLM_ERROR {
246 let reg = reg_wik.clone();
247 let s = serde_json::to_string(&json_val).unwrap_or_default();
248 tokio::spawn(async move {
249 if let Some(e) = reg.get_by_name("wik-agent").await {
250 let _ = reg
251 .send(
252 &e.id,
253 wactorz_core::Message::text(
254 Some("system".into()),
255 Some(e.id.clone()),
256 s,
257 ),
258 )
259 .await;
260 }
261 });
262 }
263
264 if topic == wactorz_mqtt::topics::SYSTEM_LLM_SWITCH {
265 let reg = reg_switch.clone();
266 let p = json_val.clone();
267 tokio::spawn(async move {
268 if let Some(e) = reg.get_by_name("main-actor").await {
269 let _ = reg
270 .send(
271 &e.id,
272 wactorz_core::Message::new(
273 Some("wik-agent".into()),
274 Some(e.id.clone()),
275 wactorz_core::MessageType::Task {
276 task_id: "wik/switch".into(),
277 description: "LLM provider switch".into(),
278 payload: p,
279 },
280 ),
281 )
282 .await;
283 }
284 });
285 }
286
287 if topic.ends_with("/chat") {
288 let from = json_val.get("from").and_then(|v| v.as_str()).unwrap_or("");
289 let content = json_val
290 .get("content")
291 .and_then(|v| v.as_str())
292 .unwrap_or("")
293 .to_string();
294
295 if !content.is_empty() && (from == "user" || from.is_empty()) {
296 if topic == wactorz_mqtt::topics::IO_CHAT {
297 let reg = reg_route.clone();
298 tokio::spawn(async move {
299 if let Some(e) = reg.get_by_name("io-agent").await {
300 let _ = reg
301 .send(
302 &e.id,
303 wactorz_core::Message::text(
304 Some("user".into()),
305 Some(e.id.clone()),
306 content,
307 ),
308 )
309 .await;
310 }
311 });
312 } else if let Some(id) = topic
313 .strip_prefix("agents/")
314 .and_then(|s| s.strip_suffix("/chat"))
315 {
316 let reg = reg_route.clone();
317 let id = id.to_string();
318 tokio::spawn(async move {
319 let _ = reg
320 .send(
321 &id,
322 wactorz_core::Message::text(
323 Some("user".into()),
324 Some(id.clone()),
325 content,
326 ),
327 )
328 .await;
329 });
330 }
331 }
332
333 let reg = reg_qa.clone();
334 let s = serde_json::to_string(&json_val).unwrap_or_default();
335 tokio::spawn(async move {
336 if let Some(e) = reg.get_by_name("qa-agent").await {
337 let _ = reg
338 .send(
339 &e.id,
340 wactorz_core::Message::text(
341 Some("mqtt-router".into()),
342 Some(e.id.clone()),
343 s,
344 ),
345 )
346 .await;
347 }
348 });
349 }
350 }
351 })
352 .await;
353 });
354
355 for topic in ["agents/#", "system/#", "system/llm/#", "nodes/#"] {
356 if let Err(e) = mqtt_client.subscribe(topic).await {
357 tracing::warn!("MQTT subscribe {topic} failed (broker may not be running): {e}");
358 }
359 }
360 if let Err(e) = mqtt_client.subscribe(wactorz_mqtt::topics::IO_CHAT).await {
361 tracing::warn!("MQTT subscribe io/chat failed: {e}");
362 }
363
364 let mqtt_bridge = Arc::clone(&mqtt_client);
365 tokio::spawn(async move {
366 while let Some((topic, payload)) = pub_rx.recv().await {
367 if let Err(e) = mqtt_bridge.publish_raw(&topic, payload).await {
368 tracing::error!("MQTT publish error: {e}");
369 }
370 }
371 });
372
373 let llm_provider = match cfg.llm_provider.as_str() {
374 "openai" => LlmProvider::OpenAI,
375 "ollama" => LlmProvider::Ollama,
376 "gemini" => LlmProvider::Gemini,
377 "nim" => LlmProvider::Nim,
378 _ => LlmProvider::Anthropic,
379 };
380 let llm_config = LlmConfig {
381 provider: llm_provider,
382 model: cfg.llm_model.clone(),
383 api_key: Some(cfg.llm_api_key.clone()).filter(|s| !s.is_empty()),
384 ..Default::default()
385 };
386
387 let mut sup = Supervisor::new(system.clone());
388
389 {
390 let lc = llm_config.clone();
391 let sys = system.clone();
392 let pub_ = publisher.clone();
393 sup.supervise(
394 "main-actor",
395 Arc::new(move || {
396 Box::new(
397 MainActor::new(
398 ActorConfig::new_with_node("main-actor", "alpha").protected(),
399 lc.clone(),
400 sys.clone(),
401 )
402 .with_publisher(pub_.clone()),
403 )
404 }),
405 SupervisorStrategy::OneForOne,
406 10,
407 60.0,
408 2.0,
409 );
410 }
411
412 sup.start().await?;
413 tracing::info!(
414 "Wactorz desktop: all agents started, serving on port {}",
415 cfg.api_port
416 );
417
418 let addr: SocketAddr = format!("127.0.0.1:{}", cfg.api_port).parse()?;
419 let ws_bridge = WsBridge::new(
420 ws_tx,
421 mqtt_client,
422 system.clone(),
423 cfg.mqtt_host.clone(),
424 cfg.mqtt_ws_port,
425 );
426 RestServer::new(
427 system,
428 addr,
429 RuntimeConfig {
430 ha_url: cfg.ha_url,
431 ha_token: cfg.ha_token,
432 mqtt_host: cfg.mqtt_host,
433 mqtt_port: cfg.mqtt_port,
434 mqtt_ws_port: cfg.mqtt_ws_port,
435 llm_provider: cfg.llm_provider,
436 llm_model: cfg.llm_model,
437 ..Default::default()
438 },
439 cfg.static_dir,
440 )
441 .with_ws(ws_bridge.router())
442 .serve()
443 .await?;
444
445 Ok(())
446}
447
448#[cfg_attr(mobile, tauri::mobile_entry_point)]
451pub fn run() {
452 let _ = dotenvy::dotenv();
453
454 tauri::Builder::default()
455 .setup(|app| {
456 let cfg = load_config(app.handle());
457 let port = cfg.api_port;
458
459 app.manage(ConfigState(Mutex::new(cfg.clone())));
460 app.manage(BadgeState(Mutex::new(0)));
461
462 tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default())
463 .title("Wactorz")
464 .inner_size(1400.0, 900.0)
465 .min_inner_size(900.0, 600.0)
466 .resizable(true)
467 .center()
468 .initialization_script(format!("window.__WACTORZ_API_PORT={port};"))
469 .build()?;
470
471 build_tray(app)?;
472
473 tauri::async_runtime::spawn(async move {
474 if let Err(e) = start_backend(cfg).await {
475 tracing::error!("Embedded backend exited: {e}");
476 }
477 });
478
479 if cfg!(debug_assertions) {
480 app.handle().plugin(
481 tauri_plugin_log::Builder::default()
482 .level(log::LevelFilter::Info)
483 .build(),
484 )?;
485 }
486 Ok(())
487 })
488 .plugin(tauri_plugin_notification::init())
489 .invoke_handler(tauri::generate_handler![
490 get_api_port,
491 get_config,
492 save_config,
493 notify,
494 add_unread,
495 clear_unread
496 ])
497 .run(tauri::generate_context!())
498 .expect("error while running tauri application");
499}