From 83459adc33a156182ddbae7ecb192fe8f5521ce9 Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Thu, 9 Apr 2026 11:59:13 +0200 Subject: [PATCH 1/2] update: ddd folder structure for backend and frontend --- .husky/pre-commit | 12 +-- e2e/setup-dashboard.spec.ts | 2 +- package.json | 5 + src-tauri/src/app.rs | 68 +++++++++++++ .../src/{ => infrastructure}/bot_lifecycle.rs | 6 +- .../http_server.rs} | 54 +++++----- src-tauri/src/infrastructure/mod.rs | 2 + src-tauri/src/lib.rs | 99 +------------------ src-tauri/src/modules/bot/commands.rs | 28 ++++++ src-tauri/src/modules/bot/mod.rs | 3 + src-tauri/src/modules/bot/repository.rs | 24 +++++ .../bot/service.rs} | 77 +-------------- src-tauri/src/modules/mod.rs | 2 + src-tauri/src/modules/ollama/constants.rs | 3 + src-tauri/src/modules/ollama/mod.rs | 2 + src-tauri/src/modules/ollama/service.rs | 64 ++++++++++++ src-tauri/src/shared/mod.rs | 1 + src-tauri/src/{ => shared}/state.rs | 35 ++----- src/App.tsx | 4 +- src/{loopback.ts => modules/bot/api/index.ts} | 37 +------ .../bot}/components/SetupWizard.tsx | 15 ++- .../bot}/components/TerminalPreview.tsx | 2 +- src/modules/bot/index.ts | 5 + .../bot/store}/appSessionStore.ts | 2 +- src/modules/bot/types.ts | 6 ++ src/modules/ollama/api/index.ts | 27 +++++ src/modules/ollama/index.ts | 2 + src/modules/ollama/types.ts | 1 + src/pages/DashboardPage.tsx | 11 ++- src/pages/LandingPage.tsx | 8 +- src/pages/SetupPage.tsx | 7 +- src/{ => shared/api}/config.ts | 0 src/{components => shared/ui}/PhoneMockup.tsx | 0 src/{components => shared/ui}/SpecMockup.tsx | 0 .../ui}/StyledQrCode.tsx | 0 src/{components => shared/ui}/TopMenu.tsx | 2 +- .../ui}/WizardLayout.tsx | 0 37 files changed, 316 insertions(+), 300 deletions(-) create mode 100644 src-tauri/src/app.rs rename src-tauri/src/{ => infrastructure}/bot_lifecycle.rs (75%) rename src-tauri/src/{connection_server.rs => infrastructure/http_server.rs} (86%) create mode 100644 src-tauri/src/infrastructure/mod.rs create mode 100644 src-tauri/src/modules/bot/commands.rs create mode 100644 src-tauri/src/modules/bot/mod.rs create mode 100644 src-tauri/src/modules/bot/repository.rs rename src-tauri/src/{telegram_service.rs => modules/bot/service.rs} (62%) create mode 100644 src-tauri/src/modules/mod.rs create mode 100644 src-tauri/src/modules/ollama/constants.rs create mode 100644 src-tauri/src/modules/ollama/mod.rs create mode 100644 src-tauri/src/modules/ollama/service.rs create mode 100644 src-tauri/src/shared/mod.rs rename src-tauri/src/{ => shared}/state.rs (69%) rename src/{loopback.ts => modules/bot/api/index.ts} (52%) rename src/{ => modules/bot}/components/SetupWizard.tsx (97%) rename src/{ => modules/bot}/components/TerminalPreview.tsx (99%) create mode 100644 src/modules/bot/index.ts rename src/{stores => modules/bot/store}/appSessionStore.ts (96%) create mode 100644 src/modules/bot/types.ts create mode 100644 src/modules/ollama/api/index.ts create mode 100644 src/modules/ollama/index.ts create mode 100644 src/modules/ollama/types.ts rename src/{ => shared/api}/config.ts (100%) rename src/{components => shared/ui}/PhoneMockup.tsx (100%) rename src/{components => shared/ui}/SpecMockup.tsx (100%) rename src/{components => shared/ui}/StyledQrCode.tsx (100%) rename src/{components => shared/ui}/TopMenu.tsx (96%) rename src/{components => shared/ui}/WizardLayout.tsx (100%) diff --git a/.husky/pre-commit b/.husky/pre-commit index 85d6e19..f6ecb81 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,13 +1,5 @@ #!/usr/bin/env sh -# Run lint-staged (ESLint + Prettier on staged TS/JS files) bunx lint-staged - -# TypeScript type check -bun run tsc --noEmit - -# Rust: format check + clippy on src-tauri -cd src-tauri -cargo fmt --all -- --check -cargo clippy --all-targets -- -D warnings -cd .. +bun run typecheck +bun run rust:check diff --git a/e2e/setup-dashboard.spec.ts b/e2e/setup-dashboard.spec.ts index 3852ec3..f50f0c4 100644 --- a/e2e/setup-dashboard.spec.ts +++ b/e2e/setup-dashboard.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { OLLAMA_API_BASE, PENGINE_API_BASE } from "../src/config"; +import { OLLAMA_API_BASE, PENGINE_API_BASE } from "../src/shared/api/config"; const CONNECTED_STORAGE_STATE = { state: { diff --git a/package.json b/package.json index 5cdb06e..04b32ef 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,12 @@ "test:e2e:ui": "playwright test --ui", "generate:logos": "bash scripts/generate-logo-assets.sh", "lint": "eslint .", + "lint:fix": "eslint . --fix", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css,json}\"", + "typecheck": "tsc --noEmit", + "rust:fmt": "cargo fmt --all --manifest-path src-tauri/Cargo.toml -- --check", + "rust:lint": "cargo clippy --all-targets --manifest-path src-tauri/Cargo.toml -- -D warnings", + "rust:check": "bun run rust:fmt && bun run rust:lint", "prepare": "husky" }, "lint-staged": { diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs new file mode 100644 index 0000000..6f4082c --- /dev/null +++ b/src-tauri/src/app.rs @@ -0,0 +1,68 @@ +use crate::infrastructure::http_server; +use crate::modules::bot::{commands, repository, service as bot_service}; +use crate::shared::state::AppState; +use std::path::PathBuf; +use tauri::Manager; + +fn store_path(app: &tauri::App) -> PathBuf { + let base = app + .path() + .app_data_dir() + .expect("failed to resolve app data dir"); + base.join("connection.json") +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + env_logger::init(); + + tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .setup(|app| { + let path = store_path(app); + let shared_state = AppState::new(path); + + { + let handle = app.handle().clone(); + let state = shared_state.clone(); + tauri::async_runtime::spawn(async move { + let mut lock = state.app_handle.lock().await; + *lock = Some(handle); + }); + } + + app.manage(shared_state.clone()); + + // Resume persisted connection if present + let resume_state = shared_state.clone(); + tauri::async_runtime::spawn(async move { + let Some(conn) = repository::load(&resume_state.store_path) else { + return; + }; + resume_state + .emit_log("ok", &format!("Resuming bot @{}…", conn.bot_username)) + .await; + let token = conn.bot_token.clone(); + { + let mut lock = resume_state.connection.lock().await; + *lock = Some(conn); + } + let shutdown = resume_state.shutdown_notify.clone(); + bot_service::start_bot(resume_state, token, shutdown).await; + }); + + // Start localhost HTTP API + let server_state = shared_state.clone(); + tauri::async_runtime::spawn(async move { + http_server::start_server(server_state).await; + }); + + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + commands::get_connection_status, + commands::disconnect_bot, + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/src-tauri/src/bot_lifecycle.rs b/src-tauri/src/infrastructure/bot_lifecycle.rs similarity index 75% rename from src-tauri/src/bot_lifecycle.rs rename to src-tauri/src/infrastructure/bot_lifecycle.rs index f62e64c..2bce7e2 100644 --- a/src-tauri/src/bot_lifecycle.rs +++ b/src-tauri/src/infrastructure/bot_lifecycle.rs @@ -1,12 +1,8 @@ -use crate::state::AppState; +use crate::shared::state::AppState; use std::time::{Duration, Instant}; -/// Max time to wait for `telegram_service::start_bot` to clear `bot_running` -/// after a shutdown notification. pub const BOT_STOP_TIMEOUT: Duration = Duration::from_secs(30); -/// Notify the running dispatcher to stop and wait until `bot_running` is false -/// (or the timeout elapses). pub async fn stop_and_wait_for_bot(state: &AppState) { let was_running = *state.bot_running.lock().await; if !was_running { diff --git a/src-tauri/src/connection_server.rs b/src-tauri/src/infrastructure/http_server.rs similarity index 86% rename from src-tauri/src/connection_server.rs rename to src-tauri/src/infrastructure/http_server.rs index a773c5e..37debab 100644 --- a/src-tauri/src/connection_server.rs +++ b/src-tauri/src/infrastructure/http_server.rs @@ -1,6 +1,6 @@ -use crate::bot_lifecycle::stop_and_wait_for_bot; -use crate::state::{AppState, ConnectionData}; -use crate::telegram_service; +use crate::infrastructure::bot_lifecycle; +use crate::modules::bot::{repository, service as bot_service}; +use crate::shared::state::{AppState, ConnectionData}; use axum::extract::State; use axum::http::StatusCode; use axum::response::{Json, Sse}; @@ -130,29 +130,37 @@ async fn handle_connect( .emit_log("run", "Verifying token with Telegram…") .await; - let me = telegram_service::verify_token(&token) + let me = bot_service::verify_token(&token) .await .map_err(|e| (StatusCode::BAD_GATEWAY, Json(ErrorResponse { error: e })))?; - let bot_id = me.id.to_string(); - let bot_username = me.username().to_string(); - - stop_and_wait_for_bot(&state).await; - let conn = ConnectionData { - bot_token: token.clone(), - bot_id: bot_id.clone(), - bot_username: bot_username.clone(), + bot_token: token, + bot_id: me.id.to_string(), + bot_username: me.username().to_string(), connected_at: Utc::now(), }; - state.persist(&conn).map_err(|e| { + bot_lifecycle::stop_and_wait_for_bot(&state).await; + + repository::persist(&state.store_path, &conn).map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e }), ) })?; + let spawn_token = conn.bot_token.clone(); + let response = ConnectResponse { + status: "connected".into(), + bot_id: conn.bot_id.clone(), + bot_username: conn.bot_username.clone(), + }; + + state + .emit_log("ok", &format!("Bot @{} connected", conn.bot_username)) + .await; + { let mut lock = state.connection.lock().await; *lock = Some(conn); @@ -160,36 +168,24 @@ async fn handle_connect( let shutdown = state.shutdown_notify.clone(); let spawn_state = state.clone(); - let spawn_token = token.clone(); tokio::spawn(async move { - telegram_service::start_bot(spawn_state, spawn_token, shutdown).await; + bot_service::start_bot(spawn_state, spawn_token, shutdown).await; }); - state - .emit_log("ok", &format!("Bot @{bot_username} connected")) - .await; - - Ok(( - StatusCode::OK, - Json(ConnectResponse { - status: "connected".into(), - bot_id, - bot_username, - }), - )) + Ok((StatusCode::OK, Json(response))) } async fn handle_disconnect( State(state): State, ) -> Result<(StatusCode, Json), (StatusCode, Json)> { - stop_and_wait_for_bot(&state).await; + bot_lifecycle::stop_and_wait_for_bot(&state).await; { let mut lock = state.connection.lock().await; *lock = None; } - state.clear_persisted().map_err(|e| { + repository::clear(&state.store_path).map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e }), diff --git a/src-tauri/src/infrastructure/mod.rs b/src-tauri/src/infrastructure/mod.rs new file mode 100644 index 0000000..c058170 --- /dev/null +++ b/src-tauri/src/infrastructure/mod.rs @@ -0,0 +1,2 @@ +pub mod bot_lifecycle; +pub mod http_server; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 67d64ab..49488d5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,97 +1,8 @@ -mod bot_lifecycle; -mod connection_server; -mod state; -mod telegram_service; +mod app; +mod infrastructure; +mod modules; +mod shared; -use state::AppState; -use std::path::PathBuf; -use tauri::Manager; - -fn store_path(app: &tauri::App) -> PathBuf { - let base = app - .path() - .app_data_dir() - .expect("failed to resolve app data dir"); - base.join("connection.json") -} - -#[tauri::command] -async fn get_connection_status( - state: tauri::State<'_, AppState>, -) -> Result { - let conn = state.connection.lock().await; - Ok(serde_json::json!({ - "connected": conn.is_some(), - "bot_username": conn.as_ref().map(|c| &c.bot_username), - "bot_id": conn.as_ref().map(|c| &c.bot_id), - })) -} - -#[tauri::command] -async fn disconnect_bot(state: tauri::State<'_, AppState>) -> Result { - bot_lifecycle::stop_and_wait_for_bot(&state).await; - - { - let mut lock = state.connection.lock().await; - *lock = None; - } - state.clear_persisted()?; - state.emit_log("ok", "Disconnected via Tauri command").await; - Ok("disconnected".into()) -} - -#[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - env_logger::init(); - - tauri::Builder::default() - .plugin(tauri_plugin_opener::init()) - .setup(|app| { - let path = store_path(app); - let shared_state = AppState::new(path); - - // Store AppHandle for event emission - { - let handle = app.handle().clone(); - let state = shared_state.clone(); - tauri::async_runtime::spawn(async move { - let mut lock = state.app_handle.lock().await; - *lock = Some(handle); - }); - } - - app.manage(shared_state.clone()); - - // Resume persisted connection if present - let resume_state = shared_state.clone(); - tauri::async_runtime::spawn(async move { - let Some(conn) = resume_state.load_persisted() else { - return; - }; - resume_state - .emit_log("ok", &format!("Resuming bot @{}…", conn.bot_username)) - .await; - let token = conn.bot_token.clone(); - { - let mut lock = resume_state.connection.lock().await; - *lock = Some(conn); - } - let shutdown = resume_state.shutdown_notify.clone(); - telegram_service::start_bot(resume_state, token, shutdown).await; - }); - - // Start localhost HTTP API - let server_state = shared_state.clone(); - tauri::async_runtime::spawn(async move { - connection_server::start_server(server_state).await; - }); - - Ok(()) - }) - .invoke_handler(tauri::generate_handler![ - get_connection_status, - disconnect_bot, - ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + app::run(); } diff --git a/src-tauri/src/modules/bot/commands.rs b/src-tauri/src/modules/bot/commands.rs new file mode 100644 index 0000000..ae4017f --- /dev/null +++ b/src-tauri/src/modules/bot/commands.rs @@ -0,0 +1,28 @@ +use crate::infrastructure::bot_lifecycle; +use crate::modules::bot::repository; +use crate::shared::state::AppState; + +#[tauri::command] +pub async fn get_connection_status( + state: tauri::State<'_, AppState>, +) -> Result { + let conn = state.connection.lock().await; + Ok(serde_json::json!({ + "connected": conn.is_some(), + "bot_username": conn.as_ref().map(|c| &c.bot_username), + "bot_id": conn.as_ref().map(|c| &c.bot_id), + })) +} + +#[tauri::command] +pub async fn disconnect_bot(state: tauri::State<'_, AppState>) -> Result { + bot_lifecycle::stop_and_wait_for_bot(&state).await; + + { + let mut lock = state.connection.lock().await; + *lock = None; + } + repository::clear(&state.store_path)?; + state.emit_log("ok", "Disconnected via Tauri command").await; + Ok("disconnected".into()) +} diff --git a/src-tauri/src/modules/bot/mod.rs b/src-tauri/src/modules/bot/mod.rs new file mode 100644 index 0000000..6a6f35e --- /dev/null +++ b/src-tauri/src/modules/bot/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +pub mod repository; +pub mod service; diff --git a/src-tauri/src/modules/bot/repository.rs b/src-tauri/src/modules/bot/repository.rs new file mode 100644 index 0000000..80c49a8 --- /dev/null +++ b/src-tauri/src/modules/bot/repository.rs @@ -0,0 +1,24 @@ +use crate::shared::state::ConnectionData; +use std::path::Path; + +pub fn persist(path: &Path, data: &ConnectionData) -> Result<(), String> { + let json = serde_json::to_string_pretty(data).map_err(|e| e.to_string())?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::write(path, json).map_err(|e| e.to_string())?; + Ok(()) +} + +pub fn load(path: &Path) -> Option { + let json = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&json).ok() +} + +pub fn clear(path: &Path) -> Result<(), String> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.to_string()), + } +} diff --git a/src-tauri/src/telegram_service.rs b/src-tauri/src/modules/bot/service.rs similarity index 62% rename from src-tauri/src/telegram_service.rs rename to src-tauri/src/modules/bot/service.rs index ac5e85e..3c1ed64 100644 --- a/src-tauri/src/telegram_service.rs +++ b/src-tauri/src/modules/bot/service.rs @@ -1,20 +1,11 @@ -use crate::state::AppState; -use std::sync::{Arc, OnceLock}; +use crate::modules::ollama::service as ollama; +use crate::shared::state::AppState; +use std::sync::Arc; use teloxide::prelude::*; use teloxide::types::Me; use teloxide::utils::command::BotCommands; use tokio::sync::Notify; -static HTTP: OnceLock = OnceLock::new(); - -const OLLAMA_PS_URL: &str = "http://localhost:11434/api/ps"; -const OLLAMA_TAGS_URL: &str = "http://localhost:11434/api/tags"; -const OLLAMA_CHAT_URL: &str = "http://localhost:11434/api/chat"; - -fn http_client() -> &'static reqwest::Client { - HTTP.get_or_init(reqwest::Client::new) -} - pub async fn verify_token(token: &str) -> Result { let bot = Bot::new(token); bot.get_me() @@ -112,12 +103,12 @@ async fn text_handler(bot: Bot, msg: Message, state: AppState) -> ResponseResult .emit_log("msg", &format!("from {}: {}", user_label(&msg), incoming)) .await; - match active_ollama_model().await { + match ollama::active_model().await { Ok(model) => { state .emit_log("tool", &format!("routing to ollama → {model}")) .await; - match ask_ollama(&model, incoming).await { + match ollama::chat(&model, incoming).await { Ok(reply) => { state.emit_log("reply", &format!("→ {reply}")).await; bot.send_message(msg.chat.id, &reply).await?; @@ -151,64 +142,6 @@ async fn send_inference_unavailable(bot: &Bot, msg: &Message, state: &AppState) } } -/// Returns the currently loaded model (from `/api/ps`), falling back to the -/// first pulled model (from `/api/tags`) if nothing is loaded yet. -async fn active_ollama_model() -> Result { - let client = http_client(); - let timeout = std::time::Duration::from_secs(5); - - // Prefer a model that is already loaded in memory - if let Ok(resp) = client.get(OLLAMA_PS_URL).timeout(timeout).send().await { - if let Ok(body) = resp.json::().await { - if let Some(name) = body["models"] - .as_array() - .and_then(|arr| arr.first()) - .and_then(|m| m["name"].as_str()) - { - return Ok(name.to_string()); - } - } - } - - // Fallback: first pulled model (Ollama will load it on demand) - let resp = client - .get(OLLAMA_TAGS_URL) - .timeout(timeout) - .send() - .await - .map_err(|e| format!("ollama unreachable: {e}"))?; - - let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; - body["models"] - .as_array() - .and_then(|arr| arr.first()) - .and_then(|m| m["name"].as_str()) - .map(|s| s.to_string()) - .ok_or_else(|| "no models pulled in ollama".to_string()) -} - -async fn ask_ollama(model: &str, prompt: &str) -> Result { - let payload = serde_json::json!({ - "model": model, - "messages": [{"role": "user", "content": format!("Think fast and answer extremely short. If you don't know the answer, say you don't know. Question: {prompt}")}], - "stream": false, - }); - - let resp = http_client() - .post(OLLAMA_CHAT_URL) - .json(&payload) - .timeout(std::time::Duration::from_secs(120)) - .send() - .await - .map_err(|e| e.to_string())?; - - let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; - body["message"]["content"] - .as_str() - .map(|s| s.to_string()) - .ok_or_else(|| "unexpected ollama response shape".to_string()) -} - fn user_label(msg: &Message) -> String { msg.from .as_ref() diff --git a/src-tauri/src/modules/mod.rs b/src-tauri/src/modules/mod.rs new file mode 100644 index 0000000..aa58b0c --- /dev/null +++ b/src-tauri/src/modules/mod.rs @@ -0,0 +1,2 @@ +pub mod bot; +pub mod ollama; diff --git a/src-tauri/src/modules/ollama/constants.rs b/src-tauri/src/modules/ollama/constants.rs new file mode 100644 index 0000000..a23f181 --- /dev/null +++ b/src-tauri/src/modules/ollama/constants.rs @@ -0,0 +1,3 @@ +pub const OLLAMA_PS_URL: &str = "http://localhost:11434/api/ps"; +pub const OLLAMA_TAGS_URL: &str = "http://localhost:11434/api/tags"; +pub const OLLAMA_CHAT_URL: &str = "http://localhost:11434/api/chat"; diff --git a/src-tauri/src/modules/ollama/mod.rs b/src-tauri/src/modules/ollama/mod.rs new file mode 100644 index 0000000..4091181 --- /dev/null +++ b/src-tauri/src/modules/ollama/mod.rs @@ -0,0 +1,2 @@ +pub mod constants; +pub mod service; diff --git a/src-tauri/src/modules/ollama/service.rs b/src-tauri/src/modules/ollama/service.rs new file mode 100644 index 0000000..6b07848 --- /dev/null +++ b/src-tauri/src/modules/ollama/service.rs @@ -0,0 +1,64 @@ +use crate::modules::ollama::constants::{OLLAMA_CHAT_URL, OLLAMA_PS_URL, OLLAMA_TAGS_URL}; +use std::sync::OnceLock; + +static HTTP: OnceLock = OnceLock::new(); + +fn http_client() -> &'static reqwest::Client { + HTTP.get_or_init(reqwest::Client::new) +} + +/// Returns the currently loaded model (from `/api/ps`), falling back to the +/// first pulled model (from `/api/tags`) if nothing is loaded yet. +pub async fn active_model() -> Result { + let client = http_client(); + let timeout = std::time::Duration::from_secs(5); + + if let Ok(resp) = client.get(OLLAMA_PS_URL).timeout(timeout).send().await { + if let Ok(body) = resp.json::().await { + if let Some(name) = body["models"] + .as_array() + .and_then(|arr| arr.first()) + .and_then(|m| m["name"].as_str()) + { + return Ok(name.to_string()); + } + } + } + + let resp = client + .get(OLLAMA_TAGS_URL) + .timeout(timeout) + .send() + .await + .map_err(|e| format!("ollama unreachable: {e}"))?; + + let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; + body["models"] + .as_array() + .and_then(|arr| arr.first()) + .and_then(|m| m["name"].as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| "no models pulled in ollama".to_string()) +} + +pub async fn chat(model: &str, prompt: &str) -> Result { + let payload = serde_json::json!({ + "model": model, + "messages": [{"role": "user", "content": format!("Think fast and answer extremely short. If you don't know the answer, say you don't know. Question: {prompt}")}], + "stream": false, + }); + + let resp = http_client() + .post(OLLAMA_CHAT_URL) + .json(&payload) + .timeout(std::time::Duration::from_secs(120)) + .send() + .await + .map_err(|e| e.to_string())?; + + let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; + body["message"]["content"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "unexpected ollama response shape".to_string()) +} diff --git a/src-tauri/src/shared/mod.rs b/src-tauri/src/shared/mod.rs new file mode 100644 index 0000000..266c62a --- /dev/null +++ b/src-tauri/src/shared/mod.rs @@ -0,0 +1 @@ +pub mod state; diff --git a/src-tauri/src/state.rs b/src-tauri/src/shared/state.rs similarity index 69% rename from src-tauri/src/state.rs rename to src-tauri/src/shared/state.rs index 58f827c..d4d5f4e 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/shared/state.rs @@ -13,6 +13,13 @@ pub struct ConnectionData { pub connected_at: DateTime, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogEntry { + pub timestamp: String, + pub kind: String, + pub message: String, +} + #[derive(Clone)] pub struct AppState { pub connection: Arc>>, @@ -23,13 +30,6 @@ pub struct AppState { pub app_handle: Arc>>, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LogEntry { - pub timestamp: String, - pub kind: String, - pub message: String, -} - impl AppState { pub fn new(store_path: PathBuf) -> Self { let (log_tx, _) = tokio::sync::broadcast::channel(256); @@ -58,25 +58,4 @@ impl AppState { let _ = handle.emit("pengine-log", &entry); } } - - pub fn persist(&self, data: &ConnectionData) -> Result<(), String> { - let json = serde_json::to_string_pretty(data).map_err(|e| e.to_string())?; - if let Some(parent) = self.store_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - std::fs::write(&self.store_path, json).map_err(|e| e.to_string())?; - Ok(()) - } - - pub fn load_persisted(&self) -> Option { - let json = std::fs::read_to_string(&self.store_path).ok()?; - serde_json::from_str(&json).ok() - } - - pub fn clear_persisted(&self) -> Result<(), String> { - if self.store_path.exists() { - std::fs::remove_file(&self.store_path).map_err(|e| e.to_string())?; - } - Ok(()) - } } diff --git a/src/App.tsx b/src/App.tsx index 528eef0..33d0af5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,10 @@ import { useEffect, useRef, useState } from "react"; import { Navigate, Route, Routes, useNavigate } from "react-router-dom"; -import { getPengineHealth } from "./loopback"; +import { getPengineHealth } from "./modules/bot/api"; +import { useAppSessionStore } from "./modules/bot/store/appSessionStore"; import { DashboardPage } from "./pages/DashboardPage"; import { LandingPage } from "./pages/LandingPage"; import { SetupPage } from "./pages/SetupPage"; -import { useAppSessionStore } from "./stores/appSessionStore"; /** One-shot: sync dashboard route with persisted session or running local app. */ function StartupDashboardRedirect() { diff --git a/src/loopback.ts b/src/modules/bot/api/index.ts similarity index 52% rename from src/loopback.ts rename to src/modules/bot/api/index.ts index 678bcdc..a2a342c 100644 --- a/src/loopback.ts +++ b/src/modules/bot/api/index.ts @@ -1,4 +1,5 @@ -import { OLLAMA_API_BASE, PENGINE_API_BASE } from "./config"; +import { PENGINE_API_BASE } from "../../../shared/api/config"; +import type { PengineHealth } from "../types"; /** Loopback HTTP API paths (Tauri `connection_server`). */ export const PENGINE = { @@ -7,40 +8,6 @@ export const PENGINE = { logs: `${PENGINE_API_BASE}/v1/logs`, } as const; -export type OllamaProbe = { reachable: boolean; model: string | null }; - -/** Prefer loaded model from `/api/ps`, else first pulled model from `/api/tags`. */ -export async function fetchOllamaModel(timeoutMs = 3000): Promise { - try { - const psResp = await fetch(`${OLLAMA_API_BASE}/api/ps`, { - signal: AbortSignal.timeout(timeoutMs), - }); - if (psResp.ok) { - const psData = await psResp.json(); - const loaded = psData.models?.[0]?.name as string | undefined; - if (loaded) return { reachable: true, model: loaded }; - } - const tagsResp = await fetch(`${OLLAMA_API_BASE}/api/tags`, { - signal: AbortSignal.timeout(timeoutMs), - }); - if (tagsResp.ok) { - const tagsData = await tagsResp.json(); - const first = (tagsData.models?.[0]?.name as string | undefined) ?? null; - return { reachable: true, model: first ?? null }; - } - return { reachable: false, model: null }; - } catch { - return { reachable: false, model: null }; - } -} - -export type PengineHealth = { - status: string; - bot_connected: boolean; - bot_username?: string; - bot_id?: string | null; -}; - /** GET `/v1/health`; JSON on 200, otherwise `null` (offline / error). */ export async function getPengineHealth(timeoutMs: number): Promise { try { diff --git a/src/components/SetupWizard.tsx b/src/modules/bot/components/SetupWizard.tsx similarity index 97% rename from src/components/SetupWizard.tsx rename to src/modules/bot/components/SetupWizard.tsx index 5fac689..394e5bc 100644 --- a/src/components/SetupWizard.tsx +++ b/src/modules/bot/components/SetupWizard.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { OLLAMA_API_BASE } from "../config"; -import { fetchOllamaModel, getPengineHealth, PENGINE, postConnect } from "../loopback"; -import { useAppSessionStore } from "../stores/appSessionStore"; -import { StyledQrCode } from "./StyledQrCode"; -import { WizardLayout } from "./WizardLayout"; +import { OLLAMA_API_BASE } from "../../../shared/api/config"; +import { fetchOllamaModel } from "../../ollama/api"; +import { getPengineHealth, PENGINE, postConnect } from "../api"; +import { useAppSessionStore } from "../store/appSessionStore"; +import { StyledQrCode } from "../../../shared/ui/StyledQrCode"; +import { WizardLayout } from "../../../shared/ui/WizardLayout"; export const SETUP_STEPS = [ { @@ -176,7 +177,6 @@ export function SetupWizard({ onStepChange, onCompleteSetup }: SetupWizardProps) canGoBack={step > 0} canGoNext={canGoNext} > - {/* Step 1: Create bot */} {step === 0 && (
@@ -233,7 +233,6 @@ export function SetupWizard({ onStepChange, onCompleteSetup }: SetupWizardProps)
)} - {/* Step 2: Ollama */} {step === 1 && (
@@ -320,7 +319,6 @@ ollama pull llama3.2`}
)} - {/* Step 3: Pengine local */} {step === 2 && (
@@ -368,7 +366,6 @@ ollama pull llama3.2`}
)} - {/* Step 4: Connect */} {step === 3 && (
diff --git a/src/components/TerminalPreview.tsx b/src/modules/bot/components/TerminalPreview.tsx similarity index 99% rename from src/components/TerminalPreview.tsx rename to src/modules/bot/components/TerminalPreview.tsx index 48a1a6f..f9c3220 100644 --- a/src/components/TerminalPreview.tsx +++ b/src/modules/bot/components/TerminalPreview.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { PENGINE } from "../loopback"; +import { PENGINE } from "../api"; type LogLine = { timestamp: string; kind: string; message: string }; diff --git a/src/modules/bot/index.ts b/src/modules/bot/index.ts new file mode 100644 index 0000000..390bdce --- /dev/null +++ b/src/modules/bot/index.ts @@ -0,0 +1,5 @@ +export { getPengineHealth, postConnect, deleteConnect, PENGINE } from "./api"; +export type { PengineHealth } from "./types"; +export { useAppSessionStore } from "./store/appSessionStore"; +export { SetupWizard, SETUP_STEPS } from "./components/SetupWizard"; +export { TerminalPreview } from "./components/TerminalPreview"; diff --git a/src/stores/appSessionStore.ts b/src/modules/bot/store/appSessionStore.ts similarity index 96% rename from src/stores/appSessionStore.ts rename to src/modules/bot/store/appSessionStore.ts index c6e7268..7a9ce40 100644 --- a/src/stores/appSessionStore.ts +++ b/src/modules/bot/store/appSessionStore.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; -import { deleteConnect } from "../loopback"; +import { deleteConnect } from "../api"; type AppSessionState = { isDeviceConnected: boolean; diff --git a/src/modules/bot/types.ts b/src/modules/bot/types.ts new file mode 100644 index 0000000..5ec4114 --- /dev/null +++ b/src/modules/bot/types.ts @@ -0,0 +1,6 @@ +export type PengineHealth = { + status: string; + bot_connected: boolean; + bot_username?: string; + bot_id?: string | null; +}; diff --git a/src/modules/ollama/api/index.ts b/src/modules/ollama/api/index.ts new file mode 100644 index 0000000..af156e0 --- /dev/null +++ b/src/modules/ollama/api/index.ts @@ -0,0 +1,27 @@ +import { OLLAMA_API_BASE } from "../../../shared/api/config"; +import type { OllamaProbe } from "../types"; + +/** Prefer loaded model from `/api/ps`, else first pulled model from `/api/tags`. */ +export async function fetchOllamaModel(timeoutMs = 3000): Promise { + try { + const psResp = await fetch(`${OLLAMA_API_BASE}/api/ps`, { + signal: AbortSignal.timeout(timeoutMs), + }); + if (psResp.ok) { + const psData = await psResp.json(); + const loaded = psData.models?.[0]?.name as string | undefined; + if (loaded) return { reachable: true, model: loaded }; + } + const tagsResp = await fetch(`${OLLAMA_API_BASE}/api/tags`, { + signal: AbortSignal.timeout(timeoutMs), + }); + if (tagsResp.ok) { + const tagsData = await tagsResp.json(); + const first = (tagsData.models?.[0]?.name as string | undefined) ?? null; + return { reachable: true, model: first ?? null }; + } + return { reachable: false, model: null }; + } catch { + return { reachable: false, model: null }; + } +} diff --git a/src/modules/ollama/index.ts b/src/modules/ollama/index.ts new file mode 100644 index 0000000..d55fb79 --- /dev/null +++ b/src/modules/ollama/index.ts @@ -0,0 +1,2 @@ +export { fetchOllamaModel } from "./api"; +export type { OllamaProbe } from "./types"; diff --git a/src/modules/ollama/types.ts b/src/modules/ollama/types.ts new file mode 100644 index 0000000..a2622e5 --- /dev/null +++ b/src/modules/ollama/types.ts @@ -0,0 +1 @@ +export type OllamaProbe = { reachable: boolean; model: string | null }; diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 7c8387f..81d24de 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -1,10 +1,11 @@ import { useCallback, useEffect, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; -import { TerminalPreview } from "../components/TerminalPreview"; -import { TopMenu } from "../components/TopMenu"; -import { PENGINE_API_BASE } from "../config"; -import { fetchOllamaModel, getPengineHealth } from "../loopback"; -import { useAppSessionStore } from "../stores/appSessionStore"; +import { getPengineHealth } from "../modules/bot/api"; +import { TerminalPreview } from "../modules/bot/components/TerminalPreview"; +import { useAppSessionStore } from "../modules/bot/store/appSessionStore"; +import { fetchOllamaModel } from "../modules/ollama/api"; +import { PENGINE_API_BASE } from "../shared/api/config"; +import { TopMenu } from "../shared/ui/TopMenu"; type ServiceInfo = { name: string; diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 9e5448a..1c781ad 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -1,8 +1,8 @@ import { Link } from "react-router-dom"; -import { PhoneMockup } from "../components/PhoneMockup"; -import { SpecMockup } from "../components/SpecMockup"; -import { TerminalPreview } from "../components/TerminalPreview"; -import { TopMenu } from "../components/TopMenu"; +import { TerminalPreview } from "../modules/bot/components/TerminalPreview"; +import { PhoneMockup } from "../shared/ui/PhoneMockup"; +import { SpecMockup } from "../shared/ui/SpecMockup"; +import { TopMenu } from "../shared/ui/TopMenu"; const steps = [ { diff --git a/src/pages/SetupPage.tsx b/src/pages/SetupPage.tsx index c767d7a..6cd1f1d 100644 --- a/src/pages/SetupPage.tsx +++ b/src/pages/SetupPage.tsx @@ -1,8 +1,9 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { SETUP_STEPS, SetupWizard } from "../components/SetupWizard"; -import { TerminalPreview } from "../components/TerminalPreview"; -import { TopMenu } from "../components/TopMenu"; +import { SETUP_STEPS, SetupWizard } from "../modules/bot/components/SetupWizard"; +import { TerminalPreview } from "../modules/bot/components/TerminalPreview"; +import { TopMenu } from "../shared/ui/TopMenu"; + const requirements = [ "Telegram account", "Bot token from BotFather", diff --git a/src/config.ts b/src/shared/api/config.ts similarity index 100% rename from src/config.ts rename to src/shared/api/config.ts diff --git a/src/components/PhoneMockup.tsx b/src/shared/ui/PhoneMockup.tsx similarity index 100% rename from src/components/PhoneMockup.tsx rename to src/shared/ui/PhoneMockup.tsx diff --git a/src/components/SpecMockup.tsx b/src/shared/ui/SpecMockup.tsx similarity index 100% rename from src/components/SpecMockup.tsx rename to src/shared/ui/SpecMockup.tsx diff --git a/src/components/StyledQrCode.tsx b/src/shared/ui/StyledQrCode.tsx similarity index 100% rename from src/components/StyledQrCode.tsx rename to src/shared/ui/StyledQrCode.tsx diff --git a/src/components/TopMenu.tsx b/src/shared/ui/TopMenu.tsx similarity index 96% rename from src/components/TopMenu.tsx rename to src/shared/ui/TopMenu.tsx index 91a9f35..92c6fb7 100644 --- a/src/components/TopMenu.tsx +++ b/src/shared/ui/TopMenu.tsx @@ -1,6 +1,6 @@ import * as Menubar from "@radix-ui/react-menubar"; import { Link, useLocation } from "react-router-dom"; -import { useAppSessionStore } from "../stores/appSessionStore"; +import { useAppSessionStore } from "../../modules/bot/store/appSessionStore"; const navLinks = [ { label: "Home", to: "/" }, diff --git a/src/components/WizardLayout.tsx b/src/shared/ui/WizardLayout.tsx similarity index 100% rename from src/components/WizardLayout.tsx rename to src/shared/ui/WizardLayout.tsx From 6f65db8eec6ca4169144041b328f71483c40693c Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Thu, 9 Apr 2026 12:05:26 +0200 Subject: [PATCH 2/2] update: added design document --- doc/design/README.md | 219 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 doc/design/README.md diff --git a/doc/design/README.md b/doc/design/README.md new file mode 100644 index 0000000..f647753 --- /dev/null +++ b/doc/design/README.md @@ -0,0 +1,219 @@ +# Pengine — Architecture & DDD Design Reference + +> **Agent rule:** Before adding, moving, or renaming any file in `src/` or `src-tauri/src/`, read this document first. It defines where code lives and why. + +--- + +## What Is Pengine? + +A Tauri v2 desktop app. The frontend (React + TypeScript, built with Vite) talks to a loopback HTTP server embedded in the Tauri Rust backend. The backend connects to Telegram (via teloxide) and Ollama (local inference) on behalf of the user. + +--- + +## Top-Level Layout + +``` +pengine/ +├── src/ # Frontend — React + TypeScript +├── src-tauri/ # Backend — Rust (Tauri + Axum + teloxide) +├── e2e/ # Playwright end-to-end tests +├── doc/design/ # Architecture docs (this file) +├── eslint.config.ts +├── .prettierrc +└── package.json +``` + +--- + +## Frontend — `src/` + +### Folder Structure + +``` +src/ +├── main.tsx # React entry point +├── App.tsx # Router + startup health-check redirect logic +├── index.css +├── styles/ +│ └── utilities.css +├── assets/ +├── pages/ # Route-level components (one file per route) +│ ├── LandingPage.tsx +│ ├── SetupPage.tsx +│ └── DashboardPage.tsx +├── modules/ # Feature modules (DDD bounded contexts) +│ ├── bot/ +│ │ ├── api/index.ts # Fetch wrappers for Pengine loopback API +│ │ ├── components/ +│ │ │ ├── SetupWizard.tsx +│ │ │ └── TerminalPreview.tsx +│ │ ├── store/ +│ │ │ └── appSessionStore.ts # Zustand store (persisted to localStorage) +│ │ ├── types.ts # PengineHealth and related types +│ │ └── index.ts # Public barrel export +│ └── ollama/ +│ ├── api/index.ts # fetchOllamaModel — probes local Ollama daemon +│ ├── types.ts # OllamaProbe +│ └── index.ts # Public barrel export +└── shared/ + ├── api/ + │ └── config.ts # PENGINE_API_BASE, OLLAMA_API_BASE constants + └── ui/ # Reusable presentational components + ├── TopMenu.tsx + ├── WizardLayout.tsx + ├── PhoneMockup.tsx + ├── SpecMockup.tsx + └── StyledQrCode.tsx +``` + +### Frontend Layer Rules + +| Layer | Path | Allowed imports | +|---|---|---| +| `pages/` | `src/pages/` | `modules/*`, `shared/*` | +| `modules/` | `src/modules//` | `shared/*`, own module internals | +| `shared/` | `src/shared/` | Nothing from `modules/` or `pages/` | + +- **Pages** compose module components and wire routing. No business logic. +- **Modules** own their api calls, state, components, and types. A module imports only from `shared/` or its own subtree. +- **Shared** is utility/primitive only — no domain knowledge, no feature state. +- Cross-module imports are **not allowed**. If two modules need the same thing, extract it to `shared/`. + +### Key Frontend Files + +- `src/shared/api/config.ts` — single source of truth for base URLs (`http://127.0.0.1:21516` for Pengine, `http://127.0.0.1:11434` for Ollama). Change ports here only. +- `src/modules/bot/api/index.ts` — all fetch calls to the Rust loopback server (`/v1/connect`, `/v1/health`, `/v1/logs`). +- `src/modules/bot/store/appSessionStore.ts` — Zustand store for bot connection state, persisted to localStorage under key `pengine-device-session`. +- `src/App.tsx` — on startup, polls `getPengineHealth()` and redirects to `/dashboard` if a bot is already connected (avoids landing on setup after restart). + +--- + +## Backend — `src-tauri/src/` + +### Folder Structure + +``` +src-tauri/src/ +├── main.rs # Binary entry (calls lib::run) +├── lib.rs # Declares top-level modules, calls app::run() +├── app.rs # Tauri builder: registers commands, spawns HTTP server +├── shared/ +│ ├── mod.rs +│ └── state.rs # AppState, ConnectionData, LogEntry — shared across all layers +├── modules/ +│ ├── mod.rs +│ ├── bot/ +│ │ ├── mod.rs +│ │ ├── commands.rs # Tauri IPC commands (get_connection_status, disconnect_bot) +│ │ ├── repository.rs # File-based persistence (persist / load / clear) +│ │ └── service.rs # Bot lifecycle (verify_token, start_bot, message handlers) +│ └── ollama/ +│ ├── mod.rs +│ ├── constants.rs # OLLAMA_PS_URL, OLLAMA_TAGS_URL, OLLAMA_CHAT_URL +│ └── service.rs # active_model(), chat() — HTTP calls to local Ollama +└── infrastructure/ + ├── mod.rs + ├── http_server.rs # Axum server: route definitions + HTTP handlers + └── bot_lifecycle.rs # stop_and_wait_for_bot() — graceful shutdown helper +``` + +### Backend Layer Rules + +| Layer | Path | Responsibility | +|---|---|---| +| `shared/` | `src-tauri/src/shared/` | Types shared across all layers; no domain logic | +| `modules/` | `src-tauri/src/modules/` | Domain logic, isolated per bounded context | +| `infrastructure/` | `src-tauri/src/infrastructure/` | Transport (HTTP, Tauri IPC); imports from `shared/` and `modules/` | +| `app.rs` | root | Wiring only — instantiates state, registers Tauri commands, spawns tasks | + +**Dependency direction:** `infrastructure` → `modules` → `shared`. Never the reverse. + +### Why `ConnectionData` Lives in `shared/state.rs` + +`ConnectionData` is defined next to `AppState` (not inside `modules/bot/`) because `AppState` holds a `Mutex>` and `AppState` is imported by `modules/bot/service.rs`. Splitting them would create a circular dependency (`shared → bot → shared`). Rule: types owned by `AppState` belong in `shared/state.rs`. + +### Key Backend Files + +- `src-tauri/src/shared/state.rs` — `AppState` is the single shared handle cloned into every Axum handler and Tauri command. It holds the Tokio broadcast channel for SSE logs, the bot running flag, the connection data, and the store path. +- `src-tauri/src/infrastructure/http_server.rs` — Axum router. Port `21516`. Routes: `POST /v1/connect`, `DELETE /v1/connect`, `GET /v1/health`, `GET /v1/logs` (SSE). Bind uses `SO_REUSEADDR` + retry loop for fast restarts. +- `src-tauri/src/modules/bot/repository.rs` — Persists `ConnectionData` as JSON to a single file at `$APP_DATA/connection.json`. `clear()` uses direct `remove_file` (not existence check first) to avoid TOCTOU. +- `src-tauri/src/modules/bot/service.rs` — `verify_token` calls Telegram `getMe`. `start_bot` runs the teloxide dispatcher and sets `bot_running` flag on entry/exit. +- `src-tauri/src/infrastructure/bot_lifecycle.rs` — `stop_and_wait_for_bot` fires `shutdown_notify`, then polls `bot_running` every 50 ms up to 30 s before giving up. + +--- + +## Communication Flow + +``` +User types bot token + │ + ▼ +SetupWizard (frontend) + POST /v1/connect ──────────────────────────────► http_server::handle_connect + │ + verify_token (Telegram getMe) + │ + persist to disk + │ + spawn start_bot (teloxide) + │ + ◄── ConnectResponse { bot_id, bot_username } + │ + ▼ +appSessionStore.connectDevice() → localStorage + │ + ▼ +redirect to /dashboard + │ +DashboardPage polls GET /v1/health every 5 s +DashboardPage streams GET /v1/logs (SSE) +``` + +Incoming Telegram messages flow: + +``` +Telegram ──► teloxide dispatcher ──► text_handler + │ + ollama::active_model() + │ + ollama::chat(model, text) + │ + bot.send_message(reply) +``` + +--- + +## Adding a New Module + +### Frontend + +1. Create `src/modules//` with `api/index.ts`, `types.ts`, `index.ts`. +2. Export public surface through `index.ts` only. +3. Import in pages via `../../modules/`. +4. Do not import between sibling modules. + +### Backend + +1. Create `src-tauri/src/modules//` with `mod.rs`, `service.rs`, and whatever else is needed. +2. Register the module in `src-tauri/src/modules/mod.rs` (`pub mod ;`). +3. If the module exposes Tauri IPC commands, add a `commands.rs` and register them in `app.rs`. +4. Keep HTTP handlers in `infrastructure/http_server.rs`, not in the module itself. + +--- + +## Tooling Quick Reference + +| Task | Command | +|---|---| +| Dev server | `bun run dev` | +| Type check | `bun run typecheck` | +| Lint (TS) | `bun run lint` | +| Format (TS) | `bun run format` | +| Rust format check | `bun run rust:fmt` | +| Rust lint | `bun run rust:lint` | +| Rust format + lint | `bun run rust:check` | +| Auto-format Rust | `cargo fmt --all --manifest-path src-tauri/Cargo.toml` | +| E2E tests | `bun run test:e2e` | +| Tauri dev | `bun run tauri dev` | + +Pre-commit hook runs: `lint-staged` → `typecheck` → `rust:check`.