add some features
This commit is contained in:
967
src-tauri/Cargo.lock
generated
967
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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:*/*" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
328
src-tauri/src/llama.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user