jackailocal / src /main.rs
jackboy70's picture
Deploy: accurate lite-builder note
f25362a
Raw
History Blame Contribute Delete
44.8 kB
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
}