add some features
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ node_modules
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
/faktury
|
||||
/trainhub-flutter
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-http": "^2.5.9",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-sql": "^2.4.0"
|
||||
},
|
||||
@@ -1229,6 +1230,15 @@
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-http": {
|
||||
"version": "2.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz",
|
||||
"integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-http": "^2.5.9",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-sql": "^2.4.0"
|
||||
},
|
||||
|
||||
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(())
|
||||
}
|
||||
257
src/lib/ai.ts
Normal file
257
src/lib/ai.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
import { LLAMA_PORT } from "./llama";
|
||||
import * as db from "./db";
|
||||
import type { AiProvider, Settings } from "./types";
|
||||
import { daysInSeason, isoDate, MONTH_NAMES, seasonMonths, todayISO } from "./dates";
|
||||
import { formatMoney, round2 } from "./money";
|
||||
import { COST_PERIOD_LABELS, costPerYear, totalCostsForYear } from "./costs";
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const AI_PROVIDER_LABELS: Record<AiProvider, string> = {
|
||||
local: "Wbudowany (llama.cpp, offline)",
|
||||
anthropic: "Anthropic (Claude)",
|
||||
openai: "OpenAI",
|
||||
ollama: "Ollama (lokalnie)",
|
||||
lmstudio: "LM Studio (lokalnie)"
|
||||
};
|
||||
|
||||
export const DEFAULT_MODELS: Record<AiProvider, string> = {
|
||||
local: "Qwen3 4B Instruct",
|
||||
anthropic: "claude-sonnet-5",
|
||||
openai: "gpt-4o-mini",
|
||||
ollama: "llama3.1",
|
||||
lmstudio: "local-model"
|
||||
};
|
||||
|
||||
export const DEFAULT_BASE_URLS: Partial<Record<AiProvider, string>> = {
|
||||
ollama: "http://localhost:11434",
|
||||
lmstudio: "http://localhost:1234"
|
||||
};
|
||||
|
||||
export function providerNeedsKey(p: AiProvider): boolean {
|
||||
return p === "anthropic" || p === "openai";
|
||||
}
|
||||
|
||||
export function isAiConfigured(s: Settings): boolean {
|
||||
return !providerNeedsKey(s.ai_provider) || !!s.ai_api_key?.trim();
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `Jesteś asystentem menedżera małego ośrodka domków letniskowych „Słoneczko" w Polsce.
|
||||
Pomagasz w: planowaniu obłożenia i cen, znajdowaniu wolnych luk w sezonie, analizie przychodów i kosztów,
|
||||
oraz podpowiadaniu, jak zwiększyć zarobki możliwie małym nakładem pracy i pieniędzy.
|
||||
Zasady:
|
||||
- Odpowiadaj po polsku, zwięźle i konkretnie; opieraj się na danych z sekcji DANE poniżej i podawaj liczby.
|
||||
- Gdy proponujesz zmiany cen lub promocje, szacuj ich wpływ na przychód.
|
||||
- Pisz zwykłym tekstem bez formatowania markdown (bez gwiazdek, krzyżyków i tabel); listy zapisuj od myślników.
|
||||
- Jeśli danych brakuje, powiedz czego brakuje, zamiast zgadywać.`;
|
||||
|
||||
/** Zwięzły obraz biznesu z bazy — doklejany do promptu systemowego przy każdej rozmowie. */
|
||||
export async function buildBusinessContext(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const from = isoDate(year, 0, 1);
|
||||
const to = isoDate(year + 1, 0, 1);
|
||||
const monthRanges = Array.from({ length: 12 }, (_, m) => ({
|
||||
from: isoDate(year, m, 1),
|
||||
to: m === 11 ? isoDate(year + 1, 0, 1) : isoDate(year, m + 1, 1)
|
||||
}));
|
||||
|
||||
const [settings, cabins, priceSeasons, costs, yearStats, cabinStats, linenSets, ...months] =
|
||||
await Promise.all([
|
||||
db.getSettings(),
|
||||
db.listCabins(),
|
||||
db.listPriceSeasons(),
|
||||
db.listCosts(),
|
||||
db.revenueStats(from, to),
|
||||
db.cabinReports(from, to),
|
||||
db.linenSetsForYear(year),
|
||||
...monthRanges.map((r) => db.revenueStats(r.from, r.to))
|
||||
]);
|
||||
|
||||
const sMonths = seasonMonths(settings.season_start_month, settings.season_end_month);
|
||||
const seasonDays = daysInSeason(year, settings.season_start_month, settings.season_end_month);
|
||||
const availableNights = cabins.length * seasonDays;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`DANE OŚRODKA (stan na ${todayISO()}, rok ${year}):`);
|
||||
lines.push(
|
||||
`Sezon: ${MONTH_NAMES[settings.season_start_month - 1]}–${MONTH_NAMES[settings.season_end_month - 1]}` +
|
||||
` (${seasonDays} dni × ${cabins.length} domków = ${availableNights} dostępnych dób).`
|
||||
);
|
||||
lines.push(
|
||||
`Domki: ${cabins.map((c) => `${c.name}${c.capacity ? ` (${c.capacity} os.)` : ""}`).join(", ")}.`
|
||||
);
|
||||
|
||||
if (priceSeasons.length > 0) {
|
||||
lines.push("Cennik sezonowy (za dobę):");
|
||||
for (const p of priceSeasons) {
|
||||
const cabinName = p.cabin_id == null
|
||||
? "wszystkie domki"
|
||||
: (cabins.find((c) => c.id === p.cabin_id)?.name ?? `domek #${p.cabin_id}`);
|
||||
lines.push(`- ${p.date_from} do ${p.date_to}: ${formatMoney(p.price)} zł (${cabinName})`);
|
||||
}
|
||||
} else {
|
||||
lines.push("Cennik sezonowy: brak zdefiniowanych okresów.");
|
||||
}
|
||||
|
||||
lines.push(`Obłożenie i przychód ${year} wg miesięcy (doby / % obłożenia / przychód, w nawiasie potwierdzony):`);
|
||||
months.forEach((m, i) => {
|
||||
if (m.nights_total <= 0 && !sMonths.includes(i + 1)) return;
|
||||
const daysM = new Date(year, i + 1, 0).getDate();
|
||||
const occ = cabins.length > 0 ? Math.round((m.nights_total / (cabins.length * daysM)) * 100) : 0;
|
||||
const seasonTag = sMonths.includes(i + 1) ? "" : " [poza sezonem]";
|
||||
lines.push(
|
||||
`- ${MONTH_NAMES[i]}: ${Math.round(m.nights_total)} dób, ${occ}%, ${formatMoney(m.revenue_total)} zł (${formatMoney(m.revenue_confirmed)} zł)${seasonTag}`
|
||||
);
|
||||
});
|
||||
|
||||
lines.push("Wg domków (doby / przychód):");
|
||||
for (const c of cabins) {
|
||||
const r = cabinStats.find((x) => x.cabin_id === c.id);
|
||||
lines.push(
|
||||
`- ${c.name}: ${Math.round(r?.nights_total ?? 0)} dób, ${formatMoney(r?.revenue_total ?? 0)} zł`
|
||||
);
|
||||
}
|
||||
|
||||
const costsTotal = totalCostsForYear(costs, year, sMonths.length);
|
||||
const linenCost = round2(linenSets * (settings.linen_cost ?? 0));
|
||||
lines.push(`Koszty roczne (${year}):`);
|
||||
for (const c of costs) {
|
||||
const yearly = costPerYear(c, year, sMonths.length);
|
||||
if (yearly > 0) {
|
||||
lines.push(`- ${c.name}: ${formatMoney(yearly)} zł (${COST_PERIOD_LABELS[c.period]} ${formatMoney(c.amount)} zł)`);
|
||||
}
|
||||
}
|
||||
if (settings.linen_cost != null) {
|
||||
lines.push(`- Pościel: ${linenSets} kompletów × ${formatMoney(settings.linen_cost)} zł = ${formatMoney(linenCost)} zł`);
|
||||
}
|
||||
const totalAllCosts = round2(costsTotal + linenCost);
|
||||
lines.push(`Koszty razem: ${formatMoney(totalAllCosts)} zł.`);
|
||||
lines.push(
|
||||
`Wynik ${year}: przychód potencjalny ${formatMoney(yearStats.revenue_total)} zł, potwierdzony ${formatMoney(yearStats.revenue_confirmed)} zł; ` +
|
||||
`przewidywany zysk (potwierdzony − koszty): ${formatMoney(round2(yearStats.revenue_confirmed - totalAllCosts))} zł.`
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// --- Streaming ---
|
||||
|
||||
function handleSseLine(line: string, onData: (data: unknown) => void): void {
|
||||
if (!line.startsWith("data: ")) return;
|
||||
const dataStr = line.slice(6).trim();
|
||||
if (!dataStr || dataStr === "[DONE]") return;
|
||||
try {
|
||||
onData(JSON.parse(dataStr));
|
||||
} catch {
|
||||
// uszkodzona linia — pomijamy
|
||||
}
|
||||
}
|
||||
|
||||
/** Czyta SSE strumieniowo; gdy strumień niedostępny, parsuje całą odpowiedź na raz. */
|
||||
async function readSse(res: Response, onData: (data: unknown) => void): Promise<void> {
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) {
|
||||
for (const line of (await res.text()).split("\n")) handleSseLine(line, onData);
|
||||
return;
|
||||
}
|
||||
const decoder = new TextDecoder();
|
||||
let pending = "";
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
pending += decoder.decode(value, { stream: true });
|
||||
const lines = pending.split("\n");
|
||||
pending = lines.pop() ?? "";
|
||||
for (const line of lines) handleSseLine(line, onData);
|
||||
}
|
||||
if (pending) handleSseLine(pending, onData);
|
||||
}
|
||||
|
||||
interface AnthropicEvent {
|
||||
type?: string;
|
||||
delta?: { type?: string; text?: string };
|
||||
error?: { message?: string };
|
||||
}
|
||||
|
||||
interface OpenAiChunk {
|
||||
choices?: { delta?: { content?: string } }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wysyła rozmowę do skonfigurowanego dostawcy i przekazuje fragmenty odpowiedzi do onDelta.
|
||||
* Kontekst biznesowy jest budowany z bazy przy każdym wywołaniu.
|
||||
*/
|
||||
export async function chat(
|
||||
history: ChatMessage[],
|
||||
settings: Settings,
|
||||
onDelta: (text: string) => void,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const system = `${SYSTEM_PROMPT}\n\n${await buildBusinessContext()}`;
|
||||
const model = settings.ai_model?.trim() || DEFAULT_MODELS[settings.ai_provider];
|
||||
const key = settings.ai_api_key?.trim() || null;
|
||||
|
||||
if (settings.ai_provider === "anthropic") {
|
||||
if (!key) throw new Error("Brak klucza API Anthropic — uzupełnij w Ustawieniach.");
|
||||
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
signal,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-api-key": key,
|
||||
"anthropic-version": "2023-06-01"
|
||||
},
|
||||
body: JSON.stringify({ model, max_tokens: 2048, system, messages: history, stream: true })
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Błąd API (${res.status}): ${(await res.text()).slice(0, 300)}`);
|
||||
}
|
||||
await readSse(res, (data) => {
|
||||
const ev = data as AnthropicEvent;
|
||||
if (ev.type === "content_block_delta" && ev.delta?.type === "text_delta") {
|
||||
onDelta(ev.delta.text ?? "");
|
||||
} else if (ev.type === "error") {
|
||||
throw new Error(ev.error?.message ?? "nieznany błąd API");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.ai_provider === "openai" && !key) {
|
||||
throw new Error("Brak klucza API OpenAI — uzupełnij w Ustawieniach.");
|
||||
}
|
||||
const isLocal = settings.ai_provider === "local";
|
||||
const base = settings.ai_base_url?.trim() || DEFAULT_BASE_URLS[settings.ai_provider] || "";
|
||||
const url = isLocal
|
||||
? `http://127.0.0.1:${LLAMA_PORT}/v1/chat/completions`
|
||||
: settings.ai_provider === "openai"
|
||||
? "https://api.openai.com/v1/chat/completions"
|
||||
: `${base}/v1/chat/completions`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
signal,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(key && !isLocal ? { authorization: `Bearer ${key}` } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...(isLocal ? {} : { model }),
|
||||
messages: [{ role: "system", content: system }, ...history],
|
||||
// parametry jak w trainhub: llama.cpp reuse'uje KV-cache wspólnego
|
||||
// prefiksu między turami — wielotury odpowiadają dużo szybciej
|
||||
...(isLocal ? { temperature: 0.7, max_tokens: 1536, cache_prompt: true } : {}),
|
||||
stream: true
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Błąd API (${res.status}): ${(await res.text()).slice(0, 300)}`);
|
||||
}
|
||||
await readSse(res, (data) => {
|
||||
const delta = (data as OpenAiChunk).choices?.[0]?.delta?.content;
|
||||
if (delta) onDelta(delta);
|
||||
});
|
||||
}
|
||||
28
src/lib/costs.ts
Normal file
28
src/lib/costs.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Cost, CostPeriod } from "./types";
|
||||
import { round2 } from "./money";
|
||||
|
||||
export const COST_PERIOD_LABELS: Record<CostPeriod, string> = {
|
||||
year: "rocznie",
|
||||
month: "miesięcznie",
|
||||
season_month: "miesięcznie w sezonie",
|
||||
once: "jednorazowo"
|
||||
};
|
||||
|
||||
/** Roczny wymiar pojedynczego kosztu dla danego roku. */
|
||||
export function costPerYear(cost: Cost, year: number, seasonMonthCount: number): number {
|
||||
switch (cost.period) {
|
||||
case "year":
|
||||
return cost.amount;
|
||||
case "month":
|
||||
return cost.amount * 12;
|
||||
case "season_month":
|
||||
return cost.amount * seasonMonthCount;
|
||||
case "once":
|
||||
return cost.year === year ? cost.amount : 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Suma kosztów stałych w roku (bez pościeli). */
|
||||
export function totalCostsForYear(costs: Cost[], year: number, seasonMonthCount: number): number {
|
||||
return round2(costs.reduce((sum, c) => sum + costPerYear(c, year, seasonMonthCount), 0));
|
||||
}
|
||||
@@ -58,6 +58,23 @@ export function formatShort(iso: string): string {
|
||||
return `${Number(iso.slice(8))}.${iso.slice(5, 7)}`;
|
||||
}
|
||||
|
||||
/** Miesiące sezonu (1–12) od start do end włącznie; obsługuje sezon przez przełom roku. */
|
||||
export function seasonMonths(start: number, end: number): number[] {
|
||||
const months: number[] = [];
|
||||
let m = start;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
months.push(m);
|
||||
if (m === end) break;
|
||||
m = m === 12 ? 1 : m + 1;
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
/** Liczba dni sezonu w danym roku. */
|
||||
export function daysInSeason(year: number, start: number, end: number): number {
|
||||
return seasonMonths(start, end).reduce((sum, m) => sum + daysInMonth(year, m - 1), 0);
|
||||
}
|
||||
|
||||
/** Liczba nocy między dwiema datami ISO (departure - arrival). */
|
||||
export function nightsBetween(arrival: string, departure: string): number {
|
||||
const a = new Date(arrival + "T00:00:00");
|
||||
|
||||
@@ -2,6 +2,8 @@ import Database from "@tauri-apps/plugin-sql";
|
||||
import type {
|
||||
Cabin,
|
||||
CabinReport,
|
||||
Cost,
|
||||
CostInput,
|
||||
Guest,
|
||||
GuestListRow,
|
||||
Invoice,
|
||||
@@ -452,7 +454,9 @@ export async function updateSettings(s: Settings): Promise<void> {
|
||||
`UPDATE settings SET
|
||||
company_name = $1, first_name = $2, last_name = $3, address = $4, nip = $5,
|
||||
bank_account = $6, bank_name = $7, issue_place = $8, default_pkwiu = $9,
|
||||
default_vat_rate = $10, energy_price = $11
|
||||
default_vat_rate = $10, energy_price = $11, season_start_month = $12,
|
||||
season_end_month = $13, linen_cost = $14, ai_provider = $15, ai_api_key = $16,
|
||||
ai_model = $17, ai_base_url = $18
|
||||
WHERE id = 1`,
|
||||
[
|
||||
s.company_name,
|
||||
@@ -465,11 +469,56 @@ export async function updateSettings(s: Settings): Promise<void> {
|
||||
s.issue_place,
|
||||
s.default_pkwiu,
|
||||
s.default_vat_rate,
|
||||
s.energy_price
|
||||
s.energy_price,
|
||||
s.season_start_month,
|
||||
s.season_end_month,
|
||||
s.linen_cost,
|
||||
s.ai_provider,
|
||||
s.ai_api_key,
|
||||
s.ai_model,
|
||||
s.ai_base_url
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// --- Koszty ---
|
||||
|
||||
export async function listCosts(): Promise<Cost[]> {
|
||||
const d = await getDb();
|
||||
return d.select<Cost[]>("SELECT * FROM costs ORDER BY name");
|
||||
}
|
||||
|
||||
export async function createCost(c: CostInput): Promise<void> {
|
||||
const d = await getDb();
|
||||
await d.execute(
|
||||
"INSERT INTO costs (name, amount, period, year, notes) VALUES ($1, $2, $3, $4, $5)",
|
||||
[c.name, c.amount, c.period, c.year, c.notes]
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateCost(c: Cost): Promise<void> {
|
||||
const d = await getDb();
|
||||
await d.execute(
|
||||
"UPDATE costs SET name = $1, amount = $2, period = $3, year = $4, notes = $5 WHERE id = $6",
|
||||
[c.name, c.amount, c.period, c.year, c.notes, c.id]
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteCost(id: number): Promise<void> {
|
||||
const d = await getDb();
|
||||
await d.execute("DELETE FROM costs WHERE id = $1", [id]);
|
||||
}
|
||||
|
||||
/** Suma kompletów pościeli (osób × pobyt) dla pobytów rozpoczętych w danym roku. */
|
||||
export async function linenSetsForYear(year: number): Promise<number> {
|
||||
const d = await getDb();
|
||||
const rows = await d.select<{ sets: number | null }[]>(
|
||||
"SELECT SUM(COALESCE(num_guests, 1)) AS sets FROM reservations WHERE arrival >= $1 AND arrival < $2",
|
||||
[`${year}-01-01`, `${year + 1}-01-01`]
|
||||
);
|
||||
return rows[0]?.sets ?? 0;
|
||||
}
|
||||
|
||||
// --- Faktury ---
|
||||
|
||||
export async function listInvoices(): Promise<(Invoice & { gross: number })[]> {
|
||||
|
||||
37
src/lib/llama.ts
Normal file
37
src/lib/llama.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
/** Port wbudowanego llama-server — musi zgadzać się z PORT w src-tauri/src/llama.rs. */
|
||||
export const LLAMA_PORT = 8123;
|
||||
|
||||
export interface LlamaStatus {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export interface LlamaProgress {
|
||||
task: string;
|
||||
progress: number | null;
|
||||
}
|
||||
|
||||
export function llamaStatus(): Promise<LlamaStatus> {
|
||||
return invoke<LlamaStatus>("llama_status");
|
||||
}
|
||||
|
||||
/** Pobiera binarkę llama.cpp i model (~2,5 GB); postęp przez onLlamaProgress. */
|
||||
export function llamaDownload(): Promise<void> {
|
||||
return invoke("llama_download");
|
||||
}
|
||||
|
||||
/** Startuje serwer (GPU, potem fallback CPU) i czeka aż będzie gotowy. */
|
||||
export function llamaStart(): Promise<void> {
|
||||
return invoke("llama_start");
|
||||
}
|
||||
|
||||
export function llamaStop(): Promise<void> {
|
||||
return invoke("llama_stop");
|
||||
}
|
||||
|
||||
export function onLlamaProgress(handler: (p: LlamaProgress) => void): Promise<UnlistenFn> {
|
||||
return listen<LlamaProgress>("llama-progress", (event) => handler(event.payload));
|
||||
}
|
||||
@@ -47,6 +47,8 @@ export type ReservationDialogParams =
|
||||
|
||||
// --- Faktury ---
|
||||
|
||||
export type AiProvider = "anthropic" | "openai" | "ollama" | "lmstudio" | "local";
|
||||
|
||||
export interface Settings {
|
||||
id: number;
|
||||
company_name: string;
|
||||
@@ -61,8 +63,33 @@ export interface Settings {
|
||||
default_vat_rate: number;
|
||||
/** Cena energii zł/kWh do rozliczania liczników */
|
||||
energy_price: number | null;
|
||||
/** Sezon działalności: miesiące 1–12 włącznie */
|
||||
season_start_month: number;
|
||||
season_end_month: number;
|
||||
/** Koszt prania/wymiany pościeli zł za komplet na osobę (na pobyt) */
|
||||
linen_cost: number | null;
|
||||
ai_provider: AiProvider;
|
||||
ai_api_key: string | null;
|
||||
ai_model: string | null;
|
||||
ai_base_url: string | null;
|
||||
}
|
||||
|
||||
// --- Koszty ---
|
||||
|
||||
export type CostPeriod = "year" | "month" | "season_month" | "once";
|
||||
|
||||
export interface Cost {
|
||||
id: number;
|
||||
name: string;
|
||||
amount: number;
|
||||
period: CostPeriod;
|
||||
/** Dla period = 'once': rok, którego koszt dotyczy */
|
||||
year: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export type CostInput = Omit<Cost, "id">;
|
||||
|
||||
export type PaymentMethod = "CASH" | "CARD" | "CARD_CASH" | "SEND";
|
||||
|
||||
export interface Invoice {
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
{ href: "/faktury", label: "Faktury" },
|
||||
{ href: "/goscie", label: "Goście" },
|
||||
{ href: "/sprzatanie", label: "Sprzątanie" },
|
||||
{ href: "/koszty", label: "Koszty" },
|
||||
{ href: "/raporty", label: "Raporty" },
|
||||
{ href: "/asystent", label: "Asystent" },
|
||||
{ href: "/ustawienia", label: "Ustawienia" }
|
||||
];
|
||||
</script>
|
||||
@@ -95,6 +97,9 @@
|
||||
:global(select:hover:not(:focus)) {
|
||||
border-color: var(--muted);
|
||||
}
|
||||
:global(input[type="checkbox"]) {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- Wspólny system przycisków --- */
|
||||
:global(.btn) {
|
||||
|
||||
457
src/routes/asystent/+page.svelte
Normal file
457
src/routes/asystent/+page.svelte
Normal file
@@ -0,0 +1,457 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import * as db from "$lib/db";
|
||||
import { chat, isAiConfigured, AI_PROVIDER_LABELS, type ChatMessage } from "$lib/ai";
|
||||
import {
|
||||
llamaDownload,
|
||||
llamaStart,
|
||||
llamaStatus,
|
||||
onLlamaProgress,
|
||||
type LlamaStatus
|
||||
} from "$lib/llama";
|
||||
import type { Settings } from "$lib/types";
|
||||
|
||||
const HISTORY_LIMIT = 12;
|
||||
const SUGGESTIONS = [
|
||||
"Jak zwiększyć przychód w tym sezonie minimalnym nakładem?",
|
||||
"Przeanalizuj obłożenie i wskaż największe luki w grafiku.",
|
||||
"Czy moje ceny za dobę są dobrze ustawione? Co bym zyskał podnosząc je o 10%?",
|
||||
"Które koszty najbardziej zjadają zysk i co z tym zrobić?"
|
||||
];
|
||||
|
||||
let settings = $state<Settings | null>(null);
|
||||
let messages = $state<ChatMessage[]>([]);
|
||||
let input = $state("");
|
||||
let streaming = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let abortController: AbortController | null = null;
|
||||
let scrollEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
// Stan wbudowanego modelu (llama.cpp)
|
||||
let llama = $state<LlamaStatus | null>(null);
|
||||
let llamaTask = $state<string | null>(null);
|
||||
let llamaProgress = $state<number | null>(null);
|
||||
let llamaBusy = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
void (async () => {
|
||||
try {
|
||||
settings = await db.getSettings();
|
||||
if (settings.ai_provider === "local") {
|
||||
llama = await llamaStatus();
|
||||
}
|
||||
unlisten = await onLlamaProgress((p) => {
|
||||
llamaTask = p.task;
|
||||
llamaProgress = p.progress;
|
||||
});
|
||||
} catch (e) {
|
||||
error = `Nie udało się wczytać ustawień: ${e}`;
|
||||
}
|
||||
})();
|
||||
return () => unlisten?.();
|
||||
});
|
||||
|
||||
let configured = $derived(settings != null && isAiConfigured(settings));
|
||||
let isLocal = $derived(settings?.ai_provider === "local");
|
||||
|
||||
async function downloadModel() {
|
||||
if (llamaBusy) return;
|
||||
llamaBusy = true;
|
||||
error = null;
|
||||
try {
|
||||
await llamaDownload();
|
||||
llama = await llamaStatus();
|
||||
// od razu ładujemy model — użytkownik nie musi klikać drugi raz
|
||||
await llamaStart();
|
||||
llama = await llamaStatus();
|
||||
llamaTask = null;
|
||||
} catch (e) {
|
||||
error = String(e instanceof Error ? e.message : e);
|
||||
} finally {
|
||||
llamaBusy = false;
|
||||
llamaProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function startModel() {
|
||||
if (llamaBusy) return;
|
||||
llamaBusy = true;
|
||||
error = null;
|
||||
try {
|
||||
await llamaStart();
|
||||
llama = await llamaStatus();
|
||||
llamaTask = null;
|
||||
} catch (e) {
|
||||
error = String(e instanceof Error ? e.message : e);
|
||||
} finally {
|
||||
llamaBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Dla wbudowanego modelu: dopilnuj, żeby serwer działał; rzuca przy porażce. */
|
||||
async function ensureLocalReady(): Promise<void> {
|
||||
llama = await llamaStatus();
|
||||
if (!llama.installed) {
|
||||
throw new Error("Najpierw pobierz wbudowany model (panel powyżej).");
|
||||
}
|
||||
if (!llama.running) {
|
||||
await llamaStart();
|
||||
llama = await llamaStatus();
|
||||
llamaTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollDown() {
|
||||
requestAnimationFrame(() => {
|
||||
scrollEl?.scrollTo({ top: scrollEl.scrollHeight });
|
||||
});
|
||||
}
|
||||
|
||||
async function send(text?: string) {
|
||||
const content = (text ?? input).trim();
|
||||
if (!content || streaming || !settings) return;
|
||||
input = "";
|
||||
error = null;
|
||||
messages.push({ role: "user", content });
|
||||
messages.push({ role: "assistant", content: "" });
|
||||
const assistantIndex = messages.length - 1;
|
||||
streaming = true;
|
||||
abortController = new AbortController();
|
||||
scrollDown();
|
||||
try {
|
||||
if (isLocal) {
|
||||
await ensureLocalReady();
|
||||
}
|
||||
const history = messages
|
||||
.slice(0, -1)
|
||||
.slice(-HISTORY_LIMIT)
|
||||
.map((m) => ({ role: m.role, content: m.content }));
|
||||
await chat(
|
||||
history,
|
||||
settings,
|
||||
(delta) => {
|
||||
messages[assistantIndex].content += delta;
|
||||
scrollDown();
|
||||
},
|
||||
abortController.signal
|
||||
);
|
||||
if (!messages[assistantIndex].content.trim()) {
|
||||
messages[assistantIndex].content = "(pusta odpowiedź — spróbuj ponownie)";
|
||||
}
|
||||
} catch (e) {
|
||||
const aborted = abortController?.signal.aborted;
|
||||
if (aborted) {
|
||||
if (!messages[assistantIndex].content.trim()) {
|
||||
messages.splice(assistantIndex - 1, 2);
|
||||
}
|
||||
} else if (messages[assistantIndex].content.trim()) {
|
||||
error = `Odpowiedź przerwana: ${e}`;
|
||||
} else {
|
||||
messages.splice(assistantIndex - 1, 2);
|
||||
error = String(e instanceof Error ? e.message : e);
|
||||
}
|
||||
} finally {
|
||||
streaming = false;
|
||||
abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
abortController?.abort();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (streaming) stop();
|
||||
messages = [];
|
||||
error = null;
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Słoneczko — asystent</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<header class="toolbar">
|
||||
<h1>Asystent</h1>
|
||||
{#if settings}
|
||||
<span class="provider">{AI_PROVIDER_LABELS[settings.ai_provider]}</span>
|
||||
{/if}
|
||||
<span class="spacer"></span>
|
||||
{#if messages.length > 0}
|
||||
<button type="button" class="btn" onclick={reset}>Nowa rozmowa</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if settings && !configured}
|
||||
<div class="empty">
|
||||
<p>Asystent nie jest jeszcze skonfigurowany.</p>
|
||||
<p class="hint">
|
||||
Wybierz dostawcę AI i podaj klucz API (albo wskaż lokalny serwer Ollama/LM Studio).
|
||||
</p>
|
||||
<a class="btn primary" href="/ustawienia">Przejdź do ustawień</a>
|
||||
</div>
|
||||
{:else if settings}
|
||||
{#if isLocal && llama}
|
||||
<div class="local-panel">
|
||||
{#if !llama.installed}
|
||||
<span>Wbudowany model nie jest jeszcze pobrany (Qwen3 4B, działa offline).</span>
|
||||
<button type="button" class="btn primary" onclick={downloadModel} disabled={llamaBusy}>
|
||||
Pobierz model (~2,5 GB)
|
||||
</button>
|
||||
{:else if llama.running}
|
||||
<span class="ok-chip">● Model działa</span>
|
||||
<span class="muted">Qwen3 4B — lokalnie, bez wysyłania danych</span>
|
||||
{:else}
|
||||
<span>Model zainstalowany.</span>
|
||||
<button type="button" class="btn" onclick={startModel} disabled={llamaBusy}>
|
||||
Uruchom teraz
|
||||
</button>
|
||||
<span class="muted">uruchomi się też sam przy pierwszej wiadomości</span>
|
||||
{/if}
|
||||
{#if llamaTask}
|
||||
<div class="task">
|
||||
<span>{llamaTask}</span>
|
||||
{#if llamaProgress != null}
|
||||
<div class="bar">
|
||||
<div class="fill" style="width: {Math.round(llamaProgress * 100)}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="chat" bind:this={scrollEl}>
|
||||
{#if messages.length === 0}
|
||||
<div class="welcome">
|
||||
<p>
|
||||
Znam Twój grafik, cennik, obłożenie i koszty — przy każdym pytaniu dostaję ich aktualny
|
||||
obraz. Zapytaj np.:
|
||||
</p>
|
||||
<div class="suggestions">
|
||||
{#each SUGGESTIONS as s (s)}
|
||||
<button type="button" class="suggestion" onclick={() => void send(s)}>{s}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#each messages as message, i (i)}
|
||||
<div class="bubble {message.role}">
|
||||
{#if message.content}
|
||||
{message.content}
|
||||
{:else if streaming && i === messages.length - 1}
|
||||
<span class="typing">Myślę…</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
class="composer"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
rows="2"
|
||||
bind:value={input}
|
||||
{onkeydown}
|
||||
placeholder="Zapytaj o obłożenie, ceny, koszty… (Enter wysyła, Shift+Enter to nowa linia)"
|
||||
disabled={streaming}
|
||||
></textarea>
|
||||
{#if streaming}
|
||||
<button type="button" class="btn danger" onclick={stop}>Stop</button>
|
||||
{:else}
|
||||
<button type="submit" class="btn primary" disabled={!input.trim()}>Wyślij</button>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 46px);
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.provider {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 60px 20px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.empty .btn {
|
||||
text-decoration: none;
|
||||
}
|
||||
.local-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ok-chip {
|
||||
color: var(--ok);
|
||||
font-weight: 600;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.task {
|
||||
flex-basis: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 3px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
.chat {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.welcome {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.suggestions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.suggestion {
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.suggestion:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.bubble {
|
||||
max-width: 85%;
|
||||
padding: 9px 13px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.bubble.user {
|
||||
align-self: flex-end;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.bubble.assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.typing {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.error {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
color: var(--danger);
|
||||
}
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
textarea {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
padding: 9px 12px;
|
||||
resize: none;
|
||||
}
|
||||
textarea:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
371
src/routes/koszty/+page.svelte
Normal file
371
src/routes/koszty/+page.svelte
Normal file
@@ -0,0 +1,371 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import * as db from "$lib/db";
|
||||
import { isoDate, seasonMonths } from "$lib/dates";
|
||||
import { formatMoney, round2 } from "$lib/money";
|
||||
import { COST_PERIOD_LABELS, costPerYear, totalCostsForYear } from "$lib/costs";
|
||||
import type { Cost, CostPeriod, RevenueStats, Settings } from "$lib/types";
|
||||
|
||||
let year = $state(new Date().getFullYear());
|
||||
let settings = $state<Settings | null>(null);
|
||||
let costs = $state<Cost[]>([]);
|
||||
let yearStats = $state<RevenueStats | null>(null);
|
||||
let linenSets = $state(0);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let newCost = $state<{ name: string; amount: number | null; period: CostPeriod; year: number | null }>({
|
||||
name: "",
|
||||
amount: null,
|
||||
period: "year",
|
||||
year: null
|
||||
});
|
||||
|
||||
let seasonMonthCount = $derived(
|
||||
settings ? seasonMonths(settings.season_start_month, settings.season_end_month).length : 12
|
||||
);
|
||||
let fixedCosts = $derived(totalCostsForYear(costs, year, seasonMonthCount));
|
||||
let linenTotal = $derived(round2(linenSets * (settings?.linen_cost ?? 0)));
|
||||
let totalCosts = $derived(round2(fixedCosts + linenTotal));
|
||||
let profitPotential = $derived(
|
||||
yearStats ? round2(yearStats.revenue_total - totalCosts) : null
|
||||
);
|
||||
let profitConfirmed = $derived(
|
||||
yearStats ? round2(yearStats.revenue_confirmed - totalCosts) : null
|
||||
);
|
||||
|
||||
async function reload() {
|
||||
error = null;
|
||||
try {
|
||||
[settings, costs, yearStats, linenSets] = await Promise.all([
|
||||
db.getSettings(),
|
||||
db.listCosts(),
|
||||
db.revenueStats(isoDate(year, 0, 1), isoDate(year + 1, 0, 1)),
|
||||
db.linenSetsForYear(year)
|
||||
]);
|
||||
} catch (e) {
|
||||
error = `Nie udało się wczytać danych: ${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void reload();
|
||||
});
|
||||
|
||||
function changeYear(delta: number) {
|
||||
year += delta;
|
||||
void reload();
|
||||
}
|
||||
|
||||
async function saveLinen() {
|
||||
if (!settings) return;
|
||||
try {
|
||||
await db.updateSettings({
|
||||
...settings,
|
||||
linen_cost:
|
||||
settings.linen_cost == null || Number.isNaN(settings.linen_cost)
|
||||
? null
|
||||
: settings.linen_cost
|
||||
});
|
||||
} catch (e) {
|
||||
error = `Nie udało się zapisać: ${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function addCost(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
error = null;
|
||||
if (!newCost.name.trim() || newCost.amount == null || Number.isNaN(newCost.amount)) return;
|
||||
try {
|
||||
await db.createCost({
|
||||
name: newCost.name.trim(),
|
||||
amount: newCost.amount,
|
||||
period: newCost.period,
|
||||
year: newCost.period === "once" ? (newCost.year ?? year) : null,
|
||||
notes: null
|
||||
});
|
||||
newCost = { name: "", amount: null, period: "year", year: null };
|
||||
await reload();
|
||||
} catch (e) {
|
||||
error = `Nie udało się dodać kosztu: ${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistCost(cost: Cost) {
|
||||
if (!cost.name.trim() || Number.isNaN(cost.amount)) return;
|
||||
error = null;
|
||||
try {
|
||||
await db.updateCost({
|
||||
...cost,
|
||||
name: cost.name.trim(),
|
||||
year: cost.period === "once" ? (cost.year ?? year) : null
|
||||
});
|
||||
await reload();
|
||||
} catch (e) {
|
||||
error = `Nie udało się zapisać kosztu: ${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCost(cost: Cost) {
|
||||
if (!confirm(`Usunąć koszt „${cost.name}"?`)) return;
|
||||
try {
|
||||
await db.deleteCost(cost.id);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
error = `Nie udało się usunąć kosztu: ${e}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Słoneczko — koszty</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<header class="toolbar">
|
||||
<h1>Koszty</h1>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="btn icon" onclick={() => changeYear(-1)} aria-label="Poprzedni rok">‹</button>
|
||||
<span class="year">{year}</span>
|
||||
<button type="button" class="btn icon" onclick={() => changeYear(1)} aria-label="Następny rok">›</button>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if settings && yearStats}
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<span class="label">Przychód potwierdzony</span>
|
||||
<span class="value">{formatMoney(yearStats.revenue_confirmed)} zł</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="label">Koszty razem</span>
|
||||
<span class="value">{formatMoney(totalCosts)} zł</span>
|
||||
</div>
|
||||
<div class="card" class:neg={profitConfirmed != null && profitConfirmed < 0}>
|
||||
<span class="label">Zysk (potwierdzony − koszty)</span>
|
||||
<span class="value">{formatMoney(profitConfirmed ?? 0)} zł</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="label">Zysk przy pełnym potencjale</span>
|
||||
<span class="value">{formatMoney(profitPotential ?? 0)} zł</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Pościel</h2>
|
||||
<div class="linen">
|
||||
<label>
|
||||
Koszt kompletu na osobę (zł)
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
bind:value={settings.linen_cost}
|
||||
onchange={saveLinen}
|
||||
/>
|
||||
</label>
|
||||
<p class="calc">
|
||||
W {year}: {linenSets} kompletów (osoby × pobyty)
|
||||
{#if settings.linen_cost != null}
|
||||
× {formatMoney(settings.linen_cost)} zł = <strong>{formatMoney(linenTotal)} zł</strong>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Koszty stałe</h2>
|
||||
{#if costs.length > 0}
|
||||
<div class="list">
|
||||
<span class="lh">Nazwa</span>
|
||||
<span class="lh">Kwota (zł)</span>
|
||||
<span class="lh">Okres</span>
|
||||
<span class="lh">Rok</span>
|
||||
<span class="lh num">W {year}</span>
|
||||
<span class="lh"></span>
|
||||
{#each costs as cost (cost.id)}
|
||||
<input type="text" bind:value={cost.name} onchange={() => persistCost(cost)} aria-label="Nazwa kosztu" />
|
||||
<input type="number" min="0" step="0.01" bind:value={cost.amount} onchange={() => persistCost(cost)} aria-label="Kwota" />
|
||||
<select bind:value={cost.period} onchange={() => persistCost(cost)} aria-label="Okres">
|
||||
{#each Object.entries(COST_PERIOD_LABELS) as [value, label] (value)}
|
||||
<option {value}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if cost.period === "once"}
|
||||
<input type="number" bind:value={cost.year} onchange={() => persistCost(cost)} aria-label="Rok kosztu" />
|
||||
{:else}
|
||||
<span class="muted">—</span>
|
||||
{/if}
|
||||
<span class="num">{formatMoney(costPerYear(cost, year, seasonMonthCount))} zł</span>
|
||||
<button type="button" class="btn sm danger" onclick={() => removeCost(cost)}>Usuń</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="sum">Koszty stałe razem: <strong>{formatMoney(fixedCosts)} zł</strong></p>
|
||||
{:else}
|
||||
<p class="hint">
|
||||
Nie ma jeszcze żadnych kosztów — dodaj np. wodę, śmieci, ubezpieczenie, podatek od
|
||||
nieruchomości czy naprawy.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<form class="add" onsubmit={addCost}>
|
||||
<input type="text" bind:value={newCost.name} placeholder="np. Ubezpieczenie" required />
|
||||
<input type="number" min="0" step="0.01" bind:value={newCost.amount} placeholder="zł" required />
|
||||
<select bind:value={newCost.period}>
|
||||
{#each Object.entries(COST_PERIOD_LABELS) as [value, label] (value)}
|
||||
<option {value}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if newCost.period === "once"}
|
||||
<input type="number" bind:value={newCost.year} placeholder={String(year)} aria-label="Rok kosztu" />
|
||||
{:else}
|
||||
<span class="muted">—</span>
|
||||
{/if}
|
||||
<button type="submit" class="btn">Dodaj</button>
|
||||
</form>
|
||||
<p class="hint">
|
||||
„Miesięcznie w sezonie" mnoży kwotę przez {seasonMonthCount}
|
||||
{seasonMonthCount === 1 ? "miesiąc" : seasonMonthCount < 5 ? "miesiące" : "miesięcy"} sezonu.
|
||||
Jednorazowe koszty (np. naprawa dachu) przypisz do konkretnego roku.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
padding: 14px 16px;
|
||||
max-width: 860px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.year {
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.card .label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.card .value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.card.neg .value {
|
||||
color: var(--danger);
|
||||
}
|
||||
section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.linen {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.linen label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.calc {
|
||||
margin: 0 0 6px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.calc strong {
|
||||
color: var(--text);
|
||||
}
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 7px 9px;
|
||||
min-width: 0;
|
||||
}
|
||||
.list,
|
||||
.add {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 100px 170px 80px 110px auto;
|
||||
gap: 6px 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.add {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.lh {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
.sum {
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
}
|
||||
.sum strong {
|
||||
color: var(--text);
|
||||
}
|
||||
.hint {
|
||||
margin: 10px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -1,21 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import * as db from "$lib/db";
|
||||
import { daysInMonth, isoDate, MONTH_NAMES } from "$lib/dates";
|
||||
import { daysInMonth, daysInSeason, isoDate, MONTH_NAMES, seasonMonths } from "$lib/dates";
|
||||
import { formatMoney } from "$lib/money";
|
||||
import { csvNumber, saveCsv, toCsv } from "$lib/csv";
|
||||
import type { Cabin, CabinReport, RevenueStats } from "$lib/types";
|
||||
import type { Cabin, CabinReport, RevenueStats, Settings } from "$lib/types";
|
||||
|
||||
let year = $state(new Date().getFullYear());
|
||||
let cabins = $state<Cabin[]>([]);
|
||||
let settings = $state<Settings | null>(null);
|
||||
let yearStats = $state<RevenueStats | null>(null);
|
||||
let cabinRows = $state<CabinReport[]>([]);
|
||||
let monthRows = $state<RevenueStats[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let isLeap = $derived(new Date(year, 1, 29).getDate() === 29);
|
||||
let daysInYear = $derived(isLeap ? 366 : 365);
|
||||
let seasonMonthList = $derived(
|
||||
settings ? seasonMonths(settings.season_start_month, settings.season_end_month) : []
|
||||
);
|
||||
/** Mianownik obłożenia: dni sezonu (nie całego roku). */
|
||||
let seasonDays = $derived(
|
||||
settings ? daysInSeason(year, settings.season_start_month, settings.season_end_month) : 365
|
||||
);
|
||||
let seasonLabel = $derived(
|
||||
settings
|
||||
? `${MONTH_NAMES[settings.season_start_month - 1].toLowerCase()}–${MONTH_NAMES[settings.season_end_month - 1].toLowerCase()}`
|
||||
: ""
|
||||
);
|
||||
|
||||
function inSeason(monthIndex: number): boolean {
|
||||
return seasonMonthList.includes(monthIndex + 1);
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
loading = true;
|
||||
@@ -27,12 +42,14 @@
|
||||
from: isoDate(year, m, 1),
|
||||
to: m === 11 ? isoDate(year + 1, 0, 1) : isoDate(year, m + 1, 1)
|
||||
}));
|
||||
const [c, ys, cr, ...months] = await Promise.all([
|
||||
const [s, c, ys, cr, ...months] = await Promise.all([
|
||||
db.getSettings(),
|
||||
db.listCabins(),
|
||||
db.revenueStats(from, to),
|
||||
db.cabinReports(from, to),
|
||||
...monthRanges.map((r) => db.revenueStats(r.from, r.to))
|
||||
]);
|
||||
settings = s;
|
||||
cabins = c;
|
||||
yearStats = ys;
|
||||
cabinRows = cr;
|
||||
@@ -70,15 +87,18 @@
|
||||
error = null;
|
||||
exportInfo = null;
|
||||
try {
|
||||
const rows: (string | number | null)[][] = [[`Raport ${year}`], []];
|
||||
const rows: (string | number | null)[][] = [
|
||||
[`Raport ${year} (sezon ${seasonLabel}, ${seasonDays} dni)`],
|
||||
[]
|
||||
];
|
||||
rows.push(["Wg domków"]);
|
||||
rows.push(["Domek", "Doby", "Obłożenie %", "Przychód", "Przychód potwierdzony"]);
|
||||
rows.push(["Domek", "Doby", "Obłożenie % (sezon)", "Przychód", "Przychód potwierdzony"]);
|
||||
for (const cabin of cabins) {
|
||||
const row = cabinRows.find((r) => r.cabin_id === cabin.id);
|
||||
rows.push([
|
||||
cabin.name,
|
||||
Math.round(row?.nights_total ?? 0),
|
||||
Math.round(((row?.nights_total ?? 0) / daysInYear) * 100),
|
||||
seasonDays > 0 ? Math.round(((row?.nights_total ?? 0) / seasonDays) * 100) : 0,
|
||||
csvNumber(row?.revenue_total ?? 0),
|
||||
csvNumber(row?.revenue_confirmed ?? 0)
|
||||
]);
|
||||
@@ -134,8 +154,8 @@
|
||||
<span class="value">{Math.round(yearStats.nights_total)}</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="label">Obłożenie</span>
|
||||
<span class="value">{occupancy(yearStats.nights_total, cabins.length * daysInYear)}</span>
|
||||
<span class="label">Obłożenie (sezon {seasonLabel})</span>
|
||||
<span class="value">{occupancy(yearStats.nights_total, cabins.length * seasonDays)}</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="label">Przychód potencjalny</span>
|
||||
@@ -190,7 +210,7 @@
|
||||
<tr>
|
||||
<td>{cabin.name}</td>
|
||||
<td class="num">{row ? Math.round(row.nights_total) : 0}</td>
|
||||
<td class="num">{occupancy(row?.nights_total ?? 0, daysInYear)}</td>
|
||||
<td class="num">{occupancy(row?.nights_total ?? 0, seasonDays)}</td>
|
||||
<td class="num">{formatMoney(row?.revenue_total ?? 0)} zł</td>
|
||||
</tr>
|
||||
{/each}
|
||||
@@ -211,8 +231,8 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each monthRows as m, i (i)}
|
||||
<tr>
|
||||
<td>{MONTH_NAMES[i]}</td>
|
||||
<tr class:off-season={!inSeason(i)}>
|
||||
<td>{MONTH_NAMES[i]}{#if !inSeason(i)}<span class="off"> · poza sezonem</span>{/if}</td>
|
||||
<td class="num">{Math.round(m.nights_total)}</td>
|
||||
<td class="num">{occupancy(m.nights_total, cabins.length * daysInMonth(year, i))}</td>
|
||||
<td class="num">{formatMoney(m.revenue_total)} zł</td>
|
||||
@@ -220,6 +240,7 @@
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="hint">Obłożenie roczne i per domek liczone dla sezonu ({seasonLabel}, {seasonDays} dni).</p>
|
||||
</section>
|
||||
</div>
|
||||
{:else if loading}
|
||||
@@ -367,4 +388,11 @@
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
tr.off-season td {
|
||||
color: var(--muted);
|
||||
}
|
||||
.off {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import * as db from "$lib/db";
|
||||
import { addDays, formatShort, todayISO } from "$lib/dates";
|
||||
import { addDays, formatShort, todayISO, weekdayShort } from "$lib/dates";
|
||||
import type { Cabin, Reservation } from "$lib/types";
|
||||
|
||||
const DAYS_AHEAD = 14;
|
||||
@@ -20,6 +20,7 @@
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const today = todayISO();
|
||||
const tomorrow = addDays(today, 1);
|
||||
|
||||
async function reload() {
|
||||
loading = true;
|
||||
@@ -68,10 +69,10 @@
|
||||
return cabins.find((c) => c.id === id)?.name ?? `Domek #${id}`;
|
||||
}
|
||||
|
||||
function dayLabel(iso: string): string {
|
||||
const weekday = new Date(iso + "T00:00:00").toLocaleDateString("pl-PL", { weekday: "long" });
|
||||
const prefix = iso === today ? "Dziś — " : iso === addDays(today, 1) ? "Jutro — " : "";
|
||||
return `${prefix}${weekday}, ${formatShort(iso)}`;
|
||||
function dayTag(iso: string): string {
|
||||
if (iso === today) return "dziś";
|
||||
if (iso === tomorrow) return "jutro";
|
||||
return weekdayShort(iso);
|
||||
}
|
||||
|
||||
function hasSameDayArrival(r: Reservation): boolean {
|
||||
@@ -110,7 +111,7 @@
|
||||
<main>
|
||||
<header class="toolbar">
|
||||
<h1>Sprzątanie</h1>
|
||||
<span class="hint">wyjazdy z najbliższych {DAYS_AHEAD} dni</span>
|
||||
<span class="hint">wyjazdy i przygotowania z najbliższych {DAYS_AHEAD} dni</span>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
@@ -118,8 +119,12 @@
|
||||
{/if}
|
||||
|
||||
{#if overdue.length > 0}
|
||||
<section class="overdue">
|
||||
<h2>Zaległe</h2>
|
||||
<div class="day overdue">
|
||||
<div class="datebox">
|
||||
<span class="num">!</span>
|
||||
<span class="wd">zaległe</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
{#each overdue as r (r.id)}
|
||||
<label class="task">
|
||||
<input
|
||||
@@ -127,11 +132,15 @@
|
||||
checked={r.cleaned}
|
||||
onchange={(e) => toggleCleaned(r, e.currentTarget.checked)}
|
||||
/>
|
||||
<span class="info">
|
||||
<span class="cabin">{cabinName(r.cabin_id)}</span>
|
||||
<span class="muted">wyjazd {formatShort(r.departure)} · {r.guest_name}</span>
|
||||
<span class="sub">wyjazd {formatShort(r.departure)} · {r.guest_name}</span>
|
||||
</span>
|
||||
<span class="badge overdue-badge">do nadrobienia</span>
|
||||
</label>
|
||||
{/each}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !loading && byDay.length === 0 && overdue.length === 0}
|
||||
@@ -141,49 +150,58 @@
|
||||
{/if}
|
||||
|
||||
{#each byDay as [day, tasks] (day)}
|
||||
<section class:today={day === today}>
|
||||
<h2>{dayLabel(day)}</h2>
|
||||
<div class="day" class:today={day === today}>
|
||||
<div class="datebox" title={day}>
|
||||
<span class="num">{Number(day.slice(8))}</span>
|
||||
<span class="wd">{dayTag(day)}</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
{#each tasks as task (`${task.kind}-${task.r.id}`)}
|
||||
{#if task.kind === "cleaning"}
|
||||
<label class="task">
|
||||
<label class="task" class:done={task.r.cleaned}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.r.cleaned}
|
||||
onchange={(e) => toggleCleaned(task.r, e.currentTarget.checked)}
|
||||
/>
|
||||
<span class="cabin" class:done={task.r.cleaned}>{cabinName(task.r.cabin_id)}</span>
|
||||
<span class="muted">po pobycie: {task.r.guest_name}</span>
|
||||
<span class="info">
|
||||
<span class="cabin">{cabinName(task.r.cabin_id)}</span>
|
||||
<span class="sub">posprzątać po pobycie: {task.r.guest_name}</span>
|
||||
</span>
|
||||
{#if hasSameDayArrival(task.r)}
|
||||
<span class="urgent">przyjazd tego samego dnia</span>
|
||||
<span class="badge urgent">przyjazd tego samego dnia</span>
|
||||
{/if}
|
||||
</label>
|
||||
{:else}
|
||||
<label class="task">
|
||||
<label class="task" class:done={task.r.prepared}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.r.prepared}
|
||||
onchange={(e) => togglePrepared(task.r, e.currentTarget.checked)}
|
||||
/>
|
||||
<span class="cabin" class:done={task.r.prepared}>{cabinName(task.r.cabin_id)}</span>
|
||||
<span class="muted">przygotować — przyjeżdża: {task.r.guest_name}</span>
|
||||
<span class="prep">pierwszy gość w domku</span>
|
||||
<span class="info">
|
||||
<span class="cabin">{cabinName(task.r.cabin_id)}</span>
|
||||
<span class="sub">przygotować — przyjeżdża: {task.r.guest_name}</span>
|
||||
</span>
|
||||
<span class="badge prep">pierwszy gość</span>
|
||||
</label>
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
padding: 14px 16px;
|
||||
max-width: 640px;
|
||||
max-width: 680px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
@@ -203,79 +221,128 @@
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
|
||||
.day {
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
section.today {
|
||||
.datebox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 56px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 0.05);
|
||||
}
|
||||
.datebox .num {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.datebox .wd {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.day.today .datebox {
|
||||
background: var(--today);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
section.overdue {
|
||||
.day.today .datebox .num,
|
||||
.day.today .datebox .wd {
|
||||
color: var(--accent);
|
||||
}
|
||||
.day.overdue .datebox {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
section.overdue h2 {
|
||||
.day.overdue .datebox .num,
|
||||
.day.overdue .datebox .wd {
|
||||
color: var(--danger);
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
.day.overdue .card {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
h2::first-letter {
|
||||
text-transform: uppercase;
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 0.05);
|
||||
}
|
||||
.day.today .card {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.task {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 14px;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.task + .task {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.task:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
.task input {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: none;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.cabin {
|
||||
font-weight: 600;
|
||||
min-width: 110px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.cabin.done {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.urgent {
|
||||
margin-left: auto;
|
||||
.task.done {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.task.done .cabin {
|
||||
text-decoration: line-through;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 20px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
.badge.urgent {
|
||||
color: var(--warn-text);
|
||||
background: var(--bar-option-bg);
|
||||
border-radius: 5px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.prep {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
.badge.prep {
|
||||
color: var(--bar-confirmed-text);
|
||||
background: var(--bar-confirmed-bg);
|
||||
border-radius: 5px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.badge.overdue-badge {
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 12%, var(--surface));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import { onMount } from "svelte";
|
||||
import { save as saveDialog } from "@tauri-apps/plugin-dialog";
|
||||
import * as db from "$lib/db";
|
||||
import { todayISO } from "$lib/dates";
|
||||
import type { Cabin, PriceSeason, Settings } from "$lib/types";
|
||||
import { MONTH_NAMES, todayISO } from "$lib/dates";
|
||||
import { AI_PROVIDER_LABELS, DEFAULT_BASE_URLS, DEFAULT_MODELS, providerNeedsKey } from "$lib/ai";
|
||||
import type { AiProvider, Cabin, PriceSeason, Settings } from "$lib/types";
|
||||
|
||||
let settings = $state<Settings | null>(null);
|
||||
let cabins = $state<Cabin[]>([]);
|
||||
@@ -116,7 +117,10 @@
|
||||
energy_price:
|
||||
settings.energy_price == null || Number.isNaN(settings.energy_price)
|
||||
? null
|
||||
: settings.energy_price
|
||||
: settings.energy_price,
|
||||
ai_api_key: settings.ai_api_key?.trim() || null,
|
||||
ai_model: settings.ai_model?.trim() || null,
|
||||
ai_base_url: settings.ai_base_url?.trim() || null
|
||||
});
|
||||
savedFlash = true;
|
||||
setTimeout(() => (savedFlash = false), 2500);
|
||||
@@ -201,6 +205,81 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Sezon działalności</h2>
|
||||
<div class="grid2">
|
||||
<label>
|
||||
Od miesiąca
|
||||
<select bind:value={settings.season_start_month}>
|
||||
{#each MONTH_NAMES as name, i (i)}
|
||||
<option value={i + 1}>{name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Do miesiąca (włącznie)
|
||||
<select bind:value={settings.season_end_month}>
|
||||
{#each MONTH_NAMES as name, i (i)}
|
||||
<option value={i + 1}>{name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Obłożenie w raportach i podpowiedzi asystenta liczone są tylko dla miesięcy sezonu.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Asystent AI</h2>
|
||||
<div class="grid2">
|
||||
<label>
|
||||
Dostawca
|
||||
<select bind:value={settings.ai_provider}>
|
||||
{#each Object.entries(AI_PROVIDER_LABELS) as [value, label] (value)}
|
||||
<option {value}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if settings.ai_provider !== "local"}
|
||||
<label>
|
||||
Model
|
||||
<input
|
||||
type="text"
|
||||
bind:value={settings.ai_model}
|
||||
placeholder={DEFAULT_MODELS[settings.ai_provider]}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
{#if providerNeedsKey(settings.ai_provider)}
|
||||
<label class="wide">
|
||||
Klucz API
|
||||
<input type="password" bind:value={settings.ai_api_key} placeholder="sk-…" />
|
||||
</label>
|
||||
{:else if settings.ai_provider !== "local"}
|
||||
<label class="wide">
|
||||
Adres serwera
|
||||
<input
|
||||
type="text"
|
||||
bind:value={settings.ai_base_url}
|
||||
placeholder={DEFAULT_BASE_URLS[settings.ai_provider as AiProvider] ?? ""}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{#if settings.ai_provider === "local"}
|
||||
<p class="hint">
|
||||
Wbudowany model ({DEFAULT_MODELS.local}) działa w całości na tym komputerze — bez
|
||||
klucza API i internetu. Pliki (~2,5 GB) pobierzesz w zakładce „Asystent".
|
||||
</p>
|
||||
{:else}
|
||||
<p class="hint">
|
||||
Asystent w zakładce „Asystent" otrzymuje przy każdej rozmowie aktualne dane o obłożeniu,
|
||||
cenniku i kosztach. Klucz API jest przechowywany lokalnie w bazie aplikacji.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
{#if savedFlash}<span class="saved">Zapisano</span>{/if}
|
||||
<button type="submit" class="btn primary">Zapisz ustawienia</button>
|
||||
|
||||
Reference in New Issue
Block a user