add some features

This commit is contained in:
Kazimierz Ciołek
2026-07-07 15:25:57 +02:00
parent b38aa268ef
commit 3175ea81fe
20 changed files with 2859 additions and 156 deletions

967
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,4 +25,9 @@ serde_json = "1"
tauri-plugin-sql = { version = "2.4.0", features = ["sqlite"] }
tauri-plugin-dialog = "2.7.1"
chrono = "0.4.45"
tauri-plugin-http = "2.5.9"
reqwest = { version = "0.13.4", features = ["stream"] }
zip = "8.6.0"
futures-util = "0.3.32"
tokio = { version = "1.52.3", features = ["time"] }

View File

@@ -8,6 +8,15 @@
"opener:default",
"sql:default",
"sql:allow-execute",
"dialog:default"
"dialog:default",
{
"identifier": "http:default",
"allow": [
{ "url": "https://api.anthropic.com/*" },
{ "url": "https://api.openai.com/*" },
{ "url": "http://localhost:*/*" },
{ "url": "http://127.0.0.1:*/*" }
]
}
]
}

View File

@@ -1,3 +1,8 @@
mod llama;
use std::sync::Mutex;
use tauri::Manager;
use tauri_plugin_sql::{Migration, MigrationKind};
fn migrations() -> Vec<Migration> {
@@ -148,14 +153,35 @@ fn migrations() -> Vec<Migration> {
UPDATE reservations SET prepared = 1 WHERE arrival < date('now');
",
kind: MigrationKind::Up,
},
Migration {
version: 6,
description: "add_season_costs_ai",
sql: "
ALTER TABLE settings ADD COLUMN season_start_month INTEGER NOT NULL DEFAULT 5;
ALTER TABLE settings ADD COLUMN season_end_month INTEGER NOT NULL DEFAULT 9;
ALTER TABLE settings ADD COLUMN linen_cost REAL;
ALTER TABLE settings ADD COLUMN ai_provider TEXT NOT NULL DEFAULT 'anthropic';
ALTER TABLE settings ADD COLUMN ai_api_key TEXT;
ALTER TABLE settings ADD COLUMN ai_model TEXT;
ALTER TABLE settings ADD COLUMN ai_base_url TEXT;
CREATE TABLE costs (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
amount REAL NOT NULL,
period TEXT NOT NULL DEFAULT 'year',
year INTEGER,
notes TEXT
);
",
kind: MigrationKind::Up,
}]
}
/// Kopia bazy przy starcie (zanim plugin SQL otworzy połączenia):
/// backups/sloneczko-RRRR-MM-DD.db obok pliku bazy, retencja 14 ostatnich.
fn auto_backup(app: &tauri::AppHandle) -> std::io::Result<()> {
use tauri::Manager;
let candidates = [app.path().app_config_dir(), app.path().app_data_dir()];
for dir in candidates.into_iter().flatten() {
let db = dir.join("sloneczko.db");
@@ -195,7 +221,14 @@ fn save_text_file(path: String, contents: String) -> Result<(), String> {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![save_text_file])
.manage(llama::LlamaState(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![
save_text_file,
llama::llama_status,
llama::llama_download,
llama::llama_start,
llama::llama_stop
])
.setup(|app| {
if let Err(e) = auto_backup(app.handle()) {
eprintln!("Nie udało się wykonać kopii zapasowej: {e}");
@@ -208,7 +241,16 @@ pub fn run() {
.build(),
)
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_opener::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run(|app, event| {
// llama-server nie może przeżyć aplikacji — zombie zjadałby RAM
if let tauri::RunEvent::Exit = event {
if let Some(state) = app.try_state::<llama::LlamaState>() {
llama::kill(&state);
}
}
});
}

328
src-tauri/src/llama.rs Normal file
View File

@@ -0,0 +1,328 @@
//! Wbudowany serwer AI: zarządzanie procesem llama.cpp (llama-server)
//! na wzór AiProcessManager z trainhub-flutter — pobieranie binarki i modelu,
//! start z fallbackiem GPU -> CPU, health-poll, ubijanie przy zamknięciu.
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use futures_util::StreamExt;
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, State};
pub const PORT: u16 = 8123;
const LLAMA_BUILD: &str = "b8130";
const SERVER_BINARY: &str = "llama-server.exe";
const MODEL_FILE: &str = "qwen3-4b-instruct-2507-q4_k_m.gguf";
const MODEL_URL: &str = "https://huggingface.co/bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF/resolve/main/Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf";
// Plik mniejszy niż to minimum to pozostałość przerwanego pobierania.
const MODEL_MIN_BYTES: u64 = 2_000_000_000;
const BINARY_MIN_BYTES: u64 = 1_000_000;
const CONTEXT_SIZE: u32 = 8192;
const GPU_LAYERS: u32 = 99;
const GPU_TIMEOUT_SECS: u64 = 300;
const CPU_TIMEOUT_SECS: u64 = 180;
pub struct LlamaState(pub Mutex<Option<Child>>);
#[derive(Serialize, Clone)]
pub struct LlamaStatus {
pub installed: bool,
pub running: bool,
}
#[derive(Serialize, Clone)]
struct LlamaProgress {
task: String,
progress: Option<f64>,
}
fn emit_progress(app: &AppHandle, task: &str, progress: Option<f64>) {
let _ = app.emit(
"llama-progress",
LlamaProgress {
task: task.to_string(),
progress,
},
);
}
fn llama_dir(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("llama");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
fn file_complete(path: &Path, min_bytes: u64) -> bool {
fs::metadata(path).map(|m| m.len() >= min_bytes).unwrap_or(false)
}
fn is_installed(dir: &Path) -> bool {
file_complete(&dir.join(SERVER_BINARY), BINARY_MIN_BYTES)
&& file_complete(&dir.join(MODEL_FILE), MODEL_MIN_BYTES)
}
async fn is_healthy() -> bool {
let url = format!("http://127.0.0.1:{PORT}/health");
match reqwest::Client::new()
.get(&url)
.timeout(Duration::from_secs(2))
.send()
.await
{
Ok(res) => res.status().is_success(),
Err(_) => false,
}
}
/// Ubija proces serwera, jeśli działa. Bezpieczne przy braku procesu.
pub fn kill(state: &LlamaState) {
if let Ok(mut guard) = state.0.lock() {
if let Some(mut child) = guard.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
/// Czy proces trzymany w stanie nadal żyje (sprząta uchwyt po padzie).
fn child_alive(state: &LlamaState) -> bool {
let mut guard = match state.0.lock() {
Ok(g) => g,
Err(_) => return false,
};
match guard.as_mut() {
Some(child) => match child.try_wait() {
Ok(None) => true,
_ => {
*guard = None;
false
}
},
None => false,
}
}
#[tauri::command]
pub async fn llama_status(
app: AppHandle,
state: State<'_, LlamaState>,
) -> Result<LlamaStatus, String> {
let dir = llama_dir(&app)?;
let installed = is_installed(&dir);
let running = child_alive(&state) && is_healthy().await;
Ok(LlamaStatus { installed, running })
}
async fn download_file(
app: &AppHandle,
url: &str,
dest: &Path,
label: &str,
overall_start: f64,
overall_end: f64,
min_bytes: u64,
) -> Result<(), String> {
if min_bytes > 0 && file_complete(dest, min_bytes) {
emit_progress(app, label, Some(overall_end));
return Ok(());
}
emit_progress(app, label, Some(overall_start));
// Pobieranie do pliku .part i podmiana na końcu — przerwane pobieranie
// nigdy nie udaje kompletnego pliku.
let part = dest.with_extension("part");
let response = reqwest::Client::new()
.get(url)
.send()
.await
.map_err(|e| format!("Błąd sieci: {e}"))?;
if !response.status().is_success() {
return Err(format!("Błąd pobierania (HTTP {}): {url}", response.status()));
}
let total = response.content_length().unwrap_or(0);
let mut file = fs::File::create(&part).map_err(|e| e.to_string())?;
let mut stream = response.bytes_stream();
let mut received: u64 = 0;
let mut last_permille: u64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Błąd sieci: {e}"))?;
file.write_all(&chunk).map_err(|e| e.to_string())?;
received += chunk.len() as u64;
if total > 0 {
let permille = received * 1000 / total;
if permille != last_permille {
last_permille = permille;
let progress =
overall_start + (received as f64 / total as f64) * (overall_end - overall_start);
emit_progress(app, label, Some(progress));
}
}
}
drop(file);
if dest.exists() {
let _ = fs::remove_file(dest);
}
fs::rename(&part, dest).map_err(|e| e.to_string())?;
Ok(())
}
/// Wyciąga llama-server.exe i biblioteki .dll z archiwum wydania llama.cpp.
fn extract_binary(archive: &Path, dest: &Path) -> Result<(), String> {
let file = fs::File::open(archive).map_err(|e| e.to_string())?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
let mut found_server = false;
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
if entry.is_dir() {
continue;
}
let name = entry.name().to_string();
let base = name
.rsplit(['/', '\\'])
.next()
.unwrap_or(&name)
.to_string();
let lower = base.to_lowercase();
if lower == SERVER_BINARY || lower.ends_with(".dll") {
let out_path = dest.join(&base);
let mut out = fs::File::create(&out_path).map_err(|e| e.to_string())?;
std::io::copy(&mut entry, &mut out).map_err(|e| e.to_string())?;
if lower == SERVER_BINARY {
found_server = true;
}
}
}
if found_server {
Ok(())
} else {
Err("Nie znaleziono llama-server.exe w archiwum.".to_string())
}
}
#[tauri::command]
pub async fn llama_download(app: AppHandle, state: State<'_, LlamaState>) -> Result<(), String> {
kill(&state);
let dir = llama_dir(&app)?;
if !file_complete(&dir.join(SERVER_BINARY), BINARY_MIN_BYTES) {
let zip_url = format!(
"https://github.com/ggml-org/llama.cpp/releases/download/{LLAMA_BUILD}/llama-{LLAMA_BUILD}-bin-win-vulkan-x64.zip"
);
let archive = dir.join("llama.zip");
download_file(&app, &zip_url, &archive, "Pobieranie llama.cpp…", 0.0, 0.06, 0).await?;
emit_progress(&app, "Rozpakowywanie llama.cpp…", Some(0.06));
let archive_clone = archive.clone();
let dir_clone = dir.clone();
tauri::async_runtime::spawn_blocking(move || extract_binary(&archive_clone, &dir_clone))
.await
.map_err(|e| e.to_string())??;
let _ = fs::remove_file(&archive);
}
download_file(
&app,
MODEL_URL,
&dir.join(MODEL_FILE),
"Pobieranie modelu Qwen3 4B (~2,4 GB)…",
0.06,
1.0,
MODEL_MIN_BYTES,
)
.await?;
emit_progress(&app, "Pobieranie zakończone.", Some(1.0));
Ok(())
}
fn spawn_server(dir: &Path, gpu_layers: u32) -> Result<Child, String> {
let log = fs::File::create(dir.join("llama.log")).map_err(|e| e.to_string())?;
let mut cmd = Command::new(dir.join(SERVER_BINARY));
cmd.args([
"-m",
&dir.join(MODEL_FILE).to_string_lossy(),
"--port",
&PORT.to_string(),
"--ctx-size",
&CONTEXT_SIZE.to_string(),
"-ngl",
&gpu_layers.to_string(),
// szablon czatu wbudowany w GGUF — wymagany dla poprawnego formatu Qwen3
"--jinja",
])
.current_dir(dir)
.stdout(Stdio::null())
.stderr(Stdio::from(log));
// bez migającego okna konsoli na Windowsie
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd.spawn().map_err(|e| format!("Nie udało się uruchomić llama-server: {e}"))
}
#[tauri::command]
pub async fn llama_start(app: AppHandle, state: State<'_, LlamaState>) -> Result<(), String> {
if child_alive(&state) && is_healthy().await {
return Ok(());
}
let dir = llama_dir(&app)?;
if !is_installed(&dir) {
return Err("Model nie jest zainstalowany — najpierw pobierz pliki.".to_string());
}
// Próba 1: pełny offload na GPU (Vulkan). Pierwsze uruchomienie kompiluje
// pipeline'y i może trwać minuty. Próba 2: sam CPU — ładuje przez mmap.
for (gpu_layers, timeout_secs, label) in
[(GPU_LAYERS, GPU_TIMEOUT_SECS, "GPU"), (0, CPU_TIMEOUT_SECS, "CPU")]
{
kill(&state);
let child = spawn_server(&dir, gpu_layers)?;
if let Ok(mut guard) = state.0.lock() {
*guard = Some(child);
}
let started = Instant::now();
while started.elapsed() < Duration::from_secs(timeout_secs) {
if !child_alive(&state) {
// proces padł — od razu przechodzimy do kolejnej próby
break;
}
if is_healthy().await {
emit_progress(&app, "Model gotowy.", None);
return Ok(());
}
emit_progress(
&app,
&format!(
"Ładowanie modelu ({label})… {}s — pierwsze uruchomienie może potrwać kilka minut",
started.elapsed().as_secs()
),
None,
);
tokio::time::sleep(Duration::from_millis(750)).await;
}
}
kill(&state);
let log_hint = dir.join("llama.log").to_string_lossy().to_string();
Err(format!(
"Serwer AI nie wystartował (próbowano GPU i CPU). Szczegóły w pliku: {log_hint}"
))
}
#[tauri::command]
pub async fn llama_stop(state: State<'_, LlamaState>) -> Result<(), String> {
kill(&state);
Ok(())
}