mod crypto_pack; mod license; mod local_ai; mod phone; mod storage; use anyhow::Result; use axum::{ extract::{ConnectInfo, Path as AxumPath, Query, Request, State}, http::StatusCode, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{get, post}, Json, Router, }; use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use clap::{Parser, Subcommand}; use serde::Deserialize; use serde_json::{json, Value}; use std::{ collections::HashMap, net::SocketAddr, path::{Path, PathBuf}, process::{Command as ProcessCommand, Stdio}, sync::Arc, time::{Duration, Instant}, }; use tower_http::{cors::CorsLayer, services::ServeDir}; #[derive(Parser)] #[command(name = "jackailocald")] struct Cli { #[command(subcommand)] command: Command, } #[derive(Subcommand)] enum Command { Serve { #[arg(long, default_value = "config/jackailocal.windows.toml")] config: String, }, Hwscan, License { #[command(subcommand)] action: LicenseCommand, }, } #[derive(Subcommand)] enum LicenseCommand { Keygen { #[arg(long, default_value = "license-keys")] out_dir: String, }, Issue { #[arg(long)] key: String, #[arg(long, default_value = "license.json")] out: String, #[arg(long, default_value = "JackAILocal")] product: String, #[arg(long, default_value = "personal")] edition: String, #[arg(long)] customer_name: String, #[arg(long)] customer_email: Option, #[arg(long)] customer_company: Option, #[arg(long)] expires: Option, #[arg(long)] max_devices: Option, #[arg(long, value_delimiter = ',')] features: Vec, #[arg(long)] notes: Option, }, Verify { #[arg(long)] file: String, #[arg(long)] pubkey: String, }, } #[derive(Clone)] struct AppState { root: PathBuf, models: Value, client: reqwest::Client, started: Instant, runtime_config: phone::RuntimeConfig, backend_config: local_ai::BackendConfig, bind: SocketAddr, phone_token: Option, } #[derive(Debug, Deserialize)] struct SaveDocument { name: String, content: String, } #[derive(Debug, Deserialize)] struct AddModelRequest { id: String, label: String, } #[derive(Debug, Deserialize)] struct SettingsRequest { mode: Option, offline_lock: Option, tools_lock: Option, phone_access_enabled: Option, } #[derive(Debug, Deserialize)] struct BenchmarkRequest { model: Option, } #[derive(Debug, Deserialize)] struct CreateThreadRequest { title: Option, model: Option, } #[derive(Debug, Deserialize)] struct RenameThreadRequest { title: String, } #[derive(Debug, Deserialize)] struct ThreadMessageRequest { content: String, model: Option, } #[derive(Debug, Deserialize)] struct PhoneAccessRequest { enabled: bool, } #[derive(Debug, Deserialize)] struct InstallLicenseRequest { content: String, } #[derive(Debug, Deserialize)] struct AgentRecommendRequest { language: Option, package_goal: Option, hardware: Option, default_model: Option, candidate_models: Option, content_packs: Option, current_config: Option, customer_notes: Option, agent_model: Option, max_params_b: Option, agent_max_tokens: Option, agent_timeout_seconds: Option, } #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt().init(); let cli = Cli::parse(); match cli.command { Command::Serve { config } => serve(config).await, Command::Hwscan => { println!("{}", hwscan_json()); Ok(()) } Command::License { action } => run_license_command(action), } } fn run_license_command(action: LicenseCommand) -> Result<()> { match action { LicenseCommand::Keygen { out_dir } => { let (private_path, public_path) = license::keygen(Path::new(&out_dir))?; println!("private signing key : {}", private_path.display()); println!("public verify key : {}", public_path.display()); println!( "Ship the PUBLIC key as {} inside every package. Keep the private key offline and never distribute it.", license::PUBLIC_KEY_RELATIVE_PATH ); Ok(()) } LicenseCommand::Issue { key, out, product, edition, customer_name, customer_email, customer_company, expires, max_devices, features, notes, } => { let expires_at = expires.map(|text| parse_expiry(&text)).transpose()?; let payload = license::issue( Path::new(&key), Path::new(&out), &product, &edition, &customer_name, customer_email, customer_company, expires_at, max_devices, features, notes, )?; println!("issued license {} -> {}", payload.license_id, out); Ok(()) } LicenseCommand::Verify { file, pubkey } => { let status = license::verify_path(Path::new(&file), Path::new(&pubkey))?; println!("{}", serde_json::to_string_pretty(&status.to_json())?); if status.state == "licensed" { Ok(()) } else { anyhow::bail!("license state: {}", status.state) } } } } fn parse_expiry(text: &str) -> Result> { if let Ok(date_time) = chrono::DateTime::parse_from_rfc3339(text) { return Ok(date_time.with_timezone(&chrono::Utc)); } let date = chrono::NaiveDate::parse_from_str(text, "%Y-%m-%d") .map_err(|_| anyhow::anyhow!("expiry must be RFC3339 or YYYY-MM-DD: {text}"))?; Ok(chrono::DateTime::from_naive_utc_and_offset( date.and_hms_opt(23, 59, 59).unwrap(), chrono::Utc, )) } async fn serve(config: String) -> Result<()> { let root = std::env::current_dir()?; let models_path = root.join("config/model-catalog.json"); let models: Value = read_json(&models_path).unwrap_or_else(|_| json!({"profiles":[]})); let ui_dir = root.join("webui"); storage::ensure_workspace(&root)?; let runtime_config = phone::load_runtime_config(&root, &config)?; let backend_config = local_ai::BackendConfig::new( runtime_config.ollama_enabled, runtime_config.ollama_url.clone(), runtime_config.llama_cpp_enabled, runtime_config.llama_cpp_url.clone(), ); let addr = phone::effective_bind(&root, &runtime_config); let phone_token = if !addr.ip().is_loopback() { Some(phone::ensure_phone_token(&root)?) } else { phone::configured_token(&root) }; let state = Arc::new(AppState { root: root.clone(), models, client: reqwest::Client::new(), started: Instant::now(), runtime_config, backend_config, bind: addr, phone_token, }); let app = Router::new() .route("/health", get(health)) .route("/api/status", get(api_status)) .route("/api/features", get(api_features)) .route("/api/models", get(list_models)) .route("/api/models/add", post(add_model)) .route("/api/documents", get(list_documents).post(save_document)) .route("/api/threads", get(list_threads).post(create_thread)) .route( "/api/threads/:id", get(get_thread).patch(rename_thread).delete(delete_thread), ) .route("/api/threads/:id/messages", post(send_thread_message)) .route("/api/packs/export", post(export_pack)) .route("/api/packs/import", post(import_pack)) .route("/api/vision/analyze", post(analyze_image)) .route("/api/voice/status", get(voice_status)) .route("/api/voice/transcribe", post(transcribe_audio)) .route("/api/voice/synthesize", post(synthesize_speech)) .route("/api/phone", get(phone_status).post(set_phone_access)) .route("/api/runtime/restart", post(restart_runtime)) .route("/api/content-packs", get(content_packs)) .route("/api/field-manual", get(field_manual)) .route("/api/settings", get(get_settings).post(save_settings)) .route("/api/benchmark/run", post(run_benchmark)) .route("/api/agent/recommend", post(agent_recommend)) .route("/api/support/bundle", post(support_bundle)) .route("/api/license", get(license_status).post(install_license)) .route("/api/legal/eula", get(legal_eula)) .route("/v1/models", get(list_models)) .route("/v1/chat/completions", post(chat_completions)) .nest_service( "/", ServeDir::new(ui_dir).append_index_html_on_directories(true), ) .layer(CorsLayer::permissive()) .layer(middleware::from_fn_with_state( state.clone(), remote_api_auth, )) .with_state(state); let listener = tokio::net::TcpListener::bind(addr).await?; axum::serve( listener, app.into_make_service_with_connect_info::(), ) .await?; Ok(()) } async fn health(State(state): State>) -> impl IntoResponse { Json( json!({"product":"JackAILocal","status":"ok","offline":true,"bind":state.bind.to_string()}), ) } async fn api_status(State(state): State>) -> impl IntoResponse { let hardware = read_json(&state.root.join("diagnostics/hardware.json")) .unwrap_or_else(|_| serde_json::from_str(&hwscan_json()).unwrap_or(json!({}))); let backends = local_ai::backend_status(&state.client, &state.backend_config).await; let backend = if backends .pointer("/ollama/available") .and_then(|v| v.as_bool()) == Some(true) { "ollama" } else if backends .pointer("/llama_cpp/available") .and_then(|v| v.as_bool()) == Some(true) { "llama.cpp" } else { "unavailable" }; let mut phone_access = phone::status(&state.root, &state.runtime_config, state.bind); if let Some(object) = phone_access.as_object_mut() { object.insert("qr_svg".to_string(), Value::Null); } Json(json!({ "product":"JackAILocal", "status":"ok", "backend":backend, "backends":backends, "offline":true, "bind":state.bind.to_string(), "uptime_seconds":state.started.elapsed().as_secs(), "hardware":hardware, "phone_access":phone_access })) } async fn api_features(State(state): State>) -> impl IntoResponse { let backends = local_ai::backend_status(&state.client, &state.backend_config).await; let installed = local_ai::installed_ollama_models(&state.client, &state.backend_config).await; let catalog = local_ai::catalog_with_availability(&state.models, &installed, &state.root); let vision = catalog .get("models") .and_then(|v| v.as_array()) .into_iter() .flatten() .any(|model| { model.get("available").and_then(|v| v.as_bool()) == Some(true) && model .get("features") .and_then(|v| v.as_array()) .into_iter() .flatten() .any(|f| f.as_str() == Some("vision")) }); let voice = local_ai::voice_status(&state.root); let chat = backends .pointer("/ollama/available") .and_then(|v| v.as_bool()) == Some(true) || backends .pointer("/llama_cpp/available") .and_then(|v| v.as_bool()) == Some(true); Json(json!({ "chat": chat, "threads": true, "model_cookbook": true, "documents": true, "encrypted_import_export": true, "scout_vision": vision, "voice_stt": voice.pointer("/stt/available").and_then(|v| v.as_bool()).unwrap_or(false), "voice_tts": voice.pointer("/tts/available").and_then(|v| v.as_bool()).unwrap_or(false), "phone_access": state.runtime_config.phone_access_available, "field_manual": state.root.join("content/field-manual/cards.json").is_file(), "benchmark": true, "settings": true, "support_bundle": true, "llm_config_agent": chat, "offline_lock": true, "shell_tools": false, "email_calendar_tools": false, "lan_exposure": !state.bind.ip().is_loopback() })) } async fn list_models(State(state): State>) -> impl IntoResponse { let mut merged = state.models.clone(); if let Ok(user) = read_json(&state.root.join("config/user-models.json")) { if let (Some(base), Some(extra)) = ( merged.get_mut("models").and_then(|v| v.as_array_mut()), user.get("models").and_then(|v| v.as_array()), ) { for p in extra { base.push(p.clone()); } } } let installed = local_ai::installed_ollama_models(&state.client, &state.backend_config).await; Json(local_ai::catalog_with_availability( &merged, &installed, &state.root, )) } async fn add_model( State(state): State>, Json(req): Json, ) -> impl IntoResponse { if !valid_id(&req.id) || req.label.trim().is_empty() { return ( StatusCode::BAD_REQUEST, Json(json!({"ok":false,"error":"invalid id or label"})), ) .into_response(); } let installed = local_ai::installed_ollama_models(&state.client, &state.backend_config).await; if !installed.iter().any(|name| { name == &req.id || name.trim_end_matches(":latest") == req.id.trim_end_matches(":latest") }) { return (StatusCode::BAD_REQUEST, Json(json!({ "ok": false, "error": "model is not installed in the local Ollama store; install it with a builder before registering it" }))).into_response(); } let path = state.root.join("config/user-models.json"); let mut user = read_json(&path).unwrap_or_else(|_| json!({"models":[]})); let model = json!({"id":req.id,"label":req.label,"ollama":req.id,"min_ram_gb":8,"min_vram_gb":0,"features":["chat"],"allowed_backends":["ollama"]}); user["models"].as_array_mut().unwrap().push(model); if let Err(e) = write_json_pretty(&path, &user) { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"ok":false,"error":e.to_string()})), ) .into_response(); } Json(json!({"ok":true,"message":"installed local model registered"})).into_response() } async fn chat_completions( State(state): State>, Json(req): Json, ) -> impl IntoResponse { match route_chat(state, req).await { Ok(v) => (StatusCode::OK, Json(v)).into_response(), Err(e) => ( StatusCode::BAD_GATEWAY, Json(json!({"error": e.to_string()})), ) .into_response(), } } async fn route_chat(state: Arc, req: local_ai::ChatInput) -> Result { local_ai::chat( &state.client, &state.models, &state.root, &state.backend_config, req, ) .await } async fn list_documents(State(state): State>) -> impl IntoResponse { match storage::list_documents_with_content(&state.root) { Ok(documents) => Json(json!({"documents": documents})).into_response(), Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, error), } } async fn save_document( State(state): State>, Json(req): Json, ) -> impl IntoResponse { match storage::save_document(&state.root, &req.name, &req.content) { Ok(path) => { Json(json!({"ok":true,"saved":path.file_name().unwrap_or_default().to_string_lossy()})) .into_response() } Err(error) => error_response(StatusCode::BAD_REQUEST, error), } } async fn list_threads( State(state): State>, Query(query): Query>, ) -> impl IntoResponse { match storage::list_threads(&state.root, query.get("q").map(String::as_str)) { Ok(threads) => Json(json!({"threads": threads})).into_response(), Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, error), } } async fn create_thread( State(state): State>, Json(req): Json, ) -> impl IntoResponse { match storage::create_thread(&state.root, req.title.as_deref(), req.model) { Ok(thread) => (StatusCode::CREATED, Json(json!({"thread": thread}))).into_response(), Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, error), } } async fn get_thread( State(state): State>, AxumPath(id): AxumPath, ) -> impl IntoResponse { match storage::load_thread(&state.root, &id) { Ok(thread) => Json(json!({"thread": thread})).into_response(), Err(error) => error_response(StatusCode::NOT_FOUND, error), } } async fn rename_thread( State(state): State>, AxumPath(id): AxumPath, Json(req): Json, ) -> impl IntoResponse { match storage::rename_thread(&state.root, &id, &req.title) { Ok(thread) => Json(json!({"thread": thread})).into_response(), Err(error) => error_response(StatusCode::BAD_REQUEST, error), } } async fn delete_thread( State(state): State>, AxumPath(id): AxumPath, ) -> impl IntoResponse { match storage::delete_thread(&state.root, &id) { Ok(_) => Json(json!({"ok": true, "deleted": id})).into_response(), Err(error) => error_response(StatusCode::BAD_REQUEST, error), } } async fn send_thread_message( State(state): State>, AxumPath(id): AxumPath, Json(req): Json, ) -> impl IntoResponse { if req.content.trim().is_empty() { return error_response( StatusCode::BAD_REQUEST, anyhow::anyhow!("message cannot be empty"), ); } let mut thread = match storage::load_thread(&state.root, &id) { Ok(value) => value, Err(error) => return error_response(StatusCode::NOT_FOUND, error), }; if thread.messages.is_empty() && thread.title == "New conversation" { thread.title = req.content.trim().chars().take(72).collect(); } let model = req.model.or_else(|| thread.model.clone()); thread.model = model.clone(); storage::append_message(&mut thread, "user", json!(req.content)); if let Err(error) = storage::save_thread(&state.root, &thread) { return error_response(StatusCode::INTERNAL_SERVER_ERROR, error); } let messages = thread .messages .iter() .map(|message| local_ai::AiMessage { role: message.role.clone(), content: message.content.clone(), }) .collect(); let input = local_ai::ChatInput { model, messages, temperature: Some(0.4), max_tokens: Some(1024), think: None, }; let response = match route_chat(state.clone(), input).await { Ok(value) => value, Err(error) => return error_response(StatusCode::BAD_GATEWAY, error), }; let content = response .pointer("/choices/0/message/content") .cloned() .unwrap_or_else(|| json!("")); storage::append_message(&mut thread, "assistant", content); if let Err(error) = storage::save_thread(&state.root, &thread) { return error_response(StatusCode::INTERNAL_SERVER_ERROR, error); } Json(json!({"thread": thread, "response": response})).into_response() } async fn export_pack( State(state): State>, Json(req): Json, ) -> impl IntoResponse { match crypto_pack::export_pack(&state.root, &req) { Ok(bytes) => Json(json!({ "ok": true, "filename": format!("jackailocal-backup-{}.jackaipack", chrono::Utc::now().format("%Y%m%d-%H%M%S")), "mime_type": "application/vnd.jackailocal.encrypted-pack+json", "data_base64": B64.encode(bytes), "encryption": "AES-256-GCM", "key_storage": "not stored" })).into_response(), Err(error) => error_response(StatusCode::BAD_REQUEST, error), } } async fn import_pack( State(state): State>, Json(req): Json, ) -> impl IntoResponse { match crypto_pack::import_pack(&state.root, &req) { Ok(result) => Json(json!({"ok": true, "result": result})).into_response(), Err(error) => error_response(StatusCode::BAD_REQUEST, error), } } async fn analyze_image( State(state): State>, Json(req): Json, ) -> impl IntoResponse { match local_ai::analyze_image( &state.client, &state.models, &state.root, &state.backend_config, req, ) .await { Ok(result) => Json(result).into_response(), Err(error) => error_response(StatusCode::BAD_GATEWAY, error), } } async fn voice_status(State(state): State>) -> impl IntoResponse { Json(local_ai::voice_status(&state.root)) } async fn transcribe_audio( State(state): State>, Json(req): Json, ) -> impl IntoResponse { match local_ai::transcribe(&state.root, &req) { Ok(result) => Json(result).into_response(), Err(error) => error_response(StatusCode::SERVICE_UNAVAILABLE, error), } } async fn synthesize_speech( State(state): State>, Json(req): Json, ) -> impl IntoResponse { match local_ai::synthesize(&state.root, &req) { Ok(result) => Json(result).into_response(), Err(error) => error_response(StatusCode::SERVICE_UNAVAILABLE, error), } } async fn license_status(State(state): State>) -> impl IntoResponse { Json(license::status(&state.root).to_json()) } async fn install_license( State(state): State>, Json(req): Json, ) -> impl IntoResponse { match license::install(&state.root, &req.content) { Ok(status) => Json(status.to_json()).into_response(), Err(err) => ( StatusCode::BAD_REQUEST, Json(json!({"error": err.to_string()})), ) .into_response(), } } async fn legal_eula( State(state): State>, Query(query): Query>, ) -> impl IntoResponse { let lang = match query.get("lang").map(String::as_str) { Some("fr") => "fr", _ => "en", }; let relative = if lang == "fr" { "legal/EULA_FR.md" } else { "legal/EULA_EN.md" }; match std::fs::read_to_string(state.root.join(relative)) { Ok(markdown) => Json(json!({"lang": lang, "markdown": markdown})).into_response(), Err(_) => ( StatusCode::NOT_FOUND, Json(json!({"error": format!("EULA file not found: {relative}")})), ) .into_response(), } } async fn phone_status(State(state): State>) -> impl IntoResponse { Json(phone::status( &state.root, &state.runtime_config, state.bind, )) } async fn set_phone_access( State(state): State>, Json(req): Json, ) -> impl IntoResponse { match phone::set_phone_access(&state.root, &state.runtime_config, req.enabled, state.bind) { Ok(status) => Json(json!({"ok": true, "phone_access": status})).into_response(), Err(error) => error_response(StatusCode::BAD_REQUEST, error), } } async fn restart_runtime(State(state): State>) -> impl IntoResponse { let executable = match std::env::current_exe() { Ok(path) => path, Err(error) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, error), }; let config = state.runtime_config.config_path.clone(); let root = state.root.clone(); if let Err(error) = spawn_delayed_restart(&executable, &config, &root) { return error_response(StatusCode::INTERNAL_SERVER_ERROR, error); } tokio::spawn(async { tokio::time::sleep(Duration::from_millis(300)).await; std::process::exit(0); }); Json(json!({"ok": true, "message": "runtime restart scheduled"})).into_response() } async fn content_packs(State(state): State>) -> impl IntoResponse { let mut catalog = read_json(&state.root.join("config/content-packs.json")) .unwrap_or_else(|_| json!({"packs":[]})); if let Some(packs) = catalog .get_mut("packs") .and_then(|value| value.as_array_mut()) { for pack in packs { let id = pack .get("id") .and_then(|value| value.as_str()) .unwrap_or(""); let configured_path = pack.get("storage_target").and_then(|value| value.as_str()); let installed = configured_path .map(|path| state.root.join(path).exists()) .unwrap_or_else(|| match id { "starter_prompt_packs" | "professional_prompt_packs" => { state.root.join("content-packs/prompts").exists() } _ => false, }); pack["installed"] = json!(installed); } } Json(catalog) } async fn field_manual(State(state): State>) -> impl IntoResponse { match read_json(&state.root.join("content/field-manual/cards.json")) { Ok(cards) => Json(cards).into_response(), Err(error) => error_response(StatusCode::NOT_FOUND, error), } } async fn get_settings(State(state): State>) -> impl IntoResponse { Json(storage::read_settings(&state.root)) } async fn save_settings( State(state): State>, Json(req): Json, ) -> impl IntoResponse { if let Some(enabled) = req.phone_access_enabled { if let Err(error) = phone::set_phone_access(&state.root, &state.runtime_config, enabled, state.bind) { return error_response(StatusCode::BAD_REQUEST, error); } } let mut settings = storage::read_settings(&state.root); if let Some(object) = settings.as_object_mut() { if let Some(mode) = req.mode { object.insert("mode".to_string(), json!(mode)); } if let Some(value) = req.offline_lock { object.insert("offline_lock".to_string(), json!(value)); } if let Some(value) = req.tools_lock { object.insert("tools_lock".to_string(), json!(value)); } } match storage::write_settings(&state.root, &settings) { Ok(_) => Json(json!({"ok":true,"settings":settings})).into_response(), Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, error), } } async fn run_benchmark( State(state): State>, Json(req): Json, ) -> impl IntoResponse { let model = req.model.unwrap_or_else(|| "auto".to_string()); let started = Instant::now(); let chat = local_ai::ChatInput { model: Some(model.clone()), temperature: Some(0.1), max_tokens: Some(96), think: None, messages: vec![local_ai::AiMessage { role: "user".to_string(), content: json!("Answer in one sentence: JackAILocal offline test"), }], }; let result = route_chat(state, chat).await; let elapsed_ms = started.elapsed().as_millis(); match result { Ok(v) => Json(json!({"ok":true,"model":model,"elapsed_ms":elapsed_ms,"sample":v})) .into_response(), Err(e) => { Json(json!({"ok":false,"model":model,"elapsed_ms":elapsed_ms,"error":e.to_string()})) .into_response() } } } async fn agent_recommend( State(state): State>, Json(req): Json, ) -> impl IntoResponse { let max_params_b = req.max_params_b.unwrap_or(32.0); if !(0.0..=32.0).contains(&max_params_b) { return error_response( StatusCode::BAD_REQUEST, anyhow::anyhow!("agent max_params_b must be greater than 0 and no more than 32"), ); } let installed = local_ai::installed_ollama_models(&state.client, &state.backend_config).await; let catalog = local_ai::catalog_with_availability(&state.models, &installed, &state.root); let agent_max_tokens = req.agent_max_tokens.unwrap_or(192).clamp(32, 512); let agent_timeout_seconds = req.agent_timeout_seconds.unwrap_or(120).clamp(15, 300); let requested_agent = req .agent_model .clone() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| preferred_agent_model(&catalog).unwrap_or_else(|| "auto".to_string())); let resolved_agent = if requested_agent.eq_ignore_ascii_case("auto") { match preferred_agent_model(&catalog) .filter(|model| find_available_model(&catalog, model, "chat", max_params_b).is_some()) { Some(model) => model, None => { return error_response( StatusCode::SERVICE_UNAVAILABLE, anyhow::anyhow!( "no installed local chat model <= {max_params_b}B is available for the LLM-in-the-loop agent; install gemma4:12b or another compatible catalog model" ), ); } } } else { if find_available_model(&catalog, &requested_agent, "chat", max_params_b).is_none() { return error_response( StatusCode::BAD_REQUEST, anyhow::anyhow!( "requested LLM-in-the-loop agent model is not installed or exceeds the configured limit: {}", requested_agent ), ); } requested_agent.clone() }; let policy_context = json!({ "hard_constraints": { "max_params_b": max_params_b, "local_runtime_only": true, "no_cloud_fallback": true, "must_choose_from_candidate_models": true, "must_not_claim_uninstalled_models": true, "agent_max_tokens": agent_max_tokens, "agent_timeout_seconds": agent_timeout_seconds }, "runtime": { "backend": state.backend_config.ollama_url, "installed_ollama_models": installed, "requested_agent_model": requested_agent, "resolved_agent_model": resolved_agent }, "hardware": req.hardware.unwrap_or_else(|| local_ai::hardware(&state.root)), "package_goal": req.package_goal.unwrap_or_else(|| "standard offline assistant".to_string()), "policy_default_model": req.default_model, "candidate_models": req.candidate_models.unwrap_or_else(|| catalog.get("models").cloned().unwrap_or_else(|| json!([]))), "content_packs": req.content_packs, "current_config": req.current_config, "customer_notes": req.customer_notes.unwrap_or_default(), }); let language = req.language.unwrap_or_else(|| "en".to_string()); let system = format!( "You are JackAILocal Config Agent. You are part of the model-selection and client-configuration decision loop. \ Use only the provided local catalog, installed model status, hardware profile, and policy constraints. \ Never recommend a model above {max_params_b}B. Never claim a model is installed unless available=true or it appears in installed_ollama_models. \ Prefer Gemma 4 12B (`gemma4:12b`) as the config-agent model when it is installed and hardware allows it, but do not invent availability. \ Return only valid JSON with these keys: selected_model_id, selected_model_ref, agent_model_role, confidence, backend, content_packs, config_changes, risk_flags, human_summary, next_steps. \ Answer language: {language}." ); let user = format!( "Review this JackAILocal client build plan and make a constrained recommendation:\n{}", serde_json::to_string_pretty(&policy_context).unwrap_or_else(|_| "{}".to_string()) ); let chat = local_ai::ChatInput { model: Some(resolved_agent.clone()), temperature: Some(0.1), max_tokens: Some(agent_max_tokens), think: Some(false), messages: vec![ local_ai::AiMessage { role: "system".to_string(), content: json!(system), }, local_ai::AiMessage { role: "user".to_string(), content: json!(user), }, ], }; match tokio::time::timeout( Duration::from_secs(agent_timeout_seconds), route_chat(state, chat), ) .await { Ok(response) => { let response = match response { Ok(response) => response, Err(error) => return error_response(StatusCode::BAD_GATEWAY, error), }; let content = response .pointer("/choices/0/message/content") .and_then(|value| value.as_str()) .unwrap_or(""); if content.trim().is_empty() { return error_response( StatusCode::BAD_GATEWAY, anyhow::anyhow!( "LLM-in-the-loop agent returned an empty response from the local backend; try a larger token budget or install gemma4:12b" ), ); } Json(json!({ "ok": true, "agent_model": resolved_agent, "requested_agent_model": requested_agent, "max_params_b": max_params_b, "agent_max_tokens": agent_max_tokens, "agent_timeout_seconds": agent_timeout_seconds, "llm_response": response, "agent_json": parse_json_object_from_text(content), "raw_content": content, "policy_context": policy_context })) .into_response() } Err(_) => error_response( StatusCode::GATEWAY_TIMEOUT, anyhow::anyhow!( "LLM-in-the-loop agent timed out after {agent_timeout_seconds}s; install a faster local agent model or lower agent_max_tokens" ), ), } } async fn support_bundle(State(state): State>) -> impl IntoResponse { match create_support_bundle(&state.root) { Ok((path, bytes)) => Json(json!({ "ok": true, "filename": path.file_name().unwrap_or_default().to_string_lossy(), "path": path.to_string_lossy(), "mime_type": "application/zip", "data_base64": B64.encode(bytes), "privacy": "conversations and documents are not included" })) .into_response(), Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, error), } } async fn remote_api_auth( State(state): State>, ConnectInfo(peer): ConnectInfo, req: Request, next: Next, ) -> Response { let protected = req.uri().path().starts_with("/api/") || req.uri().path().starts_with("/v1/") || req.uri().path() == "/health"; if !protected || peer.ip().is_loopback() { return next.run(req).await; } let supplied = req .headers() .get("x-jackailocal-token") .and_then(|value| value.to_str().ok()); if supplied.is_some() && supplied == state.phone_token.as_deref() { return next.run(req).await; } ( StatusCode::UNAUTHORIZED, Json(json!({ "error": "phone pairing token required", "header": "X-JackAILocal-Token" })), ) .into_response() } fn error_response(status: StatusCode, error: impl std::fmt::Display) -> Response { ( status, Json(json!({"ok": false, "error": error.to_string()})), ) .into_response() } #[cfg(target_os = "windows")] fn spawn_delayed_restart(executable: &Path, config: &Path, root: &Path) -> Result<()> { use std::os::windows::process::CommandExt; let quote = |value: &Path| value.to_string_lossy().replace('\'', "''"); let command = format!( "Start-Sleep -Seconds 1; & '{}' serve --config '{}'", quote(executable), quote(config) ); ProcessCommand::new("powershell.exe") .args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", &command]) .current_dir(root) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .creation_flags(0x08000000) .spawn()?; Ok(()) } #[cfg(not(target_os = "windows"))] fn spawn_delayed_restart(executable: &Path, config: &Path, root: &Path) -> Result<()> { ProcessCommand::new("sh") .args([ "-c", "sleep 1; exec \"$1\" serve --config \"$2\"", "jackailocal-restart", ]) .arg(executable) .arg(config) .current_dir(root) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn()?; Ok(()) } fn create_support_bundle(root: &Path) -> Result<(PathBuf, Vec)> { let filename = format!( "JackAILocal-support-{}.zip", chrono::Utc::now().format("%Y%m%d-%H%M%S") ); let path = root.join("workspace/exports").join(filename); let file = std::fs::File::create(&path)?; let mut archive = zip::ZipWriter::new(file); let options = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated); let fixed_files = [ ("diagnostics/hardware.json", "hardware.json"), ("manifest/sha256-manifest.json", "sha256-manifest.json"), ("manifest/build-manifest.json", "build-manifest.json"), ("config/model-catalog.json", "model-catalog.json"), ]; for (relative, archive_name) in fixed_files { let source = root.join(relative); if source.is_file() { archive.start_file(archive_name, options)?; std::io::Write::write_all(&mut archive, &std::fs::read(source)?)?; } } for (relative, archive_prefix) in [("logs", "logs"), (".jackailocal/logs", "runtime-logs")] { let logs = root.join(relative); if logs.is_dir() { for entry in std::fs::read_dir(logs)? { let entry = entry?; if entry.path().is_file() { let name = format!("{archive_prefix}/{}", entry.file_name().to_string_lossy()); archive.start_file(name, options)?; std::io::Write::write_all(&mut archive, &std::fs::read(entry.path())?)?; } } } } archive.finish()?; let bytes = std::fs::read(&path)?; Ok((path, bytes)) } fn preferred_agent_model(catalog: &Value) -> Option { find_available_model(catalog, "gemma_config_agent_12b", "chat", 32.0) .or_else(|| find_available_model(catalog, "gemma4:12b", "chat", 32.0)) .or_else(|| { catalog .get("models")? .as_array()? .iter() .find(|model| { model.get("available").and_then(|value| value.as_bool()) == Some(true) && model_params_b(model) <= 32.0 && model_has_feature(model, "chat") }) .and_then(|model| { model .get("id") .or_else(|| model.get("ollama")) .and_then(|value| value.as_str()) .map(str::to_string) }) }) } fn find_available_model( catalog: &Value, requested: &str, required_feature: &str, max_params_b: f64, ) -> Option { catalog .get("models")? .as_array()? .iter() .find(|model| { (model.get("id").and_then(|value| value.as_str()) == Some(requested) || model.get("ollama").and_then(|value| value.as_str()) == Some(requested)) && model.get("available").and_then(|value| value.as_bool()) == Some(true) && model_params_b(model) <= max_params_b && model_has_feature(model, required_feature) }) .and_then(|model| { model .get("id") .or_else(|| model.get("ollama")) .and_then(|value| value.as_str()) .map(str::to_string) }) } fn model_params_b(model: &Value) -> f64 { model .get("params_b") .and_then(|value| value.as_f64()) .unwrap_or(999.0) } fn model_has_feature(model: &Value, feature: &str) -> bool { model .get("features") .and_then(|value| value.as_array()) .into_iter() .flatten() .any(|value| value.as_str() == Some(feature)) } fn parse_json_object_from_text(text: &str) -> Value { serde_json::from_str::(text) .ok() .or_else(|| { let start = text.find('{')?; let end = text.rfind('}')?; serde_json::from_str::(&text[start..=end]).ok() }) .unwrap_or(Value::Null) } fn read_json(path: &Path) -> Result { let s = std::fs::read_to_string(path)?; Ok(serde_json::from_str(&s)?) } fn write_json_pretty(path: &Path, value: &Value) -> Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(path, serde_json::to_string_pretty(value)?.as_bytes())?; Ok(()) } fn valid_id(s: &str) -> bool { !s.is_empty() && s.len() <= 80 && s.chars().all(|c| { c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == ':' || c == '/' }) } fn hwscan_json() -> String { json!({"ram_gb": sys_total_memory_gb(), "vram_gb": 0, "gpu_vendor":"unknown", "cpu_threads": std::thread::available_parallelism().map(|n|n.get()).unwrap_or(1)}).to_string() } #[cfg(target_os = "windows")] #[repr(C)] #[allow(non_snake_case)] struct MEMORYSTATUSEX { dwLength: u32, dwMemoryLoad: u32, ullTotalPhys: u64, ullAvailPhys: u64, ullTotalPageFile: u64, ullAvailPageFile: u64, ullTotalVirtual: u64, ullAvailVirtual: u64, ullAvailExtendedVirtual: u64, } #[cfg(target_os = "windows")] extern "system" { fn GlobalMemoryStatusEx(lpBuffer: *mut MEMORYSTATUSEX) -> i32; } #[cfg(target_os = "windows")] fn sys_total_memory_gb() -> f64 { let mut mem_info = MEMORYSTATUSEX { dwLength: std::mem::size_of::() as u32, dwMemoryLoad: 0, ullTotalPhys: 0, ullAvailPhys: 0, ullTotalPageFile: 0, ullAvailPageFile: 0, ullTotalVirtual: 0, ullAvailVirtual: 0, ullAvailExtendedVirtual: 0, }; unsafe { if GlobalMemoryStatusEx(&mut mem_info) != 0 { let bytes = mem_info.ullTotalPhys as f64; let gb = bytes / (1024.0 * 1024.0 * 1024.0); (gb * 10.0).round() / 10.0 } else { 8.0 } } } #[cfg(not(target_os = "windows"))] fn sys_total_memory_gb() -> f64 { let meminfo = std::fs::read_to_string("/proc/meminfo").unwrap_or_default(); for line in meminfo.lines() { if line.starts_with("MemTotal:") { let kb: f64 = line .split_whitespace() .nth(1) .unwrap_or("0") .parse() .unwrap_or(0.0); return (kb / 1024.0 / 1024.0 * 10.0).round() / 10.0; } } 8.0 }