| 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<String>, |
| #[arg(long)] |
| customer_company: Option<String>, |
| #[arg(long)] |
| expires: Option<String>, |
| #[arg(long)] |
| max_devices: Option<u32>, |
| #[arg(long, value_delimiter = ',')] |
| features: Vec<String>, |
| #[arg(long)] |
| notes: Option<String>, |
| }, |
| 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<String>, |
| } |
|
|
| #[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<String>, |
| offline_lock: Option<bool>, |
| tools_lock: Option<bool>, |
| phone_access_enabled: Option<bool>, |
| } |
| #[derive(Debug, Deserialize)] |
| struct BenchmarkRequest { |
| model: Option<String>, |
| } |
| #[derive(Debug, Deserialize)] |
| struct CreateThreadRequest { |
| title: Option<String>, |
| model: Option<String>, |
| } |
| #[derive(Debug, Deserialize)] |
| struct RenameThreadRequest { |
| title: String, |
| } |
| #[derive(Debug, Deserialize)] |
| struct ThreadMessageRequest { |
| content: String, |
| model: Option<String>, |
| } |
| #[derive(Debug, Deserialize)] |
| struct PhoneAccessRequest { |
| enabled: bool, |
| } |
| #[derive(Debug, Deserialize)] |
| struct InstallLicenseRequest { |
| content: String, |
| } |
| #[derive(Debug, Deserialize)] |
| struct AgentRecommendRequest { |
| language: Option<String>, |
| package_goal: Option<String>, |
| hardware: Option<Value>, |
| default_model: Option<Value>, |
| candidate_models: Option<Value>, |
| content_packs: Option<Value>, |
| current_config: Option<Value>, |
| customer_notes: Option<String>, |
| agent_model: Option<String>, |
| max_params_b: Option<f64>, |
| agent_max_tokens: Option<u32>, |
| agent_timeout_seconds: Option<u64>, |
| } |
|
|
| #[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<chrono::DateTime<chrono::Utc>> { |
| 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::<SocketAddr>(), |
| ) |
| .await?; |
| Ok(()) |
| } |
|
|
| async fn health(State(state): State<Arc<AppState>>) -> impl IntoResponse { |
| Json( |
| json!({"product":"JackAILocal","status":"ok","offline":true,"bind":state.bind.to_string()}), |
| ) |
| } |
|
|
| async fn api_status(State(state): State<Arc<AppState>>) -> 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<Arc<AppState>>) -> 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<Arc<AppState>>) -> 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<Arc<AppState>>, |
| Json(req): Json<AddModelRequest>, |
| ) -> 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<Arc<AppState>>, |
| Json(req): Json<local_ai::ChatInput>, |
| ) -> 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<AppState>, req: local_ai::ChatInput) -> Result<Value> { |
| local_ai::chat( |
| &state.client, |
| &state.models, |
| &state.root, |
| &state.backend_config, |
| req, |
| ) |
| .await |
| } |
|
|
| async fn list_documents(State(state): State<Arc<AppState>>) -> 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<Arc<AppState>>, |
| Json(req): Json<SaveDocument>, |
| ) -> 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<Arc<AppState>>, |
| Query(query): Query<HashMap<String, String>>, |
| ) -> 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<Arc<AppState>>, |
| Json(req): Json<CreateThreadRequest>, |
| ) -> 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<Arc<AppState>>, |
| AxumPath(id): AxumPath<String>, |
| ) -> 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<Arc<AppState>>, |
| AxumPath(id): AxumPath<String>, |
| Json(req): Json<RenameThreadRequest>, |
| ) -> 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<Arc<AppState>>, |
| AxumPath(id): AxumPath<String>, |
| ) -> 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<Arc<AppState>>, |
| AxumPath(id): AxumPath<String>, |
| Json(req): Json<ThreadMessageRequest>, |
| ) -> 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<Arc<AppState>>, |
| Json(req): Json<crypto_pack::ExportOptions>, |
| ) -> 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<Arc<AppState>>, |
| Json(req): Json<crypto_pack::ImportOptions>, |
| ) -> 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<Arc<AppState>>, |
| Json(req): Json<local_ai::VisionInput>, |
| ) -> 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<Arc<AppState>>) -> impl IntoResponse { |
| Json(local_ai::voice_status(&state.root)) |
| } |
|
|
| async fn transcribe_audio( |
| State(state): State<Arc<AppState>>, |
| Json(req): Json<local_ai::TranscriptionInput>, |
| ) -> 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<Arc<AppState>>, |
| Json(req): Json<local_ai::SpeechInput>, |
| ) -> 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<Arc<AppState>>) -> impl IntoResponse { |
| Json(license::status(&state.root).to_json()) |
| } |
|
|
| async fn install_license( |
| State(state): State<Arc<AppState>>, |
| Json(req): Json<InstallLicenseRequest>, |
| ) -> 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<Arc<AppState>>, |
| Query(query): Query<HashMap<String, String>>, |
| ) -> 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<Arc<AppState>>) -> impl IntoResponse { |
| Json(phone::status( |
| &state.root, |
| &state.runtime_config, |
| state.bind, |
| )) |
| } |
|
|
| async fn set_phone_access( |
| State(state): State<Arc<AppState>>, |
| Json(req): Json<PhoneAccessRequest>, |
| ) -> 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<Arc<AppState>>) -> 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<Arc<AppState>>) -> 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<Arc<AppState>>) -> 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<Arc<AppState>>) -> impl IntoResponse { |
| Json(storage::read_settings(&state.root)) |
| } |
|
|
| async fn save_settings( |
| State(state): State<Arc<AppState>>, |
| Json(req): Json<SettingsRequest>, |
| ) -> 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<Arc<AppState>>, |
| Json(req): Json<BenchmarkRequest>, |
| ) -> 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<Arc<AppState>>, |
| Json(req): Json<AgentRecommendRequest>, |
| ) -> 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<Arc<AppState>>) -> 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<Arc<AppState>>, |
| ConnectInfo(peer): ConnectInfo<SocketAddr>, |
| 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<u8>)> { |
| 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<String> { |
| 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<String> { |
| 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::<Value>(text) |
| .ok() |
| .or_else(|| { |
| let start = text.find('{')?; |
| let end = text.rfind('}')?; |
| serde_json::from_str::<Value>(&text[start..=end]).ok() |
| }) |
| .unwrap_or(Value::Null) |
| } |
|
|
| fn read_json(path: &Path) -> Result<Value> { |
| 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::<MEMORYSTATUSEX>() 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 |
| } |
|
|