/piper",
+ "voice_path": "models/piper/en_US-libritts_r-medium.onnx",
+ "runtime": "local",
+ },
+ "model_install_plan": model_install_plan,
+ "compatibility": {
+ "ollama_models_use_ollama_refs": True,
+ "llama_cpp_models_use_gguf_paths": True,
+ "vllm_models_use_hf_repos": True,
+ "voice_models_are_backend_specific": True,
+ },
+ }
+
+def select_config_agent_model(allowed: list[dict[str, Any]]) -> dict[str, Any] | None:
+ for model in allowed:
+ if model["id"] == CONFIG_AGENT_MODEL_ID:
+ return model
+ return None
+
+def build_plan_object(language: str, sample_name: str, raw_profile_json: str, package_goal: str) -> dict[str, Any]:
+ profile = normalize_profile(parse_profile_json(raw_profile_json, sample_name))
+ allowed = compatible_models(profile)
+ default = select_default_model(allowed, profile, package_goal)
+ agent_model = select_config_agent_model(allowed)
+ return {
+ "product": PRODUCT_NAME,
+ "mode": PRODUCT_MODE,
+ "selector_version": SELECTOR_VERSION,
+ "build_id": str(uuid4()),
+ "created_at": datetime.now(timezone.utc).isoformat(),
+ "package_goal": package_goal,
+ "profile_source": (
+ "custom_json" if raw_profile_json and raw_profile_json.strip()
+ else "real_scan" if sample_name == PROFILE_SOURCE_REAL
+ else "sample"
+ ),
+ "hardware": profile,
+ "default_model": default,
+ "agent_model": agent_model,
+ "llm_in_the_loop": {
+ "enabled": True,
+ "preferred_model_ref": CONFIG_AGENT_MODEL_REF,
+ "selected_agent_model_ref": agent_model["model_ref"] if agent_model else None,
+ "selected_agent_model_id": agent_model["id"] if agent_model else None,
+ "runtime_endpoint": "/api/agent/recommend",
+ "strict_local_runtime": True,
+ "max_params_b": MAX_MODEL_PARAMS_B,
+ "fallback_behavior": "Fail explicitly if the requested local agent model is not installed. Use Auto only when the operator allows another installed local model."
+ },
+ "allowed_models": allowed,
+ "content_packs": select_content_packs(profile, package_goal),
+ "backends": backend_policy(profile, default),
+ "backend_plan": build_backend_plan(profile, default, agent_model, allowed),
+ "agent_decision": {
+ "status": "not_run",
+ "provider": None,
+ "model": None,
+ "applied": False,
+ "reason": "Base plan is deterministic. Hosted AI-in-the-loop runs during final export/build when Modal or HF inference secrets are configured."
+ },
+ "max_params_b": MAX_MODEL_PARAMS_B,
+ "selection_policy": {
+ "is_hardcoded_static_result": False,
+ "is_rule_based_catalog": True,
+ "uses_ai_in_loop": False,
+ "uses_live_hardware_scan_when_local": IS_LOCAL,
+ "uses_custom_json_when_provided": True,
+ "whichllm_is_optional": True,
+ "standard_pack_cap_b": 9,
+ "power_pack_cap_b": 32,
+ "default_is_not_limit": True
+ },
+ "download_policy": "Models and content packs are fetched during package build or factory preload, not during normal USB insertion.",
+ "runtime_policy": "Windows portable first. Linux bootable is secondary. USB runtime binds to 127.0.0.1 only.",
+ "product_policy": "The default pack is a practical offline baseline; the customer can keep, remove, or add compatible models based on hardware.",
+ "security_policy": {
+ "signed_updates": True,
+ "sha256_manifest": True,
+ "local_only": True,
+ "no_shell_tools_by_default": True,
+ "no_admin_required_for_normal_use": True
+ }
+ }
+
+def as_bullet_list(value: Any) -> list[str]:
+ """Normalize an LLM field to a list of strings. A model may return a bare
+ string for risk_flags/next_steps; iterating it directly would render one
+ bullet per character, so wrap a non-empty string in a single-item list."""
+ if value is None or value == "":
+ return []
+ if isinstance(value, str):
+ return [value]
+ if isinstance(value, (list, tuple)):
+ return [str(item) for item in value if str(item).strip()]
+ return [str(value)]
+
+def parse_json_object_from_text(text: str) -> dict[str, Any] | None:
+ if not text:
+ return None
+ stripped = text.strip()
+ if stripped.startswith("```"):
+ stripped = re.sub(r"^```(?:json)?\s*", "", stripped, flags=re.IGNORECASE)
+ stripped = re.sub(r"\s*```$", "", stripped)
+ try:
+ value = json.loads(stripped)
+ return value if isinstance(value, dict) else None
+ except json.JSONDecodeError:
+ pass
+ start = stripped.find("{")
+ end = stripped.rfind("}")
+ if start >= 0 and end > start:
+ try:
+ value = json.loads(stripped[start:end + 1])
+ return value if isinstance(value, dict) else None
+ except json.JSONDecodeError:
+ return None
+ return None
+
+def agent_messages_for_plan(plan: dict[str, Any], language: str) -> list[dict[str, str]]:
+ lang = lang_key(language)
+ system = (
+ "You are JackAILocal Packaging Agent. You are in the packaging decision loop, not a chatbot. "
+ "Use only the provided hardware profile, compatible model list, backend options, content packs, and constraints. "
+ f"Never select a model above {MAX_MODEL_PARAMS_B}B. Do not invent installed models or unsupported backends. "
+ "Return only valid JSON with keys: selected_client_model_id, selected_client_model_ref, selected_backend, "
+ "selected_runtime_agent_model_ref, backend_plan_changes, content_pack_ids, risk_flags, human_summary, next_steps. "
+ "selected_backend must be one of ollama, llama.cpp, or vllm. "
+ "The runtime package must remain local-first/offline after model preload; hosted inference is only for packaging intelligence. "
+ f"Answer human_summary and next_steps in {'French' if lang == 'fr' else 'English'}."
+ )
+ user = json.dumps({
+ "task": "Finalize the customer packaging plan.",
+ "base_plan": plan,
+ "optimization_goals": [
+ "choose the best client default model for the actual hardware",
+ "choose backend per model family",
+ "keep the end-user workflow non-technical",
+ "avoid over-downloading models",
+ "preserve offline local runtime after build",
+ ],
+ }, ensure_ascii=False, indent=2)
+ return [{"role": "system", "content": system}, {"role": "user", "content": user}]
+
+def call_modal_vllm_agent(provider: dict[str, Any], messages: list[dict[str, str]]) -> dict[str, Any]:
+ url = provider["base_url"].rstrip("/") + "/v1/chat/completions"
+ response = requests.post(
+ url,
+ headers={
+ "Authorization": f"Bearer {provider['api_key']}",
+ "Content-Type": "application/json",
+ },
+ json={
+ "model": provider["model"],
+ "messages": messages,
+ "temperature": 0.1,
+ "max_tokens": 768,
+ "response_format": {"type": "json_object"},
+ "chat_template_kwargs": {"enable_thinking": False},
+ },
+ timeout=REMOTE_AGENT_TIMEOUT_SECONDS,
+ )
+ if response.status_code != 200:
+ raise RuntimeError(f"Modal vLLM agent failed: HTTP {response.status_code} {response.text[:800]}")
+ payload = response.json()
+ content = payload.get("choices", [{}])[0].get("message", {}).get("content", "")
+ parsed = parse_json_object_from_text(content)
+ if not parsed:
+ raise RuntimeError("Modal vLLM agent returned non-JSON content")
+ return {"provider": "modal_vllm", "model": provider["model"], "raw": content, "parsed": parsed, "response": payload}
+
+def call_hf_inference_agent(provider: dict[str, Any], messages: list[dict[str, str]]) -> dict[str, Any]:
+ try:
+ from huggingface_hub import InferenceClient
+ except ImportError as exc:
+ raise RuntimeError("huggingface_hub is not installed; add it to the Space requirements") from exc
+ # Not pinned to one model: try the configured model first, then the shared
+ # served-model fallback list; keep the first that returns valid JSON.
+ candidates: list[str] = [provider["model"], *decision_engine.cloud_agent_candidates()]
+ seen: set[str] = set()
+ models = [m for m in candidates if m and not (m in seen or seen.add(m))]
+ last_error = None
+ for model in models:
+ try:
+ client = InferenceClient(
+ model=model,
+ provider=provider.get("hf_provider", "auto"),
+ token=provider["api_key"],
+ timeout=REMOTE_AGENT_TIMEOUT_SECONDS,
+ )
+ response = client.chat.completions.create(
+ model=model,
+ messages=messages,
+ temperature=0.1,
+ max_tokens=768,
+ response_format={"type": "json_object"},
+ )
+ message = response.choices[0].message
+ content = (message.content if hasattr(message, "content") else str(message)) or ""
+ parsed = parse_json_object_from_text(content)
+ if parsed:
+ return {"provider": "hf_inference", "model": model, "raw": content, "parsed": parsed, "response": response}
+ last_error = f"empty/non-JSON from {model}"
+ except Exception as exc:
+ last_error = f"{model}: {exc}"
+ continue
+ raise RuntimeError(f"HF Inference agent returned no valid JSON (last error: {last_error})")
+
+def ui_remote_agent_provider() -> dict[str, Any] | None:
+ """Cloud agent provider configured through the UI: a Hugging Face token
+ pasted in the API accordion makes the Gemma 4 review work on a hosted
+ Space even when no HF_TOKEN secret is set."""
+ provider = decision_engine.configured_provider()
+ if not provider or not provider.get("api_key"):
+ return None
+ if provider.get("id") == "hf":
+ return {
+ "id": "hf_inference",
+ "api_key": provider["api_key"],
+ "model": provider.get("model") or REMOTE_CONFIG_AGENT_MODEL_ID,
+ "hf_provider": "auto",
+ }
+ return None
+
+def oauth_agent_provider(oauth_token) -> dict[str, Any] | None:
+ """Per-visitor provider from 'Sign in with Hugging Face' (Space OAuth).
+ Inference is billed to the VISITOR's account, never to the Space owner,
+ and the token is per-session (unlike env/UI configuration)."""
+ token_value = getattr(oauth_token, "token", None) if oauth_token else None
+ if not token_value:
+ return None
+ # The visitor's HF token bills HF Inference Providers, so this MUST be a
+ # model actually served on the HF router (HF_OAUTH_MODEL_ID), kept separate
+ # from MODEL_ID which may point at a sponsor endpoint (Modal/MiniCPM/etc.).
+ return {
+ "id": "hf_inference",
+ "api_key": token_value,
+ "model": os.getenv("HF_OAUTH_MODEL_ID", REMOTE_CONFIG_AGENT_MODEL_ID).strip() or REMOTE_CONFIG_AGENT_MODEL_ID,
+ "hf_provider": os.getenv("HF_INFERENCE_PROVIDER", "auto").strip() or "auto",
+ }
+
+def call_remote_config_agent(plan: dict[str, Any], language: str, oauth_token=None) -> dict[str, Any]:
+ provider = oauth_agent_provider(oauth_token) or remote_agent_provider() or ui_remote_agent_provider()
+ if not provider:
+ raise RuntimeError("No remote config agent is configured. Sign in with Hugging Face, set MODAL_BASE_URL/MODAL_API_KEY or HF_TOKEN, or paste a Hugging Face token in the API panel.")
+ messages = agent_messages_for_plan(plan, language)
+ if provider["id"] == "modal_vllm":
+ return call_modal_vllm_agent(provider, messages)
+ if provider["id"] == "hf_inference":
+ return call_hf_inference_agent(provider, messages)
+ raise RuntimeError(f"Unsupported remote agent provider: {provider['id']}")
+
+def apply_agent_decision(plan: dict[str, Any], agent_result: dict[str, Any]) -> dict[str, Any]:
+ finalized = copy.deepcopy(plan)
+ parsed = agent_result["parsed"]
+ allowed = finalized["allowed_models"]
+ selected_ref = parsed.get("selected_client_model_ref") or parsed.get("client_model_ref") or parsed.get("selected_model_ref")
+ selected_id = parsed.get("selected_client_model_id") or parsed.get("client_model_id")
+ selected_model = None
+ for model in allowed:
+ if selected_ref and model.get("model_ref") == selected_ref:
+ selected_model = model
+ break
+ if selected_id and model.get("id") == selected_id:
+ selected_model = model
+ break
+ applied_fields = []
+ rejected_fields = []
+ if selected_model and selected_model.get("task") == "chat" and selected_model.get("tier") != "agent":
+ finalized["default_model"] = selected_model
+ applied_fields.append("default_model")
+ elif selected_ref or selected_id:
+ rejected_fields.append({
+ "field": "default_model",
+ "reason": "agent selected a model that is not an allowed client chat model",
+ "selected_ref": selected_ref,
+ "selected_id": selected_id,
+ })
+
+ selected_backend = parsed.get("selected_backend")
+ supported_backends = {option.get("backend") for option in backend_options_for_model(finalized["default_model"])}
+ if selected_backend in {"ollama", "llama.cpp", "vllm"} and selected_backend in supported_backends:
+ finalized["preferred_backend"] = selected_backend
+ applied_fields.append("preferred_backend")
+ elif selected_backend:
+ rejected_fields.append({
+ "field": "preferred_backend",
+ "reason": "backend is unsupported for the selected client model",
+ "value": selected_backend,
+ "supported": sorted(supported_backends),
+ })
+
+ agent_model = finalized.get("agent_model")
+ finalized["backends"] = backend_policy(finalized["hardware"], finalized["default_model"])
+ finalized["backend_plan"] = build_backend_plan(finalized["hardware"], finalized["default_model"], agent_model, allowed)
+ if finalized.get("preferred_backend"):
+ preferred_option = next(
+ (
+ option for option in finalized["backend_plan"]["chat"].get("alternatives", [])
+ if option.get("backend") == finalized["preferred_backend"]
+ ),
+ None,
+ )
+ if preferred_option:
+ finalized["backend_plan"]["chat"]["selected"] = preferred_option
+ finalized["agent_decision"] = {
+ "status": "applied" if applied_fields else "reviewed",
+ "provider": agent_result["provider"],
+ "model": agent_result["model"],
+ "applied": bool(applied_fields),
+ "applied_fields": applied_fields,
+ "rejected_fields": rejected_fields,
+ "parsed": parsed,
+ "raw_content": agent_result["raw"],
+ }
+ finalized["selection_policy"]["uses_ai_in_loop"] = True
+ finalized["selection_policy"]["ai_provider"] = agent_result["provider"]
+ finalized["selection_policy"]["ai_model"] = agent_result["model"]
+ return finalized
+
+ALLOWED_INTERNAL_PATCH_KEYS = {
+ "distribution_mode",
+ "target_mode",
+ "requires_usb",
+ "remote_ai_required",
+ "selected_backend",
+ "selected_model",
+ "selected_client_model_ref",
+ "selected_runtime_agent_model_ref",
+ "content_pack_ids",
+ "phone_access",
+ "voice_mode",
+ "scout_vision",
+ "risk_flags",
+ "notes",
+ "policy_override",
+}
+
+
+def parse_optional_json_object(raw_json: str, label: str) -> dict[str, Any]:
+ if not raw_json or not raw_json.strip():
+ return {}
+ try:
+ parsed = json.loads(raw_json)
+ except json.JSONDecodeError as exc:
+ raise gr.Error(f"Invalid {label} JSON: {exc}") from exc
+ if not isinstance(parsed, dict):
+ raise gr.Error(f"{label} JSON must be an object.")
+ return parsed
+
+
+def target_available_for_decision(target_mode: str, target_drive: str) -> bool:
+ mode = target_mode or ""
+ if mode in {"USB", "SSD"}:
+ if not IS_LOCAL:
+ return False
+ drives = get_real_drives(mode)
+ return bool(target_drive and target_drive in drives)
+ if mode == "LocalFolder":
+ return bool(target_drive or not IS_LOCAL)
+ return False
+
+
+def requested_action_for_target(simple_target: str, target_mode: str) -> str:
+ if simple_target in {"publish_windows", "publish_macos"}:
+ return "BUILD_ZIP_PACKAGE"
+ if simple_target == "publish_appliance":
+ return "PUBLISH_APPLIANCE"
+ if target_mode in {"USB", "SSD"}:
+ return "BUILD_USB_PACKAGE"
+ if target_mode == "LocalFolder":
+ return "BUILD_LOCAL_FOLDER"
+ return "BUILD_ZIP_PACKAGE"
+
+
+def build_decision_app_state(
+ language: str,
+ plan: dict[str, Any],
+ raw_profile_json: str,
+ simple_target: str = "",
+ target_mode: str = "",
+ target_drive: str = "",
+ user_answers_json: str = "",
+ operator_notes: str = "",
+ decision_engine_required: bool = False,
+) -> dict[str, Any]:
+ answers = parse_optional_json_object(user_answers_json, "decision answers")
+ mode = target_mode or simple_target_mode(simple_target) or ""
+ allowed_compact = [
+ {
+ "id": model.get("id"),
+ "model_ref": model.get("model_ref"),
+ "hf": model.get("hf"),
+ "provider": model.get("provider"),
+ "params_b": model.get("params_b"),
+ "task": model.get("task"),
+ "tier": model.get("tier"),
+ "features": model.get("features", []),
+ }
+ for model in plan.get("allowed_models", [])
+ ]
+ return {
+ "language": lang_key(language),
+ "product": PRODUCT_NAME,
+ "selector_version": SELECTOR_VERSION,
+ "build_id": plan.get("build_id"),
+ "package_goal": plan.get("package_goal"),
+ "requested_action": requested_action_for_target(simple_target, mode),
+ "simple_target": simple_target or "",
+ "target_mode": mode,
+ "target_drive": target_drive or "",
+ "target_available": target_available_for_decision(mode, target_drive),
+ "running_in_hosted_space": not IS_LOCAL,
+ "profile_source": plan.get("profile_source") or ("custom_json" if raw_profile_json and raw_profile_json.strip() else "sample"),
+ "hardware_profile": plan.get("hardware", {}),
+ "default_model": plan.get("default_model"),
+ "agent_model": plan.get("agent_model"),
+ "allowed_models": allowed_compact,
+ "backend_plan": plan.get("backend_plan", {}),
+ "content_packs": plan.get("content_packs", []),
+ "max_params_b": MAX_MODEL_PARAMS_B,
+ "constraints": {
+ "no_fake_or_simulated_backend": True,
+ "local_runtime_after_preload": True,
+ "max_params_b": MAX_MODEL_PARAMS_B,
+ "normal_runtime_local_only": True,
+ "backend_specific_models": True,
+ },
+ "user_answers": answers,
+ "operator_notes": operator_notes or "",
+ "decision_engine_required": decision_engine_required,
+ }
+
+
+def extract_default_features_from_goal(package_goal: str, notes: str) -> dict[str, bool]:
+ goal = str(package_goal).lower()
+ notes_lower = str(notes).lower()
+
+ # Heuristics for features based on goals and notes
+ voice = any(w in goal or w in notes_lower for w in ["voice", "vocal", "talk", "parler", "speech", "tts", "stt"])
+ phone = any(w in goal or w in notes_lower for w in ["phone", "mobile", "lan", "network", "remote", "share", "partager"])
+ vision = any(w in goal or w in notes_lower for w in ["vision", "scout", "image", "multimodal", "voir", "camera", "photo"])
+
+ return {
+ "voice_mode": voice,
+ "phone_access": phone,
+ "scout_vision": vision,
+ }
+
+
+def apply_internal_decision_to_plan(plan: dict[str, Any], decision: dict[str, Any]) -> dict[str, Any]:
+ finalized = copy.deepcopy(plan)
+ finalized["internal_decision"] = decision
+ provider_status = decision.get("provider_status", {})
+ applied_fields: list[str] = []
+ rejected_fields: list[dict[str, Any]] = []
+
+ if provider_status.get("configured"):
+ finalized["selection_policy"]["uses_ai_in_loop"] = True
+ finalized["selection_policy"]["ai_provider"] = provider_status.get("provider")
+ finalized["selection_policy"]["ai_model"] = provider_status.get("model")
+
+ # Always initialize default features
+ defaults = extract_default_features_from_goal(plan.get("package_goal", ""), "")
+ finalized["features"] = defaults
+
+ if decision.get("decision") in {"ASK_USER", "ASK_HUMAN_REVIEW", "REJECT"} or decision.get("blocked"):
+ finalized["agent_decision"] = {
+ "status": str(decision.get("decision", "review_required")).lower(),
+ "provider": provider_status.get("provider"),
+ "model": provider_status.get("model"),
+ "applied": False,
+ "questions": decision.get("questions", []),
+ "parsed": decision,
+ }
+ return finalized
+
+ patch = {
+ key: value
+ for key, value in (decision.get("manifest_patch") or {}).items()
+ if key in ALLOWED_INTERNAL_PATCH_KEYS
+ }
+ finalized["manifest_patch"] = patch
+
+ # Apply features from patch if present, otherwise fall back to defaults
+ finalized["features"] = {
+ "voice_mode": patch.get("voice_mode", defaults["voice_mode"]),
+ "phone_access": patch.get("phone_access", defaults["phone_access"]),
+ "scout_vision": patch.get("scout_vision", defaults["scout_vision"]),
+ }
+
+ # Apply content packs from patch if present
+ content_pack_ids = patch.get("content_pack_ids")
+ if isinstance(content_pack_ids, list):
+ finalized["content_packs"] = [
+ pack for pack in CONTENT_PACKS if pack["id"] in content_pack_ids
+ ]
+
+
+ selected_ref = (
+ decision.get("selected_client_model_ref")
+ or patch.get("selected_client_model_ref")
+ or patch.get("selected_model")
+ or decision.get("selected_model")
+ )
+ selected_model = None
+ for model in finalized.get("allowed_models", []):
+ if selected_ref and selected_ref in {model.get("model_ref"), model.get("id"), model.get("hf")}:
+ selected_model = model
+ break
+ if selected_model and selected_model.get("task") == "chat" and selected_model.get("tier") != "agent":
+ finalized["default_model"] = selected_model
+ applied_fields.append("default_model")
+ elif selected_ref:
+ rejected_fields.append({
+ "field": "default_model",
+ "reason": "selected model is not an allowed client chat model for this hardware profile",
+ "value": selected_ref,
+ })
+
+ selected_backend = patch.get("selected_backend") or decision.get("selected_backend")
+ if selected_backend in {"ollama", "llama.cpp", "vllm"}:
+ supported_backends = {option.get("backend") for option in backend_options_for_model(finalized["default_model"])}
+ if selected_backend in supported_backends:
+ finalized["preferred_backend"] = selected_backend
+ applied_fields.append("preferred_backend")
+ else:
+ rejected_fields.append({
+ "field": "preferred_backend",
+ "reason": "backend is unsupported for the selected client model",
+ "value": selected_backend,
+ "supported": sorted(supported_backends),
+ })
+
+ agent_model = finalized.get("agent_model")
+ finalized["backends"] = backend_policy(finalized["hardware"], finalized["default_model"])
+ finalized["backend_plan"] = build_backend_plan(finalized["hardware"], finalized["default_model"], agent_model, finalized["allowed_models"])
+ if finalized.get("preferred_backend"):
+ preferred_option = next(
+ (
+ option for option in finalized["backend_plan"]["chat"].get("alternatives", [])
+ if option.get("backend") == finalized["preferred_backend"]
+ ),
+ None,
+ )
+ if preferred_option:
+ finalized["backend_plan"]["chat"]["selected"] = preferred_option
+
+ finalized["internal_decision"]["applied_fields"] = applied_fields
+ finalized["internal_decision"]["rejected_fields"] = rejected_fields
+ finalized["agent_decision"] = {
+ "status": "applied" if applied_fields else "reviewed",
+ "provider": provider_status.get("provider"),
+ "model": provider_status.get("model"),
+ "applied": bool(applied_fields),
+ "applied_fields": applied_fields,
+ "rejected_fields": rejected_fields,
+ "parsed": decision,
+ }
+ return finalized
+
+
+def decision_requires_user_response(decision: dict[str, Any]) -> bool:
+ return decision.get("decision") in {"ASK_USER", "ASK_HUMAN_REVIEW", "REJECT"} or bool(decision.get("blocked"))
+
+
+def decision_questions_text(decision: dict[str, Any]) -> str:
+ questions = decision.get("questions") or []
+ if not questions:
+ return "No question returned."
+ return "\n".join(
+ f"- {item.get('label') or item.get('id')}: {item.get('question')} "
+ f"(type: {item.get('answer_type')}, required: {item.get('required')})"
+ for item in questions
+ )
+
+
+def format_internal_decision_markdown(decision: dict[str, Any], language: str) -> str:
+ lang = lang_key(language)
+ provider = decision.get("provider_status", {})
+ reasons = decision.get("reasons") or []
+ questions = decision.get("questions") or []
+
+ labels = {
+ "fr": {
+ "title": "Moteur de décision IA",
+ "status": "Décision",
+ "confidence": "Confiance",
+ "risk": "Risque",
+ "provider": "Fournisseur",
+ "model": "Modèle",
+ "source": "Source",
+ "reasons": "Raisons de la recommandation",
+ "questions": "Questions à résoudre",
+ "none": "Aucune",
+ "type": "Type",
+ "required": "requis",
+ },
+ "en": {
+ "title": "AI Decision Engine",
+ "status": "Decision",
+ "confidence": "Confidence",
+ "risk": "Risk",
+ "provider": "Provider",
+ "model": "Model",
+ "source": "Source",
+ "reasons": "Reasons for recommendation",
+ "questions": "Questions to resolve",
+ "none": "None",
+ "type": "Type",
+ "required": "required",
+ },
+ }[lang]
+
+ reasons_html = (
+ "".join(f"{html.escape(str(item))} " for item in reasons)
+ if reasons
+ else f"{labels['none']} "
+ )
+
+ if questions:
+ questions_html = "" + "".join(
+ f"{html.escape(str(item.get('label') or item.get('id') or ''))} : "
+ f"{html.escape(str(item.get('question') or ''))} "
+ f"{labels['type']}: {html.escape(str(item.get('answer_type') or ''))}, "
+ f"{labels['required']}: {html.escape(str(item.get('required')))} "
+ for item in questions
+ ) + " "
+ else:
+ questions_html = f"{labels['none']}
"
+
+ return f"""
+
+
{labels['title']}
+
{labels['status']} : {html.escape(str(decision.get('decision') or 'n/a'))}
+
{labels['confidence']} : {html.escape(str(decision.get('confidence') or 'n/a'))}
+
{labels['risk']} : {html.escape(str(decision.get('risk_level') or 'n/a'))}
+
{labels['provider']} : {html.escape(str(provider.get('provider') or 'n/a'))}
+
{labels['model']} : {html.escape(str(provider.get('model') or 'n/a'))}
+
{labels['source']} : {html.escape(str(decision.get('source') or 'n/a'))}
+
+
{labels['reasons']}
+
+
+
{labels['questions']}
+ {questions_html}
+
+"""
+
+
+def localized_decision_label(decision_status: str, lang: str) -> str:
+ labels = {
+ "BUILD_LOCAL_FOLDER": {"fr": "Créer un dossier local", "en": "Build a local folder"},
+ "BUILD_USB_PACKAGE": {"fr": "Préparer une clé USB", "en": "Build a USB package"},
+ "BUILD_ZIP_PACKAGE": {"fr": "Créer un package ZIP", "en": "Build a ZIP package"},
+ "PUBLISH_APPLIANCE": {"fr": "Publier l'appliance", "en": "Publish the appliance"},
+ "ASK_USER": {"fr": "Informations supplémentaires requises", "en": "More information required"},
+ "ASK_HUMAN_REVIEW": {"fr": "Validation humaine requise", "en": "Human review required"},
+ "REJECT": {"fr": "Configuration refusée", "en": "Configuration rejected"},
+ }
+ return labels.get(decision_status, {}).get(lang, decision_status or "n/a")
+
+
+def localized_provider_label(provider: str, lang: str) -> str:
+ labels = {
+ "ollama": {"fr": "Ollama local", "en": "Local Ollama"},
+ "local": {"fr": "Serveur local", "en": "Local server"},
+ "modal": {"fr": "Modal", "en": "Modal"},
+ "hf": {"fr": "Hugging Face", "en": "Hugging Face"},
+ "none": {"fr": "Non configuré", "en": "Not configured"},
+ "not configured": {"fr": "Non configuré", "en": "Not configured"},
+ }
+ return labels.get(provider, {}).get(lang, provider or "n/a")
+
+
+def plan_summary_card(plan: dict[str, Any], language: str) -> str:
+ lang = lang_key(language)
+ model = plan.get("default_model", {})
+ backend = plan.get("backend_plan", {}).get("chat", {}).get("selected", {})
+ decision = plan.get("internal_decision") or plan.get("agent_decision", {})
+ provider = (decision.get("provider_status") or {}).get("provider") or decision.get("provider") or "not configured"
+ decision_status = decision.get("decision") or decision.get("status", "not_run")
+ labels = {
+ "fr": {
+ "title": "Configuration recommandée",
+ "model": "Modèle local",
+ "backend": "Moteur d'exécution",
+ "action": "Action recommandée",
+ "decision_engine": "Moteur de décision",
+ "features": "Fonctionnalités",
+ "voice": "Mode vocal hors ligne",
+ "phone": "Accès depuis un téléphone",
+ "vision": "Analyse d'images",
+ "yes": "Oui",
+ "no": "Non",
+ "packs": "Packs de contenu",
+ "none": "Aucun",
+ "build": "Identifiant du build",
+ },
+ "en": {
+ "title": "Recommended Configuration",
+ "model": "Local model",
+ "backend": "Runtime backend",
+ "action": "Recommended action",
+ "decision_engine": "Decision engine",
+ "features": "Features",
+ "voice": "Offline voice mode",
+ "phone": "Phone access",
+ "vision": "Image analysis",
+ "yes": "Yes",
+ "no": "No",
+ "packs": "Content packs",
+ "none": "None",
+ "build": "Build ID",
+ },
+ }[lang]
+
+ features = plan.get("features", {})
+ feature_rows = [
+ (labels["voice"], bool(features.get("voice_mode", False))),
+ (labels["phone"], bool(features.get("phone_access", False))),
+ (labels["vision"], bool(features.get("scout_vision", False))),
+ ]
+ feature_items = "".join(
+ f'{feature_label} '
+ f''
+ f'{labels["yes"] if enabled else labels["no"]} '
+ for feature_label, enabled in feature_rows
+ )
+ features_html = (
+ f'{labels["features"]} '
+ f'
'
+ )
+
+ packs = plan.get("content_packs", [])
+ if packs:
+ packs_list = "".join(
+ f"{html.escape(str(pack.get('label_fr' if lang == 'fr' else 'label_en') or pack.get('id') or ''))} "
+ for pack in packs
+ )
+ packs_html = f""
+ else:
+ packs_html = f"{labels['packs']} {labels['none']}
"
+
+ decision_label = localized_decision_label(str(decision_status), lang)
+ provider_label = localized_provider_label(str(provider), lang)
+
+ return f"""
+
+
{labels['title']}
+
{labels['model']} {html.escape(str(model.get('model_ref', 'n/a')))}
+
{labels['backend']} {html.escape(str(backend.get('backend', 'n/a')))}
+
{labels['action']} {html.escape(decision_label)} {html.escape(str(decision_status))}
+
{labels['decision_engine']} {html.escape(provider_label)}
+ {features_html}
+ {packs_html}
+
{labels['build']} {html.escape(str(plan.get('build_id', 'n/a')))}
+
+"""
+
+
+
+def run_internal_decision_ui(
+ language: str,
+ sample_name: str,
+ raw_profile_json: str,
+ package_goal: str,
+ simple_target: str,
+ target_mode: str,
+ target_drive: str,
+ decision_answers_json: str,
+ operator_notes: str,
+) -> tuple[str, str]:
+ plan = build_plan_object(language, sample_name, raw_profile_json, package_goal)
+ app_state = build_decision_app_state(
+ language,
+ plan,
+ raw_profile_json,
+ simple_target=simple_target,
+ target_mode=target_mode,
+ target_drive=target_drive,
+ user_answers_json=decision_answers_json,
+ operator_notes=operator_notes,
+ decision_engine_required=True,
+ )
+ try:
+ decision = decision_engine.decide_internal_action(app_state)
+ except Exception as exc:
+ raise gr.Error(f"AI decision engine failed: {exc}") from exc
+ finalized = apply_internal_decision_to_plan(plan, decision)
+ return (
+ plan_summary_card(finalized, language) + format_internal_decision_markdown(decision, language),
+ json.dumps(finalized, indent=2, ensure_ascii=False),
+ )
+
+
+def run_unified_ai_audit(
+ language: str,
+ sample_name: str,
+ raw_profile_json: str,
+ package_goal: str,
+ simple_target: str,
+ target_mode: str,
+ target_drive: str,
+ decision_answers_json: str,
+ operator_notes: str,
+ agent_model_choice: str,
+ oauth_token: "gr.OAuthToken | None" = None,
+) -> tuple[str, str]:
+ plan = build_plan_object(language, sample_name, raw_profile_json, package_goal)
+
+ # 1. Run Hosted/SaaS Decision Engine (if configured)
+ has_remote = False
+ decision_card = ""
+ if decision_engine.configured_provider():
+ app_state = build_decision_app_state(
+ language,
+ plan,
+ raw_profile_json,
+ simple_target=simple_target,
+ target_mode=target_mode,
+ target_drive=target_drive,
+ user_answers_json=decision_answers_json,
+ operator_notes=operator_notes,
+ decision_engine_required=False,
+ )
+ try:
+ decision = decision_engine.decide_internal_action(app_state)
+ plan = apply_internal_decision_to_plan(plan, decision)
+ decision_card = plan_summary_card(plan, language) + format_internal_decision_markdown(decision, language)
+ has_remote = True
+ except Exception as exc:
+ decision_card = f"### {get_txt('audit_saas_warning_title', language)}\n{get_txt('audit_saas_warning_body', language).format(detail=exc)}\n"
+ else:
+ decision_card = f"### {get_txt('audit_saas_skipped_title', language)}\n{get_txt('audit_saas_skipped_body', language)}\n"
+
+ # 2. Run Local LLM Agent Review (if Ollama or local daemon is running)
+ agent_markdown = ""
+ try:
+ agent_markdown, plan_json_str = run_agent_plan_review(
+ language,
+ sample_name,
+ raw_profile_json,
+ package_goal,
+ agent_model_choice,
+ operator_notes,
+ oauth_token=oauth_token,
+ )
+ plan = json.loads(plan_json_str)
+ except Exception as exc:
+ agent_markdown = f"\n\n### {get_txt('audit_local_warning_title', language)}\n{get_txt('audit_local_warning_body', language).format(detail=exc)}"
+
+ # 3. Combine reports
+ merged_markdown = ""
+ if has_remote:
+ merged_markdown += decision_card
+ merged_markdown += "\n---\n"
+ merged_markdown += agent_markdown
+ else:
+ merged_markdown += decision_card
+ merged_markdown += "\n"
+ merged_markdown += agent_markdown
+
+ # Attach the full agent trace (prompt + raw model output + validated
+ # decision): verifiable proof that a real LLM call produced the review.
+ display_payload = dict(plan)
+ trace = decision_engine.get_last_decision_trace()
+ if trace:
+ display_payload["agent_trace"] = trace
+ return merged_markdown, json.dumps(display_payload, indent=2, ensure_ascii=False)
+
+
+def build_final_plan_object(
+ language: str,
+ sample_name: str,
+ raw_profile_json: str,
+ package_goal: str,
+ require_remote_agent: bool | None = None,
+ simple_target: str = "",
+ target_mode: str = "",
+ target_drive: str = "",
+ decision_answers_json: str = "",
+ operator_notes: str = "",
+) -> dict[str, Any]:
+ plan = build_plan_object(language, sample_name, raw_profile_json, package_goal)
+ must_use_agent = REMOTE_AGENT_REQUIRED if require_remote_agent is None else require_remote_agent
+ app_state = build_decision_app_state(
+ language,
+ plan,
+ raw_profile_json,
+ simple_target=simple_target,
+ target_mode=target_mode,
+ target_drive=target_drive,
+ user_answers_json=decision_answers_json,
+ operator_notes=operator_notes,
+ decision_engine_required=must_use_agent,
+ )
+ if not decision_engine.configured_provider():
+ if must_use_agent:
+ raise gr.Error(get_txt("err_remote_required", language))
+ plan["internal_decision"] = decision_engine.decide_internal_action(app_state)
+ return plan
+ try:
+ decision = decision_engine.decide_internal_action(app_state)
+ finalized = apply_internal_decision_to_plan(plan, decision)
+ block_on_questions = os.getenv("BLOCK_ON_AI_DECISION_QUESTIONS", "true").lower() not in {"0", "false", "no"}
+ if decision_requires_user_response(decision) and (must_use_agent or block_on_questions):
+ raise gr.Error(
+ get_txt("err_more_info", language).format(questions=decision_questions_text(decision))
+ )
+ return finalized
+ except Exception as exc:
+ if must_use_agent:
+ raise gr.Error(get_txt("err_remote_failed", language).format(detail=exc)) from exc
+ plan["internal_decision"] = {
+ "status": "failed",
+ "provider": (decision_engine.provider_status() or {}).get("provider"),
+ "model": (decision_engine.provider_status() or {}).get("model"),
+ "applied": False,
+ "error": str(exc),
+ }
+ return plan
+
+def plan_to_markdown(plan: dict[str, Any], language: str) -> str:
+ lang = lang_key(language)
+ default = plan["default_model"]
+ agent = plan.get("agent_model")
+ label_key = "label_fr" if lang == "fr" else "label_en"
+ notes_key = "notes_fr" if lang == "fr" else "notes_en"
+ agent_line = (
+ f"{agent[label_key]} `{agent['model_ref']}`"
+ if agent
+ else get_txt("plan_agent_fallback", language).format(model_ref=CONFIG_AGENT_MODEL_REF)
+ )
+ agent_decision = plan.get("agent_decision", {})
+ agent_status = agent_decision.get("status", "not_run")
+ agent_provider = agent_decision.get("provider") or get_txt("plan_not_configured", language)
+ planning_agent = plan.get("backend_plan", {}).get("planning_agent")
+ planning_agent_line = (
+ f"{planning_agent.get('backend')} `{planning_agent.get('model')}`"
+ if planning_agent
+ else get_txt("plan_no_hosted_agent", language)
+ )
+
+ model_rows = []
+ for model in plan["allowed_models"]:
+ model_rows.append(
+ f"- **{model[label_key]}**: `{model['model_ref']}` / {model['params_b']}B / "
+ f"{model['task']} / {model['tier']} / {model[notes_key]}"
+ )
+
+ pack_rows = []
+ for pack in plan["content_packs"]:
+ pack_rows.append(f"- **{pack[label_key]}** (`{pack['id']}`)")
+
+ why_rows = "\n".join(get_txt("why_rules", language))
+ policy_rows = "\n".join(get_txt("plan_policy_rules", language))
+ return f"""
+### {get_txt("plan_title", language)}
+
+**{get_txt("plan_default_model", language)}**: {default[label_key]} `{default['model_ref']}`
+**{get_txt("plan_agent_label", language)}**: {agent_line}
+**{get_txt("plan_hosted_agent", language)}**: {planning_agent_line}
+**{get_txt("plan_agent_status", language)}**: `{agent_status}` / `{agent_provider}`
+**{get_txt("plan_goal", language)}**: {plan['package_goal']}
+**{get_txt("plan_backends", language)}**: {', '.join(plan['backends'])}
+**{get_txt("plan_build_id", language)}**: `{plan['build_id']}`
+
+### {get_txt("why_title", language)}
+{why_rows}
+
+### {get_txt("plan_models_title", language)}
+{chr(10).join(model_rows)}
+
+### {get_txt("plan_packs_title", language)}
+{chr(10).join(pack_rows) if pack_rows else get_txt("plan_no_packs", language)}
+
+### {get_txt("plan_policy_title", language)}
+{policy_rows}
+"""
+
+def build_plan(language: str, sample_name: str, raw_profile_json: str, package_goal: str) -> tuple[str, str]:
+ plan = build_plan_object(language, sample_name, raw_profile_json, package_goal)
+ return json.dumps(plan, indent=2, ensure_ascii=False), plan_to_markdown(plan, language)
+
+def export_manifest_zip(
+ language: str,
+ sample_name: str,
+ raw_profile_json: str,
+ package_goal: str,
+ simple_target: str = "",
+ target_mode: str = "",
+ target_drive: str = "",
+ decision_answers_json: str = "",
+ operator_notes: str = "",
+) -> str:
+ plan = build_final_plan_object(
+ language,
+ sample_name,
+ raw_profile_json,
+ package_goal,
+ simple_target=simple_target,
+ target_mode=target_mode,
+ target_drive=target_drive,
+ decision_answers_json=decision_answers_json,
+ operator_notes=operator_notes,
+ )
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".zip", prefix="jackailocal-build-manifest-") as tmp:
+ output_path = tmp.name
+ readme = f"""# JackAILocal Build Manifest
+
+Build ID: {plan['build_id']}
+Selector: {SELECTOR_VERSION}
+Default model: {plan['default_model']['model_ref']}
+Agent model: {(plan.get('agent_model') or {}).get('model_ref') or 'not selected for this hardware profile'}
+Planning agent: {plan.get('agent_decision', {}).get('provider') or 'not configured'} / {plan.get('agent_decision', {}).get('model') or 'n/a'}
+Agent decision: {plan.get('agent_decision', {}).get('status')}
+Package goal: {plan['package_goal']}
+
+This manifest ZIP drives the real builder. It does not contain the runtime payload by itself.
+Use a platform builder ZIP to install the runtime and preload models on a target drive.
+"""
+ with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as archive:
+ archive.writestr("manifest/build-manifest.json", json.dumps(plan, indent=2, ensure_ascii=False).encode("utf-8"))
+ archive.writestr("manifest/model-policy.json", json.dumps({
+ "max_params_b": MAX_MODEL_PARAMS_B,
+ "default_is_not_limit": True,
+ "standard_pack_cap_b": 9,
+ "power_pack_cap_b": 32,
+ "selector_version": SELECTOR_VERSION
+ }, indent=2).encode("utf-8"))
+ archive.writestr("examples/hardware-profile-gpu-workstation.json", json.dumps(SAMPLE_PROFILES["GPU workstation (24 GB VRAM)"], indent=2).encode("utf-8"))
+ archive.writestr("examples/hardware-profile-normal-laptop.json", json.dumps(SAMPLE_PROFILES["Normal laptop"], indent=2).encode("utf-8"))
+ archive.writestr("README.md", readme.encode("utf-8"))
+ return output_path
+
+def export_profiler_zip() -> str:
+ """ZIP of the standalone PC analyzer the customer runs on their own machine."""
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".zip", prefix="jackailocal-pc-analyzer-") as tmp:
+ output_path = tmp.name
+ profiler_dir = os.path.join(WORKSPACE_ROOT, "profiler")
+ names = ["README.txt", "SCAN-MY-PC.cmd", "scan-windows.ps1", "SCAN-MY-MAC.command", "scan-unix.sh"]
+ added = 0
+ with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as archive:
+ for name in names:
+ path = os.path.join(profiler_dir, name)
+ if os.path.isfile(path):
+ archive.write(path, f"JackAILocal-PC-Analyzer/{name}")
+ added += 1
+ if added == 0:
+ raise gr.Error("Profiler files are missing from the workspace (profiler/ folder).")
+ return output_path
+
+def load_profile_file(file_path: str | None) -> str:
+ """Reads an uploaded hardware-profile JSON into the profile textbox."""
+ if not file_path:
+ return ""
+ try:
+ with open(file_path, "r", encoding="utf-8-sig") as handle:
+ content = handle.read()
+ parsed = json.loads(content)
+ except Exception as error:
+ raise gr.Error(f"Invalid hardware profile file: {error}")
+ if not isinstance(parsed, dict) or not any(
+ key in parsed for key in ("ram_gb", "cpu_name", "vram_gb", "gpus", "os")
+ ):
+ raise gr.Error("This JSON file is not a JackAILocal hardware profile (no hardware keys found).")
+ return content
+
+def export_platform_builder_zip(
+ language: str,
+ sample_name: str,
+ raw_profile_json: str,
+ package_goal: str,
+ platform: str,
+ simple_target: str = "",
+ target_mode: str = "",
+ target_drive: str = "",
+ decision_answers_json: str = "",
+ operator_notes: str = "",
+) -> str:
+ platform_key = "macos" if "mac" in str(platform).lower() else "windows"
+ plan = build_final_plan_object(
+ language,
+ sample_name,
+ raw_profile_json,
+ package_goal,
+ simple_target=simple_target or f"publish_{platform_key}",
+ target_mode=target_mode,
+ target_drive=target_drive,
+ decision_answers_json=decision_answers_json,
+ operator_notes=operator_notes,
+ )
+ prefix = "jackailocal-macos-builder-" if platform_key == "macos" else "jackailocal-windows-builder-"
+ # Core (small) files must always be present; the heavy native binaries are
+ # the runtime payload. On a hosted Space those binaries are not shipped
+ # (HF rejects large binaries), so we still export a "lite" builder whose
+ # START-HERE script fetches the runtime locally on first run.
+ core_files = [
+ "config/voice-assets.json",
+ "webui/index.html",
+ "webui/app-v15.js",
+ ]
+ if platform_key == "windows":
+ binary_payload = ["bin/jackailocald.exe", "backends/ollama/windows/ollama.exe"]
+ else:
+ binary_payload = ["bin/jackailocald", "backends/ollama/macos/ollama", "backends/whisper.cpp/macos/whisper-cli"]
+ missing_core = [p for p in core_files if not os.path.isfile(os.path.join(WORKSPACE_ROOT, p))]
+ if missing_core:
+ raise gr.Error(
+ f"Cannot export a {platform_key} builder. Missing core files: {', '.join(missing_core)}"
+ )
+ missing_binaries = [p for p in binary_payload if not os.path.isfile(os.path.join(WORKSPACE_ROOT, p))]
+ if missing_binaries and IS_LOCAL:
+ # Locally the binaries are expected; their absence is a real error.
+ raise gr.Error(
+ f"Cannot export a real {platform_key} builder. Missing required payload files: {', '.join(missing_binaries)}"
+ )
+ lite_build = bool(missing_binaries)
+ payload_items = [
+ "START-HERE.cmd", "START-DESKTOP.cmd", "STOP-JACKAILOCAL.cmd", "START-HERE.command", "STOP-JACKAILOCAL.command",
+ "BUILD-LOCAL.cmd", "BUILD-USB.cmd", "BUILD-SSD.cmd", "BUILD-LOCAL-MAC.command", "BUILD-USB-MAC.command", "BUILD-SSD-MAC.command", "README-FIRST.txt", "autorun.inf",
+ "webui", "windows", "macos", "unix", "config", "manifest", "models", "backends", "bin",
+ "docs", "tools", "scripts", "updates", "licenses", "client-builder", "content", "content-packs",
+ "installer", "deploy",
+ ]
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".zip", prefix=prefix) as tmp:
+ output_path = tmp.name
+
+ plan["hosted_lite_build"] = lite_build
+ plan["bundled_native_binaries"] = not lite_build
+ manifest_bytes = json.dumps(plan, indent=2, ensure_ascii=False).encode("utf-8")
+ with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as archive:
+ archive.writestr("manifest/build-manifest.json", manifest_bytes)
+ if lite_build:
+ payload_note = (
+ "This is a LITE package exported from the hosted demo Space. It contains "
+ "the real builder scripts, WebUI and the AI-reviewed build manifest, but "
+ "NOT the native runtime binaries (the hosted Space cannot ship them).\n\n"
+ "It is meant for inspection of the plan/manifest. To actually BUILD and RUN "
+ "JackAILocal locally, use the full package (the GitHub repository, which "
+ "includes the binaries) and run BUILD-LOCAL.cmd / BUILD-USB.cmd / BUILD-SSD.cmd. "
+ "The builder also works if a system-wide Ollama is already installed, but it "
+ "still requires bin/jackailocald.exe from the full package.\n\n"
+ f"Binaries not bundled: {', '.join(binary_payload)}\n"
+ )
+ else:
+ payload_note = (
+ "This package contains the real JackAILocal builder and runtime payload. "
+ "The embedded manifest drives local model installation.\n"
+ )
+ archive.writestr("README.md", (
+ f"# JackAILocal {platform_key} Builder{' (lite)' if lite_build else ''}\n\n"
+ f"Build ID: {plan['build_id']}\n"
+ f"Default model: {plan['default_model']['model_ref']}\n\n"
+ f"Agent model: {(plan.get('agent_model') or {}).get('model_ref') or 'not selected for this hardware profile'}\n\n"
+ + payload_note
+ ).encode("utf-8"))
+ for item in payload_items:
+ source = os.path.join(WORKSPACE_ROOT, item)
+ if os.path.isfile(source):
+ archive.write(source, item)
+ elif os.path.isdir(source):
+ for current_root, dirs, files in os.walk(source):
+ dirs[:] = [name for name in dirs if name not in {".git", "__pycache__", "target", ".jackailocal-builder", "license-keys"}]
+ for filename in files:
+ full_path = os.path.join(current_root, filename)
+ relative = os.path.relpath(full_path, WORKSPACE_ROOT).replace("\\", "/")
+ if relative == "config/update-private-key.xml" or filename.endswith(".pyc"):
+ continue
+ archive.write(full_path, relative)
+ return output_path
+
+def _write_payload_item(archive: zipfile.ZipFile, item: str) -> None:
+ source = os.path.join(WORKSPACE_ROOT, item)
+ if os.path.isfile(source):
+ archive.write(source, item.replace("\\", "/"))
+ return
+ if not os.path.isdir(source):
+ return
+ excluded_dirs = {".git", "__pycache__", "target", ".jackailocal-builder", ".jackailocal", "workspace", "diagnostics", "license-keys"}
+ for current_root, dirs, files in os.walk(source):
+ dirs[:] = [name for name in dirs if name not in excluded_dirs]
+ for filename in files:
+ if filename.endswith(".pyc"):
+ continue
+ full_path = os.path.join(current_root, filename)
+ relative = os.path.relpath(full_path, WORKSPACE_ROOT).replace("\\", "/")
+ if relative == "config/update-private-key.xml":
+ continue
+ archive.write(full_path, relative)
+
+def export_appliance_builder_zip(
+ language: str,
+ sample_name: str,
+ raw_profile_json: str,
+ package_goal: str,
+ decision_answers_json: str = "",
+ operator_notes: str = "",
+) -> str:
+ plan = build_final_plan_object(
+ language,
+ sample_name,
+ raw_profile_json,
+ package_goal,
+ simple_target="publish_appliance",
+ decision_answers_json=decision_answers_json,
+ operator_notes=operator_notes,
+ )
+ required_files = [
+ "Cargo.toml",
+ "Cargo.lock",
+ "deploy/appliance/Dockerfile",
+ "deploy/appliance/docker-compose.yml",
+ "deploy/appliance/entrypoint.sh",
+ "src/main.rs",
+ "config/jackailocal.appliance.toml",
+ "config/voice-assets.json",
+ "webui/index.html",
+ "webui/app-v15.js",
+ ]
+ missing = [path for path in required_files if not os.path.isfile(os.path.join(WORKSPACE_ROOT, path))]
+ if missing:
+ raise gr.Error(
+ f"Cannot export a Linux appliance package. Missing required files: {', '.join(missing)}"
+ )
+
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".zip", prefix="jackailocal-linux-appliance-") as tmp:
+ output_path = tmp.name
+
+ model_ref = plan["default_model"]["model_ref"]
+ readme = f"""# JackAILocal Linux Appliance
+
+Build ID: {plan['build_id']}
+Default model: {model_ref}
+
+This package publishes a Docker/Linux runtime for a mini-PC, NAS, or local server.
+
+Quick start:
+
+```bash
+cd deploy/appliance
+cp .env.example .env
+docker compose up --build -d
+./show-url.sh
+```
+
+The first start preloads the selected Ollama model into the appliance volume.
+After preload, normal use stays local.
+"""
+
+ payload_items = [
+ "Cargo.toml", "Cargo.lock", "src",
+ "deploy/appliance", "factory/unix", "unix",
+ "webui", "config", "manifest", "content", "content-packs", "licenses",
+ "docs", "README-FIRST.txt",
+ ]
+
+ with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as archive:
+ archive.writestr("manifest/build-manifest.json", json.dumps(plan, indent=2, ensure_ascii=False).encode("utf-8"))
+ archive.writestr("README.md", readme.encode("utf-8"))
+ archive.writestr("deploy/appliance/.env.example", (
+ f"JACKAILOCAL_MODEL={model_ref}\n"
+ "JACKAILOCAL_PORT=4891\n"
+ ).encode("utf-8"))
+ for item in payload_items:
+ _write_payload_item(archive, item)
+ return output_path
+
+def simple_package_goal(simple_goal: str, fallback_goal: str = "Standard offline assistant") -> str:
+ return SIMPLE_GOAL_TO_PACKAGE_GOAL.get(simple_goal, fallback_goal)
+
+def simple_target_mode(simple_target: str) -> str | None:
+ return SIMPLE_TARGET_MODES.get(simple_target)
+
+def default_local_target_path() -> str:
+ return os.path.join(os.environ.get("LOCALAPPDATA") or os.path.expanduser("~"), "JackAILocal")
+
+def target_drive_choices_for_mode(mode: str, current_value: str = "") -> tuple[list[str], str]:
+ drives = get_real_drives(mode)
+ fallback = default_local_target_path() if mode == "LocalFolder" else "E:\\"
+ choices = list(drives) if drives else [fallback]
+ value = fallback if mode == "LocalFolder" else (current_value or (drives[0] if drives else fallback))
+ if mode != "LocalFolder" and drives and value not in drives:
+ value = drives[0]
+ if value and value not in choices:
+ choices.append(value)
+ return choices, value
+
+def sync_simple_goal(simple_goal: str) -> dict:
+ return gr.update(value=simple_package_goal(simple_goal))
+
+def sync_simple_target(simple_target: str, current_drive: str) -> tuple[dict, dict]:
+ mode = simple_target_mode(simple_target) or "LocalFolder"
+ choices, value = target_drive_choices_for_mode(mode, current_drive)
+ return gr.update(value=mode), gr.update(choices=choices, value=value)
+
+def simple_flow_summary(plan: dict[str, Any], simple_target: str, language: str) -> str:
+ target_label = simple_target_label(simple_target, language)
+ model = plan["default_model"]["model_ref"]
+ decision = plan.get("internal_decision") or plan.get("agent_decision", {})
+ provider = (
+ (decision.get("provider_status") or {}).get("provider")
+ or decision.get("provider")
+ or get_txt("plan_not_configured", language)
+ )
+ ai_status = decision.get("decision") or decision.get("status", "not_run")
+ ai_line = f"{ai_status} / {provider}"
+ return (
+ f"### {get_txt('flow_title', language)}\n\n"
+ f"- {get_txt('flow_target', language)}: **{target_label}**\n"
+ f"- {get_txt('flow_model', language)}: `{model}`\n"
+ f"- {get_txt('flow_agent', language)}: `{ai_line}`\n"
+ f"- {get_txt('flow_updates', language)}\n"
+ f"- {get_txt('flow_enduser', language)}\n"
+ )
+
+def run_one_click_flow(
+ language: str,
+ simple_goal: str,
+ simple_target: str,
+ sample_name: str,
+ raw_profile_json: str,
+ target_drive: str,
+ decision_answers_json: str = "",
+ operator_notes: str = "",
+ oauth_token: "gr.OAuthToken | None" = None,
+):
+ package_goal = simple_package_goal(simple_goal)
+ mode = simple_target_mode(simple_target) or "LocalFolder"
+ if mode == "LocalFolder":
+ target_drive = default_local_target_path()
+ try:
+ plan = build_final_plan_object(
+ language,
+ sample_name,
+ raw_profile_json,
+ package_goal,
+ simple_target=simple_target,
+ target_mode=mode,
+ target_drive=target_drive,
+ decision_answers_json=decision_answers_json,
+ operator_notes=operator_notes,
+ )
+ except Exception as exc:
+ err_msg = f"[ERROR] {get_txt('err_plan_failed', language)}\n{exc}"
+ yield (
+ err_msg,
+ False,
+ get_sidebar_status(False, False, language, "None")[0],
+ gr.update(visible=True),
+ gr.update(visible=False),
+ err_msg,
+ None,
+ "",
+ "",
+ )
+ return
+ plan_json_text = json.dumps(plan, indent=2, ensure_ascii=False)
+ plan_md_text = plan_to_markdown(plan, language)
+ target_label = simple_target_label(simple_target, language)
+ summary = simple_flow_summary(plan, simple_target, language)
+
+ # The config agent (Gemma 4 12B) is part of the one-click flow, not an
+ # optional advanced step: its review is shown in the console before the
+ # build starts. If no local agent is reachable, say so explicitly.
+ yield (
+ f"[AGENT] {get_txt('flow_agent_running', language)}",
+ False,
+ get_sidebar_status(False, False, language, plan["default_model"]["model_ref"])[0],
+ gr.update(visible=False),
+ gr.update(visible=False),
+ f"{plan_md_text}\n\n*{get_txt('flow_agent_running', language)}*",
+ None,
+ plan_json_text,
+ plan_md_text,
+ )
+ try:
+ audit_md, _audit_json = run_agent_plan_review(
+ language, sample_name, raw_profile_json, package_goal, "auto", operator_notes or "",
+ oauth_token=oauth_token,
+ )
+ except Exception as exc:
+ audit_md = get_txt("flow_agent_failed", language).format(detail=exc)
+ summary = f"{plan_md_text}\n\n---\n\n{audit_md}\n\n---\n\n{summary}"
+
+ if simple_target == "publish_windows":
+ yield (
+ f"[PUBLISH] {get_txt('publish_windows', language).format(target=target_label)}",
+ False,
+ get_sidebar_status(False, False, language, plan["default_model"]["model_ref"])[0],
+ gr.update(visible=False),
+ gr.update(visible=False),
+ summary,
+ None,
+ plan_json_text,
+ plan_md_text,
+ )
+ package_path = export_platform_builder_zip(
+ language,
+ sample_name,
+ raw_profile_json,
+ package_goal,
+ "windows",
+ simple_target,
+ mode,
+ target_drive,
+ decision_answers_json,
+ operator_notes,
+ )
+ msg = "\n" + get_txt("ready_windows", language)
+ yield (
+ f"[SUCCESS] {get_txt('success_windows', language)}\n{package_path}",
+ False,
+ get_sidebar_status(False, False, language, plan["default_model"]["model_ref"])[0],
+ gr.update(visible=False),
+ gr.update(visible=False),
+ summary + msg,
+ package_path,
+ plan_json_text,
+ plan_md_text,
+ )
+ return
+
+ if simple_target == "publish_macos":
+ yield (
+ f"[PUBLISH] {get_txt('publish_macos', language).format(target=target_label)}",
+ False,
+ get_sidebar_status(False, False, language, plan["default_model"]["model_ref"])[0],
+ gr.update(visible=False),
+ gr.update(visible=False),
+ summary,
+ None,
+ plan_json_text,
+ plan_md_text,
+ )
+ package_path = export_platform_builder_zip(
+ language,
+ sample_name,
+ raw_profile_json,
+ package_goal,
+ "macos",
+ simple_target,
+ mode,
+ target_drive,
+ decision_answers_json,
+ operator_notes,
+ )
+ msg = "\n" + get_txt("ready_macos", language)
+ yield (
+ f"[SUCCESS] {get_txt('success_macos', language)}\n{package_path}",
+ False,
+ get_sidebar_status(False, False, language, plan["default_model"]["model_ref"])[0],
+ gr.update(visible=False),
+ gr.update(visible=False),
+ summary + msg,
+ package_path,
+ plan_json_text,
+ plan_md_text,
+ )
+ return
+
+ if simple_target == "publish_appliance":
+ yield (
+ f"[PUBLISH] {get_txt('publish_appliance', language).format(target=target_label)}",
+ False,
+ get_sidebar_status(False, False, language, plan["default_model"]["model_ref"])[0],
+ gr.update(visible=False),
+ gr.update(visible=False),
+ summary,
+ None,
+ plan_json_text,
+ plan_md_text,
+ )
+ package_path = export_appliance_builder_zip(
+ language,
+ sample_name,
+ raw_profile_json,
+ package_goal,
+ decision_answers_json,
+ operator_notes,
+ )
+ msg = "\n" + get_txt("ready_appliance", language)
+ yield (
+ f"[SUCCESS] {get_txt('success_appliance', language)}\n{package_path}",
+ False,
+ get_sidebar_status(False, False, language, plan["default_model"]["model_ref"])[0],
+ gr.update(visible=False),
+ gr.update(visible=False),
+ summary + msg,
+ package_path,
+ plan_json_text,
+ plan_md_text,
+ )
+ return
+
+ for log_text, ready, sidebar_html, start_update, stop_update in run_physical_usb_build(
+ language,
+ sample_name,
+ raw_profile_json,
+ package_goal,
+ target_drive,
+ mode,
+ ):
+ ready_note = ""
+ if ready:
+ started, start_detail = start_target_runtime_no_browser(target_drive)
+ sidebar_html, start_update, stop_update = get_sidebar_status(
+ ready,
+ started,
+ language,
+ plan["default_model"]["model_ref"],
+ )
+ if started:
+ ready_note = "\n" + get_txt("target_ready_started", language)
+ else:
+ ready_note = "\n" + get_txt("target_ready_attention", language).format(detail=start_detail)
+ yield (
+ log_text,
+ ready,
+ sidebar_html,
+ start_update,
+ stop_update,
+ summary + ready_note,
+ None,
+ plan_json_text,
+ plan_md_text,
+ )
+
+def run_agent_plan_review(language: str, sample_name: str, raw_profile_json: str, package_goal: str, agent_model_choice: str, customer_notes: str, oauth_token=None) -> tuple[str, str]:
+ plan = build_plan_object(language, sample_name, raw_profile_json, package_goal)
+ if oauth_agent_provider(oauth_token) or remote_agent_provider() or ui_remote_agent_provider():
+ plan["operator_notes"] = customer_notes or ""
+ result = call_remote_config_agent(plan, language, oauth_token)
+ finalized = apply_agent_decision(plan, result)
+ parsed = result["parsed"]
+ summary = parsed.get("human_summary") or result["raw"]
+ selected = parsed.get("selected_client_model_ref") or parsed.get("selected_model_ref") or finalized["default_model"]["model_ref"]
+ markdown = f"""### {get_txt("agent_review_hosted_title", language)}
+
+**{get_txt("agent_lbl_provider", language)}**: `{result['provider']}`
+**{get_txt("agent_lbl_model", language)}**: `{result['model']}`
+**{get_txt("agent_lbl_selected_client", language)}**: `{selected}`
+**{get_txt("agent_lbl_final_default", language)}**: `{finalized['default_model']['model_ref']}`
+**{get_txt("agent_lbl_decision_status", language)}**: `{finalized['agent_decision']['status']}`
+
+{summary}
+
+**{get_txt("agent_risk_flags", language)}**
+{chr(10).join(f"- {item}" for item in as_bullet_list(parsed.get("risk_flags"))) if as_bullet_list(parsed.get("risk_flags")) else get_txt("agent_none_reported", language)}
+
+**{get_txt("agent_next_steps", language)}**
+{chr(10).join(f"- {item}" for item in as_bullet_list(parsed.get("next_steps"))) if as_bullet_list(parsed.get("next_steps")) else get_txt("agent_none_reported", language)}
+"""
+ return markdown, json.dumps(finalized, indent=2, ensure_ascii=False)
+
+ agent_model = agent_model_choice or "auto"
+ payload = {
+ "language": lang_key(language),
+ "package_goal": package_goal,
+ "hardware": plan["hardware"],
+ "default_model": plan["default_model"],
+ "candidate_models": plan["allowed_models"],
+ "content_packs": plan["content_packs"],
+ "current_config": {
+ "target": "Gradio builder manifest",
+ "build_id": plan["build_id"],
+ "llm_in_the_loop": plan["llm_in_the_loop"],
+ },
+ "customer_notes": customer_notes or "",
+ "agent_model": agent_model,
+ "max_params_b": MAX_MODEL_PARAMS_B,
+ "agent_max_tokens": 192,
+ "agent_timeout_seconds": 180,
+ }
+
+ daemon_success = False
+ result = {}
+ try:
+ response = requests.post("http://127.0.0.1:4891/api/agent/recommend", json=payload, timeout=5)
+ if response.status_code == 200:
+ result = response.json()
+ daemon_success = True
+ except Exception:
+ pass
+
+ if daemon_success:
+ parsed = result.get("agent_json")
+ raw = result.get("raw_content", "")
+ if parsed:
+ summary = parsed.get("human_summary") or parsed.get("summary") or raw
+ selected = parsed.get("selected_model_ref") or parsed.get("selected_model_id") or "n/a"
+ confidence = parsed.get("confidence", "n/a")
+ risk_flags = as_bullet_list(parsed.get("risk_flags"))
+ next_steps = as_bullet_list(parsed.get("next_steps"))
+ markdown = f"""### {get_txt("agent_review_local_title", language)}
+
+**{get_txt("agent_lbl_agent_model", language)}**: `{result.get('agent_model')}`
+**{get_txt("agent_lbl_selected", language)}**: `{selected}`
+**{get_txt("agent_lbl_confidence", language)}**: `{confidence}`
+
+{summary}
+
+**{get_txt("agent_risk_flags", language)}**
+{chr(10).join(f"- {item}" for item in risk_flags) if risk_flags else get_txt("agent_none_reported", language)}
+
+**{get_txt("agent_next_steps", language)}**
+{chr(10).join(f"- {item}" for item in next_steps) if next_steps else get_txt("agent_none_reported", language)}
+"""
+ else:
+ markdown = f"""### {get_txt("agent_review_local_title", language)}
+
+**{get_txt("agent_lbl_agent_model", language)}**: `{result.get('agent_model')}`
+
+{get_txt("agent_non_json", language)}
+
+```text
+{raw}
+```
+"""
+ return markdown, json.dumps(result, indent=2, ensure_ascii=False)
+
+ # FALLBACK: Try querying local Ollama instance directly!
+ ollama_endpoints = []
+ env_host = os.environ.get("OLLAMA_HOST", "").strip().rstrip("/")
+ if env_host:
+ if not env_host.startswith("http"):
+ if ":" in env_host:
+ ollama_endpoints.append(f"http://{env_host}")
+ else:
+ ollama_endpoints.append(f"http://127.0.0.1:{env_host}")
+ else:
+ ollama_endpoints.append(env_host)
+ ollama_endpoints.extend(["http://127.0.0.1:11434", "http://127.0.0.1:11435"])
+
+ ollama_host = None
+ installed = []
+ for endpoint in ollama_endpoints:
+ try:
+ r = requests.get(f"{endpoint}/api/tags", timeout=1.5)
+ if r.status_code == 200:
+ installed = [m["name"] for m in r.json().get("models", [])]
+ ollama_host = endpoint
+ break
+ except Exception:
+ continue
+
+ if ollama_host and installed:
+ model_to_use = None
+ requested = CONFIG_AGENT_MODEL_REF # gemma4:12b
+ if agent_model_choice != "auto":
+ if requested in installed:
+ model_to_use = requested
+ else:
+ for inst in installed:
+ if inst.startswith(requested) or requested.startswith(inst):
+ model_to_use = inst
+ break
+ if not model_to_use:
+ for p in ["gemma4:12b", "gemma4:latest"]:
+ if p in installed:
+ model_to_use = p
+ break
+ if not model_to_use and os.environ.get("JACKAI_AGENT_ALLOW_FALLBACK", "").strip() == "1":
+ priority = ["qwen3.5:9b", "qwen3.5:4b", "qwen3.5:2b", "gemma:latest"]
+ for p in priority:
+ if p in installed:
+ model_to_use = p
+ break
+ if not model_to_use:
+ model_to_use = installed[0]
+ if not model_to_use:
+ # The configuration agent must be Gemma 4 12B; a silent downgrade
+ # would produce lower-quality plans without the operator knowing.
+ error_md = (
+ f"### {get_txt('agent_review_local_title', language)}\n\n"
+ + get_txt("agent_gemma_missing", language)
+ )
+ return error_md, json.dumps(
+ {"error": "config agent gemma4:12b is not installed", "installed": installed},
+ indent=2,
+ )
+
+ try:
+ url = f"{ollama_host}/api/chat"
+ chat_payload = {
+ "model": model_to_use,
+ "messages": agent_messages_for_plan(plan, language),
+ "stream": False,
+ "options": {
+ "temperature": 0.1,
+ "num_predict": 768
+ },
+ "format": "json"
+ }
+ res = requests.post(url, json=chat_payload, timeout=180)
+ if res.status_code == 200:
+ response_json = res.json()
+ content = response_json.get("message", {}).get("content", "")
+ parsed = parse_json_object_from_text(content)
+ if parsed:
+ result = {
+ "provider": "local_ollama",
+ "model": model_to_use,
+ "raw": content,
+ "parsed": parsed,
+ }
+ finalized = apply_agent_decision(plan, result)
+ summary = parsed.get("human_summary") or parsed.get("summary") or content
+ selected = parsed.get("selected_client_model_ref") or parsed.get("selected_model_ref") or finalized["default_model"]["model_ref"]
+ confidence = parsed.get("confidence", "n/a")
+ risk_flags = as_bullet_list(parsed.get("risk_flags"))
+ next_steps = as_bullet_list(parsed.get("next_steps"))
+
+ markdown = f"""### {get_txt("agent_review_ollama_title", language)}
+
+**{get_txt("agent_lbl_agent_model", language)}**: `{model_to_use}`
+**{get_txt("agent_lbl_selected", language)}**: `{selected}`
+**{get_txt("agent_lbl_confidence", language)}**: `{confidence}`
+
+{summary}
+
+**{get_txt("agent_risk_flags", language)}**
+{chr(10).join(f"- {item}" for item in risk_flags) if risk_flags else get_txt("agent_none_reported", language)}
+
+**{get_txt("agent_next_steps", language)}**
+{chr(10).join(f"- {item}" for item in next_steps) if next_steps else get_txt("agent_none_reported", language)}
+"""
+ return markdown, json.dumps(finalized, indent=2, ensure_ascii=False)
+ except Exception as ollama_err:
+ raise gr.Error(get_txt("agent_ollama_query_failed", language).format(detail=ollama_err))
+
+ raise gr.Error(get_txt("agent_no_runtime", language))
+
+# REAL TARGET DETECTOR (Only works locally on Windows)
+def get_real_drives(target_mode: str = "USB") -> list[str]:
+ if not IS_LOCAL:
+ return []
+ if target_mode == "LocalFolder":
+ local_base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
+ return [os.path.join(local_base, "JackAILocal")]
+ try:
+ # Get removable drives (USB keys)
+ cmd = "powershell -Command \"Get-CimInstance Win32_LogicalDisk | Where-Object { $_.DriveType -eq 2 } | ForEach-Object { $_.DeviceID }\""
+ res = subprocess.check_output(cmd, shell=True, text=True, errors="replace")
+ removable = [d.strip() + "\\" for d in res.split("\n") if d.strip()]
+ if target_mode != "SSD":
+ return removable
+
+ # Fixed non-system drives are eligible for the external SSD mode.
+ cmd2 = "powershell -Command \"$system=$env:SystemDrive; Get-CimInstance Win32_LogicalDisk | Where-Object { $_.DriveType -eq 3 -and $_.DeviceID -ne $system } | ForEach-Object { $_.DeviceID }\""
+ res2 = subprocess.check_output(cmd2, shell=True, text=True, errors="replace")
+ fixed = [d.strip() + "\\" for d in res2.split("\n") if d.strip()]
+ return removable + fixed
+ except Exception:
+ return []
+
+# REAL HARDWARE SCANNER
+def get_real_system_hardware(max_age_seconds: int = 3600) -> dict[str, Any] | None:
+ if not IS_LOCAL:
+ return None
+ try:
+ diag_dir = os.path.join(WORKSPACE_ROOT, "diagnostics")
+ os.makedirs(diag_dir, exist_ok=True)
+ out_path = os.path.join(diag_dir, "hardware.json")
+
+ # Reuse a recent scan: the PowerShell scanner takes ~40 s and local
+ # hardware does not change between Gradio sessions.
+ try:
+ if max_age_seconds and time.time() - os.path.getmtime(out_path) < max_age_seconds:
+ with open(out_path, "r", encoding="utf-8-sig") as f:
+ cached = json.load(f)
+ if isinstance(cached, dict) and cached.get("ram_gb"):
+ return cached
+ except OSError:
+ pass
+
+ # Run local scanner script
+ subprocess.run([
+ "powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
+ os.path.join(WORKSPACE_ROOT, "windows", "hwscan-windows.ps1"), "-OutputPath", out_path
+ ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=WORKSPACE_ROOT)
+
+ with open(out_path, "r", encoding="utf-8-sig") as f:
+ hw = json.load(f)
+ return hw
+ except Exception:
+ return None
+
+def is_local_runtime_reachable() -> bool:
+ if not IS_LOCAL:
+ return False
+ try:
+ response = requests.get("http://127.0.0.1:4891/health", timeout=2)
+ return response.status_code == 200
+ except Exception:
+ return False
+
+def start_target_runtime_no_browser(target_drive: str) -> tuple[bool, str]:
+ if not IS_LOCAL:
+ return False, "Runtime start is only available from the local Windows builder."
+ if is_local_runtime_reachable():
+ return True, "Runtime already running."
+ drive_path = target_drive or default_local_target_path()
+ script_path = os.path.join(drive_path, "windows", "Start-JackAILocal.ps1")
+ if not os.path.exists(script_path):
+ return False, f"Start script not found: {script_path}"
+ try:
+ cmd = [
+ "powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
+ script_path, "-NoBrowser"
+ ]
+ DETACHED_PROCESS = 0x00000008
+ subprocess.Popen(cmd, creationflags=DETACHED_PROCESS, cwd=drive_path)
+ for _ in range(40):
+ if is_local_runtime_reachable():
+ return True, "Runtime started."
+ time.sleep(0.5)
+ return False, "Runtime did not answer on 127.0.0.1:4891 after start."
+ except Exception as exc:
+ return False, str(exc)
+
+# Physical target build execution. Hosted Spaces can export builders but cannot write a client drive.
+def run_physical_usb_build(
+ language: str,
+ sample_name: str,
+ raw_profile_json: str,
+ package_goal: str,
+ target_drive: str,
+ target_mode: str,
+ decision_answers_json: str = "",
+ operator_notes: str = "",
+):
+ try:
+ plan = build_final_plan_object(
+ language,
+ sample_name,
+ raw_profile_json,
+ package_goal,
+ target_mode=target_mode,
+ target_drive=target_drive,
+ decision_answers_json=decision_answers_json,
+ operator_notes=operator_notes,
+ )
+ except Exception as exc:
+ err_msg = f"[ERROR] {get_txt('err_plan_failed', language)}\n{exc}"
+ sidebar_html, start_upd, stop_upd = get_sidebar_status(False, False, language, "None")
+ yield err_msg, False, sidebar_html, start_upd, stop_upd
+ return
+
+ default_model = plan["default_model"]
+
+ lang = lang_key(language)
+ logs = []
+
+ def make_yields(log_str, formatted, running):
+ sidebar_html, start_upd, stop_upd = get_sidebar_status(formatted, running, language, default_model["model_ref"])
+ return log_str, formatted, sidebar_html, start_upd, stop_upd
+
+ if not IS_LOCAL:
+ message = get_txt("build_hosted_unavailable", language)
+ yield make_yields(message, False, False)
+ return
+
+ if not target_drive.strip():
+ yield make_yields(f"[ERROR] {get_txt('err_select_drive', language)}", False, False)
+ return
+
+ # LOCAL REAL PATH EXECUTION
+ if IS_LOCAL:
+ yield make_yields(f"[REAL BUILD] {get_txt('build_stop_processes', language)}", False, False)
+ try:
+ workspace_stop = os.path.join(WORKSPACE_ROOT, "windows", "Stop-JackAILocal.ps1")
+ if os.path.exists(workspace_stop):
+ subprocess.run([
+ "powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
+ workspace_stop
+ ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5)
+ except Exception as e:
+ yield make_yields(f"[REAL BUILD] {get_txt('build_warn_shutdown', language).format(detail=e)}", False, False)
+ time.sleep(0.5)
+
+ # 1. Pre-stage directories and write real manifest file
+ manifest_dir = os.path.join(WORKSPACE_ROOT, "manifest-input")
+ os.makedirs(manifest_dir, exist_ok=True)
+ manifest_path = os.path.join(manifest_dir, "build-manifest.json")
+ with open(manifest_path, "w", encoding="utf-8") as f:
+ json.dump(plan, f, indent=2, ensure_ascii=False)
+
+ yield make_yields(f"[REAL BUILD] {get_txt('build_starting', language)}", False, False)
+
+ # Copy system Ollama binary if it exists in local Programs folder
+ local_app_data = os.environ.get("LOCALAPPDATA", "")
+ ollama_system = os.path.join(local_app_data, "Programs", "Ollama", "ollama.exe") if local_app_data else ""
+ ollama_local = os.path.join(WORKSPACE_ROOT, "backends", "ollama", "windows", "ollama.exe")
+ if ollama_system and os.path.exists(ollama_system) and not os.path.exists(ollama_local):
+ yield make_yields(f"[REAL BUILD] {get_txt('build_copy_ollama', language)}", False, False)
+ os.makedirs(os.path.dirname(ollama_local), exist_ok=True)
+ try:
+ shutil.copy2(ollama_system, ollama_local)
+ except Exception as e:
+ yield make_yields(f"[REAL BUILD] {get_txt('build_warn_ollama', language).format(detail=e)}", False, False)
+ time.sleep(0.5)
+
+ # 2. Start real PowerShell installer subprocess
+ cmd = [
+ "powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
+ os.path.join(WORKSPACE_ROOT, "windows", "JackAILocal-USB-Builder.ps1"),
+ "-ManifestPath", manifest_path,
+ "-TargetDrive", target_drive,
+ "-TargetMode", target_mode
+ ]
+
+ env = os.environ.copy()
+ env["AUTO_CONFIRM"] = "true" # skips PowerShell prompts
+
+ try:
+ process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1,
+ env=env,
+ cwd=WORKSPACE_ROOT,
+ encoding="utf-8",
+ errors="replace"
+ )
+
+ while True:
+ line = process.stdout.readline()
+ if not line and process.poll() is not None:
+ break
+ if line:
+ logs.append(line.rstrip())
+ yield make_yields("\n".join(logs), False, False)
+
+ rc = process.wait()
+ if rc == 0:
+ yield make_yields("\n".join(logs) + f"\n\n[SUCCESS] {get_txt('build_done', language)}", True, False)
+ else:
+ yield make_yields("\n".join(logs) + f"\n\n[ERROR] {get_txt('build_failed_rc', language).format(code=rc)}", False, False)
+ except Exception as e:
+ yield make_yields("\n".join(logs) + f"\n\n[ERROR] {get_txt('build_failed_start', language).format(detail=e)}", False, False)
+
+# SIDEBAR STATUS UPDATE
+def get_sidebar_status(usb_formatted: bool, running: bool, lang: str, recommended_model_ref: str = "") -> tuple[str, dict, dict]:
+ dot_color = "red"
+ if running:
+ dot_color = "green"
+ text = get_txt("status_running", lang)
+ elif usb_formatted:
+ dot_color = "orange"
+ text = get_txt("status_ready", lang)
+ else:
+ text = get_txt("status_disconnected", lang)
+
+ html = f"""
+
+
+
+ {text}
+
+ """
+ if usb_formatted and recommended_model_ref:
+ model_text = get_txt("sidebar_model", lang).format(model=recommended_model_ref)
+ html += f'
{model_text}
'
+ html += "
"
+
+ start_visible = usb_formatted and not running
+ stop_visible = running
+
+ return html, gr.update(visible=start_visible), gr.update(visible=stop_visible)
+
+# ROUTING FUNCTION
+def make_route(target_view: str) -> list[dict]:
+ updates = []
+ for name in VIEW_NAMES:
+ updates.append(gr.update(visible=(name == target_view)))
+ return updates
+
+def route_to(target_view: str, lang: str) -> list[Any]:
+ title_key = "nav_config" if target_view == "saas" else f"nav_{target_view}"
+ title_text = get_txt(title_key, lang)
+ view_updates = make_route(target_view)
+ return view_updates + [f"{title_text} "]
+
+def route_to_console_and_reset(lang: str) -> list[Any]:
+ view_updates = route_to("console", lang)
+ console_init = f"[REAL BUILD] {get_txt('console_reset', lang)}\n"
+ return view_updates + [gr.update(value=console_init), gr.update(value=None)]
+
+# DYNAMIC CHAT DROP DOWN ACCORDING TO COMPILED MANIFEST
+def update_chat_models(language: str, sample_name: str, raw_profile_json: str):
+ profile = normalize_profile(parse_profile_json(raw_profile_json, sample_name))
+ allowed = compatible_models(profile)
+ label_key = "label_fr" if lang_key(language) == "fr" else "label_en"
+ choices = [(f"{m[label_key]} ({m['model_ref']})", m["model_ref"]) for m in allowed]
+ default_model = select_default_model(allowed, profile, "Standard")
+ return gr.update(choices=choices, value=default_model["model_ref"])
+
+# GET STARTER PROMPTS
+def get_starters(goal: str, language: str) -> list[str]:
+ lang = lang_key(language)
+ goal_lower = str(goal).lower()
+
+ if "survival" in goal_lower or "manuel" in goal_lower:
+ if lang == "fr":
+ return [
+ "Comment filtrer de l'eau en forêt ?",
+ "Quels sont les premiers soins en cas de brûlure ?",
+ "Comment allumer un feu sans allumettes ?"
+ ]
+ else:
+ return [
+ "How do I filter water in the wild?",
+ "What is the first aid for a severe burn?",
+ "How do I build a temporary shelter?"
+ ]
+ elif "privacy" in goal_lower or "confidential" in goal_lower:
+ if lang == "fr":
+ return [
+ "Pourquoi l'exécution hors ligne est-elle sécurisée ?",
+ "Comment vérifier que le réseau local n'y accède pas ?",
+ "Où sont stockées mes conversations ?"
+ ]
+ else:
+ return [
+ "Why is offline execution secure?",
+ "How do I verify LAN access is blocked?",
+ "Where are my conversations stored?"
+ ]
+ elif "knowledge" in goal_lower or "notes" in goal_lower:
+ if lang == "fr":
+ return [
+ "Explique le fonctionnement de la cryptographie.",
+ "Synthétise l'histoire de la conquête spatiale.",
+ "Qu'est-ce que le paradoxe de Fermi ?"
+ ]
+ else:
+ return [
+ "Explain the basics of cryptography.",
+ "Summarize the history of space exploration.",
+ "What is the Fermi paradox?"
+ ]
+ elif "vision" in goal_lower:
+ if lang == "fr":
+ return [
+ "Comment fonctionne l'analyse d'images hors ligne ?",
+ "Peut-on extraire le texte d'un PDF scanné ?",
+ "Quelles résolutions d'images sont supportées ?"
+ ]
+ else:
+ return [
+ "How does offline image analysis work?",
+ "Can we extract text from a scanned PDF?",
+ "What image resolutions are supported?"
+ ]
+ elif "power" in goal_lower or "station" in goal_lower:
+ if lang == "fr":
+ return [
+ "Écris un serveur TCP asynchrone en Rust.",
+ "Optimise un plan d'exécution SQL complexe.",
+ "Explique la quantification Q4_K_M vs Q8_0."
+ ]
+ else:
+ return [
+ "Write an asynchronous TCP server in Rust.",
+ "Optimize a complex SQL execution plan.",
+ "Explain Q4_K_M vs Q8_0 quantization."
+ ]
+ else: # Standard
+ if lang == "fr":
+ return [
+ "Quelles sont tes capacités hors ligne ?",
+ "Génère une fonction Python pour trier une liste.",
+ "Donne-moi 5 idées de projets de codage simples."
+ ]
+ else:
+ return [
+ "What are your offline capabilities?",
+ "Generate a Python function to sort a list.",
+ "Give me 5 simple coding project ideas."
+ ]
+
+def update_starter_btn_labels(goal: str, language: str) -> tuple[dict, dict, dict]:
+ starters = get_starters(goal, language)
+ return gr.update(value=starters[0]), gr.update(value=starters[1]), gr.update(value=starters[2])
+
+# Start a real local runtime and verify it through the local API.
+def handle_start_usb(language: str, sample_name: str, raw_profile_json: str, package_goal: str, target_drive: str):
+ plan = build_plan_object(language, sample_name, raw_profile_json, package_goal)
+ profile = plan["hardware"]
+ default_model = plan["default_model"]
+
+ running = False
+ formatted = True
+
+ lang = lang_key(language)
+
+ # Start the real target runtime if we are running locally.
+ if IS_LOCAL:
+ drive_path = target_drive
+ script_path = os.path.join(drive_path, "windows", "Start-JackAILocal.ps1")
+ if os.path.exists(script_path):
+ cmd = [
+ "powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
+ script_path, "-NoBrowser"
+ ]
+ DETACHED_PROCESS = 0x00000008
+ subprocess.Popen(cmd, creationflags=DETACHED_PROCESS, cwd=drive_path)
+
+ # Fetch dashboard state only from the running local API.
+ hw_info = None
+ model_catalog = None
+ if IS_LOCAL:
+ for _ in range(30):
+ try:
+ r = requests.get("http://127.0.0.1:4891/api/status", timeout=2)
+ if r.status_code == 200:
+ hw_info = r.json()
+ running = True
+ models_response = requests.get("http://127.0.0.1:4891/api/models", timeout=2)
+ if models_response.status_code == 200:
+ model_catalog = models_response.json()
+ break
+ except Exception:
+ time.sleep(0.5)
+
+ sidebar_html, start_upd, stop_upd = get_sidebar_status(formatted, running, language, default_model["model_ref"])
+
+ # Compile dashboard display outputs
+ if hw_info and "hardware" in hw_info:
+ real_hw = hw_info["hardware"]
+ runtime_text = "jackailocald v1.5"
+ backend_text = str(hw_info.get("backend", "unavailable"))
+ hardware_text = f"RAM: {real_hw.get('ram_gb', profile['ram_gb'])}GB | CPU: {real_hw.get('cpu_threads', profile['cpu_threads'])} threads"
+ gpu_text = f"VRAM: {real_hw.get('vram_gb', 0)}GB | GPU Vendor: {real_hw.get('gpu_vendor', 'unknown')}"
+ else:
+ runtime_text = get_txt("dash_unavailable", language)
+ backend_text = get_txt("dash_no_backend", language)
+ hardware_text = get_txt("dash_no_hw", language)
+ gpu_text = get_txt("dash_no_gpu", language)
+
+ model_name = default_model['label_fr'] if lang == 'fr' else default_model['label_en']
+ available_models = (model_catalog or {}).get("models", [])
+ model_available = any(
+ item.get("available") and item.get("ollama") == default_model["model_ref"]
+ for item in available_models
+ )
+ model_state = get_txt("dash_model_available", language) if model_available else get_txt("dash_model_missing", language)
+ model_text = f"{model_name} ({default_model['model_ref']}) - {model_state}"
+ network_text = str(hw_info.get("bind", "127.0.0.1:4891")) if hw_info else get_txt("dash_unavailable", language)
+
+ # Preparation Checklist HTML
+ dot = "green" if running else "red"
+ model_dot = "green" if model_available else "red"
+ api_state = get_txt("dash_api_active", language) if running else get_txt("dash_unavailable", language)
+ check_api = f' {get_txt("dash_api_label", language)} : {api_state}
'
+ check_model = f' {get_txt("dash_default_model_label", language)} : {model_state}
'
+ check_security = f' {get_txt("dash_api_binding_label", language)} : {network_text}
'
+ checklist_html = f"{check_api}{check_model}{check_security}"
+
+ route_updates = route_to("dashboard", language)
+
+ return [
+ running,
+ sidebar_html,
+ start_upd,
+ stop_upd,
+ runtime_text,
+ backend_text,
+ hardware_text,
+ gpu_text,
+ model_text,
+ network_text,
+ checklist_html
+ ] + route_updates
+
+# Stop a real local runtime.
+def handle_stop_usb(language: str, sample_name: str, raw_profile_json: str, target_drive: str):
+ plan = build_plan_object(language, sample_name, raw_profile_json, "Standard")
+ default_model = plan["default_model"]
+
+ running = False
+ formatted = True
+
+ if IS_LOCAL:
+ # Run real Stop script
+ drive_path = target_drive
+ script_path = os.path.join(drive_path, "windows", "Stop-JackAILocal.ps1")
+ if os.path.exists(script_path):
+ cmd = [
+ "powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
+ script_path
+ ]
+ subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+
+ sidebar_html, start_upd, stop_upd = get_sidebar_status(formatted, running, language, default_model["model_ref"])
+ route_updates = route_to("dashboard", language)
+
+ inactive_metric = "---"
+ check_api = f' {get_txt("dash_api_label", language)} : {get_txt("dash_api_stopped", language)}
'
+ check_model = f' {get_txt("dash_model_catalog_label", language)} : {get_txt("dash_inactive", language)}
'
+ check_security = f' {get_txt("dash_security_label", language)} : {get_txt("dash_inactive", language)}
'
+ checklist_html = f"{check_api}{check_model}{check_security}"
+
+ return [
+ running,
+ sidebar_html,
+ start_upd,
+ stop_upd,
+ inactive_metric,
+ inactive_metric,
+ inactive_metric,
+ inactive_metric,
+ inactive_metric,
+ inactive_metric,
+ checklist_html
+ ] + route_updates
+
+def chat_history_to_messages(history: list[Any]) -> list[dict[str, str]]:
+ messages: list[dict[str, str]] = []
+ for item in history or []:
+ if isinstance(item, dict):
+ role = str(item.get("role", "")).strip()
+ content = str(item.get("content", "")).strip()
+ if role in {"user", "assistant", "system"} and content:
+ messages.append({"role": role, "content": content})
+ elif isinstance(item, (list, tuple)) and len(item) >= 2:
+ if item[0]:
+ messages.append({"role": "user", "content": str(item[0])})
+ if item[1]:
+ messages.append({"role": "assistant", "content": str(item[1])})
+ return messages
+
+def run_live_chat_messages(message: str, history: list[Any], language: str, sample_name: str, raw_profile_json: str, package_goal: str, selected_model_id: str, usb_ready: bool, runtime_running: bool):
+ lang = lang_key(language)
+ history_messages = chat_history_to_messages(history)
+ if not message.strip():
+ return "", history_messages, ""
+
+ history_messages.append({"role": "user", "content": message})
+ runtime_available = runtime_running or is_local_runtime_reachable()
+ if not (usb_ready and runtime_available):
+ history_messages.append({"role": "assistant", "content": f"ERROR: {get_txt('run_first_prompt', language)}"})
+ return "", history_messages, ""
+ if not IS_LOCAL:
+ err = get_txt("chat_hosted_unavailable", language)
+ history_messages.append({"role": "assistant", "content": err})
+ return "", history_messages, ""
+
+ start_time = time.time()
+ body = {
+ "model": selected_model_id,
+ "messages": history_messages,
+ "temperature": 0.4,
+ }
+ try:
+ resp = requests.post(
+ "http://127.0.0.1:4891/v1/chat/completions",
+ json=body,
+ headers={"Content-Type": "application/json"},
+ timeout=120,
+ )
+ if resp.status_code == 200:
+ res_json = resp.json()
+ answer = res_json["choices"][0]["message"]["content"]
+ elapsed = max(time.time() - start_time, 0.001)
+ words_per_second = round(max(len(answer.split()), 1) / elapsed, 1)
+ backend = res_json.get("backend", "local")
+ stats = get_txt("chat_stats", language).format(
+ wps=words_per_second, ms=round(elapsed * 1000), backend=backend
+ )
+ history_messages.append({"role": "assistant", "content": answer})
+ return "", history_messages, stats
+ detail = resp.text[:500]
+ except Exception as exc:
+ detail = str(exc)
+
+ err = get_txt("chat_api_error", language).format(detail=detail)
+ history_messages.append({"role": "assistant", "content": err})
+ return "", history_messages, ""
+
+# Real documents list from a prepared local drive.
+def load_initial_docs() -> list[dict[str, str]]:
+ return []
+
+def get_documents_list(target_drive: str) -> list[dict[str, str]]:
+ if IS_LOCAL and target_drive:
+ docs_dir = os.path.join(target_drive, "workspace", "documents")
+ if os.path.exists(docs_dir):
+ docs = []
+ for f in os.listdir(docs_dir):
+ fp = os.path.join(docs_dir, f)
+ if os.path.isfile(fp):
+ try:
+ with open(fp, "r", encoding="utf-8") as file:
+ content = file.read()
+ docs.append({"name": f, "content": content})
+ except Exception:
+ pass
+ if docs:
+ return docs
+ return []
+
+def save_document(title: str, content: str, docs: list[dict[str, str]], usb_ready: bool, runtime_running: bool, lang_choice: str, target_drive: str) -> tuple[list[dict[str, str]], str, dict]:
+ if not (usb_ready and runtime_running) and not is_local_runtime_reachable():
+ err = get_txt("run_first_prompt", lang_choice)
+ return docs, f"❌ {err}", gr.update()
+ if not IS_LOCAL:
+ err = get_txt("doc_hosted_unavailable", lang_choice)
+ return docs, f"❌ {err}", gr.update()
+
+ if not title.strip():
+ return docs, f"❌ {get_txt('doc_title_empty', lang_choice)}", gr.update()
+
+ # Real file saving if local
+ if IS_LOCAL and target_drive:
+ try:
+ docs_dir = os.path.join(target_drive, "workspace", "documents")
+ os.makedirs(docs_dir, exist_ok=True)
+ fp = os.path.join(docs_dir, title.strip())
+ with open(fp, "w", encoding="utf-8") as f:
+ f.write(content)
+ docs = get_documents_list(target_drive)
+ status = f"💾 {get_txt('doc_saved', lang_choice).format(path=fp)}"
+ except Exception as e:
+ status = f"❌ {get_txt('doc_save_error', lang_choice).format(detail=e)}"
+ else:
+ status = f"❌ {get_txt('doc_no_target', lang_choice)}"
+
+ doc_md = "\n".join([f"- **{d['name']}** ({len(d['content'])} chars)" for d in docs])
+ return docs, status, gr.update(value=doc_md)
+
+# Real benchmark runner through the local Rust API.
+def run_benchmark_real(language: str, sample_name: str, raw_profile_json: str, model_id: str, usb_ready: bool, runtime_running: bool):
+ if not (usb_ready and runtime_running) and not is_local_runtime_reachable():
+ err = get_txt("run_first_prompt", language)
+ yield f"❌ {err}"
+ return
+ if not IS_LOCAL:
+ yield get_txt("bench_hosted_unavailable", language)
+ return
+
+ yield get_txt("bench_running", language)
+ try:
+ url = "http://127.0.0.1:4891/api/benchmark/run"
+ resp = requests.post(url, json={"model": model_id}, timeout=180)
+ if resp.status_code != 200:
+ yield get_txt("bench_status_error", language).format(code=resp.status_code, detail=resp.text[:500])
+ return
+ res = resp.json()
+ if not res.get("ok"):
+ yield get_txt("bench_service_error", language).format(detail=res.get("error"))
+ return
+ elapsed = max(float(res.get("elapsed_ms") or 0), 1.0)
+ sample = res.get("sample", {}).get("choices", [{}])[0].get("message", {}).get("content", "")
+ output = get_txt("bench_report", language).format(
+ model=res.get("model"),
+ ms=f"{elapsed:.0f}",
+ wps=round(max(len(sample.split()), 1) / (elapsed / 1000.0), 2),
+ sample=sample,
+ )
+ yield output
+ except Exception as e:
+ yield get_txt("bench_connect_error", language).format(detail=e)
+
+# CUSTOM CSS FOR STUNNING DESIGN
+custom_css = """
+body, .gradio-container {
+ background-color: #070a0f !important;
+ background-image:
+ radial-gradient(circle at 10% 0%, rgba(191, 255, 79, 0.12), transparent 35%),
+ radial-gradient(circle at 90% 10%, rgba(110, 231, 249, 0.10), transparent 35%),
+ linear-gradient(150deg, #05070b 0%, #0b111a 50%, #06080d 100%) !important;
+ color: #edf2f7 !important;
+ font-family: 'Outfit', 'Inter', -apple-system, sans-serif !important;
+}
+
+/* No backdrop-filter on panels that contain dropdowns: it creates a CSS
+ containing block, which breaks the fixed positioning of Gradio dropdown
+ option lists (they render too high or too low). Opaque backgrounds keep
+ the same look without the filter. */
+.sidebar-panel {
+ background: rgba(11, 17, 26, 0.94) !important;
+ border: 1px solid rgba(148, 163, 184, 0.15) !important;
+ border-radius: 20px !important;
+ padding: 20px !important;
+ box-shadow: 0 10px 40px rgba(0,0,0,0.5) !important;
+}
+
+.view-panel {
+ background: rgba(17, 24, 39, 0.92) !important;
+ border: 1px solid rgba(148, 163, 184, 0.15) !important;
+ border-radius: 24px !important;
+ padding: 24px !important;
+ box-shadow: 0 12px 50px rgba(0,0,0,0.4) !important;
+}
+
+.ux-hero {
+ border: 1px solid rgba(191, 255, 79, 0.24);
+ border-radius: 22px;
+ padding: 22px;
+ margin-bottom: 16px;
+ background: linear-gradient(135deg, rgba(191,255,79,0.12), rgba(15,23,42,0.7) 48%, rgba(110,231,249,0.08));
+}
+
+.ux-hero h1 {
+ margin: 0 0 8px 0;
+ color: #edf2f7;
+ font-size: 1.7rem;
+ letter-spacing: -0.03em;
+}
+
+.ux-hero p {
+ color: #cbd5e1;
+ max-width: 820px;
+ margin: 0;
+ line-height: 1.55;
+}
+
+.ux-step-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+ margin: 14px 0 18px 0;
+}
+
+.ux-step {
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ border-radius: 16px;
+ padding: 14px;
+ background: rgba(15, 23, 42, 0.68);
+}
+
+.ux-step strong {
+ color: #bfff4f;
+ display: block;
+ margin-bottom: 5px;
+}
+
+.ux-step span {
+ color: #94a3b8;
+ font-size: 0.92rem;
+}
+
+.ux-result-card {
+ border: 1px solid rgba(110, 231, 249, 0.2);
+ border-radius: 18px;
+ padding: 16px;
+ background: rgba(8, 13, 22, 0.72);
+}
+
+.ux-ai-card {
+ border: 1px solid rgba(191, 255, 79, 0.25);
+ border-radius: 18px;
+ padding: 16px;
+ background: rgba(8, 13, 22, 0.76);
+ margin-top: 16px;
+}
+
+.ux-ai-card h3 {
+ margin-top: 0;
+ color: #bfff4f;
+ font-size: 1.25rem;
+}
+
+.ux-ai-card p {
+ margin: 8px 0;
+}
+
+.ux-ai-card ul {
+ margin: 8px 0;
+ padding-left: 20px;
+ color: #94a3b8;
+}
+
+.ux-ai-card li {
+ margin: 4px 0;
+}
+
+.ux-result-card h3 {
+ margin-top: 0;
+ color: #bfff4f;
+}
+
+.ux-feature-summary {
+ margin: 14px 0;
+}
+
+.ux-feature-list {
+ display: grid;
+ gap: 6px;
+ list-style: none;
+ margin: 8px 0 0;
+ padding: 0;
+}
+
+.ux-feature-list li {
+ align-items: center;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.12);
+ display: flex;
+ gap: 16px;
+ justify-content: space-between;
+ min-height: 30px;
+}
+
+.ux-feature-list strong {
+ flex: 0 0 auto;
+}
+
+.ux-feature-list .is-enabled {
+ color: #bfff4f;
+}
+
+.ux-feature-list .is-disabled {
+ color: #94a3b8;
+}
+
+.compact-json textarea,
+.compact-json pre {
+ max-height: 260px !important;
+}
+
+@media (max-width: 900px) {
+ .ux-step-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.brand-mark {
+ width: 48px;
+ height: 48px;
+ border-radius: 15px;
+ display: grid;
+ place-items: center;
+ font-size: 24px;
+ font-weight: 950;
+ color: #0a0f16;
+ background: linear-gradient(135deg, #bfff4f, #ffffff 62%, #6ee7f9);
+ box-shadow: 0 8px 24px rgba(191, 255, 79, 0.22);
+}
+
+.brand-title {
+ font-size: 1.7em !important;
+ font-weight: 900 !important;
+ background: linear-gradient(135deg, #bfff4f 0%, #edf2f7 60%, #6ee7f9 100%) !important;
+ -webkit-background-clip: text !important;
+ -webkit-text-fill-color: transparent !important;
+ letter-spacing: -0.5px !important;
+ margin: 0 !important;
+}
+
+.terminal-console {
+ background: #030508 !important;
+ color: #6ee7f9 !important;
+ font-family: 'Consolas', 'Fira Code', monospace !important;
+ font-size: 0.9em !important;
+ border: 1px solid rgba(110, 231, 249, 0.25) !important;
+ border-radius: 14px !important;
+ padding: 16px !important;
+ line-height: 1.6 !important;
+ overflow-y: auto !important;
+ height: 350px !important;
+ box-shadow: inset 0 4px 20px rgba(0,0,0,0.7) !important;
+}
+
+.metric-card {
+ background: rgba(11, 17, 26, 0.7) !important;
+ border: 1px solid rgba(148, 163, 184, 0.15) !important;
+ border-radius: 16px !important;
+ padding: 18px !important;
+ text-align: center !important;
+ transition: transform 0.2s ease, border-color 0.2s ease !important;
+}
+
+.metric-card:hover {
+ transform: translateY(-2px) !important;
+ border-color: rgba(191, 255, 79, 0.35) !important;
+}
+
+.metric-val {
+ font-size: 1.5em !important;
+ font-weight: 800 !important;
+ color: #bfff4f !important;
+ margin-top: 4px;
+}
+
+.metric-lbl {
+ font-size: 0.85em !important;
+ text-transform: uppercase !important;
+ letter-spacing: 0.5px !important;
+ color: #9aa8bc !important;
+}
+
+.lock-badge {
+ background-color: rgba(52, 211, 153, 0.12) !important;
+ border: 1px solid rgba(52, 211, 153, 0.3) !important;
+ color: #34d399 !important;
+ padding: 4px 10px !important;
+ border-radius: 12px !important;
+ font-size: 0.8em !important;
+ font-weight: 700 !important;
+ display: inline-flex !important;
+ align-items: center !important;
+ gap: 6px !important;
+}
+
+.check-row {
+ display: flex !important;
+ align-items: center !important;
+ gap: 10px !important;
+ padding: 8px 0 !important;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.08) !important;
+}
+
+.check-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50% !important;
+ display: inline-block !important;
+}
+
+.check-dot.green {
+ background-color: #34d399 !important;
+ box-shadow: 0 0 10px #34d399 !important;
+}
+
+.check-dot.orange {
+ background-color: #fbbf24 !important;
+ box-shadow: 0 0 10px #fbbf24 !important;
+}
+
+.check-dot.red {
+ background-color: #fb7185 !important;
+ box-shadow: 0 0 10px #fb7185 !important;
+}
+"""
+
+HEAD_HTML = """
+
+
+
+ """
+
+def gradio_launch_kwargs() -> dict[str, Any]:
+ # Gradio 6 moved theme/css/head from the Blocks constructor to launch().
+ return {
+ "theme": gr.themes.Soft(primary_hue="lime", neutral_hue="slate"),
+ "css": custom_css,
+ "head": HEAD_HTML,
+ }
+
+def find_free_port(start: int, attempts: int = 20) -> int:
+ """First free port at or after `start`, so a second instance or another
+ app on 7860 does not kill the builder at startup."""
+ import socket
+ for port in range(start, start + attempts):
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as candidate:
+ try:
+ candidate.bind(("127.0.0.1", port))
+ return port
+ except OSError:
+ continue
+ return start
+
+# REFRESH DRIVES LIST
+def refresh_target_drives(mode, current_val):
+ drives = get_real_drives(mode)
+ fallback = os.path.join(os.environ.get("LOCALAPPDATA") or os.path.expanduser("~"), "JackAILocal") if mode == "LocalFolder" else "E:\\"
+ val = current_val if current_val and (mode == "LocalFolder" or current_val in drives) else (drives[0] if drives else fallback)
+ choices = list(drives) if drives else [fallback]
+ if val and val not in choices:
+ choices.append(val)
+ return gr.update(choices=choices, value=val)
+
+def make_header_html(lang: str) -> str:
+ return f"""
+
+
J
+
+
JackAILocal
+
{get_txt("header_subtitle", lang)}
+
+
+ """
+
+def make_hero_html(lang: str) -> str:
+ return f"""
+
+
{get_txt("hero_title", lang)}
+
{get_txt("hero_desc", lang)}
+
+
+
{get_txt("hero_step1_title", lang)} {get_txt("hero_step1_desc", lang)}
+
{get_txt("hero_step2_title", lang)} {get_txt("hero_step2_desc", lang)}
+
{get_txt("hero_step3_title", lang)} {get_txt("hero_step3_desc", lang)}
+
+ """
+
+def make_footer_html(lang: str) -> str:
+ return f"""
+
+ {get_txt("footer_text", lang)}
+
+ """
+
+def build_ui() -> gr.Blocks:
+ with gr.Blocks(title=get_txt("app_title", "EN")) as demo:
+ # States
+ usb_ready = gr.State(value=False)
+ runtime_running = gr.State(value=False)
+ saved_docs = gr.State(value=load_initial_docs())
+ language = gr.State(value="EN")
+
+ # Header Area
+ with gr.Row():
+ with gr.Column(scale=4):
+ header_html = gr.HTML(make_header_html("EN"))
+
+ # Main Workspace Row
+ with gr.Row():
+ # Left Sidebar
+ with gr.Column(scale=1, elem_classes=["sidebar-panel"]):
+ sidebar_header_md = gr.Markdown(f"### {get_txt('sidebar_header', 'EN')}")
+
+ lang_radio = gr.Radio(
+ ["EN", "FR"],
+ value="EN",
+ label=get_txt("lang_label", "EN"),
+ )
+
+ if not IS_LOCAL:
+ # Hosted Space: each visitor signs in with their own
+ # Hugging Face account, so the Gemma 4 review bills THEIR
+ # inference quota, never the Space owner's token.
+ gr.LoginButton()
+ gr.Markdown(get_txt("oauth_note", "EN"))
+
+ # USB Status Widget
+ sidebar_status_html = gr.HTML(
+ value=get_sidebar_status(False, False, "EN")[0]
+ )
+
+ # Runtime Launcher Buttons
+ btn_start_usb = gr.Button(
+ get_txt("btn_start", "EN"),
+ variant="primary",
+ visible=False
+ )
+ btn_stop_usb = gr.Button(
+ get_txt("btn_stop", "EN"),
+ variant="stop",
+ visible=False
+ )
+
+ gr.Markdown("---")
+
+ # Navigation Router Buttons. The builder ONLY creates packages.
+ # Runtime functions (dashboard, chat, documents, benchmark)
+ # live in the WebUI of the delivered runtime, not here: those
+ # views stay hidden so nothing non-functional is shown.
+ nav_saas = gr.Button(get_txt("nav_config", "EN"), variant="secondary", size="sm")
+ nav_console = gr.Button(get_txt("nav_console", "EN"), variant="secondary", size="sm")
+ nav_dashboard = gr.Button(get_txt("nav_dashboard", "EN"), variant="secondary", size="sm", visible=False)
+ nav_chat = gr.Button(get_txt("nav_chat", "EN"), variant="secondary", size="sm", visible=False)
+ nav_docs = gr.Button(get_txt("nav_docs", "EN"), variant="secondary", size="sm", visible=False)
+ nav_bench = gr.Button(get_txt("nav_bench", "EN"), variant="secondary", size="sm", visible=False)
+ nav_policy = gr.Button(get_txt("nav_policy", "EN"), variant="secondary", size="sm")
+ builder_scope_md = gr.Markdown(get_txt("builder_scope_note", "EN"))
+
+ gr.Markdown("---")
+ hackathon_badge_md = gr.Markdown(get_txt("hackathon_badge", "EN"))
+
+ footer_html = gr.HTML(make_footer_html("EN"))
+
+ # Right View Area
+ with gr.Column(scale=3, elem_classes=["view-panel"]):
+ right_title = gr.HTML(f"{get_txt('nav_config', 'EN')} ")
+
+ # ------------------
+ # 1. VIEW CONFIG / SAAS BUILDER
+ # ------------------
+ with gr.Column(visible=True) as view_saas:
+ hero_html = gr.HTML(make_hero_html("EN"))
+ with gr.Tabs() as saas_tabs:
+ with gr.Tab(get_txt("tab_light", "EN"), id="tab_light"):
+ simple_title_md = gr.Markdown(f"### {get_txt('simple_title', 'EN')}")
+ simple_desc_md = gr.Markdown(get_txt("simple_desc", "EN"))
+ with gr.Row():
+ simple_goal = gr.Dropdown(
+ choices=simple_goal_choices("EN"),
+ value="daily_private",
+ label=get_txt("simple_goal_label", "EN")
+ )
+ simple_target = gr.Dropdown(
+ choices=simple_target_choices("EN"),
+ value="local_runtime" if IS_LOCAL else "publish_windows",
+ label=get_txt("simple_target_label", "EN")
+ )
+
+ with gr.Row(equal_height=True):
+ drives = get_real_drives("LocalFolder")
+ default_drive = default_local_target_path()
+ if drives:
+ default_drive = drives[0]
+ simple_target_drive = gr.Dropdown(
+ choices=drives if drives else [default_drive],
+ value=default_drive,
+ label=get_txt("target_drive_label", "EN"),
+ allow_custom_value=True,
+ scale=4
+ )
+ simple_btn_refresh_drives = gr.Button(get_txt("refresh_btn", "EN"), variant="secondary", scale=1, min_width=80)
+
+ simple_prepare_btn = gr.Button(get_txt("simple_prepare", "EN"), variant="primary")
+ simple_ready_md = gr.Markdown(get_txt("simple_ready", "EN"))
+
+ with gr.Tab(get_txt("tab_advanced", "EN"), id="tab_advanced"):
+ advanced_options_md = gr.Markdown(f"### {get_txt('advanced_options', 'EN')}")
+
+ # STEP 1: Hardware & Target Configuration
+ step1_md = gr.Markdown(f"#### {get_txt('step1_title', 'EN')}")
+ with gr.Row():
+ with gr.Column(scale=1):
+ sample = gr.Dropdown(profile_source_choices("EN"), value=default_profile_source(), label=get_txt("sample_label", "EN"))
+ package_goal = gr.Dropdown(
+ package_goal_choices("EN"),
+ value="Standard offline assistant",
+ label=get_txt("package_goal_label", "EN")
+ )
+ target_mode = gr.Dropdown(
+ choices=target_mode_choices("EN"),
+ value="LocalFolder",
+ label=get_txt("target_mode_label", "EN")
+ )
+ with gr.Row(equal_height=True):
+ # Dropdown containing connected logical drives + Refresh button
+ drives = get_real_drives("LocalFolder")
+ default_drive = default_local_target_path()
+ if drives:
+ default_drive = drives[0]
+
+ target_drive = gr.Dropdown(
+ choices=drives if drives else [default_drive],
+ value=default_drive,
+ label=get_txt("target_drive_label", "EN"),
+ allow_custom_value=True,
+ scale=4
+ )
+ btn_refresh_drives = gr.Button(get_txt("refresh_btn", "EN"), variant="secondary", scale=1, min_width=80)
+
+ with gr.Column(scale=1):
+ profile_json = gr.Textbox(
+ label=get_txt("hardware_json_label", "EN"),
+ lines=11,
+ value="",
+ placeholder=json.dumps(PROFILE_EXAMPLE, indent=2)
+ )
+ profile_upload = gr.File(
+ label=get_txt("profiler_upload_label", "EN"),
+ file_types=[".json"],
+ type="filepath",
+ )
+ with gr.Row():
+ profiler_zip_btn = gr.Button(
+ get_txt("profiler_btn", "EN"),
+ variant="secondary",
+ )
+ profiler_zip_file = gr.File(label=get_txt("profiler_zip_label", "EN"), visible=True)
+
+ gr.Markdown("---")
+
+ # STEP 2: Optional AI Verification & Review
+ with gr.Accordion(get_txt("audit_accordion", "EN"), open=False) as audit_accordion:
+ audit_desc_md = gr.Markdown(get_txt("audit_desc", "EN"))
+
+ decision_notes = gr.Textbox(
+ label=get_txt("operator_notes_label", "EN"),
+ lines=3,
+ placeholder=get_txt("operator_notes_ph", "EN")
+ )
+
+ with gr.Row():
+ with gr.Column(scale=1):
+ with gr.Group():
+ saas_config_md = gr.Markdown(f"##### {get_txt('audit_saas_title', 'EN')}")
+ decision_answers = gr.Textbox(
+ label=get_txt("answers_label", "EN"),
+ lines=3,
+ value="{}",
+ placeholder='{"ram_gb": 16, "target_os": "windows", "usb_target": "switch_to_zip"}'
+ )
+ # The pasted API key is process-wide, not per-session:
+ # hidden on hosted Spaces (visitors sign in with HF
+ # instead), available in the single-operator local console.
+ with gr.Accordion(get_txt("api_accordion", "EN"), open=False, visible=IS_LOCAL) as api_accordion:
+ api_desc_md = gr.Markdown(get_txt("api_desc", "EN"))
+ with gr.Row():
+ ui_api_provider = gr.Dropdown(
+ choices=["none", "ollama", "openai", "anthropic", "gemini", "openrouter", "modal", "hf"],
+ value="none",
+ label=get_txt("api_provider_label", "EN")
+ )
+ ui_api_model = gr.Textbox(
+ label=get_txt("api_model_label", "EN"),
+ placeholder="e.g. gpt-4o-mini, gemma4:12b, etc.",
+ value=""
+ )
+ with gr.Row():
+ ui_api_key = gr.Textbox(
+ label=get_txt("api_key_label", "EN"),
+ placeholder=get_txt("api_key_ph", "EN"),
+ type="password",
+ value=""
+ )
+ ui_api_url = gr.Textbox(
+ label=get_txt("api_url_label", "EN"),
+ placeholder="e.g. https://api.openai.com/v1",
+ value=""
+ )
+
+ with gr.Column(scale=1):
+ with gr.Group():
+ local_llm_md = gr.Markdown(f"##### {get_txt('audit_local_title', 'EN')}")
+ agent_model_choice = gr.Dropdown(
+ choices=[
+ (get_txt("agent_choice_auto", "EN"), "auto"),
+ (get_txt("agent_choice_exact", "EN"), CONFIG_AGENT_MODEL_ID),
+ ],
+ value="auto",
+ label=get_txt("agent_model_label", "EN")
+ )
+
+ preview_decision_btn = gr.Button(get_txt("audit_run_btn", "EN"), variant="primary")
+
+ audit_results_md = gr.Markdown(f"##### {get_txt('audit_results_title', 'EN')}")
+ decision_review_md = gr.Markdown(value=get_txt("audit_initial", "EN"))
+ decision_review_json = gr.Code(label=get_txt("audit_json_label", "EN"), language="json", elem_classes=["compact-json"])
+
+ gr.Markdown("---")
+
+ # STEP 3: Execution & Exporting
+ step3_md = gr.Markdown(f"#### {get_txt('step3_title', 'EN')}")
+ with gr.Row():
+ with gr.Column(scale=2):
+ btn_build = gr.Button(get_txt("btn_build", "EN"), variant="primary")
+ plan_md = gr.Markdown(value=get_txt("plan_initial", "EN"))
+
+ with gr.Column(scale=2):
+ with gr.Accordion(get_txt("export_accordion", "EN"), open=False) as export_accordion:
+ with gr.Row():
+ export_manifest_btn = gr.Button(get_txt("export_manifest", "EN"))
+ win_builder_btn = gr.Button(get_txt("export_windows", "EN"))
+ mac_builder_btn = gr.Button(get_txt("export_macos", "EN"))
+
+ with gr.Row():
+ manifest_file = gr.File(label=get_txt("manifest_file", "EN"))
+ win_builder_file = gr.File(label=get_txt("windows_file", "EN"))
+ mac_builder_file = gr.File(label=get_txt("macos_file", "EN"))
+
+ plan_json = gr.Code(label=get_txt("lbl_json_manifest", "EN"), language="json", visible=False, elem_classes=["compact-json"])
+
+ # ------------------
+ # 2. VIEW BUILD CONSOLE
+ # ------------------
+ with gr.Column(visible=False) as view_console:
+ console_title_md = gr.Markdown(f"### {get_txt('console_title', 'EN')}")
+ console_log = gr.Code(
+ value=get_txt("console_ready", "EN"),
+ language=None,
+ elem_classes=["terminal-console"]
+ )
+ simple_status_md = gr.Markdown(value="")
+ simple_package_file = gr.File(label=get_txt("published_package", "EN"))
+
+ # ------------------
+ # 3. VIEW DASHBOARD
+ # ------------------
+ with gr.Column(visible=False) as view_dashboard:
+ dash_lock_html = gr.HTML(f"🔒 {get_txt('status_running', 'EN')}
")
+
+ with gr.Row():
+ with gr.Column(elem_classes=["metric-card"]):
+ dash_runtime_lbl_html = gr.HTML(f"{get_txt('dash_runtime_lbl', 'EN')}
")
+ dash_runtime = gr.HTML("---
")
+ dash_backend = gr.HTML("---
")
+ with gr.Column(elem_classes=["metric-card"]):
+ dash_hardware_lbl_html = gr.HTML(f"{get_txt('dash_hardware_lbl', 'EN')}
")
+ dash_hardware = gr.HTML("---
")
+ dash_gpu = gr.HTML("---
")
+ with gr.Column(elem_classes=["metric-card"]):
+ dash_model_lbl_html = gr.HTML(f"{get_txt('dash_model_lbl', 'EN')}
")
+ dash_model = gr.HTML("---
")
+ dash_policy_note_html = gr.HTML(f"{get_txt('dash_policy_note', 'EN')}
")
+ with gr.Column(elem_classes=["metric-card"]):
+ dash_network_lbl_html = gr.HTML(f"{get_txt('dash_network_lbl', 'EN')}
")
+ dash_network = gr.HTML("---
")
+ dash_loopback_note_html = gr.HTML(f"{get_txt('dash_loopback_note', 'EN')}
")
+
+ gr.Markdown("---")
+ dash_checklist_title_md = gr.Markdown(f"### {get_txt('dash_checklist_title', 'EN')}")
+ dash_checklist = gr.HTML(
+ value=f' {get_txt("dash_api_label", "EN")} : {get_txt("dash_api_stopped", "EN")}
'
+ )
+
+ # ------------------
+ # 4. VIEW INTERACTIVE CHAT
+ # ------------------
+ with gr.Column(visible=False) as view_chat:
+ with gr.Row():
+ chat_model_select = gr.Dropdown(
+ choices=[(f"{m['label_en']} ({m['model_ref']})", m["model_ref"]) for m in MODEL_CATALOG],
+ value=MODEL_CATALOG[2]["model_ref"],
+ label=get_txt("lbl_chat_model", "EN")
+ )
+
+ # Starter prompts buttons
+ starter_md = gr.Markdown(f"***{get_txt('lbl_starter_prompts', 'EN')}***")
+ with gr.Row():
+ starter_1 = gr.Button(get_txt("starter_capability", "EN"), variant="secondary", size="sm")
+ starter_2 = gr.Button(get_txt("starter_coding", "EN"), variant="secondary", size="sm")
+ starter_3 = gr.Button(get_txt("starter_privacy", "EN"), variant="secondary", size="sm")
+
+ chatbot = gr.Chatbot(height=350)
+
+ with gr.Row():
+ prompt_input = gr.Textbox(
+ show_label=False,
+ placeholder=get_txt("lbl_input_prompt", "EN"),
+ scale=4
+ )
+ btn_send = gr.Button(get_txt("lbl_send", "EN"), variant="primary", scale=1)
+ btn_clear = gr.Button(get_txt("lbl_clear", "EN"), variant="secondary", scale=1)
+
+ # Stats display beneath chatbot
+ stats_banner = gr.Markdown(value=f"*{get_txt('chat_offline', 'EN')}*")
+
+ # ------------------
+ # 5. VIEW DOCUMENTS MANAGER
+ # ------------------
+ with gr.Column(visible=False) as view_docs:
+ docs_title_md = gr.Markdown(f"### {get_txt('docs_title', 'EN')}")
+ with gr.Row():
+ with gr.Column(scale=2):
+ doc_title = gr.Textbox(label=get_txt("lbl_doc_title", "EN"), placeholder="guide_eau.txt")
+ doc_content = gr.Textbox(label=get_txt("lbl_doc_content", "EN"), lines=8, placeholder=get_txt("doc_content_ph", "EN"))
+ btn_save_doc = gr.Button(get_txt("lbl_save_doc", "EN"), variant="primary")
+ doc_save_status = gr.Markdown("")
+ with gr.Column(scale=1):
+ docs_list_title_md = gr.Markdown(f"#### {get_txt('lbl_doc_list', 'EN')}")
+ initial_md = "\n".join([f"- **{d['name']}** ({len(d['content'])} chars)" for d in load_initial_docs()])
+ docs_list_display = gr.Markdown(value=initial_md)
+
+ # ------------------
+ # 6. VIEW BENCHMARK UTILITY
+ # ------------------
+ with gr.Column(visible=False) as view_bench:
+ bench_title_md = gr.Markdown(f"### {get_txt('bench_title', 'EN')}")
+ btn_run_bench = gr.Button(get_txt("lbl_run_bench", "EN"), variant="primary")
+ bench_output = gr.Code(
+ value=get_txt("bench_initial", "EN"),
+ language=None,
+ elem_classes=["terminal-console"]
+ )
+
+ # ------------------
+ # 7. VIEW SYSTEM POLICY
+ # ------------------
+ with gr.Column(visible=False) as view_policy:
+ policy_md_view = gr.Markdown(get_txt("policy_body", "EN"))
+
+ # VIEW ROUTER CALLBACKS
+ # Bind nav buttons to routing
+ nav_saas.click(
+ lambda lang: route_to("saas", lang),
+ inputs=[language],
+ outputs=[view_saas, view_console, view_dashboard, view_chat, view_docs, view_bench, view_policy, right_title],
+ )
+ nav_console.click(
+ lambda lang: route_to("console", lang),
+ inputs=[language],
+ outputs=[view_saas, view_console, view_dashboard, view_chat, view_docs, view_bench, view_policy, right_title],
+ )
+ nav_dashboard.click(
+ lambda lang: route_to("dashboard", lang),
+ inputs=[language],
+ outputs=[view_saas, view_console, view_dashboard, view_chat, view_docs, view_bench, view_policy, right_title],
+ )
+ nav_chat.click(
+ lambda lang: route_to("chat", lang),
+ inputs=[language],
+ outputs=[view_saas, view_console, view_dashboard, view_chat, view_docs, view_bench, view_policy, right_title],
+ )
+ nav_docs.click(
+ lambda lang: route_to("docs", lang),
+ inputs=[language],
+ outputs=[view_saas, view_console, view_dashboard, view_chat, view_docs, view_bench, view_policy, right_title],
+ )
+ nav_bench.click(
+ lambda lang: route_to("bench", lang),
+ inputs=[language],
+ outputs=[view_saas, view_console, view_dashboard, view_chat, view_docs, view_bench, view_policy, right_title],
+ )
+ nav_policy.click(
+ lambda lang: route_to("policy", lang),
+ inputs=[language],
+ outputs=[view_saas, view_console, view_dashboard, view_chat, view_docs, view_bench, view_policy, right_title],
+ )
+
+ # LANGUAGE SWITCH UPDATE
+ # Dynamically updates UI text labels
+ def update_language_labels(lang: str):
+ # Nav buttons
+ saas_upd = gr.update(value=get_txt("nav_config", lang))
+ console_upd = gr.update(value=get_txt("nav_console", lang))
+ dashboard_upd = gr.update(value=get_txt("nav_dashboard", lang))
+ chat_upd = gr.update(value=get_txt("nav_chat", lang))
+ docs_upd = gr.update(value=get_txt("nav_docs", lang))
+ bench_upd = gr.update(value=get_txt("nav_bench", lang))
+ policy_upd = gr.update(value=get_txt("nav_policy", lang))
+
+ # Action buttons & text
+ btn_build_upd = gr.update(value=get_txt("btn_build", lang))
+ btn_run_bench_upd = gr.update(value=get_txt("lbl_run_bench", lang))
+ btn_save_doc_upd = gr.update(value=get_txt("lbl_save_doc", lang))
+
+ # Chat prompts & labels
+ prompt_ph = gr.update(placeholder=get_txt("lbl_input_prompt", lang))
+ btn_send_upd = gr.update(value=get_txt("lbl_send", lang))
+ btn_clear_upd = gr.update(value=get_txt("lbl_clear", lang))
+ chat_model_lbl = gr.update(label=get_txt("lbl_chat_model", lang))
+ simple_title_upd = gr.update(value=f"### {get_txt('simple_title', lang)}")
+ simple_desc_upd = gr.update(value=get_txt("simple_desc", lang))
+ simple_goal_upd = gr.update(choices=simple_goal_choices(lang), label=get_txt("simple_goal_label", lang))
+ simple_target_upd = gr.update(choices=simple_target_choices(lang), label=get_txt("simple_target_label", lang))
+ simple_prepare_upd = gr.update(value=get_txt("simple_prepare", lang))
+ simple_ready_upd = gr.update(value=get_txt("simple_ready", lang))
+ advanced_options_upd = gr.update(value=f"### {get_txt('advanced_options', lang)}")
+ sample_upd = gr.update(label=get_txt("sample_label", lang), choices=profile_source_choices(lang))
+ package_goal_upd = gr.update(choices=package_goal_choices(lang), label=get_txt("package_goal_label", lang))
+ target_mode_upd = gr.update(choices=target_mode_choices(lang), label=get_txt("target_mode_label", lang))
+ target_drive_upd = gr.update(label=get_txt("target_drive_label", lang))
+ profile_json_upd = gr.update(label=get_txt("hardware_json_label", lang))
+ export_manifest_upd = gr.update(value=get_txt("export_manifest", lang))
+ win_builder_upd = gr.update(value=get_txt("export_windows", lang))
+ mac_builder_upd = gr.update(value=get_txt("export_macos", lang))
+ manifest_file_upd = gr.update(label=get_txt("manifest_file", lang))
+ win_builder_file_upd = gr.update(label=get_txt("windows_file", lang))
+ mac_builder_file_upd = gr.update(label=get_txt("macos_file", lang))
+ console_title_upd = gr.update(value=f"### {get_txt('console_title', lang)}")
+ simple_file_upd = gr.update(label=get_txt("published_package", lang))
+ simple_target_drive_upd = gr.update(label=get_txt("target_drive_label", lang))
+ profile_upload_upd = gr.update(label=get_txt("profiler_upload_label", lang))
+ profiler_zip_btn_upd = gr.update(value=get_txt("profiler_btn", lang))
+ profiler_zip_file_upd = gr.update(label=get_txt("profiler_zip_label", lang))
+ simple_refresh_upd = gr.update(value=get_txt("refresh_btn", lang))
+ adv_refresh_upd = gr.update(value=get_txt("refresh_btn", lang))
+
+ header_upd = gr.update(value=make_header_html(lang))
+ sidebar_header_upd = gr.update(value=f"### {get_txt('sidebar_header', lang)}")
+ hero_upd = gr.update(value=make_hero_html(lang))
+ footer_upd = gr.update(value=make_footer_html(lang))
+ step1_upd = gr.update(value=f"#### {get_txt('step1_title', lang)}")
+ audit_accordion_upd = gr.update(label=get_txt("audit_accordion", lang))
+ audit_desc_upd = gr.update(value=get_txt("audit_desc", lang))
+ decision_notes_upd = gr.update(
+ label=get_txt("operator_notes_label", lang),
+ placeholder=get_txt("operator_notes_ph", lang),
+ )
+ saas_config_upd = gr.update(value=f"##### {get_txt('audit_saas_title', lang)}")
+ decision_answers_upd = gr.update(label=get_txt("answers_label", lang))
+ api_accordion_upd = gr.update(label=get_txt("api_accordion", lang))
+ api_desc_upd = gr.update(value=get_txt("api_desc", lang))
+ api_provider_upd = gr.update(label=get_txt("api_provider_label", lang))
+ api_model_upd = gr.update(label=get_txt("api_model_label", lang))
+ api_key_upd = gr.update(label=get_txt("api_key_label", lang), placeholder=get_txt("api_key_ph", lang))
+ api_url_upd = gr.update(label=get_txt("api_url_label", lang))
+ local_llm_upd = gr.update(value=f"##### {get_txt('audit_local_title', lang)}")
+ agent_model_choice_upd = gr.update(
+ choices=[
+ (get_txt("agent_choice_auto", lang), "auto"),
+ (get_txt("agent_choice_exact", lang), CONFIG_AGENT_MODEL_ID),
+ ],
+ label=get_txt("agent_model_label", lang),
+ )
+ preview_decision_upd = gr.update(value=get_txt("audit_run_btn", lang))
+ audit_results_upd = gr.update(value=f"##### {get_txt('audit_results_title', lang)}")
+ decision_review_json_upd = gr.update(label=get_txt("audit_json_label", lang))
+ step3_upd = gr.update(value=f"#### {get_txt('step3_title', lang)}")
+ export_accordion_upd = gr.update(label=get_txt("export_accordion", lang))
+ plan_json_upd = gr.update(label=get_txt("lbl_json_manifest", lang))
+ dash_lock_upd = gr.update(value=f"🔒 {get_txt('status_running', lang)}
")
+ dash_runtime_lbl_upd = gr.update(value=f"{get_txt('dash_runtime_lbl', lang)}
")
+ dash_hardware_lbl_upd = gr.update(value=f"{get_txt('dash_hardware_lbl', lang)}
")
+ dash_model_lbl_upd = gr.update(value=f"{get_txt('dash_model_lbl', lang)}
")
+ dash_policy_note_upd = gr.update(value=f"{get_txt('dash_policy_note', lang)}
")
+ dash_network_lbl_upd = gr.update(value=f"{get_txt('dash_network_lbl', lang)}
")
+ dash_loopback_note_upd = gr.update(value=f"{get_txt('dash_loopback_note', lang)}
")
+ dash_checklist_title_upd = gr.update(value=f"### {get_txt('dash_checklist_title', lang)}")
+ starter_md_upd = gr.update(value=f"***{get_txt('lbl_starter_prompts', lang)}***")
+ docs_title_upd = gr.update(value=f"### {get_txt('docs_title', lang)}")
+ doc_title_upd = gr.update(label=get_txt("lbl_doc_title", lang))
+ doc_content_upd = gr.update(label=get_txt("lbl_doc_content", lang), placeholder=get_txt("doc_content_ph", lang))
+ docs_list_title_upd = gr.update(value=f"#### {get_txt('lbl_doc_list', lang)}")
+ bench_title_upd = gr.update(value=f"### {get_txt('bench_title', lang)}")
+ policy_body_upd = gr.update(value=get_txt("policy_body", lang))
+ builder_scope_upd = gr.update(value=get_txt("builder_scope_note", lang))
+ hackathon_badge_upd = gr.update(value=get_txt("hackathon_badge", lang))
+
+ return [
+ saas_upd, console_upd, dashboard_upd, chat_upd, docs_upd, bench_upd, policy_upd,
+ btn_build_upd, btn_run_bench_upd, btn_save_doc_upd, prompt_ph, btn_send_upd, btn_clear_upd, chat_model_lbl,
+ simple_title_upd, simple_desc_upd, simple_goal_upd, simple_target_upd, simple_prepare_upd, simple_ready_upd,
+ advanced_options_upd, sample_upd, package_goal_upd, target_mode_upd, target_drive_upd, profile_json_upd,
+ export_manifest_upd, win_builder_upd, mac_builder_upd, manifest_file_upd, win_builder_file_upd, mac_builder_file_upd,
+ console_title_upd, simple_file_upd, simple_target_drive_upd,
+ profile_upload_upd, profiler_zip_btn_upd, profiler_zip_file_upd, simple_refresh_upd, adv_refresh_upd,
+ header_upd, sidebar_header_upd, hero_upd, footer_upd, step1_upd,
+ audit_accordion_upd, audit_desc_upd, decision_notes_upd, saas_config_upd, decision_answers_upd,
+ api_accordion_upd, api_desc_upd, api_provider_upd, api_model_upd, api_key_upd, api_url_upd,
+ local_llm_upd, agent_model_choice_upd, preview_decision_upd, audit_results_upd, decision_review_json_upd,
+ step3_upd, export_accordion_upd, plan_json_upd,
+ dash_lock_upd, dash_runtime_lbl_upd, dash_hardware_lbl_upd, dash_model_lbl_upd, dash_policy_note_upd,
+ dash_network_lbl_upd, dash_loopback_note_upd, dash_checklist_title_upd,
+ starter_md_upd, docs_title_upd, doc_title_upd, doc_content_upd, docs_list_title_upd,
+ bench_title_upd, policy_body_upd, builder_scope_upd, hackathon_badge_upd,
+ ]
+
+ lang_radio.change(lambda l: l, inputs=[lang_radio], outputs=[language], queue=False)
+ lang_radio.change(
+ update_language_labels,
+ inputs=[lang_radio],
+ outputs=[
+ nav_saas, nav_console, nav_dashboard, nav_chat, nav_docs, nav_bench, nav_policy,
+ btn_build, btn_run_bench, btn_save_doc, prompt_input, btn_send, btn_clear, chat_model_select,
+ simple_title_md, simple_desc_md, simple_goal, simple_target, simple_prepare_btn, simple_ready_md,
+ advanced_options_md, sample, package_goal, target_mode, target_drive, profile_json,
+ export_manifest_btn, win_builder_btn, mac_builder_btn, manifest_file, win_builder_file, mac_builder_file,
+ console_title_md, simple_package_file, simple_target_drive,
+ profile_upload, profiler_zip_btn, profiler_zip_file, simple_btn_refresh_drives, btn_refresh_drives,
+ header_html, sidebar_header_md, hero_html, footer_html, step1_md,
+ audit_accordion, audit_desc_md, decision_notes, saas_config_md, decision_answers,
+ api_accordion, api_desc_md, ui_api_provider, ui_api_model, ui_api_key, ui_api_url,
+ local_llm_md, agent_model_choice, preview_decision_btn, audit_results_md, decision_review_json,
+ step3_md, export_accordion, plan_json,
+ dash_lock_html, dash_runtime_lbl_html, dash_hardware_lbl_html, dash_model_lbl_html, dash_policy_note_html,
+ dash_network_lbl_html, dash_loopback_note_html, dash_checklist_title_md,
+ starter_md, docs_title_md, doc_title, doc_content, docs_list_title_md,
+ bench_title_md, policy_md_view, builder_scope_md, hackathon_badge_md,
+ ],
+ )
+
+ # ONE-CLICK SIMPLE FLOW
+ simple_goal.change(
+ sync_simple_goal,
+ inputs=[simple_goal],
+ outputs=[package_goal]
+ )
+ simple_target.change(
+ sync_simple_target,
+ inputs=[simple_target, target_drive],
+ outputs=[target_mode, target_drive]
+ ).then(
+ lambda val: val,
+ inputs=[target_drive],
+ outputs=[simple_target_drive],
+ queue=False
+ )
+
+ # Sync simple_target_drive and target_drive bidirectional changes
+ simple_target_drive.change(
+ lambda val: val,
+ inputs=[simple_target_drive],
+ outputs=[target_drive],
+ queue=False
+ )
+ target_drive.change(
+ lambda val: val,
+ inputs=[target_drive],
+ outputs=[simple_target_drive],
+ queue=False
+ )
+
+ # Refresh target drives from Simple tab
+ simple_btn_refresh_drives.click(
+ refresh_target_drives,
+ inputs=[target_mode, simple_target_drive],
+ outputs=[simple_target_drive]
+ )
+
+ def update_ui_api_config(provider, model, key, url):
+ decision_engine.set_ui_api_config(provider, model, key, url)
+
+ for comp in [ui_api_provider, ui_api_model, ui_api_key, ui_api_url]:
+ comp.change(
+ update_ui_api_config,
+ inputs=[ui_api_provider, ui_api_model, ui_api_key, ui_api_url],
+ show_progress=False,
+ queue=False
+ )
+
+ def on_sample_change(sample_name):
+ if sample_name in SAMPLE_PROFILES:
+ return json.dumps(SAMPLE_PROFILES[sample_name], indent=2)
+ if sample_name == PROFILE_SOURCE_REAL and IS_LOCAL:
+ hw = get_real_system_hardware()
+ if hw:
+ return json.dumps(hw, indent=2)
+ # Upload source (or scan failure): leave the JSON empty so the
+ # uploaded/real profile is the only thing that can fill it.
+ return ""
+
+ sample.change(
+ on_sample_change,
+ inputs=[sample],
+ outputs=[profile_json]
+ )
+
+ preview_decision_btn.click(
+ run_unified_ai_audit,
+ inputs=[
+ language,
+ sample,
+ profile_json,
+ package_goal,
+ simple_target,
+ target_mode,
+ target_drive,
+ decision_answers,
+ decision_notes,
+ agent_model_choice,
+ ],
+ outputs=[decision_review_md, decision_review_json]
+ )
+ simple_prepare_btn.click(
+ route_to_console_and_reset,
+ inputs=[language],
+ outputs=[view_saas, view_console, view_dashboard, view_chat, view_docs, view_bench, view_policy, right_title, console_log, simple_package_file]
+ ).then(
+ run_one_click_flow,
+ inputs=[language, simple_goal, simple_target, sample, profile_json, target_drive, decision_answers, decision_notes],
+ outputs=[
+ console_log,
+ usb_ready,
+ sidebar_status_html,
+ btn_start_usb,
+ btn_stop_usb,
+ simple_status_md,
+ simple_package_file,
+ plan_json,
+ plan_md,
+ ]
+ ).then(
+ update_chat_models,
+ inputs=[language, sample, profile_json],
+ outputs=[chat_model_select]
+ )
+
+ # REGENERATES PLAN, ROUTES TO CONSOLE, AND RUNS PHYSICAL TARGET CREATION
+ btn_build.click(
+ route_to_console_and_reset,
+ inputs=[language],
+ outputs=[view_saas, view_console, view_dashboard, view_chat, view_docs, view_bench, view_policy, right_title, console_log, simple_package_file]
+ ).then(
+ build_plan,
+ inputs=[language, sample, profile_json, package_goal],
+ outputs=[plan_json, plan_md]
+ ).then(
+ run_physical_usb_build,
+ inputs=[language, sample, profile_json, package_goal, target_drive, target_mode, decision_answers, decision_notes],
+ outputs=[console_log, usb_ready, sidebar_status_html, btn_start_usb, btn_stop_usb]
+ ).then(
+ update_chat_models,
+ inputs=[language, sample, profile_json],
+ outputs=[chat_model_select]
+ )
+
+ # EXPORTS CALLBACKS
+ export_manifest_btn.click(
+ export_manifest_zip,
+ inputs=[language, sample, profile_json, package_goal, simple_target, target_mode, target_drive, decision_answers, decision_notes],
+ outputs=[manifest_file]
+ )
+ win_builder_btn.click(
+ lambda l, s, p, g, st, tm, td, da, dn: export_platform_builder_zip(l, s, p, g, "windows", st, tm, td, da, dn),
+ inputs=[language, sample, profile_json, package_goal, simple_target, target_mode, target_drive, decision_answers, decision_notes],
+ outputs=[win_builder_file]
+ )
+ mac_builder_btn.click(
+ lambda l, s, p, g, st, tm, td, da, dn: export_platform_builder_zip(l, s, p, g, "macos", st, tm, td, da, dn),
+ inputs=[language, sample, profile_json, package_goal, simple_target, target_mode, target_drive, decision_answers, decision_notes],
+ outputs=[mac_builder_file]
+ )
+
+ # RUNTIME START/STOP
+ btn_start_usb.click(
+ handle_start_usb,
+ inputs=[language, sample, profile_json, package_goal, target_drive],
+ outputs=[
+ runtime_running,
+ sidebar_status_html,
+ btn_start_usb,
+ btn_stop_usb,
+ dash_runtime,
+ dash_backend,
+ dash_hardware,
+ dash_gpu,
+ dash_model,
+ dash_network,
+ dash_checklist,
+ view_saas, view_console, view_dashboard, view_chat, view_docs, view_bench, view_policy, right_title
+ ]
+ )
+
+ btn_stop_usb.click(
+ handle_stop_usb,
+ inputs=[language, sample, profile_json, target_drive],
+ outputs=[
+ runtime_running,
+ sidebar_status_html,
+ btn_start_usb,
+ btn_stop_usb,
+ dash_runtime,
+ dash_backend,
+ dash_hardware,
+ dash_gpu,
+ dash_model,
+ dash_network,
+ dash_checklist,
+ view_saas, view_console, view_dashboard, view_chat, view_docs, view_bench, view_policy, right_title
+ ]
+ )
+
+ # STARTER PROMPTS ACTIONS
+ # Update starter buttons labels when Package Goal or Language changes
+ package_goal.change(update_starter_btn_labels, inputs=[package_goal, language], outputs=[starter_1, starter_2, starter_3])
+
+ # When starter prompt clicked, set input box value
+ starter_1.click(lambda text: text, inputs=[starter_1], outputs=[prompt_input])
+ starter_2.click(lambda text: text, inputs=[starter_2], outputs=[prompt_input])
+ starter_3.click(lambda text: text, inputs=[starter_3], outputs=[prompt_input])
+
+ # LIVE CHAT INTERACTION
+ btn_send.click(
+ run_live_chat_messages,
+ inputs=[prompt_input, chatbot, language, sample, profile_json, package_goal, chat_model_select, usb_ready, runtime_running],
+ outputs=[prompt_input, chatbot, stats_banner]
+ )
+ prompt_input.submit(
+ run_live_chat_messages,
+ inputs=[prompt_input, chatbot, language, sample, profile_json, package_goal, chat_model_select, usb_ready, runtime_running],
+ outputs=[prompt_input, chatbot, stats_banner]
+ )
+ btn_clear.click(lambda: ([], ""), outputs=[chatbot, stats_banner])
+
+ # SAVING LOCAL DOCUMENTS (Direct write on USB Key if local)
+ btn_save_doc.click(
+ save_document,
+ inputs=[doc_title, doc_content, saved_docs, usb_ready, runtime_running, language, target_drive],
+ outputs=[saved_docs, doc_save_status, docs_list_display]
+ )
+
+ # BENCHMARK RUNNER (Queries local API endpoint directly)
+ btn_run_bench.click(
+ run_benchmark_real,
+ inputs=[language, sample, profile_json, chat_model_select, usb_ready, runtime_running],
+ outputs=[bench_output]
+ )
+
+
+
+ btn_refresh_drives.click(
+ refresh_target_drives,
+ inputs=[target_mode, target_drive],
+ outputs=[target_drive]
+ )
+ target_mode.change(
+ refresh_target_drives,
+ inputs=[target_mode, target_drive],
+ outputs=[target_drive]
+ )
+
+ profile_upload.upload(load_profile_file, inputs=[profile_upload], outputs=[profile_json])
+ profiler_zip_btn.click(export_profiler_zip, outputs=[profiler_zip_file])
+
+ # STARTUP & DYNAMIC LOADER
+ # If running locally, automatically update UI parameters with real hardware scan!
+ def on_startup_scan(lang):
+ hw = get_real_system_hardware()
+ if hw:
+ # Compile a custom description for the user
+ os_str = hw.get("os", "windows")
+ cpu = hw.get("cpu_threads", 4)
+ ram = hw.get("ram_gb", 8.0)
+ vram = hw.get("vram_gb", 0.0)
+ gpu = hw.get("gpu_name", "Integrated graphics")
+
+ details = get_txt("scan_detected", lang).format(
+ os=os_str.upper(), cpu=cpu, ram=ram, vram=vram, gpu=gpu
+ )
+
+ # Show the scanner output as-is: no invented disk/storage values.
+ return gr.update(value=json.dumps(hw, indent=2)), gr.update(value=f"### {details}")
+ return gr.update(), gr.update(value="")
+
+ # Trigger startup callbacks
+ demo.load(on_startup_scan, inputs=[language], outputs=[profile_json, plan_md])
+ demo.load(update_starter_btn_labels, inputs=[package_goal, language], outputs=[starter_1, starter_2, starter_3])
+
+ # If a JackAILocal runtime is already running on this machine, unlock
+ # the local console functions immediately instead of asking the user
+ # to rebuild a target first.
+ def on_startup_runtime_check(lang):
+ running = IS_LOCAL and is_local_runtime_reachable()
+ sidebar_html, start_upd, stop_upd = get_sidebar_status(running, running, lang)
+ return running, running, sidebar_html, start_upd, stop_upd
+
+ demo.load(
+ on_startup_runtime_check,
+ inputs=[language],
+ outputs=[usb_ready, runtime_running, sidebar_status_html, btn_start_usb, btn_stop_usb],
+ )
+
+ return demo
+
+if __name__ == "__main__":
+ requested_port = int(os.environ.get("GRADIO_SERVER_PORT", os.environ.get("PORT", "7860")))
+ port = find_free_port(requested_port)
+ if port != requested_port:
+ print(f"Port {requested_port} is busy; using {port} instead -> http://127.0.0.1:{port}")
+ build_ui().launch(
+ server_name="127.0.0.1",
+ server_port=port,
+ **gradio_launch_kwargs(),
+ )
+
diff --git a/saas/gradio/decision_engine.py b/saas/gradio/decision_engine.py
new file mode 100644
index 0000000000000000000000000000000000000000..32a876197e01413584d2848bedc02656c1e0d27b
--- /dev/null
+++ b/saas/gradio/decision_engine.py
@@ -0,0 +1,796 @@
+from __future__ import annotations
+
+import json
+import os
+import re
+from typing import Any, Literal
+
+import requests
+from pydantic import BaseModel, Field, ValidationError
+
+
+DEFAULT_MODEL_ID = "Qwen/Qwen2.5-7B-Instruct"
+MODEL_ID = os.getenv("MODEL_ID", os.getenv("REMOTE_CONFIG_AGENT_MODEL_ID", DEFAULT_MODEL_ID)).strip() or DEFAULT_MODEL_ID
+TIMEOUT_SECONDS = int(os.getenv("REMOTE_AGENT_TIMEOUT_SECONDS", "240"))
+
+# Cloud agent is NOT pinned to one model: on the HF router we try a priority
+# list of served, instruct, <=32B models and keep the first that returns valid
+# JSON. This survives a model losing its inference provider or a "thinking"
+# model returning empty content. Override the list with CLOUD_AGENT_MODELS
+# (comma-separated). MODEL_ID (if set) is always tried first.
+_FALLBACK_CLOUD_MODELS = [
+ "Qwen/Qwen2.5-7B-Instruct",
+ "meta-llama/Llama-3.1-8B-Instruct",
+ "google/gemma-3-27b-it",
+ "Qwen/Qwen2.5-Coder-7B-Instruct",
+]
+
+def cloud_agent_candidates() -> list[str]:
+ raw = os.getenv("CLOUD_AGENT_MODELS", "").strip()
+ candidates = [m.strip() for m in raw.split(",") if m.strip()] if raw else [MODEL_ID, *_FALLBACK_CLOUD_MODELS]
+ seen: set[str] = set()
+ ordered: list[str] = []
+ for model in candidates:
+ if model and model not in seen:
+ seen.add(model)
+ ordered.append(model)
+ return ordered
+
+# Module-level state for UI overrides
+_ui_provider = "none"
+_ui_model = ""
+_ui_key = ""
+_ui_url = ""
+
+# Last real decision trace (prompt, raw model output, validated decision).
+# Exposed in the UI so anyone can verify a genuine LLM call happened.
+_last_trace: dict[str, Any] | None = None
+
+
+def get_last_decision_trace() -> dict[str, Any] | None:
+ return _last_trace
+
+
+def set_ui_api_config(provider: str, model: str, key: str, url: str):
+ global _ui_provider, _ui_model, _ui_key, _ui_url
+ _ui_provider = provider.strip() if provider else "none"
+ _ui_model = model.strip() if model else ""
+ _ui_key = key.strip() if key else ""
+ _ui_url = url.strip() if url else ""
+
+
+
+class DecisionQuestion(BaseModel):
+ id: str
+ label: str
+ question: str
+ answer_type: Literal["text", "json", "boolean", "choice", "number"] = "text"
+ options: list[str] = Field(default_factory=list)
+ required: bool = True
+ reason: str = ""
+
+
+class DecisionEnvelope(BaseModel):
+ decision: Literal[
+ "BUILD_USB_PACKAGE",
+ "BUILD_ZIP_PACKAGE",
+ "BUILD_LOCAL_FOLDER",
+ "PUBLISH_APPLIANCE",
+ "ASK_USER",
+ "ASK_HUMAN_REVIEW",
+ "REJECT",
+ ] = "BUILD_LOCAL_FOLDER"
+ confidence: float = Field(default=1.0, ge=0.0, le=1.0)
+ risk_level: Literal["low", "medium", "high"] = "low"
+ selected_backend: Literal["modal", "hf", "ollama", "llama.cpp", "vllm", "none"] = "none"
+ selected_model: str = ""
+ selected_client_model_ref: str | None = None
+ selected_runtime_agent_model_ref: str | None = None
+ reasons: list[str] = Field(default_factory=list)
+ required_human_review: bool = False
+ missing_secrets: list[str] = Field(default_factory=list)
+ questions: list[DecisionQuestion] = Field(default_factory=list)
+ manifest_patch: dict[str, Any] = Field(default_factory=dict)
+ blocked: bool = False
+
+
+def _validate_decision(data: dict[str, Any]) -> DecisionEnvelope:
+ try:
+ return DecisionEnvelope.model_validate(data)
+ except AttributeError:
+ return DecisionEnvelope.parse_obj(data)
+
+
+def _dump_decision(decision: DecisionEnvelope) -> dict[str, Any]:
+ try:
+ return decision.model_dump()
+ except AttributeError:
+ return decision.dict()
+
+
+def _parse_json_object_from_text(text: str) -> dict[str, Any] | None:
+ if not text:
+ return None
+ stripped = text.strip()
+ if stripped.startswith("```"):
+ stripped = re.sub(r"^```(?:json)?\s*", "", stripped, flags=re.IGNORECASE)
+ stripped = re.sub(r"\s*```$", "", stripped)
+ try:
+ value = json.loads(stripped)
+ return value if isinstance(value, dict) else None
+ except json.JSONDecodeError:
+ pass
+ start = stripped.find("{")
+ end = stripped.rfind("}")
+ if start >= 0 and end > start:
+ try:
+ value = json.loads(stripped[start:end + 1])
+ return value if isinstance(value, dict) else None
+ except json.JSONDecodeError:
+ return None
+ return None
+
+
+def get_installed_ollama_models(endpoint: str) -> list[str]:
+ try:
+ response = requests.get(f"{endpoint.rstrip('/')}/api/tags", timeout=2)
+ if response.status_code == 200:
+ data = response.json()
+ return [model["name"] for model in data.get("models", [])]
+ except Exception:
+ pass
+ return []
+
+
+OLLAMA_MODEL_PRIORITY = ["gemma4:12b", "gemma4:latest", "qwen3.5:9b", "qwen3.5:4b", "qwen3.5:2b", "qwen3.6:27b", "gemma:latest"]
+
+
+def _ollama_base_url(endpoint: str) -> str:
+ """Normalize an Ollama endpoint (with or without a trailing /v1) to the bare base URL."""
+ url = endpoint.strip().rstrip("/")
+ if url.endswith("/v1"):
+ url = url[:-3].rstrip("/")
+ return url
+
+
+def _ollama_chat_url(endpoint: str) -> str:
+ return _ollama_base_url(endpoint) + "/v1/chat/completions"
+
+
+def _pick_ollama_model(endpoint: str, requested: str) -> str | None:
+ """Pick a really-installed Ollama model. Returns None when nothing is
+ installed or the tags probe fails: the caller must treat that as
+ 'no provider' instead of guessing a tag that would 404 later."""
+ if requested:
+ return requested
+ installed = get_installed_ollama_models(_ollama_base_url(endpoint))
+ if not installed:
+ return None
+ for p in OLLAMA_MODEL_PRIORITY:
+ if p in installed:
+ return p
+ return installed[0]
+
+
+def configured_provider() -> dict[str, Any] | None:
+ # 1. First check if UI has overridden provider config
+ if _ui_provider != "none":
+ prov = _ui_provider.lower()
+ if prov == "openai":
+ return {
+ "id": "openai",
+ "transport": "openai_compatible",
+ "url": (_ui_url or "https://api.openai.com/v1").rstrip("/") + "/chat/completions",
+ "api_key": _ui_key,
+ "model": _ui_model or "gpt-4o-mini",
+ }
+ elif prov == "anthropic":
+ return {
+ "id": "anthropic",
+ "transport": "anthropic",
+ "url": (_ui_url or "https://api.anthropic.com/v1/messages").rstrip("/"),
+ "api_key": _ui_key,
+ "model": _ui_model or "claude-3-5-sonnet-20241022",
+ }
+ elif prov == "gemini":
+ return {
+ "id": "gemini",
+ "transport": "openai_compatible",
+ "url": (_ui_url or "https://generativelanguage.googleapis.com/v1beta/openai").rstrip("/") + "/chat/completions",
+ "api_key": _ui_key,
+ "model": _ui_model or "gemini-1.5-flash",
+ }
+ elif prov == "openrouter":
+ return {
+ "id": "openrouter",
+ "transport": "openai_compatible",
+ "url": (_ui_url or "https://openrouter.ai/api/v1").rstrip("/") + "/chat/completions",
+ "api_key": _ui_key,
+ "model": _ui_model or "google/gemini-2.5-flash",
+ }
+ elif prov == "modal":
+ return {
+ "id": "modal",
+ "transport": "openai_compatible",
+ "url": (_ui_url or "https://router.modal.com").rstrip("/") + "/v1/chat/completions",
+ "api_key": _ui_key,
+ "model": _ui_model or DEFAULT_MODEL_ID,
+ }
+ elif prov == "hf":
+ return {
+ "id": "hf",
+ "transport": "openai_compatible",
+ "url": "https://router.huggingface.co/v1/chat/completions",
+ "api_key": _ui_key,
+ "model": _ui_model or DEFAULT_MODEL_ID,
+ }
+ elif prov == "ollama":
+ url_str = _ui_url or "http://localhost:11434"
+ model_to_use = _pick_ollama_model(url_str, _ui_model.strip())
+ if not model_to_use:
+ # Ollama reachable or not, no installed model was found: do not
+ # guess a tag that would 404 at decision time.
+ return None
+ return {
+ "id": "ollama",
+ "transport": "openai_compatible",
+ "url": _ollama_chat_url(url_str),
+ "api_key": "ollama-key",
+ "model": model_to_use,
+ }
+
+ # 2. Check environment variables
+ openai_key = os.getenv("OPENAI_API_KEY", "").strip()
+ anthropic_key = os.getenv("ANTHROPIC_API_KEY", "").strip()
+ gemini_key = os.getenv("GEMINI_API_KEY", "").strip()
+ openrouter_key = os.getenv("OPENROUTER_API_KEY", "").strip()
+
+ requested = os.getenv("CONFIG_AGENT_PROVIDER", "").strip().lower()
+ env_model = os.getenv("CONFIG_AGENT_MODEL", "").strip()
+
+ if (requested == "openai" or (requested == "" and openai_key)) and openai_key:
+ return {
+ "id": "openai",
+ "transport": "openai_compatible",
+ "url": "https://api.openai.com/v1/chat/completions",
+ "api_key": openai_key,
+ "model": env_model or "gpt-4o-mini",
+ }
+ if (requested == "anthropic" or (requested == "" and anthropic_key)) and anthropic_key:
+ return {
+ "id": "anthropic",
+ "transport": "anthropic",
+ "url": "https://api.anthropic.com/v1/messages",
+ "api_key": anthropic_key,
+ "model": env_model or "claude-3-5-sonnet-20241022",
+ }
+ if (requested == "gemini" or (requested == "" and gemini_key)) and gemini_key:
+ return {
+ "id": "gemini",
+ "transport": "openai_compatible",
+ "url": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
+ "api_key": gemini_key,
+ "model": env_model or "gemini-1.5-flash",
+ }
+ if (requested == "openrouter" or (requested == "" and openrouter_key)) and openrouter_key:
+ return {
+ "id": "openrouter",
+ "transport": "openai_compatible",
+ "url": "https://openrouter.ai/api/v1/chat/completions",
+ "api_key": openrouter_key,
+ "model": env_model or "google/gemini-2.5-flash",
+ }
+
+ modal_base_url = os.getenv("MODAL_BASE_URL", "").strip().rstrip("/")
+ modal_api_key = os.getenv("MODAL_API_KEY", "").strip()
+ hf_token = os.getenv("HF_TOKEN", "").strip()
+ local_endpoint = os.getenv("LOCAL_AGENT_ENDPOINT", "http://localhost:8000/v1").strip().rstrip("/")
+ ollama_endpoint = os.getenv("OLLAMA_AGENT_ENDPOINT", "http://localhost:11434/v1").strip().rstrip("/")
+
+ if requested in {"", "modal", "modal_vllm"} and modal_base_url and modal_api_key:
+ return {
+ "id": "modal",
+ "transport": "openai_compatible",
+ "url": f"{modal_base_url}/v1/chat/completions",
+ "api_key": modal_api_key,
+ "model": MODEL_ID,
+ }
+ if requested in {"", "hf", "huggingface", "hf_inference"} and hf_token:
+ return {
+ "id": "hf",
+ "transport": "openai_compatible",
+ "url": "https://router.huggingface.co/v1/chat/completions",
+ "api_key": hf_token,
+ "model": MODEL_ID,
+ }
+ if requested == "ollama":
+ model_to_use = _pick_ollama_model(ollama_endpoint, env_model or _ui_model.strip())
+ if not model_to_use:
+ return None
+ return {
+ "id": "ollama",
+ "transport": "openai_compatible",
+ "url": _ollama_chat_url(ollama_endpoint),
+ "api_key": "ollama-key",
+ "model": model_to_use,
+ }
+ if requested == "local":
+ return {
+ "id": "local",
+ "transport": "openai_compatible",
+ "url": f"{local_endpoint}/chat/completions",
+ "api_key": "local-key",
+ "model": MODEL_ID,
+ }
+
+ # Auto-detect local agents if no remote credentials are configured
+ if requested == "" and not modal_api_key and not hf_token:
+ import socket
+ from urllib.parse import urlparse
+
+ # 1. Try Ollama (port 11434)
+ try:
+ parsed = urlparse(ollama_endpoint)
+ host = parsed.hostname or "localhost"
+ port = parsed.port or 11434
+ with socket.create_connection((host, port), timeout=0.1):
+ model_to_use = _pick_ollama_model(f"http://{host}:{port}", env_model or _ui_model.strip())
+ if model_to_use:
+ return {
+ "id": "ollama",
+ "transport": "openai_compatible",
+ "url": _ollama_chat_url(ollama_endpoint),
+ "api_key": "ollama-key",
+ "model": model_to_use,
+ }
+ except Exception:
+ pass
+
+ # 2. Try WSL API Server (port 8000)
+ try:
+ parsed = urlparse(local_endpoint)
+ host = parsed.hostname or "localhost"
+ port = parsed.port or 8000
+ with socket.create_connection((host, port), timeout=0.1):
+ return {
+ "id": "local",
+ "transport": "openai_compatible",
+ "url": f"{local_endpoint}/chat/completions",
+ "api_key": "local-key",
+ "model": MODEL_ID,
+ }
+ except Exception:
+ pass
+
+ return None
+
+
+def provider_status() -> dict[str, Any]:
+ provider = configured_provider()
+ if not provider:
+ return {
+ "configured": False,
+ "provider": "none",
+ "model": MODEL_ID,
+ "missing_any_of": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY", "MODAL_BASE_URL+MODAL_API_KEY", "HF_TOKEN", "local Ollama with an installed model"],
+ }
+ return {
+ "configured": True,
+ "provider": provider["id"],
+ "model": provider["model"],
+ "endpoint": provider["url"].replace("/chat/completions", "").replace("/v1/messages", ""),
+ }
+
+
+def _missing_provider_decision(required: bool) -> DecisionEnvelope:
+ return DecisionEnvelope(
+ decision="REJECT" if required else "ASK_HUMAN_REVIEW",
+ confidence=1.0,
+ risk_level="high" if required else "medium",
+ selected_backend="none",
+ selected_model=MODEL_ID,
+ reasons=[
+ "No real AI decision backend is configured.",
+ "Set OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, OPENROUTER_API_KEY, MODAL_API_KEY, or HF_TOKEN, "
+ "or run a local Ollama with at least one installed model (e.g. `ollama pull gemma4:12b`).",
+ "The app does not fabricate a decision when no model is available.",
+ ],
+ required_human_review=True,
+ missing_secrets=["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY", "MODAL_API_KEY", "HF_TOKEN"],
+ questions=[
+ DecisionQuestion(
+ id="configure_remote_agent",
+ label="Decision agent",
+ question="Configure a cloud API key, Hugging Face secrets, or a local Ollama model, then run the decision again.",
+ answer_type="text",
+ required=True,
+ reason="A real cloud/local LLM call is required for AI-in-the-loop decisions.",
+ )
+ ],
+ manifest_patch={},
+ blocked=required,
+ )
+
+
+def _state_questions(app_state: dict[str, Any]) -> list[DecisionQuestion]:
+ questions: list[DecisionQuestion] = []
+ hardware = app_state.get("hardware_profile") or {}
+ user_answers = app_state.get("user_answers") or {}
+
+ if not hardware.get("os"):
+ questions.append(
+ DecisionQuestion(
+ id="target_os",
+ label="Target OS",
+ question="Which operating system should this package target: windows, macos, or linux?",
+ answer_type="choice",
+ options=["windows", "macos", "linux"],
+ reason="Backend binaries and builder scripts are platform-specific.",
+ )
+ )
+ if not hardware.get("ram_gb"):
+ questions.append(
+ DecisionQuestion(
+ id="ram_gb",
+ label="RAM",
+ question="How much system RAM is available on the target computer, in GB?",
+ answer_type="number",
+ reason="RAM determines the safe local model size.",
+ )
+ )
+ if app_state.get("target_mode") == "USB" and not app_state.get("target_available"):
+ questions.append(
+ DecisionQuestion(
+ id="usb_target",
+ label="USB target",
+ question="No real USB target is currently available. Should the builder switch to ZIP/local-folder, or will you provide a USB drive?",
+ answer_type="choice",
+ options=["switch_to_zip", "switch_to_local_folder", "provide_usb_drive"],
+ reason="The product must not claim to build a USB package without a detected target.",
+ )
+ )
+ if app_state.get("profile_source") == "sample" and not user_answers.get("confirmed_sample_profile"):
+ questions.append(
+ DecisionQuestion(
+ id="confirm_profile",
+ label="Hardware profile",
+ question="Is this sample hardware profile representative of the real client machine?",
+ answer_type="boolean",
+ reason="A sample profile is useful for demo, but it should be confirmed before client packaging.",
+ required=False,
+ )
+ )
+ return questions
+
+
+def build_decision_prompt(app_state: dict[str, Any]) -> list[dict[str, str]]:
+ system = f"""
+You are JackAILocal's internal packaging decision engine, not a chatbot.
+
+CRITICAL: You MUST NOT output any or thinking/reasoning process. Do not explain your choices or write any comments. Start your output directly with the opening curly brace '{{' of the JSON object.
+
+Return only a single valid JSON object. No markdown, no prose outside JSON.
+
+Hard rules:
+1. Never invent API keys, files, installed models, endpoints, drives, hardware, or user answers.
+2. If critical information is missing, use decision ASK_USER and provide concrete questions.
+3. If human review is safer than proceeding, use ASK_HUMAN_REVIEW.
+4. Never select a model above {app_state.get("max_params_b", 32)}B parameters.
+5. Hosted AI is for packaging decisions only. The delivered runtime must remain local/offline after preload.
+6. A USB build requires a real available target. If not available, ask the user or select ZIP/local-folder.
+7. Choose backend per model support: Ollama refs for Ollama, GGUF paths for llama.cpp, HF repos for vLLM.
+8. Prefer Cloud API when configured, otherwise auto-detected local Ollama.
+9. Do not execute actions. Return a decision and manifest_patch only.
+
+Manifest Patch Guidelines:
+- Under "manifest_patch", recommend software features and content packs for the target system.
+- "voice_mode" (boolean): set to true if the user's goal or operator notes mention voice, TTS, STT, vocal, or talk.
+- "phone_access" (boolean): set to true if they mention phone, mobile, sharing, LAN, network access, or sharing.
+- "scout_vision" (boolean): set to true if they mention vision, scout, camera, image, or multimodal capabilities.
+- "content_pack_ids" (array of strings): select from ["field_manual_core", "starter_prompt_packs"] based on storage limits and goals.
+- "selected_backend" (string): must be one of "ollama", "llama.cpp", "vllm".
+- "selected_client_model_ref" (string): the exact model ref matching a compatible model in app_state.allowed_models.
+
+Required JSON contract:
+{{
+ "decision": "BUILD_USB_PACKAGE | BUILD_ZIP_PACKAGE | BUILD_LOCAL_FOLDER | PUBLISH_APPLIANCE | ASK_USER | ASK_HUMAN_REVIEW | REJECT",
+ "confidence": 0.0,
+ "risk_level": "low | medium | high",
+ "selected_backend": "modal | hf | ollama | llama.cpp | vllm | none",
+ "selected_model": "string",
+ "selected_client_model_ref": "string or null",
+ "selected_runtime_agent_model_ref": "string or null",
+ "reasons": ["string"],
+ "required_human_review": false,
+ "missing_secrets": ["string"],
+ "questions": [
+ {{
+ "id": "string",
+ "label": "string",
+ "question": "string",
+ "answer_type": "text | json | boolean | choice | number",
+ "options": ["string"],
+ "required": true,
+ "reason": "string"
+ }}
+ ],
+ "manifest_patch": {{
+ "selected_backend": "string",
+ "selected_client_model_ref": "string",
+ "voice_mode": false,
+ "phone_access": false,
+ "scout_vision": false,
+ "content_pack_ids": ["string"]
+ }},
+ "blocked": false
+}}
+"""
+ user = {
+ "task": "Choose the safest deployable JackAILocal packaging configuration.",
+ "instructions": "Start your output directly with the JSON opening brace '{'. Do not write any reasoning, explanations, or thinking block.",
+ "app_state": app_state,
+ "provider_status": provider_status(),
+ }
+ return [
+ {"role": "system", "content": system.strip()},
+ {"role": "user", "content": json.dumps(user, ensure_ascii=False)},
+ ]
+
+
+def call_decision_model(app_state: dict[str, Any]) -> tuple[DecisionEnvelope, str, dict[str, Any] | None]:
+ """Run the decision against a real LLM provider.
+
+ Returns (decision, source, provider) where source reflects the path that
+ actually executed: 'real_local_llm' (ollama/local), 'real_remote_llm'
+ (cloud APIs), or 'not_configured'. No decision is ever fabricated when no
+ provider is available."""
+ global _last_trace
+ provider = configured_provider()
+ if not provider:
+ _last_trace = {
+ "source": "not_configured",
+ "note": "No LLM provider available: the app refused to fabricate a decision.",
+ }
+ return (
+ _missing_provider_decision(bool(app_state.get("decision_engine_required"))),
+ "not_configured",
+ None,
+ )
+
+ messages = build_decision_prompt(app_state)
+
+ # Check transport type
+ is_anthropic = (provider.get("transport") == "anthropic")
+
+ if is_anthropic:
+ system_prompt = messages[0]["content"]
+ user_content = messages[1]["content"]
+ headers = {
+ "x-api-key": provider["api_key"],
+ "anthropic-version": "2023-06-01",
+ "content-type": "application/json",
+ }
+ request_json = {
+ "model": provider["model"],
+ "max_tokens": 1100,
+ "temperature": 0.0,
+ "system": system_prompt,
+ "messages": [
+ {"role": "user", "content": user_content}
+ ]
+ }
+ else:
+ request_json = {
+ "model": provider["model"],
+ "messages": messages,
+ "temperature": 0.0,
+ "max_tokens": 1100,
+ }
+ # Add response format for models supporting JSON output mode
+ if provider["id"] in {"openai", "gemini", "openrouter", "modal", "hf"}:
+ request_json["response_format"] = {"type": "json_object"}
+
+ if provider["id"] == "modal":
+ request_json["chat_template_kwargs"] = {"enable_thinking": False}
+
+ headers = {
+ "Authorization": f"Bearer {provider['api_key']}",
+ "Content-Type": "application/json",
+ }
+
+ log_dir = os.path.join(os.getenv("JACKAILOCAL_ROOT", "."), ".jackailocal", "logs")
+ os.makedirs(log_dir, exist_ok=True)
+ debug_log_path = os.path.join(log_dir, "decision_agent_debug.log")
+
+ # On the HF router we may try several served models; everywhere else the
+ # provider's single configured model is used. First valid JSON wins, and
+ # provider["model"] is updated to whatever actually answered.
+ models_to_try = cloud_agent_candidates() if (not is_anthropic and provider["id"] == "hf") else [provider["model"]]
+
+ content = ""
+ parsed = None
+ last_error = None
+ for candidate in models_to_try:
+ if not is_anthropic:
+ request_json["model"] = candidate
+ try:
+ with open(debug_log_path, "a", encoding="utf-8") as f:
+ f.write(f"\n\n=========================================\n")
+ f.write(f"REQUEST - Provider: {provider['id']}, Model: {candidate}, URL: {provider['url']}\n")
+ f.write(f"Prompt JSON: {json.dumps(messages, indent=2, ensure_ascii=False)}\n")
+ except Exception:
+ pass
+
+ try:
+ response = requests.post(
+ provider["url"],
+ headers=headers,
+ json=request_json,
+ timeout=TIMEOUT_SECONDS,
+ )
+ except Exception as err:
+ last_error = f"connection error ({candidate}): {err}"
+ try:
+ with open(debug_log_path, "a", encoding="utf-8") as f:
+ f.write(f"CONNECTION ERROR: {err}\n")
+ except Exception:
+ pass
+ continue
+
+ try:
+ with open(debug_log_path, "a", encoding="utf-8") as f:
+ f.write(f"RESPONSE Status: {response.status_code}\n")
+ f.write(f"RESPONSE Text: {response.text[:2000]}\n")
+ except Exception:
+ pass
+
+ if response.status_code != 200:
+ last_error = f"HTTP {response.status_code} ({candidate}): {response.text[:300]}"
+ continue
+
+ payload = response.json()
+ if is_anthropic:
+ content = payload.get("content", [{}])[0].get("text", "") or ""
+ else:
+ content = payload.get("choices", [{}])[0].get("message", {}).get("content", "") or ""
+
+ candidate_parsed = _parse_json_object_from_text(content)
+ if candidate_parsed:
+ parsed = candidate_parsed
+ provider["model"] = candidate
+ break
+ last_error = f"non-JSON/empty content from {candidate}"
+
+ if not parsed:
+ raise RuntimeError(f"AI decision backend returned non-JSON content (last error: {last_error}); content head: {content[:400]}")
+
+ try:
+ decision = _validate_decision(parsed)
+ except ValidationError as exc:
+ try:
+ with open(debug_log_path, "a", encoding="utf-8") as f:
+ f.write(f"VALIDATION ERROR: {exc}\nParsed JSON: {json.dumps(parsed, indent=2, ensure_ascii=False)}\n")
+ except Exception:
+ pass
+ raise RuntimeError(f"AI decision schema validation failed: {exc}") from exc
+
+ try:
+ with open(debug_log_path, "a", encoding="utf-8") as f:
+ f.write(f"VALIDATION SUCCESSFUL! Decision: {decision.decision}\n")
+ except Exception:
+ pass
+
+ source = "real_local_llm" if provider["id"] in {"ollama", "local"} else "real_remote_llm"
+ _last_trace = {
+ "source": source,
+ "provider": provider["id"],
+ "model": provider["model"],
+ "endpoint": provider["url"],
+ "messages": messages,
+ "raw_model_output": content,
+ "validated_decision": _dump_decision(decision),
+ }
+ return decision, source, provider
+
+
+def policy_gate(decision: DecisionEnvelope, app_state: dict[str, Any], provider: dict[str, Any] | None = None) -> DecisionEnvelope:
+ provider_id = provider["id"] if provider else "none"
+
+ if decision.selected_backend in {"modal", "hf"} and decision.selected_backend != provider_id:
+ return DecisionEnvelope(
+ decision="ASK_HUMAN_REVIEW",
+ confidence=1.0,
+ risk_level="medium",
+ selected_backend=provider_id if provider_id in {"modal", "hf"} else "none",
+ selected_model=MODEL_ID,
+ reasons=[
+ f"AI selected backend {decision.selected_backend}, but configured provider is {provider_id}.",
+ "Policy gate stopped the mismatch before manifest application.",
+ ],
+ required_human_review=True,
+ missing_secrets=decision.missing_secrets,
+ questions=[
+ DecisionQuestion(
+ id="backend_mismatch",
+ label="Backend mismatch",
+ question="Update the Space secrets/provider or rerun the decision with the configured backend.",
+ answer_type="text",
+ reason="The selected hosted backend must match the configured real provider.",
+ )
+ ],
+ manifest_patch={},
+ blocked=False,
+ )
+
+ preflight_questions = _state_questions(app_state)
+ required_preflight_questions = [question for question in preflight_questions if question.required]
+ if required_preflight_questions and decision.decision not in {"ASK_USER", "ASK_HUMAN_REVIEW", "REJECT"}:
+ return DecisionEnvelope(
+ decision="ASK_USER",
+ confidence=1.0,
+ risk_level="medium",
+ selected_backend=decision.selected_backend,
+ selected_model=decision.selected_model,
+ selected_client_model_ref=decision.selected_client_model_ref,
+ selected_runtime_agent_model_ref=decision.selected_runtime_agent_model_ref,
+ reasons=[
+ "Policy gate requires more user configuration before package generation.",
+ *decision.reasons,
+ ],
+ required_human_review=False,
+ missing_secrets=decision.missing_secrets,
+ questions=required_preflight_questions,
+ manifest_patch=decision.manifest_patch,
+ blocked=False,
+ )
+
+ if decision.decision == "BUILD_USB_PACKAGE" and not app_state.get("target_available"):
+ return DecisionEnvelope(
+ decision="ASK_USER",
+ confidence=1.0,
+ risk_level="medium",
+ selected_backend=decision.selected_backend,
+ selected_model=decision.selected_model,
+ selected_client_model_ref=decision.selected_client_model_ref,
+ selected_runtime_agent_model_ref=decision.selected_runtime_agent_model_ref,
+ reasons=[
+ "The model selected a USB build, but no real USB target is available.",
+ "Policy gate requires the user to provide a USB target or switch deployment mode.",
+ ],
+ required_human_review=False,
+ missing_secrets=decision.missing_secrets,
+ questions=[
+ DecisionQuestion(
+ id="usb_target",
+ label="USB target",
+ question="Provide a real USB target or choose ZIP/local-folder deployment.",
+ answer_type="choice",
+ options=["provide_usb_drive", "switch_to_zip", "switch_to_local_folder"],
+ reason="A USB build cannot be real without a detected target.",
+ )
+ ],
+ manifest_patch={
+ **decision.manifest_patch,
+ "policy_override": "usb_target_missing",
+ },
+ blocked=False,
+ )
+
+ return decision
+
+
+def decide_internal_action(app_state: dict[str, Any]) -> dict[str, Any]:
+ decision, source, provider = call_decision_model(app_state)
+ gated = policy_gate(decision, app_state, provider)
+ output = _dump_decision(gated)
+ if provider:
+ output["provider_status"] = {
+ "configured": True,
+ "provider": provider["id"],
+ "model": provider["model"],
+ "endpoint": provider["url"].replace("/chat/completions", "").replace("/v1/messages", ""),
+ }
+ else:
+ output["provider_status"] = provider_status()
+ output["source"] = source
+ return output
diff --git a/saas/gradio/examples/linux-appliance.json b/saas/gradio/examples/linux-appliance.json
new file mode 100644
index 0000000000000000000000000000000000000000..b913acca9026f2c1366ced219831fa731e0b485d
--- /dev/null
+++ b/saas/gradio/examples/linux-appliance.json
@@ -0,0 +1,8 @@
+{
+ "os": "linux",
+ "cpu_threads": 16,
+ "ram_gb": 32,
+ "vram_gb": 12,
+ "gpu_name": "NVIDIA GPU",
+ "disk": "NVMe"
+}
diff --git a/saas/gradio/examples/low-end-laptop.json b/saas/gradio/examples/low-end-laptop.json
new file mode 100644
index 0000000000000000000000000000000000000000..5d6af3e3fab375e9fcbdbe5e3ff615e31640b606
--- /dev/null
+++ b/saas/gradio/examples/low-end-laptop.json
@@ -0,0 +1,8 @@
+{
+ "os": "windows",
+ "cpu_threads": 4,
+ "ram_gb": 8,
+ "vram_gb": 0,
+ "gpu_name": "Integrated graphics",
+ "disk": "USB 3.0"
+}
diff --git a/saas/gradio/examples/normal-laptop.json b/saas/gradio/examples/normal-laptop.json
new file mode 100644
index 0000000000000000000000000000000000000000..9423ff1f92118a66ac80fab04dbf4b16d3465f81
--- /dev/null
+++ b/saas/gradio/examples/normal-laptop.json
@@ -0,0 +1,8 @@
+{
+ "os": "windows",
+ "cpu_threads": 8,
+ "ram_gb": 16,
+ "vram_gb": 4,
+ "gpu_name": "Entry NVIDIA/AMD GPU",
+ "disk": "USB 3.2 SSD"
+}
diff --git a/saas/gradio/requirements.txt b/saas/gradio/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..82b11076759098c128ece92315805612025a9c66
--- /dev/null
+++ b/saas/gradio/requirements.txt
@@ -0,0 +1,4 @@
+gradio>=4.44.0
+requests
+huggingface_hub>=0.34.0
+pydantic>=2.0.0
diff --git a/saas/worker/build_package.py b/saas/worker/build_package.py
new file mode 100644
index 0000000000000000000000000000000000000000..cb9bb4e1e7341d3b9328f3344538afa130a5a018
--- /dev/null
+++ b/saas/worker/build_package.py
@@ -0,0 +1,21 @@
+#!/usr/bin/env python3
+"""Builds a downloadable JackAILocal customer bundle from a manifest."""
+import json, zipfile, sys, pathlib, hashlib
+
+def sha256(p):
+ h=hashlib.sha256(); h.update(pathlib.Path(p).read_bytes()); return h.hexdigest()
+
+def main():
+ if len(sys.argv) != 4:
+ print("usage: build_package.py "); sys.exit(2)
+ seed=pathlib.Path(sys.argv[1]); manifest=pathlib.Path(sys.argv[2]); out=pathlib.Path(sys.argv[3])
+ excluded_dirs={'.git','target','workspace','diagnostics','.jackailocal','.jackailocal-builder','__pycache__','license-keys'}
+ excluded_files={'config/update-private-key.xml'}
+ with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z:
+ for p in seed.rglob('*'):
+ rel=p.relative_to(seed).as_posix()
+ if p.is_file() and not any(part in excluded_dirs for part in p.parts) and rel not in excluded_files and not p.name.endswith('.pyc'):
+ z.write(p, p.relative_to(seed))
+ z.write(manifest, 'manifest/build-manifest.json')
+ print(out, sha256(out))
+if __name__ == '__main__': main()
diff --git a/scratch/chat_gemma.py b/scratch/chat_gemma.py
new file mode 100644
index 0000000000000000000000000000000000000000..889896d5575df91412836049fb8277de0ceb6d4d
--- /dev/null
+++ b/scratch/chat_gemma.py
@@ -0,0 +1,62 @@
+import sys
+from transformers import AutoModelForCausalLM, AutoTokenizer, TextStreamer
+
+repo_id = "OBLITERATUS/Gemma-4-12B-OBLITERATED"
+
+print(f"Loading tokenizer for {repo_id}...")
+tokenizer = AutoTokenizer.from_pretrained(repo_id, trust_remote_code=True)
+print(f"Loading model weights for {repo_id} (device_map='auto')...")
+model = AutoModelForCausalLM.from_pretrained(
+ repo_id,
+ device_map="auto",
+ torch_dtype="auto",
+ trust_remote_code=True,
+)
+print("Model loaded successfully!")
+
+messages = []
+
+print("\n--- CLI Chat with Gemma 4 ---")
+print("Type 'exit' or 'quit' to end the chat.")
+
+while True:
+ try:
+ user_input = input("\nYou: ")
+ if not user_input.strip():
+ continue
+ if user_input.strip().lower() in ["exit", "quit"]:
+ break
+
+ messages.append({"role": "user", "content": user_input})
+
+ text = tokenizer.apply_chat_template(
+ messages,
+ tokenize=False,
+ add_generation_prompt=True,
+ enable_thinking=False,
+ )
+
+ inputs = tokenizer(text, return_tensors="pt").to(model.device)
+
+ print("\nAssistant: ", end="", flush=True)
+ streamer = TextStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
+
+ output = model.generate(
+ **inputs,
+ max_new_tokens=1024,
+ temperature=0.7,
+ top_p=0.9,
+ top_k=40,
+ do_sample=True,
+ repetition_penalty=1.1,
+ streamer=streamer,
+ )
+
+ generated_text = tokenizer.decode(output[0][inputs["input_ids"].shape[-1]:], skip_special_tokens=True)
+ messages.append({"role": "assistant", "content": generated_text})
+
+ except KeyboardInterrupt:
+ print("\nExiting...")
+ break
+ except Exception as e:
+ print(f"\nError: {e}")
diff --git a/scratch/download_shard.py b/scratch/download_shard.py
new file mode 100644
index 0000000000000000000000000000000000000000..38b3a15d1c9a935ba16047f7d11f6f782ef52528
--- /dev/null
+++ b/scratch/download_shard.py
@@ -0,0 +1,20 @@
+import logging
+import os
+import sys
+from huggingface_hub import hf_hub_download
+
+# Set up debug logging to stdout
+logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
+
+repo_id = "OBLITERATUS/Gemma-4-12B-OBLITERATED"
+filename = "model-00010-of-00013.safetensors"
+
+print("Starting download with debug logging...")
+try:
+ path = hf_hub_download(
+ repo_id=repo_id,
+ filename=filename,
+ )
+ print(f"Success! Downloaded to {path}")
+except Exception as e:
+ print(f"Failed with exception: {e}")
diff --git a/scratch/serve_gemma.py b/scratch/serve_gemma.py
new file mode 100644
index 0000000000000000000000000000000000000000..478c6d3932fbc6abf324ce12e85257115d2c9327
--- /dev/null
+++ b/scratch/serve_gemma.py
@@ -0,0 +1,286 @@
+import argparse
+import asyncio
+import json
+import os
+import sys
+import threading
+import time
+from typing import List, Dict, Any, Union
+from fastapi import FastAPI, Request
+from fastapi.responses import StreamingResponse
+from huggingface_hub import HfApi
+from pydantic import BaseModel
+import torch
+import uvicorn
+from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
+
+app = FastAPI(title="Gemma-4 HF API Server")
+
+# Global model & tokenizer references
+model = None
+tokenizer = None
+loaded_repo_id = None
+
+WEIGHT_FILENAMES = {
+ "model.safetensors",
+ "model.safetensors.index.json",
+ "pytorch_model.bin",
+ "pytorch_model.bin.index.json",
+}
+
+class ChatMessage(BaseModel):
+ role: str
+ content: str
+
+class ChatCompletionRequest(BaseModel):
+ model: str = "OBLITERATUS/Gemma-4-12B-OBLITERATED"
+ messages: List[ChatMessage]
+ temperature: float = 0.7
+ top_p: float = 0.9
+ top_k: int = 40
+ max_tokens: int = 512
+ stream: bool = False
+ repetition_penalty: float = 1.1
+
+def repo_has_transformers_weights(repo_id: str, token: str | None) -> bool:
+ files = HfApi(token=token).list_repo_files(repo_id=repo_id, repo_type="model")
+ return any(
+ filename in WEIGHT_FILENAMES
+ or filename.endswith(".safetensors")
+ or filename.endswith(".bin")
+ for filename in files
+ )
+
+
+def resolve_repo_id(
+ repo_id: str,
+ fallback_repo_id: str | None,
+ wait_for_weights: int,
+ poll_interval: int,
+ token: str | None,
+) -> str:
+ deadline = time.monotonic() + wait_for_weights
+
+ while True:
+ if repo_has_transformers_weights(repo_id, token):
+ return repo_id
+
+ if time.monotonic() >= deadline:
+ break
+
+ remaining = max(0, int(deadline - time.monotonic()))
+ print(
+ f"No Transformers weights are published for {repo_id} yet. "
+ f"Checking again in {poll_interval}s ({remaining}s remaining)...",
+ flush=True,
+ )
+ time.sleep(min(poll_interval, remaining))
+
+ if fallback_repo_id:
+ if not repo_has_transformers_weights(fallback_repo_id, token):
+ raise RuntimeError(
+ f"Neither {repo_id} nor fallback {fallback_repo_id} contains "
+ "Transformers weights."
+ )
+ print(
+ f"WARNING: {repo_id} has no Transformers weights. "
+ f"Using the explicitly requested fallback {fallback_repo_id}.",
+ flush=True,
+ )
+ return fallback_repo_id
+
+ raise RuntimeError(
+ f"{repo_id} does not currently contain model weights. Its Hugging Face "
+ "repository only publishes configuration/tokenizer files, so "
+ "AutoModelForCausalLM cannot load it.\n"
+ "Wait for the advertised weight files to finish publishing, or run an "
+ "explicit fallback, for example:\n"
+ " --fallback-repo-id google/gemma-4-12B-it\n"
+ "To wait for an in-progress upload, add:\n"
+ " --wait-for-weights 3600"
+ )
+
+
+def load_model(
+ repo_id: str,
+ fallback_repo_id: str | None = None,
+ wait_for_weights: int = 0,
+ poll_interval: int = 60,
+):
+ global model, tokenizer, loaded_repo_id
+ token = os.environ.get("HF_TOKEN") or None
+ selected_repo_id = resolve_repo_id(
+ repo_id=repo_id,
+ fallback_repo_id=fallback_repo_id,
+ wait_for_weights=max(0, wait_for_weights),
+ poll_interval=max(5, poll_interval),
+ token=token,
+ )
+
+ if token is None:
+ print(
+ "HF_TOKEN is not set. Public downloads still work, but Hugging Face "
+ "applies lower rate limits.",
+ flush=True,
+ )
+
+ print(f"Loading tokenizer for {selected_repo_id}...")
+ tokenizer = AutoTokenizer.from_pretrained(
+ selected_repo_id,
+ trust_remote_code=True,
+ token=token,
+ )
+ print(f"Loading model weights for {selected_repo_id} (device_map='auto')...")
+ model = AutoModelForCausalLM.from_pretrained(
+ selected_repo_id,
+ device_map="auto",
+ torch_dtype="auto",
+ trust_remote_code=True,
+ token=token,
+ )
+ loaded_repo_id = selected_repo_id
+ print(f"Model loaded successfully: {selected_repo_id}")
+
+
+@app.get("/health")
+async def health():
+ return {
+ "status": "ok" if model is not None and tokenizer is not None else "loading",
+ "model": loaded_repo_id,
+ }
+
+async def stream_generator(streamer: TextIteratorStreamer):
+ loop = asyncio.get_event_loop()
+ while True:
+ try:
+ token = await loop.run_in_executor(None, lambda: next(streamer, None))
+ if token is None:
+ break
+
+ chunk = {
+ "choices": [
+ {
+ "delta": {"content": token},
+ "finish_reason": None,
+ "index": 0
+ }
+ ]
+ }
+ yield f"data: {json.dumps(chunk)}\n\n"
+ except Exception as e:
+ print(f"Error in stream: {e}")
+ break
+
+ chunk_done = {
+ "choices": [
+ {
+ "delta": {},
+ "finish_reason": "stop",
+ "index": 0
+ }
+ ]
+ }
+ yield f"data: {json.dumps(chunk_done)}\n\n"
+ yield "data: [DONE]\n\n"
+
+@app.post("/v1/chat/completions")
+async def chat_completions(request: ChatCompletionRequest):
+ global model, tokenizer
+ if model is None or tokenizer is None:
+ return {"error": "Model not loaded"}
+
+ messages_list = [{"role": msg.role, "content": msg.content} for msg in request.messages]
+ prompt = tokenizer.apply_chat_template(
+ messages_list,
+ tokenize=False,
+ add_generation_prompt=True,
+ enable_thinking=False,
+ )
+
+ inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
+
+ if request.stream:
+ streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
+
+ generation_kwargs = dict(
+ **inputs,
+ max_new_tokens=request.max_tokens,
+ temperature=request.temperature,
+ top_p=request.top_p,
+ top_k=request.top_k,
+ do_sample=True,
+ repetition_penalty=request.repetition_penalty,
+ streamer=streamer,
+ )
+
+ thread = threading.Thread(target=model.generate, kwargs=generation_kwargs)
+ thread.start()
+
+ return StreamingResponse(stream_generator(streamer), media_type="text/event-stream")
+
+ else:
+ with torch.no_grad():
+ output = model.generate(
+ **inputs,
+ max_new_tokens=request.max_tokens,
+ temperature=request.temperature,
+ top_p=request.top_p,
+ top_k=request.top_k,
+ do_sample=True,
+ repetition_penalty=request.repetition_penalty,
+ )
+
+ generated_text = tokenizer.decode(output[0][inputs["input_ids"].shape[-1]:], skip_special_tokens=True)
+
+ return {
+ "choices": [
+ {
+ "message": {
+ "role": "assistant",
+ "content": generated_text
+ },
+ "finish_reason": "stop",
+ "index": 0
+ }
+ ]
+ }
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--repo-id", type=str, default="OBLITERATUS/Gemma-4-12B-OBLITERATED")
+ parser.add_argument(
+ "--fallback-repo-id",
+ type=str,
+ default=None,
+ help="Explicit model to use only when --repo-id has no published weights.",
+ )
+ parser.add_argument(
+ "--wait-for-weights",
+ type=int,
+ default=0,
+ metavar="SECONDS",
+ help="Wait for an in-progress Hugging Face upload before failing.",
+ )
+ parser.add_argument(
+ "--poll-interval",
+ type=int,
+ default=60,
+ metavar="SECONDS",
+ help="Hugging Face polling interval used with --wait-for-weights.",
+ )
+ parser.add_argument("--host", type=str, default="0.0.0.0")
+ parser.add_argument("--port", type=int, default=8000)
+ args = parser.parse_args()
+
+ try:
+ load_model(
+ repo_id=args.repo_id,
+ fallback_repo_id=args.fallback_repo_id,
+ wait_for_weights=args.wait_for_weights,
+ poll_interval=args.poll_interval,
+ )
+ except RuntimeError as exc:
+ print(f"\nERROR: {exc}", file=sys.stderr)
+ raise SystemExit(2) from None
+
+ uvicorn.run(app, host=args.host, port=args.port)
diff --git a/scratch/test_gemma.py b/scratch/test_gemma.py
new file mode 100644
index 0000000000000000000000000000000000000000..647a8ea7cc1635771be166d03fa266d2ae4e0d53
--- /dev/null
+++ b/scratch/test_gemma.py
@@ -0,0 +1,35 @@
+from transformers import AutoModelForCausalLM, AutoTokenizer, TextStreamer
+
+repo_id = "OBLITERATUS/Gemma-4-12B-OBLITERATED"
+
+tokenizer = AutoTokenizer.from_pretrained(repo_id, trust_remote_code=True)
+model = AutoModelForCausalLM.from_pretrained(
+ repo_id,
+ device_map="auto",
+ torch_dtype="auto",
+ trust_remote_code=True,
+)
+
+messages = [
+ {"role": "user", "content": "Write a concise Python function that merges overlapping intervals."}
+]
+text = tokenizer.apply_chat_template(
+ messages,
+ tokenize=False,
+ add_generation_prompt=True,
+ enable_thinking=False,
+)
+inputs = tokenizer(text, return_tensors="pt").to(model.device)
+
+streamer = TextStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
+
+output = model.generate(
+ **inputs,
+ max_new_tokens=50,
+ temperature=0.7,
+ top_p=0.9,
+ top_k=40,
+ do_sample=True,
+ repetition_penalty=1.1,
+ streamer=streamer,
+)
diff --git a/scratch/test_gradio_decision.py b/scratch/test_gradio_decision.py
new file mode 100644
index 0000000000000000000000000000000000000000..afbbccf6f6993eb506ea4a2bbab82cb30e85b91b
--- /dev/null
+++ b/scratch/test_gradio_decision.py
@@ -0,0 +1,37 @@
+import sys
+import os
+import json
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../saas/gradio")))
+import app
+import decision_engine
+
+print("Provider status:", decision_engine.provider_status())
+
+# Get the list of sample profiles
+profiles = app.SAMPLE_PROFILES
+print("Available profiles:", list(profiles.keys()))
+
+for name in profiles.keys():
+ print(f"\n--- Testing profile: {name} ---")
+ plan = app.build_plan_object("EN", name, "", "Standard offline assistant")
+ app_state = app.build_decision_app_state(
+ "EN",
+ plan,
+ "",
+ simple_target="local_runtime",
+ target_mode="LocalFolder",
+ target_drive="C:/",
+ user_answers_json="{}",
+ operator_notes="",
+ decision_engine_required=True,
+ )
+
+ try:
+ decision = decision_engine.decide_internal_action(app_state)
+ print("Decision returned successfully!")
+ print("Decision:", decision.get("decision"))
+ print("Selected Model:", decision.get("selected_client_model_ref") or decision.get("selected_model"))
+ print("Selected Backend:", decision.get("selected_backend"))
+ print("Reasons:", decision.get("reasons"))
+ except Exception as e:
+ print(f"FAILED with exception: {type(e).__name__}: {e}")
diff --git a/scratch/test_ollama_raw.py b/scratch/test_ollama_raw.py
new file mode 100644
index 0000000000000000000000000000000000000000..d41923904dd02ed64027da6a214aa4c6fd5acbc6
--- /dev/null
+++ b/scratch/test_ollama_raw.py
@@ -0,0 +1,89 @@
+import sys
+import os
+import json
+import urllib.request
+import urllib.error
+
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../saas/gradio")))
+import decision_engine
+
+app_state = {
+ "language": "en",
+ "product": "JackAILocal",
+ "selector_version": "1.0.0",
+ "build_id": "test-build-123",
+ "package_goal": "Standard offline assistant",
+ "requested_action": "BUILD_LOCAL_FOLDER",
+ "simple_target": "local_runtime",
+ "target_mode": "LocalFolder",
+ "target_drive": "C:/",
+ "target_available": True,
+ "running_in_hosted_space": False,
+ "profile_source": "sample",
+ "hardware_profile": {
+ "os": "windows",
+ "cpu_threads": 8,
+ "ram_gb": 16,
+ "vram_gb": 0,
+ "gpu_name": "CPU-only office laptop",
+ "disk": "USB 3.2 SSD",
+ "usb_storage_gb": 128
+ },
+ "default_model": {
+ "id": "qwen35_smart_9b",
+ "model_ref": "qwen3.5:9b"
+ },
+ "allowed_models": [
+ {"id": "qwen35_fast_2b", "model_ref": "qwen3.5:2b", "task": "chat", "tier": "standard"},
+ {"id": "qwen35_balanced_4b", "model_ref": "qwen3.5:4b", "task": "chat", "tier": "standard"},
+ {"id": "qwen35_smart_9b", "model_ref": "qwen3.5:9b", "task": "chat", "tier": "standard"}
+ ],
+ "max_params_b": 32,
+ "constraints": {
+ "no_fake_or_simulated_backend": True,
+ "local_runtime_after_preload": True,
+ "max_params_b": 32,
+ "normal_runtime_local_only": True,
+ "backend_specific_models": True
+ },
+ "user_answers": {},
+ "operator_notes": ""
+}
+
+messages = decision_engine.build_decision_prompt(app_state)
+
+url = "http://localhost:11434/v1/chat/completions"
+request_json = {
+ "model": "gemma4:12b",
+ "messages": messages,
+ "temperature": 0.0,
+ "max_tokens": 1100,
+ "response_format": {"type": "json_object"}
+}
+
+req = urllib.request.Request(
+ url,
+ data=json.dumps(request_json).encode("utf-8"),
+ headers={"Content-Type": "application/json"},
+ method="POST"
+)
+
+print(f"Sending request to Ollama at {url} using urllib...")
+try:
+ with urllib.request.urlopen(req, timeout=120) as response:
+ status_code = response.getcode()
+ body = response.read().decode("utf-8")
+ print(f"Status Code: {status_code}")
+ print("Response Body:")
+ print(body)
+
+ payload = json.loads(body)
+ content = payload.get("choices", [{}])[0].get("message", {}).get("content", "")
+ print(f"\nGenerated content string: '{content}'")
+
+ parsed = decision_engine._parse_json_object_from_text(content)
+ print(f"Parsed JSON object: {parsed}")
+except urllib.error.HTTPError as e:
+ print(f"HTTPError: {e.code} - {e.read().decode('utf-8')}")
+except Exception as e:
+ print(f"Error: {e}")
diff --git a/scratch/test_port.py b/scratch/test_port.py
new file mode 100644
index 0000000000000000000000000000000000000000..18b20ad9863d935a201e8ee29c8b1781532720c5
--- /dev/null
+++ b/scratch/test_port.py
@@ -0,0 +1,15 @@
+import socket
+
+def check_port(host, port):
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(2)
+ try:
+ s.connect((host, port))
+ print(f"Port {port} is OPEN!")
+ s.close()
+ return True
+ except Exception as e:
+ print(f"Port {port} is CLOSED: {e}")
+ return False
+
+check_port("127.0.0.1", 7870)
diff --git a/scratch/test_unified_audit.py b/scratch/test_unified_audit.py
new file mode 100644
index 0000000000000000000000000000000000000000..637fd049238fde8307b7f656c1c5d391bca07cf3
--- /dev/null
+++ b/scratch/test_unified_audit.py
@@ -0,0 +1,30 @@
+import sys
+import os
+import json
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../saas/gradio")))
+import app
+
+print("Testing run_unified_ai_audit...")
+
+markdown_result, json_result = app.run_unified_ai_audit(
+ language="EN",
+ sample_name="Normal laptop",
+ raw_profile_json="",
+ package_goal="Standard offline assistant",
+ simple_target="local_runtime",
+ target_mode="LocalFolder",
+ target_drive="C:/",
+ decision_answers_json="{}",
+ operator_notes="Must support voice offline, target is somewhat limited hardware.",
+ agent_model_choice="auto"
+)
+
+print("\n--- AUDIT MARKDOWN OUTPUT ---")
+print(markdown_result)
+print("\n--- AUDIT JSON OUTPUT ---")
+try:
+ parsed = json.loads(json_result)
+ print(json.dumps(parsed, indent=2))
+except Exception as e:
+ print(f"Error parsing JSON output: {e}")
+ print(json_result)
diff --git a/scripts/New-SvnDevBranch.ps1 b/scripts/New-SvnDevBranch.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..c9614c23b40b0e053715d8d3830cce36116a10bf
--- /dev/null
+++ b/scripts/New-SvnDevBranch.ps1
@@ -0,0 +1,178 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$BaseBranchUrl,
+
+ [Parameter(Mandatory = $false)]
+ [string]$BranchName,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Ticket,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Description,
+
+ [Parameter(Mandatory = $false)]
+ [string]$AuthorInitials,
+
+ [Parameter(Mandatory = $false)]
+ [string]$CommitMessage,
+
+ [Parameter(Mandatory = $false)]
+ [string]$CheckoutPath,
+
+ [switch]$Force,
+ [switch]$WhatIfOnly
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+function Assert-SvnCli {
+ if (-not (Get-Command svn -ErrorAction SilentlyContinue)) {
+ throw "Le client svn n'est pas disponible dans le PATH."
+ }
+}
+
+function Normalize-Slug {
+ param([string]$Value)
+
+ if ([string]::IsNullOrWhiteSpace($Value)) {
+ return "modif"
+ }
+
+ $normalized = $Value.ToLowerInvariant()
+ $normalized = $normalized -replace "[^a-z0-9]+", "-"
+ $normalized = $normalized.Trim("-")
+
+ if ([string]::IsNullOrWhiteSpace($normalized)) {
+ return "modif"
+ }
+
+ return $normalized
+}
+
+function Get-ParentUrl {
+ param([string]$Url)
+
+ $trimmed = $Url.TrimEnd("/")
+ $lastSlashIndex = $trimmed.LastIndexOf("/")
+ if ($lastSlashIndex -lt 0) {
+ throw "Impossible de determiner le parent de l'URL: $Url"
+ }
+
+ return $trimmed.Substring(0, $lastSlashIndex)
+}
+
+function Test-BranchExists {
+ param([string]$Url)
+
+ & svn info "$Url" *> $null
+ return ($LASTEXITCODE -eq 0)
+}
+
+function New-DefaultBranchName {
+ param(
+ [string]$Ticket,
+ [string]$Description,
+ [string]$AuthorInitials
+ )
+
+ $datePart = (Get-Date).ToString("yyyyMMdd")
+ $ticketPart = if ([string]::IsNullOrWhiteSpace($Ticket)) { "NO-TICKET" } else { $Ticket.Trim().ToUpperInvariant() }
+ $authorPart = if ([string]::IsNullOrWhiteSpace($AuthorInitials)) { "XX" } else { $AuthorInitials.Trim().ToUpperInvariant() }
+ $slugPart = Normalize-Slug -Value $Description
+
+ return "dev_${ticketPart}_${authorPart}_${slugPart}_${datePart}"
+}
+
+function Validate-BranchName {
+ param([string]$Name)
+
+ $regex = "^dev_[A-Z0-9-]+_[A-Z]{2,5}_[a-z0-9-]+_[0-9]{8}$"
+ if ($Name -notmatch $regex) {
+ throw @"
+Nom de branche invalide: $Name
+Pattern attendu: dev____
+Exemple: dev_SPRF-3124_HBK_fix-journalisation-404_20260319
+"@
+ }
+}
+
+function New-DefaultCommitMessage {
+ param(
+ [string]$Ticket,
+ [string]$Description,
+ [string]$BranchName
+ )
+
+ $ticketPart = if ([string]::IsNullOrWhiteSpace($Ticket)) { "NO-TICKET" } else { $Ticket.Trim().ToUpperInvariant() }
+ $descriptionPart = if ([string]::IsNullOrWhiteSpace($Description)) { "Correctifs" } else { $Description.Trim() }
+
+ return "$ticketPart - $descriptionPart | Branche: $BranchName"
+}
+
+Assert-SvnCli
+
+if ([string]::IsNullOrWhiteSpace($BranchName)) {
+ $BranchName = New-DefaultBranchName -Ticket $Ticket -Description $Description -AuthorInitials $AuthorInitials
+}
+
+Validate-BranchName -Name $BranchName
+
+$baseUrl = $BaseBranchUrl.TrimEnd("/")
+$parentUrl = Get-ParentUrl -Url $baseUrl
+$newBranchUrl = "$parentUrl/$BranchName"
+
+if ([string]::IsNullOrWhiteSpace($CommitMessage)) {
+ $CommitMessage = New-DefaultCommitMessage -Ticket $Ticket -Description $Description -BranchName $BranchName
+}
+
+Write-Host "Base branch URL : $baseUrl" -ForegroundColor Cyan
+Write-Host "New branch name : $BranchName" -ForegroundColor Cyan
+Write-Host "New branch URL : $newBranchUrl" -ForegroundColor Cyan
+Write-Host "Create message : $CommitMessage" -ForegroundColor Cyan
+if (-not [string]::IsNullOrWhiteSpace($CheckoutPath)) {
+ Write-Host "Checkout path : $CheckoutPath" -ForegroundColor Cyan
+}
+
+if ((Test-BranchExists -Url $newBranchUrl) -and -not $Force) {
+ throw "La branche existe deja: $newBranchUrl. Utilisez -Force pour continuer."
+}
+
+if ($WhatIfOnly) {
+ Write-Host "Mode simulation: aucune commande svn copy/checkout executee." -ForegroundColor Yellow
+ return
+}
+
+if (Test-BranchExists -Url $newBranchUrl) {
+ Write-Host "La branche existe deja et -Force est actif. Aucune creation effectuee." -ForegroundColor Yellow
+}
+else {
+ Write-Host "Creation de la branche..." -ForegroundColor Green
+ & svn copy "$baseUrl" "$newBranchUrl" -m "$CommitMessage"
+ if ($LASTEXITCODE -ne 0) {
+ throw "Echec de creation de branche SVN."
+ }
+}
+
+if (-not [string]::IsNullOrWhiteSpace($CheckoutPath)) {
+ if (Test-Path -LiteralPath $CheckoutPath) {
+ if (-not $Force) {
+ throw "Le repertoire de checkout existe deja: $CheckoutPath. Utilisez -Force pour l'autoriser."
+ }
+
+ Write-Host "Le repertoire existe deja, checkout ignore en mode -Force." -ForegroundColor Yellow
+ }
+ else {
+ Write-Host "Checkout de la nouvelle branche..." -ForegroundColor Green
+ & svn checkout "$newBranchUrl" "$CheckoutPath"
+ if ($LASTEXITCODE -ne 0) {
+ throw "Echec du checkout SVN."
+ }
+ }
+}
+
+Write-Host "Termine." -ForegroundColor Green
+Write-Host "Message de commit suggere pour les changements de code:" -ForegroundColor Cyan
+Write-Host ("{0} - Correctifs journalisation (404 toleres + fallback reponse vide)" -f ($(if ([string]::IsNullOrWhiteSpace($Ticket)) { "NO-TICKET" } else { $Ticket.Trim().ToUpperInvariant() }))) -ForegroundColor Gray
diff --git a/scripts/dev-start.ps1 b/scripts/dev-start.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..e324bf704ee2473e805f92f0e0b168dda9a5e956
--- /dev/null
+++ b/scripts/dev-start.ps1
@@ -0,0 +1,45 @@
+# dev-start.ps1 — Lance API + UI en mode watch (Kestrel) sans IIS
+# Usage : .\dev-start.ps1 (lance les deux)
+# .\dev-start.ps1 -api (API seulement)
+# .\dev-start.ps1 -ui (UI seulement)
+
+param(
+ [switch]$api,
+ [switch]$ui
+)
+
+$projetApi = ""
+$projetUi = ""
+$urlApi = "https://localhost:5001"
+$urlUi = "https://localhost:5002"
+
+# Si aucun flag, lancer les deux
+if (-not $api -and -not $ui) {
+ $api = $true
+ $ui = $true
+}
+
+if ($api) {
+ if ([string]::IsNullOrWhiteSpace($projetApi)) {
+ Write-Host "[ERREUR] Chemin API non configure dans ce depot." -ForegroundColor Red
+ exit 1
+ }
+ Write-Host "[DEV] Demarrage API sur $urlApi ..." -ForegroundColor Cyan
+ Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd '$PSScriptRoot'; dotnet watch run --project $projetApi --urls $urlApi"
+}
+
+if ($ui) {
+ if ([string]::IsNullOrWhiteSpace($projetUi)) {
+ Write-Host "[ERREUR] Chemin UI non configure dans ce depot." -ForegroundColor Red
+ exit 1
+ }
+ Write-Host "[DEV] Demarrage UI sur $urlUi ..." -ForegroundColor Green
+ Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd '$PSScriptRoot'; dotnet watch run --project $projetUi --urls $urlUi"
+}
+
+Write-Host ""
+Write-Host "=== Serveurs de developpement ===" -ForegroundColor Yellow
+if ($api) { Write-Host " API : $urlApi/swagger" -ForegroundColor Cyan }
+if ($ui) { Write-Host " UI : $urlUi" -ForegroundColor Green }
+Write-Host " Ctrl+C dans chaque fenetre pour arreter" -ForegroundColor DarkGray
+Write-Host ""
diff --git a/scripts/run-gradio-saas-preview.cmd b/scripts/run-gradio-saas-preview.cmd
new file mode 100644
index 0000000000000000000000000000000000000000..3e3974cade5c7938a79aa4d8e454d1ef0873c129
--- /dev/null
+++ b/scripts/run-gradio-saas-preview.cmd
@@ -0,0 +1,10 @@
+@echo off
+setlocal
+cd /d "%~dp0..\saas\gradio"
+if not exist .venv (
+ python -m venv .venv
+)
+call .venv\Scripts\activate.bat
+python -m pip install --upgrade pip
+python -m pip install -r requirements.txt
+python app.py
diff --git a/scripts/support-bundle.ps1 b/scripts/support-bundle.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..3ca8f77a7059746c3c25241de5abb95cce5fc392
--- /dev/null
+++ b/scripts/support-bundle.ps1
@@ -0,0 +1,10 @@
+param([string]$OutDir = ".\support")
+$ErrorActionPreference = "Stop"
+New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
+$root = Split-Path -Parent $PSScriptRoot
+Copy-Item "$root\diagnostics\hardware.json" "$OutDir\hardware.json" -ErrorAction SilentlyContinue
+Copy-Item "$root\manifest\sha256-manifest.json" "$OutDir\sha256-manifest.json" -ErrorAction SilentlyContinue
+Copy-Item "$root\config\model-catalog.json" "$OutDir\model-catalog.json" -ErrorAction SilentlyContinue
+Get-ChildItem "$root\logs" -ErrorAction SilentlyContinue | Copy-Item -Destination $OutDir -ErrorAction SilentlyContinue
+Compress-Archive -Path "$OutDir\*" -DestinationPath "$OutDir\JackAILocal-support-bundle.zip" -Force
+Write-Host "Created $OutDir\JackAILocal-support-bundle.zip"
diff --git a/src/crypto_pack.rs b/src/crypto_pack.rs
new file mode 100644
index 0000000000000000000000000000000000000000..526f2f6616e8eec3b1b4b96eba433d61add06a71
--- /dev/null
+++ b/src/crypto_pack.rs
@@ -0,0 +1,205 @@
+use crate::storage::{self, PackedDocument, ThreadRecord};
+use aes_gcm::{
+ aead::{Aead, Payload},
+ Aes256Gcm, KeyInit, Nonce,
+};
+use anyhow::{bail, Context, Result};
+use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
+use chrono::Utc;
+use pbkdf2::pbkdf2_hmac;
+use rand::{rngs::OsRng, RngCore};
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+use sha2::Sha256;
+use std::path::Path;
+
+const FORMAT: &str = "jackailocal-encrypted-pack";
+const VERSION: u32 = 1;
+const PBKDF2_ITERATIONS: u32 = 210_000;
+const AAD: &[u8] = b"JackAILocal encrypted transfer pack v1";
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct ExportOptions {
+ pub passphrase: String,
+ pub include_threads: Option,
+ pub include_documents: Option,
+ pub include_settings: Option,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct ImportOptions {
+ pub passphrase: String,
+ pub data_base64: String,
+ pub mode: Option,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct ImportResult {
+ pub threads_imported: usize,
+ pub documents_imported: usize,
+ pub settings_imported: bool,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+struct PlainPack {
+ product: String,
+ format_version: u32,
+ exported_at: String,
+ threads: Vec,
+ documents: Vec,
+ settings: Option,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+struct EncryptedEnvelope {
+ format: String,
+ version: u32,
+ cipher: String,
+ kdf: String,
+ iterations: u32,
+ salt_base64: String,
+ nonce_base64: String,
+ ciphertext_base64: String,
+}
+
+pub fn export_pack(root: &Path, options: &ExportOptions) -> Result> {
+ validate_passphrase(&options.passphrase)?;
+ let plain = PlainPack {
+ product: "JackAILocal".to_string(),
+ format_version: VERSION,
+ exported_at: Utc::now().to_rfc3339(),
+ threads: if options.include_threads.unwrap_or(true) {
+ storage::read_all_threads(root)?
+ } else {
+ Vec::new()
+ },
+ documents: if options.include_documents.unwrap_or(true) {
+ storage::list_documents_with_content(root)?
+ } else {
+ Vec::new()
+ },
+ settings: if options.include_settings.unwrap_or(false) {
+ Some(sanitized_settings(storage::read_settings(root)))
+ } else {
+ None
+ },
+ };
+ let plaintext = serde_json::to_vec(&plain)?;
+
+ let mut salt = [0_u8; 16];
+ let mut nonce = [0_u8; 12];
+ OsRng.fill_bytes(&mut salt);
+ OsRng.fill_bytes(&mut nonce);
+ let key = derive_key(options.passphrase.as_bytes(), &salt, PBKDF2_ITERATIONS);
+ let cipher = Aes256Gcm::new_from_slice(&key).context("cannot initialize AES-256-GCM")?;
+ let ciphertext = cipher
+ .encrypt(
+ Nonce::from_slice(&nonce),
+ Payload {
+ msg: &plaintext,
+ aad: AAD,
+ },
+ )
+ .map_err(|_| anyhow::anyhow!("pack encryption failed"))?;
+
+ let envelope = EncryptedEnvelope {
+ format: FORMAT.to_string(),
+ version: VERSION,
+ cipher: "AES-256-GCM".to_string(),
+ kdf: "PBKDF2-HMAC-SHA256".to_string(),
+ iterations: PBKDF2_ITERATIONS,
+ salt_base64: B64.encode(salt),
+ nonce_base64: B64.encode(nonce),
+ ciphertext_base64: B64.encode(ciphertext),
+ };
+ Ok(serde_json::to_vec_pretty(&envelope)?)
+}
+
+pub fn import_pack(root: &Path, options: &ImportOptions) -> Result {
+ validate_passphrase(&options.passphrase)?;
+ let envelope_bytes = B64
+ .decode(options.data_base64.trim())
+ .context("pack data is not valid base64")?;
+ let envelope: EncryptedEnvelope =
+ serde_json::from_slice(&envelope_bytes).context("pack envelope is not valid JSON")?;
+ if envelope.format != FORMAT || envelope.version != VERSION {
+ bail!("unsupported JackAILocal pack format or version");
+ }
+ if envelope.cipher != "AES-256-GCM" || envelope.kdf != "PBKDF2-HMAC-SHA256" {
+ bail!("unsupported pack cryptography");
+ }
+ if envelope.iterations < 100_000 {
+ bail!("pack key derivation settings are too weak");
+ }
+ let salt = B64
+ .decode(&envelope.salt_base64)
+ .context("invalid pack salt")?;
+ let nonce = B64
+ .decode(&envelope.nonce_base64)
+ .context("invalid pack nonce")?;
+ let ciphertext = B64
+ .decode(&envelope.ciphertext_base64)
+ .context("invalid pack ciphertext")?;
+ if salt.len() != 16 || nonce.len() != 12 {
+ bail!("invalid pack salt or nonce length");
+ }
+ let key = derive_key(options.passphrase.as_bytes(), &salt, envelope.iterations);
+ let cipher = Aes256Gcm::new_from_slice(&key).context("cannot initialize AES-256-GCM")?;
+ let plaintext = cipher
+ .decrypt(
+ Nonce::from_slice(&nonce),
+ Payload {
+ msg: &ciphertext,
+ aad: AAD,
+ },
+ )
+ .map_err(|_| anyhow::anyhow!("wrong passphrase or corrupted pack"))?;
+ let pack: PlainPack =
+ serde_json::from_slice(&plaintext).context("decrypted pack is invalid")?;
+ if pack.product != "JackAILocal" || pack.format_version != VERSION {
+ bail!("decrypted data is not a compatible JackAILocal pack");
+ }
+
+ let replace = options.mode.as_deref() == Some("replace");
+ let threads_imported = storage::import_threads(root, &pack.threads, replace)?;
+ let mut documents_imported = 0;
+ for document in pack.documents {
+ storage::save_document(root, &document.name, &document.content)?;
+ documents_imported += 1;
+ }
+ let settings_imported = if let Some(settings) = pack.settings {
+ storage::write_settings(root, &sanitized_settings(settings))?;
+ true
+ } else {
+ false
+ };
+ Ok(ImportResult {
+ threads_imported,
+ documents_imported,
+ settings_imported,
+ })
+}
+
+fn validate_passphrase(passphrase: &str) -> Result<()> {
+ if passphrase.chars().count() < 8 {
+ bail!("passphrase must contain at least 8 characters");
+ }
+ Ok(())
+}
+
+fn derive_key(passphrase: &[u8], salt: &[u8], iterations: u32) -> [u8; 32] {
+ let mut key = [0_u8; 32];
+ pbkdf2_hmac::(passphrase, salt, iterations, &mut key);
+ key
+}
+
+fn sanitized_settings(mut settings: Value) -> Value {
+ if !settings.is_object() {
+ settings = json!({});
+ }
+ if let Some(object) = settings.as_object_mut() {
+ object.insert("phone_access_enabled".to_string(), json!(false));
+ object.remove("phone_access_token");
+ }
+ settings
+}
diff --git a/src/license.rs b/src/license.rs
new file mode 100644
index 0000000000000000000000000000000000000000..f9c45b7c76c84a625a38a5e15b95793187fd2030
--- /dev/null
+++ b/src/license.rs
@@ -0,0 +1,345 @@
+use anyhow::{Context, Result};
+use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
+use chrono::{DateTime, Utc};
+use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
+use rand::rngs::OsRng;
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+use std::path::{Path, PathBuf};
+
+pub const LICENSE_RELATIVE_PATH: &str = "license/license.json";
+pub const PUBLIC_KEY_RELATIVE_PATH: &str = "config/license-public-key.txt";
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LicensePayload {
+ pub license_id: String,
+ pub product: String,
+ pub edition: String,
+ pub customer_name: String,
+ #[serde(default)]
+ pub customer_email: Option,
+ #[serde(default)]
+ pub customer_company: Option,
+ pub issued_at: DateTime,
+ #[serde(default)]
+ pub expires_at: Option>,
+ #[serde(default)]
+ pub max_devices: Option,
+ #[serde(default)]
+ pub features: Vec,
+ #[serde(default)]
+ pub notes: Option,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+struct LicenseFile {
+ payload_b64: String,
+ signature_b64: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct LicenseStatus {
+ pub state: &'static str,
+ pub reason: Option,
+ pub payload: Option,
+}
+
+impl LicenseStatus {
+ pub fn to_json(&self) -> Value {
+ json!({
+ "state": self.state,
+ "licensed": self.state == "licensed",
+ "reason": self.reason,
+ "license": self.payload.as_ref().map(|p| json!({
+ "license_id": p.license_id,
+ "product": p.product,
+ "edition": p.edition,
+ "customer_name": p.customer_name,
+ "customer_company": p.customer_company,
+ "issued_at": p.issued_at.to_rfc3339(),
+ "expires_at": p.expires_at.map(|d| d.to_rfc3339()),
+ "features": p.features,
+ })),
+ })
+ }
+}
+
+pub fn status(root: &Path) -> LicenseStatus {
+ let verifying_key = match load_public_key(root) {
+ Ok(Some(key)) => key,
+ Ok(None) => {
+ return LicenseStatus {
+ state: "no_public_key",
+ reason: Some(format!("missing {PUBLIC_KEY_RELATIVE_PATH}")),
+ payload: None,
+ }
+ }
+ Err(err) => {
+ return LicenseStatus {
+ state: "no_public_key",
+ reason: Some(err.to_string()),
+ payload: None,
+ }
+ }
+ };
+ let license_path = root.join(LICENSE_RELATIVE_PATH);
+ if !license_path.exists() {
+ return LicenseStatus {
+ state: "unlicensed",
+ reason: Some("no license file installed".to_string()),
+ payload: None,
+ };
+ }
+ let text = match std::fs::read_to_string(&license_path) {
+ Ok(text) => text,
+ Err(err) => {
+ return LicenseStatus {
+ state: "invalid",
+ reason: Some(format!("cannot read license file: {err}")),
+ payload: None,
+ }
+ }
+ };
+ match verify_text(&text, &verifying_key) {
+ Ok(payload) => evaluate_payload(payload),
+ Err(err) => LicenseStatus {
+ state: "invalid",
+ reason: Some(err.to_string()),
+ payload: None,
+ },
+ }
+}
+
+pub fn install(root: &Path, content: &str) -> Result {
+ let verifying_key = load_public_key(root)?
+ .with_context(|| format!("missing {PUBLIC_KEY_RELATIVE_PATH}"))?;
+ let payload = verify_text(content, &verifying_key)?;
+ let status = evaluate_payload(payload);
+ if status.state == "invalid" {
+ anyhow::bail!(
+ "license rejected: {}",
+ status.reason.as_deref().unwrap_or("unknown reason")
+ );
+ }
+ let license_path = root.join(LICENSE_RELATIVE_PATH);
+ if let Some(parent) = license_path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ std::fs::write(&license_path, content)?;
+ Ok(status)
+}
+
+pub fn keygen(out_dir: &Path) -> Result<(PathBuf, PathBuf)> {
+ std::fs::create_dir_all(out_dir)?;
+ let signing_key = SigningKey::generate(&mut OsRng);
+ let private_path = out_dir.join("license-signing-key.txt");
+ let public_path = out_dir.join("license-public-key.txt");
+ std::fs::write(&private_path, B64.encode(signing_key.to_bytes()))?;
+ std::fs::write(
+ &public_path,
+ B64.encode(signing_key.verifying_key().to_bytes()),
+ )?;
+ Ok((private_path, public_path))
+}
+
+#[allow(clippy::too_many_arguments)]
+pub fn issue(
+ signing_key_path: &Path,
+ out_path: &Path,
+ product: &str,
+ edition: &str,
+ customer_name: &str,
+ customer_email: Option,
+ customer_company: Option,
+ expires_at: Option>,
+ max_devices: Option,
+ features: Vec,
+ notes: Option,
+) -> Result {
+ let key_text = std::fs::read_to_string(signing_key_path)
+ .with_context(|| format!("cannot read signing key {}", signing_key_path.display()))?;
+ let key_bytes: [u8; 32] = B64
+ .decode(key_text.trim())
+ .context("signing key is not valid base64")?
+ .try_into()
+ .map_err(|_| anyhow::anyhow!("signing key must be 32 bytes"))?;
+ let signing_key = SigningKey::from_bytes(&key_bytes);
+
+ let payload = LicensePayload {
+ license_id: uuid::Uuid::new_v4().to_string(),
+ product: product.to_string(),
+ edition: edition.to_string(),
+ customer_name: customer_name.to_string(),
+ customer_email,
+ customer_company,
+ issued_at: Utc::now(),
+ expires_at,
+ max_devices,
+ features,
+ notes,
+ };
+ let payload_bytes = serde_json::to_vec(&payload)?;
+ let signature = signing_key.sign(&payload_bytes);
+ let file = LicenseFile {
+ payload_b64: B64.encode(&payload_bytes),
+ signature_b64: B64.encode(signature.to_bytes()),
+ };
+ if let Some(parent) = out_path.parent() {
+ if !parent.as_os_str().is_empty() {
+ std::fs::create_dir_all(parent)?;
+ }
+ }
+ std::fs::write(out_path, serde_json::to_string_pretty(&file)?)?;
+ Ok(payload)
+}
+
+pub fn verify_path(license_path: &Path, public_key_path: &Path) -> Result {
+ let key_text = std::fs::read_to_string(public_key_path)
+ .with_context(|| format!("cannot read public key {}", public_key_path.display()))?;
+ let verifying_key = parse_public_key(&key_text)?;
+ let text = std::fs::read_to_string(license_path)
+ .with_context(|| format!("cannot read license {}", license_path.display()))?;
+ let payload = verify_text(&text, &verifying_key)?;
+ Ok(evaluate_payload(payload))
+}
+
+fn evaluate_payload(payload: LicensePayload) -> LicenseStatus {
+ if let Some(expires_at) = payload.expires_at {
+ if Utc::now() > expires_at {
+ return LicenseStatus {
+ state: "expired",
+ reason: Some(format!("license expired {}", expires_at.to_rfc3339())),
+ payload: Some(payload),
+ };
+ }
+ }
+ LicenseStatus {
+ state: "licensed",
+ reason: None,
+ payload: Some(payload),
+ }
+}
+
+fn verify_text(text: &str, verifying_key: &VerifyingKey) -> Result {
+ let file: LicenseFile =
+ serde_json::from_str(text).context("license file is not valid JSON")?;
+ let payload_bytes = B64
+ .decode(file.payload_b64.trim())
+ .context("license payload is not valid base64")?;
+ let signature_bytes: [u8; 64] = B64
+ .decode(file.signature_b64.trim())
+ .context("license signature is not valid base64")?
+ .try_into()
+ .map_err(|_| anyhow::anyhow!("license signature must be 64 bytes"))?;
+ let signature = Signature::from_bytes(&signature_bytes);
+ verifying_key
+ .verify(&payload_bytes, &signature)
+ .map_err(|_| anyhow::anyhow!("license signature does not match"))?;
+ serde_json::from_slice(&payload_bytes).context("license payload is not valid JSON")
+}
+
+fn load_public_key(root: &Path) -> Result> {
+ let path = root.join(PUBLIC_KEY_RELATIVE_PATH);
+ if !path.exists() {
+ return Ok(None);
+ }
+ let text = std::fs::read_to_string(&path)
+ .with_context(|| format!("cannot read public key {}", path.display()))?;
+ Ok(Some(parse_public_key(&text)?))
+}
+
+fn parse_public_key(text: &str) -> Result {
+ let bytes: [u8; 32] = B64
+ .decode(text.trim())
+ .context("public key is not valid base64")?
+ .try_into()
+ .map_err(|_| anyhow::anyhow!("public key must be 32 bytes"))?;
+ VerifyingKey::from_bytes(&bytes).context("public key is not a valid Ed25519 key")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn issued_license_verifies_and_round_trips() {
+ let dir = std::env::temp_dir().join(format!("jkl-license-test-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let (private_path, public_path) = keygen(&dir).unwrap();
+ let license_path = dir.join("license.json");
+ issue(
+ &private_path,
+ &license_path,
+ "JackAILocal",
+ "pro",
+ "Test Customer",
+ Some("test@example.com".to_string()),
+ None,
+ None,
+ Some(3),
+ vec!["chat".to_string()],
+ None,
+ )
+ .unwrap();
+ let status = verify_path(&license_path, &public_path).unwrap();
+ assert_eq!(status.state, "licensed");
+ assert_eq!(status.payload.unwrap().customer_name, "Test Customer");
+ std::fs::remove_dir_all(&dir).ok();
+ }
+
+ #[test]
+ fn tampered_license_is_rejected() {
+ let dir = std::env::temp_dir().join(format!("jkl-license-tamper-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let (private_path, public_path) = keygen(&dir).unwrap();
+ let license_path = dir.join("license.json");
+ issue(
+ &private_path,
+ &license_path,
+ "JackAILocal",
+ "personal",
+ "Honest Customer",
+ None,
+ None,
+ None,
+ None,
+ vec![],
+ None,
+ )
+ .unwrap();
+ let text = std::fs::read_to_string(&license_path).unwrap();
+ let mut file: LicenseFile = serde_json::from_str(&text).unwrap();
+ let mut payload: LicensePayload =
+ serde_json::from_slice(&B64.decode(&file.payload_b64).unwrap()).unwrap();
+ payload.edition = "whitelabel".to_string();
+ file.payload_b64 = B64.encode(serde_json::to_vec(&payload).unwrap());
+ std::fs::write(&license_path, serde_json::to_string(&file).unwrap()).unwrap();
+ assert!(verify_path(&license_path, &public_path).is_err());
+ std::fs::remove_dir_all(&dir).ok();
+ }
+
+ #[test]
+ fn expired_license_reports_expired() {
+ let dir = std::env::temp_dir().join(format!("jkl-license-exp-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let (private_path, public_path) = keygen(&dir).unwrap();
+ let license_path = dir.join("license.json");
+ issue(
+ &private_path,
+ &license_path,
+ "JackAILocal",
+ "pro",
+ "Late Customer",
+ None,
+ None,
+ Some(Utc::now() - chrono::Duration::days(1)),
+ None,
+ vec![],
+ None,
+ )
+ .unwrap();
+ let status = verify_path(&license_path, &public_path).unwrap();
+ assert_eq!(status.state, "expired");
+ std::fs::remove_dir_all(&dir).ok();
+ }
+}
diff --git a/src/local_ai.rs b/src/local_ai.rs
new file mode 100644
index 0000000000000000000000000000000000000000..4936f350d35dd6a3070f24fea862b3c0649e7f67
--- /dev/null
+++ b/src/local_ai.rs
@@ -0,0 +1,696 @@
+use anyhow::{bail, Context, Result};
+use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+use std::{
+ env, fs,
+ io::Write,
+ path::{Path, PathBuf},
+ process::{Command, Stdio},
+};
+use uuid::Uuid;
+
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct AiMessage {
+ pub role: String,
+ pub content: Value,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+pub struct ChatInput {
+ pub model: Option,
+ pub messages: Vec,
+ pub temperature: Option,
+ pub max_tokens: Option,
+ pub think: Option,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct VisionInput {
+ pub model: Option,
+ pub prompt: Option,
+ pub image_base64: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct TranscriptionInput {
+ pub audio_base64: String,
+ pub format: Option,
+ pub language: Option,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct SpeechInput {
+ pub text: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct BackendConfig {
+ pub ollama_enabled: bool,
+ pub ollama_url: String,
+ pub llama_cpp_enabled: bool,
+ pub llama_cpp_url: String,
+}
+
+impl BackendConfig {
+ pub fn new(
+ ollama_enabled: bool,
+ ollama_url: Option,
+ llama_cpp_enabled: bool,
+ llama_cpp_url: Option,
+ ) -> Self {
+ Self {
+ ollama_enabled,
+ ollama_url: normalize_url(ollama_url.unwrap_or_else(default_ollama_url)),
+ llama_cpp_enabled,
+ llama_cpp_url: normalize_url(llama_cpp_url.unwrap_or_else(default_llama_cpp_url)),
+ }
+ }
+}
+
+pub async fn chat(
+ client: &reqwest::Client,
+ models: &Value,
+ root: &Path,
+ backend: &BackendConfig,
+ input: ChatInput,
+) -> Result {
+ let hardware = hardware(root);
+ let installed = installed_ollama_models(client, backend).await;
+ let available_catalog = catalog_with_availability(models, &installed, root);
+ let selected = select_model(
+ &available_catalog,
+ input.model.as_deref(),
+ &hardware,
+ "chat",
+ )
+ .context("no compatible chat model is configured")?;
+ let profile_id = selected
+ .get("id")
+ .and_then(|v| v.as_str())
+ .unwrap_or("auto");
+ let temperature = input.temperature.unwrap_or(0.4);
+
+ if backend.ollama_enabled {
+ if let Some(ollama_model) = selected.get("ollama").and_then(|v| v.as_str()) {
+ let mut options = json!({
+ "temperature": temperature,
+ "num_predict": input.max_tokens.unwrap_or(512),
+ "num_ctx": effective_num_ctx(selected, &hardware)
+ });
+ merge_model_options(&mut options, selected);
+ let mut body = json!({
+ "model": ollama_model,
+ "messages": input.messages,
+ "stream": false,
+ "keep_alive": model_keep_alive(selected),
+ "options": options
+ });
+ if let Some(think) = input.think {
+ body["think"] = json!(think);
+ }
+ if let Ok(response) = client
+ .post(format!("{}/api/chat", backend.ollama_url))
+ .json(&body)
+ .send()
+ .await
+ {
+ if response.status().is_success() {
+ let payload: Value =
+ response.json().await.context("invalid Ollama response")?;
+ let content = payload
+ .pointer("/message/content")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+ return Ok(openai_response(
+ content,
+ profile_id,
+ "ollama",
+ payload.get("eval_count").cloned(),
+ ));
+ }
+ }
+ }
+ }
+
+ if !backend.llama_cpp_enabled {
+ bail!("Ollama is unavailable and llama.cpp is disabled by runtime configuration");
+ }
+
+ let llama_body = json!({
+ "messages": input.messages,
+ "stream": false,
+ "temperature": temperature,
+ "max_tokens": input.max_tokens.unwrap_or(512)
+ });
+ let response = client
+ .post(format!("{}/v1/chat/completions", backend.llama_cpp_url))
+ .json(&llama_body)
+ .send()
+ .await
+ .context("Ollama is unavailable and llama.cpp is not reachable")?;
+ if !response.status().is_success() {
+ bail!("llama.cpp returned HTTP {}", response.status());
+ }
+ let payload: Value = response
+ .json()
+ .await
+ .context("invalid llama.cpp response")?;
+ Ok(payload)
+}
+
+pub async fn analyze_image(
+ client: &reqwest::Client,
+ models: &Value,
+ root: &Path,
+ backend: &BackendConfig,
+ input: VisionInput,
+) -> Result {
+ if !backend.ollama_enabled {
+ bail!("Ollama vision backend is disabled by runtime configuration");
+ }
+ let hardware = hardware(root);
+ let installed = installed_ollama_models(client, backend).await;
+ let available_catalog = catalog_with_availability(models, &installed, root);
+ let selected = select_model(
+ &available_catalog,
+ input.model.as_deref(),
+ &hardware,
+ "vision",
+ )
+ .context("no compatible vision model is configured")?;
+ let model_id = selected
+ .get("id")
+ .and_then(|v| v.as_str())
+ .unwrap_or("vision");
+ let ollama_model = selected
+ .get("ollama")
+ .and_then(|v| v.as_str())
+ .context("the selected vision model has no Ollama model reference")?;
+ let image = strip_data_url(&input.image_base64);
+ B64.decode(image).context("image is not valid base64")?;
+ let prompt = input.prompt.unwrap_or_else(|| {
+ "Describe this image carefully and extract any readable text. State uncertainty explicitly.".to_string()
+ });
+ let mut options = json!({ "num_ctx": effective_num_ctx(selected, &hardware) });
+ merge_model_options(&mut options, selected);
+ let body = json!({
+ "model": ollama_model,
+ "messages": [{
+ "role": "user",
+ "content": prompt,
+ "images": [image]
+ }],
+ "stream": false,
+ "keep_alive": model_keep_alive(selected),
+ "options": options
+ });
+ let response = client
+ .post(format!("{}/api/chat", backend.ollama_url))
+ .json(&body)
+ .send()
+ .await
+ .context("Ollama is not reachable for vision analysis")?;
+ if !response.status().is_success() {
+ let status = response.status();
+ let detail = response.text().await.unwrap_or_default();
+ bail!(
+ "Ollama vision request returned HTTP {status}: {}",
+ detail.trim()
+ );
+ }
+ let payload: Value = response
+ .json()
+ .await
+ .context("invalid Ollama vision response")?;
+ let content = payload
+ .pointer("/message/content")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+ Ok(json!({
+ "ok": true,
+ "model": model_id,
+ "backend_model": ollama_model,
+ "content": content,
+ "backend": "ollama"
+ }))
+}
+
+pub async fn backend_status(client: &reqwest::Client, backend: &BackendConfig) -> Value {
+ let ollama = if backend.ollama_enabled {
+ match client
+ .get(format!("{}/api/tags", backend.ollama_url))
+ .send()
+ .await
+ {
+ Ok(response) if response.status().is_success() => {
+ json!({"available": true, "enabled": true, "url": backend.ollama_url})
+ }
+ Ok(response) => {
+ json!({"available": false, "enabled": true, "url": backend.ollama_url, "error": format!("HTTP {}", response.status())})
+ }
+ Err(error) => {
+ json!({"available": false, "enabled": true, "url": backend.ollama_url, "error": error.to_string()})
+ }
+ }
+ } else {
+ json!({"available": false, "enabled": false, "url": backend.ollama_url})
+ };
+ let llamacpp = if backend.llama_cpp_enabled {
+ match client
+ .get(format!("{}/v1/models", backend.llama_cpp_url))
+ .send()
+ .await
+ {
+ Ok(response) if response.status().is_success() => {
+ json!({"available": true, "enabled": true, "url": backend.llama_cpp_url})
+ }
+ Ok(response) => {
+ json!({"available": false, "enabled": true, "url": backend.llama_cpp_url, "error": format!("HTTP {}", response.status())})
+ }
+ Err(error) => {
+ json!({"available": false, "enabled": true, "url": backend.llama_cpp_url, "error": error.to_string()})
+ }
+ }
+ } else {
+ json!({"available": false, "enabled": false, "url": backend.llama_cpp_url})
+ };
+ json!({"ollama": ollama, "llama_cpp": llamacpp})
+}
+
+pub async fn installed_ollama_models(
+ client: &reqwest::Client,
+ backend: &BackendConfig,
+) -> Vec {
+ if !backend.ollama_enabled {
+ return Vec::new();
+ }
+ let response = match client
+ .get(format!("{}/api/tags", backend.ollama_url))
+ .send()
+ .await
+ {
+ Ok(value) if value.status().is_success() => value,
+ _ => return Vec::new(),
+ };
+ let payload: Value = match response.json().await {
+ Ok(value) => value,
+ Err(_) => return Vec::new(),
+ };
+ payload
+ .get("models")
+ .and_then(|v| v.as_array())
+ .into_iter()
+ .flatten()
+ .filter_map(|model| {
+ model
+ .get("name")
+ .and_then(|v| v.as_str())
+ .map(str::to_string)
+ })
+ .collect()
+}
+
+pub fn catalog_with_availability(models: &Value, installed: &[String], root: &Path) -> Value {
+ let mut output = models.clone();
+ if let Some(entries) = output.get_mut("models").and_then(|v| v.as_array_mut()) {
+ for model in entries {
+ let ollama = model.get("ollama").and_then(|v| v.as_str());
+ let installed_ollama = ollama
+ .map(|name| model_name_matches(installed, name))
+ .unwrap_or(false);
+ let gguf = model
+ .get("gguf")
+ .and_then(|v| v.as_str())
+ .map(|path| root.join(path).is_file())
+ .unwrap_or(false);
+ model["available"] = json!(installed_ollama || gguf);
+ model["installed_ollama"] = json!(installed_ollama);
+ model["installed_gguf"] = json!(gguf);
+ }
+ }
+ output
+}
+
+pub fn voice_status(root: &Path) -> Value {
+ let whisper_binary = find_first(root, whisper_binary_candidates());
+ let whisper_model = find_by_extension(&root.join("models/whisper"), &["bin"]);
+ let piper_binary = find_first(root, piper_binary_candidates());
+ let piper_model = find_by_extension(&root.join("models/piper"), &["onnx"]);
+ json!({
+ "stt": {
+ "available": whisper_binary.is_some() && whisper_model.is_some(),
+ "engine": "whisper.cpp",
+ "binary": display_option(&whisper_binary),
+ "model": display_option(&whisper_model)
+ },
+ "tts": {
+ "available": piper_binary.is_some() && piper_model.is_some(),
+ "engine": "piper",
+ "binary": display_option(&piper_binary),
+ "model": display_option(&piper_model)
+ }
+ })
+}
+
+pub fn transcribe(root: &Path, input: &TranscriptionInput) -> Result {
+ let whisper_binary = find_first(root, whisper_binary_candidates())
+ .context("whisper.cpp binary is not installed")?;
+ let whisper_model = find_by_extension(&root.join("models/whisper"), &["bin"])
+ .context("whisper.cpp model is not installed")?;
+ let audio = B64
+ .decode(strip_data_url(&input.audio_base64))
+ .context("audio is not valid base64")?;
+ let extension = sanitize_audio_extension(input.format.as_deref().unwrap_or("wav"));
+ let id = Uuid::new_v4().to_string();
+ let input_path = root
+ .join("workspace/voice")
+ .join(format!("{id}.{extension}"));
+ let output_prefix = root
+ .join("workspace/voice")
+ .join(format!("{id}-transcript"));
+ fs::write(&input_path, audio).context("cannot write temporary audio")?;
+
+ let mut command = Command::new(&whisper_binary);
+ command
+ .arg("-m")
+ .arg(&whisper_model)
+ .arg("-f")
+ .arg(&input_path)
+ .arg("-otxt")
+ .arg("-of")
+ .arg(&output_prefix)
+ .arg("-nt");
+ if let Some(language) = input.language.as_deref() {
+ if !language.trim().is_empty() {
+ command.arg("-l").arg(language.trim());
+ }
+ }
+ let output = command.output().context("cannot run whisper.cpp")?;
+ let _ = fs::remove_file(&input_path);
+ if !output.status.success() {
+ bail!(
+ "whisper.cpp failed: {}",
+ String::from_utf8_lossy(&output.stderr).trim()
+ );
+ }
+ let transcript_path = output_prefix.with_extension("txt");
+ let text =
+ fs::read_to_string(&transcript_path).context("whisper.cpp did not produce a transcript")?;
+ let _ = fs::remove_file(transcript_path);
+ Ok(json!({"ok": true, "text": text.trim(), "engine": "whisper.cpp"}))
+}
+
+pub fn synthesize(root: &Path, input: &SpeechInput) -> Result {
+ if input.text.trim().is_empty() {
+ bail!("text cannot be empty");
+ }
+ let piper_binary =
+ find_first(root, piper_binary_candidates()).context("Piper binary is not installed")?;
+ let piper_model = find_by_extension(&root.join("models/piper"), &["onnx"])
+ .context("Piper voice model is not installed")?;
+ let output_path = root
+ .join("workspace/voice")
+ .join(format!("{}.wav", Uuid::new_v4()));
+ let mut child = Command::new(&piper_binary)
+ .arg("--model")
+ .arg(&piper_model)
+ .arg("--output_file")
+ .arg(&output_path)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .context("cannot run Piper")?;
+ child
+ .stdin
+ .as_mut()
+ .context("cannot open Piper stdin")?
+ .write_all(input.text.as_bytes())
+ .context("cannot send text to Piper")?;
+ let output = child.wait_with_output().context("cannot wait for Piper")?;
+ if !output.status.success() {
+ bail!(
+ "Piper failed: {}",
+ String::from_utf8_lossy(&output.stderr).trim()
+ );
+ }
+ let audio = fs::read(&output_path).context("Piper did not produce audio")?;
+ let _ = fs::remove_file(output_path);
+ Ok(json!({
+ "ok": true,
+ "engine": "piper",
+ "mime_type": "audio/wav",
+ "audio_base64": B64.encode(audio)
+ }))
+}
+
+pub fn select_model<'a>(
+ models: &'a Value,
+ requested: Option<&str>,
+ hardware: &Value,
+ feature: &str,
+) -> Option<&'a Value> {
+ let entries = models.get("models")?.as_array()?;
+ if let Some(requested) = requested.filter(|value| *value != "auto") {
+ if let Some(model) = entries.iter().find(|model| {
+ model.get("id").and_then(|v| v.as_str()) == Some(requested)
+ || model.get("ollama").and_then(|v| v.as_str()) == Some(requested)
+ }) {
+ return if has_feature(model, feature)
+ && model
+ .get("available")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false)
+ {
+ Some(model)
+ } else {
+ None
+ };
+ }
+ if let Some(default_id) = models
+ .get("profiles")?
+ .as_array()?
+ .iter()
+ .find(|profile| profile.get("id").and_then(|v| v.as_str()) == Some(requested))
+ .and_then(|profile| profile.get("recommended_default").and_then(|v| v.as_str()))
+ {
+ if let Some(model) = entries
+ .iter()
+ .find(|model| model.get("id").and_then(|v| v.as_str()) == Some(default_id))
+ {
+ return if has_feature(model, feature)
+ && model
+ .get("available")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false)
+ {
+ Some(model)
+ } else {
+ None
+ };
+ }
+ }
+ return None;
+ }
+ let ram = hardware
+ .get("ram_gb")
+ .and_then(|v| v.as_f64())
+ .unwrap_or(8.0);
+ let vram = hardware
+ .get("vram_gb")
+ .and_then(|v| v.as_f64())
+ .unwrap_or(0.0);
+ entries
+ .iter()
+ .filter(|model| has_feature(model, feature))
+ .filter(|model| {
+ model
+ .get("available")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false)
+ })
+ .filter(|model| {
+ // 3% slack: physical "32 GB" machines report ~31.7 GB usable.
+ ram >= model
+ .get("min_ram_gb")
+ .and_then(|v| v.as_f64())
+ .unwrap_or(0.0)
+ * 0.97
+ })
+ .filter(|model| {
+ vram >= model
+ .get("min_vram_gb")
+ .and_then(|v| v.as_f64())
+ .unwrap_or(0.0)
+ * 0.97
+ })
+ .last()
+}
+
+/// Context window for the request: the catalog's `default_context`, scaled to
+/// the customer hardware so weak machines never thrash and strong machines
+/// get the room they paid for. A per-model `options.num_ctx` overrides this.
+pub fn effective_num_ctx(model: &Value, hardware: &Value) -> u64 {
+ let base = model
+ .get("default_context")
+ .and_then(|v| v.as_u64())
+ .unwrap_or(4096);
+ let ram = hardware.get("ram_gb").and_then(|v| v.as_f64()).unwrap_or(8.0);
+ let vram = hardware.get("vram_gb").and_then(|v| v.as_f64()).unwrap_or(0.0);
+ if vram >= 16.0 || ram >= 32.0 {
+ base.max(8192)
+ } else if ram < 10.0 && vram < 4.0 {
+ base.min(4096)
+ } else {
+ base
+ }
+}
+
+/// How long Ollama keeps the model loaded after a request. Reloading a model
+/// between messages is the single biggest perceived slowdown on USB targets.
+pub fn model_keep_alive(model: &Value) -> String {
+ model
+ .get("keep_alive")
+ .and_then(|v| v.as_str())
+ .unwrap_or("30m")
+ .to_string()
+}
+
+/// Merges the catalog model's optional `options` object (verbatim Ollama
+/// options such as num_gpu, num_thread, top_p) over the computed defaults.
+pub fn merge_model_options(options: &mut Value, model: &Value) {
+ if let Some(extra) = model.get("options").and_then(|v| v.as_object()) {
+ if let Some(target) = options.as_object_mut() {
+ for (key, value) in extra {
+ target.insert(key.clone(), value.clone());
+ }
+ }
+ }
+}
+
+pub fn hardware(root: &Path) -> Value {
+ let path = root.join("diagnostics/hardware.json");
+ fs::read_to_string(path)
+ .ok()
+ .and_then(|text| serde_json::from_str(&text).ok())
+ .unwrap_or_else(|| json!({"ram_gb": 8, "vram_gb": 0}))
+}
+
+fn has_feature(model: &Value, feature: &str) -> bool {
+ model
+ .get("features")
+ .and_then(|v| v.as_array())
+ .into_iter()
+ .flatten()
+ .any(|entry| entry.as_str() == Some(feature))
+}
+
+fn openai_response(content: &str, model: &str, backend: &str, eval_count: Option) -> Value {
+ json!({
+ "id": format!("jackailocal-{}", Uuid::new_v4()),
+ "object": "chat.completion",
+ "model": model,
+ "backend": backend,
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": content}, "finish_reason": "stop"}],
+ "usage": {"completion_tokens": eval_count}
+ })
+}
+
+fn model_name_matches(installed: &[String], expected: &str) -> bool {
+ installed.iter().any(|name| {
+ name == expected || name.trim_end_matches(":latest") == expected.trim_end_matches(":latest")
+ })
+}
+
+fn default_ollama_url() -> String {
+ env::var("OLLAMA_HOST").unwrap_or_else(|_| "http://127.0.0.1:11434".to_string())
+}
+
+fn default_llama_cpp_url() -> String {
+ env::var("LLAMA_CPP_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string())
+}
+
+fn normalize_url(value: String) -> String {
+ let value = value.trim().trim_end_matches('/').to_string();
+ if value.starts_with("http://") || value.starts_with("https://") {
+ value
+ } else {
+ format!("http://{value}")
+ }
+}
+
+fn strip_data_url(value: &str) -> &str {
+ value.split_once(',').map(|(_, data)| data).unwrap_or(value)
+}
+
+fn sanitize_audio_extension(value: &str) -> String {
+ let extension = value.trim().trim_start_matches('.').to_ascii_lowercase();
+ if matches!(extension.as_str(), "wav" | "mp3" | "flac" | "ogg") {
+ extension
+ } else {
+ "wav".to_string()
+ }
+}
+
+fn find_first(root: &Path, candidates: &[&str]) -> Option {
+ candidates
+ .iter()
+ .map(|candidate| root.join(candidate))
+ .find(|path| path.is_file())
+}
+
+fn find_by_extension(dir: &Path, extensions: &[&str]) -> Option {
+ fs::read_dir(dir)
+ .ok()?
+ .flatten()
+ .map(|entry| entry.path())
+ .find(|path| {
+ path.is_file()
+ && path
+ .extension()
+ .and_then(|v| v.to_str())
+ .map(|v| extensions.contains(&v))
+ .unwrap_or(false)
+ })
+}
+
+fn display_option(path: &Option) -> Option {
+ path.as_ref()
+ .map(|value| value.to_string_lossy().to_string())
+}
+
+#[cfg(target_os = "windows")]
+fn whisper_binary_candidates() -> &'static [&'static str] {
+ &[
+ "backends/whisper.cpp/windows/whisper-cli.exe",
+ "backends/whisper.cpp/windows/main.exe",
+ ]
+}
+#[cfg(target_os = "macos")]
+fn whisper_binary_candidates() -> &'static [&'static str] {
+ &[
+ "backends/whisper.cpp/macos/whisper-cli",
+ "backends/whisper.cpp/macos/main",
+ ]
+}
+#[cfg(all(unix, not(target_os = "macos")))]
+fn whisper_binary_candidates() -> &'static [&'static str] {
+ &[
+ "backends/whisper.cpp/linux/whisper-cli",
+ "backends/whisper.cpp/linux/main",
+ ]
+}
+
+#[cfg(target_os = "windows")]
+fn piper_binary_candidates() -> &'static [&'static str] {
+ &["backends/piper/windows/piper.exe"]
+}
+#[cfg(target_os = "macos")]
+fn piper_binary_candidates() -> &'static [&'static str] {
+ &["backends/piper/macos/piper"]
+}
+#[cfg(all(unix, not(target_os = "macos")))]
+fn piper_binary_candidates() -> &'static [&'static str] {
+ &["backends/piper/linux/piper"]
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000000000000000000000000000000000000..e21ee5adf568b199e69b08627349968dd2c549dd
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,1289 @@
+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
+}
diff --git a/src/phone.rs b/src/phone.rs
new file mode 100644
index 0000000000000000000000000000000000000000..05852015f0dcaefda47f2562e23a4c7ecfbb0de8
--- /dev/null
+++ b/src/phone.rs
@@ -0,0 +1,199 @@
+use crate::storage;
+use anyhow::{Context, Result};
+use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
+use qrcode::{render::svg, QrCode};
+use rand::{rngs::OsRng, RngCore};
+use serde_json::{json, Value};
+use std::{
+ net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket},
+ path::{Path, PathBuf},
+};
+
+#[derive(Debug, Clone)]
+pub struct RuntimeConfig {
+ pub config_path: PathBuf,
+ pub default_bind: SocketAddr,
+ pub phone_access_available: bool,
+ pub ollama_enabled: bool,
+ pub ollama_url: Option,
+ pub llama_cpp_enabled: bool,
+ pub llama_cpp_url: Option,
+}
+
+pub fn load_runtime_config(root: &Path, config_path: &str) -> Result {
+ let path = if Path::new(config_path).is_absolute() {
+ PathBuf::from(config_path)
+ } else {
+ root.join(config_path)
+ };
+ let text = std::fs::read_to_string(&path)
+ .with_context(|| format!("cannot read config {}", path.display()))?;
+ let value: toml::Value =
+ toml::from_str(&text).with_context(|| format!("cannot parse config {}", path.display()))?;
+
+ let bind_text = value
+ .get("bind")
+ .and_then(|v| v.as_str())
+ .map(str::to_string)
+ .or_else(|| {
+ let host = value.get("server")?.get("host")?.as_str()?;
+ let port = value.get("server")?.get("port")?.as_integer()?;
+ Some(format!("{host}:{port}"))
+ })
+ .unwrap_or_else(|| "127.0.0.1:4891".to_string());
+ let default_bind = bind_text
+ .parse::()
+ .with_context(|| format!("invalid bind address: {bind_text}"))?;
+ let phone_access_available = value
+ .get("phone_access_available")
+ .and_then(|v| v.as_bool())
+ .or_else(|| {
+ value
+ .get("security")?
+ .get("phone_access_available")?
+ .as_bool()
+ })
+ .unwrap_or(false);
+ let (ollama_enabled, ollama_url) = backend_settings(&value, &["ollama"], true);
+ let (llama_cpp_enabled, llama_cpp_url) =
+ backend_settings(&value, &["llamacpp", "llama_cpp"], true);
+ Ok(RuntimeConfig {
+ config_path: path,
+ default_bind,
+ phone_access_available,
+ ollama_enabled,
+ ollama_url,
+ llama_cpp_enabled,
+ llama_cpp_url,
+ })
+}
+
+pub fn effective_bind(root: &Path, config: &RuntimeConfig) -> SocketAddr {
+ let settings = storage::read_settings(root);
+ let enabled = settings
+ .get("phone_access_enabled")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+ if enabled && config.phone_access_available {
+ SocketAddr::new(
+ IpAddr::V4(Ipv4Addr::UNSPECIFIED),
+ config.default_bind.port(),
+ )
+ } else {
+ config.default_bind
+ }
+}
+
+pub fn ensure_phone_token(root: &Path) -> Result {
+ let mut settings = storage::read_settings(root);
+ if let Some(token) = settings.get("phone_access_token").and_then(|v| v.as_str()) {
+ if token.len() >= 32 {
+ return Ok(token.to_string());
+ }
+ }
+ let mut bytes = [0_u8; 24];
+ OsRng.fill_bytes(&mut bytes);
+ let token = URL_SAFE_NO_PAD.encode(bytes);
+ if let Some(object) = settings.as_object_mut() {
+ object.insert("phone_access_token".to_string(), json!(token));
+ }
+ storage::write_settings(root, &settings)?;
+ Ok(token)
+}
+
+pub fn set_phone_access(
+ root: &Path,
+ config: &RuntimeConfig,
+ enabled: bool,
+ active_bind: SocketAddr,
+) -> Result {
+ if enabled && !config.phone_access_available {
+ anyhow::bail!("phone access is disabled by the runtime configuration");
+ }
+ let mut settings = storage::read_settings(root);
+ if let Some(object) = settings.as_object_mut() {
+ object.insert("phone_access_enabled".to_string(), json!(enabled));
+ }
+ storage::write_settings(root, &settings)?;
+ if enabled {
+ ensure_phone_token(root)?;
+ }
+ Ok(status(root, config, active_bind))
+}
+
+pub fn status(root: &Path, config: &RuntimeConfig, active_bind: SocketAddr) -> Value {
+ let settings = storage::read_settings(root);
+ let enabled = settings
+ .get("phone_access_enabled")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+ let token = settings
+ .get("phone_access_token")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+ let active = enabled && !active_bind.ip().is_loopback();
+ let local_ip = local_ipv4();
+ let url = if enabled && !token.is_empty() {
+ local_ip.map(|ip| format!("http://{ip}:{}?pair={token}", active_bind.port()))
+ } else {
+ None
+ };
+ let qr_svg = url.as_deref().and_then(qr_svg);
+ json!({
+ "available": config.phone_access_available,
+ "enabled": enabled,
+ "active": active,
+ "bind": active_bind.to_string(),
+ "url": url,
+ "qr_svg": qr_svg,
+ "pairing_required": true,
+ "restart_required": enabled != active,
+ "config": config.config_path.to_string_lossy()
+ })
+}
+
+pub fn configured_token(root: &Path) -> Option {
+ storage::read_settings(root)
+ .get("phone_access_token")
+ .and_then(|v| v.as_str())
+ .map(str::to_string)
+}
+
+fn local_ipv4() -> Option {
+ let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
+ socket.connect("192.0.2.1:80").ok()?;
+ match socket.local_addr().ok()?.ip() {
+ IpAddr::V4(ip) if !ip.is_loopback() && !ip.is_unspecified() => Some(ip),
+ _ => None,
+ }
+}
+
+fn qr_svg(value: &str) -> Option {
+ let code = QrCode::new(value.as_bytes()).ok()?;
+ Some(
+ code.render::()
+ .min_dimensions(220, 220)
+ .dark_color(svg::Color("#0b111a"))
+ .light_color(svg::Color("#ffffff"))
+ .build(),
+ )
+}
+
+fn backend_settings(
+ value: &toml::Value,
+ names: &[&str],
+ default_enabled: bool,
+) -> (bool, Option) {
+ let backend = value
+ .get("backends")
+ .and_then(|backends| names.iter().find_map(|name| backends.get(*name)));
+ let enabled = backend
+ .and_then(|entry| entry.get("enabled"))
+ .and_then(|entry| entry.as_bool())
+ .unwrap_or(default_enabled);
+ let url = backend
+ .and_then(|entry| entry.get("url").or_else(|| entry.get("base_url")))
+ .and_then(|entry| entry.as_str())
+ .map(str::to_string);
+ (enabled, url)
+}
diff --git a/src/storage.rs b/src/storage.rs
new file mode 100644
index 0000000000000000000000000000000000000000..7644360ced958ebba17bc8f9269cc5bf3772c51b
--- /dev/null
+++ b/src/storage.rs
@@ -0,0 +1,300 @@
+use anyhow::{bail, Context, Result};
+use chrono::Utc;
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+use std::path::{Path, PathBuf};
+use uuid::Uuid;
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct StoredMessage {
+ pub role: String,
+ pub content: Value,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct ThreadRecord {
+ pub id: String,
+ pub title: String,
+ pub model: Option,
+ pub created_at: String,
+ pub updated_at: String,
+ pub messages: Vec,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct ThreadSummary {
+ pub id: String,
+ pub title: String,
+ pub model: Option,
+ pub created_at: String,
+ pub updated_at: String,
+ pub message_count: usize,
+ pub preview: String,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct PackedDocument {
+ pub name: String,
+ pub content: String,
+}
+
+pub fn ensure_workspace(root: &Path) -> Result<()> {
+ for relative in [
+ "workspace/documents",
+ "workspace/settings",
+ "workspace/threads",
+ "workspace/exports",
+ "workspace/voice",
+ "workspace/scout",
+ "logs",
+ ] {
+ std::fs::create_dir_all(root.join(relative))
+ .with_context(|| format!("cannot create {relative}"))?;
+ }
+ Ok(())
+}
+
+pub fn create_thread(
+ root: &Path,
+ title: Option<&str>,
+ model: Option,
+) -> Result {
+ let now = Utc::now().to_rfc3339();
+ let thread = ThreadRecord {
+ id: Uuid::new_v4().to_string(),
+ title: clean_title(title.unwrap_or("New conversation")),
+ model,
+ created_at: now.clone(),
+ updated_at: now,
+ messages: Vec::new(),
+ };
+ save_thread(root, &thread)?;
+ Ok(thread)
+}
+
+pub fn list_threads(root: &Path, filter: Option<&str>) -> Result> {
+ let filter = filter.unwrap_or("").trim().to_lowercase();
+ let mut summaries = Vec::new();
+ let dir = root.join("workspace/threads");
+ if !dir.exists() {
+ return Ok(summaries);
+ }
+ for entry in
+ std::fs::read_dir(&dir).with_context(|| format!("cannot read {}", dir.display()))?
+ {
+ let entry = match entry {
+ Ok(value) => value,
+ Err(_) => continue,
+ };
+ if !entry.path().is_file()
+ || entry.path().extension().and_then(|v| v.to_str()) != Some("json")
+ {
+ continue;
+ }
+ let thread: ThreadRecord = match read_json_typed(&entry.path()) {
+ Ok(value) => value,
+ Err(_) => continue,
+ };
+ let preview = thread
+ .messages
+ .iter()
+ .rev()
+ .find_map(|message| message.content.as_str())
+ .unwrap_or("")
+ .chars()
+ .take(160)
+ .collect::();
+ if !filter.is_empty()
+ && !thread.title.to_lowercase().contains(&filter)
+ && !preview.to_lowercase().contains(&filter)
+ {
+ continue;
+ }
+ summaries.push(ThreadSummary {
+ id: thread.id,
+ title: thread.title,
+ model: thread.model,
+ created_at: thread.created_at,
+ updated_at: thread.updated_at,
+ message_count: thread.messages.len(),
+ preview,
+ });
+ }
+ summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
+ Ok(summaries)
+}
+
+pub fn load_thread(root: &Path, id: &str) -> Result {
+ validate_thread_id(id)?;
+ let path = thread_path(root, id);
+ read_json_typed(&path).with_context(|| format!("thread not found: {id}"))
+}
+
+pub fn save_thread(root: &Path, thread: &ThreadRecord) -> Result<()> {
+ validate_thread_id(&thread.id)?;
+ write_json_pretty(&thread_path(root, &thread.id), thread)
+}
+
+pub fn rename_thread(root: &Path, id: &str, title: &str) -> Result {
+ let mut thread = load_thread(root, id)?;
+ thread.title = clean_title(title);
+ thread.updated_at = Utc::now().to_rfc3339();
+ save_thread(root, &thread)?;
+ Ok(thread)
+}
+
+pub fn delete_thread(root: &Path, id: &str) -> Result<()> {
+ validate_thread_id(id)?;
+ let path = thread_path(root, id);
+ if path.exists() {
+ std::fs::remove_file(&path).with_context(|| format!("cannot delete {}", path.display()))?;
+ }
+ Ok(())
+}
+
+pub fn append_message(thread: &mut ThreadRecord, role: &str, content: Value) {
+ thread.messages.push(StoredMessage {
+ role: role.to_string(),
+ content,
+ });
+ thread.updated_at = Utc::now().to_rfc3339();
+}
+
+pub fn list_documents_with_content(root: &Path) -> Result> {
+ let mut documents = Vec::new();
+ let dir = root.join("workspace/documents");
+ if !dir.exists() {
+ return Ok(documents);
+ }
+ for entry in
+ std::fs::read_dir(&dir).with_context(|| format!("cannot read {}", dir.display()))?
+ {
+ let entry = match entry {
+ Ok(value) => value,
+ Err(_) => continue,
+ };
+ if !entry.path().is_file() {
+ continue;
+ }
+ let name = entry.file_name().to_string_lossy().to_string();
+ if !safe_filename(&name) {
+ continue;
+ }
+ let content = match std::fs::read_to_string(entry.path()) {
+ Ok(value) => value,
+ Err(_) => continue,
+ };
+ documents.push(PackedDocument { name, content });
+ }
+ documents.sort_by(|a, b| a.name.cmp(&b.name));
+ Ok(documents)
+}
+
+pub fn save_document(root: &Path, name: &str, content: &str) -> Result {
+ if !safe_filename(name) {
+ bail!("unsafe filename");
+ }
+ let path = root.join("workspace/documents").join(name);
+ std::fs::write(&path, content.as_bytes())
+ .with_context(|| format!("cannot write {}", path.display()))?;
+ Ok(path)
+}
+
+pub fn read_settings(root: &Path) -> Value {
+ let path = root.join("workspace/settings/settings.json");
+ read_json_value(&path).unwrap_or_else(|_| {
+ json!({
+ "mode": "simple",
+ "offline_lock": true,
+ "tools_lock": true,
+ "phone_access_enabled": false
+ })
+ })
+}
+
+pub fn write_settings(root: &Path, settings: &Value) -> Result<()> {
+ write_json_pretty(&root.join("workspace/settings/settings.json"), settings)
+}
+
+pub fn read_all_threads(root: &Path) -> Result> {
+ let mut threads = Vec::new();
+ for summary in list_threads(root, None)? {
+ if let Ok(thread) = load_thread(root, &summary.id) {
+ threads.push(thread);
+ }
+ }
+ Ok(threads)
+}
+
+pub fn import_threads(root: &Path, threads: &[ThreadRecord], replace: bool) -> Result {
+ let dir = root.join("workspace/threads");
+ if replace && dir.exists() {
+ for entry in std::fs::read_dir(&dir)? {
+ let entry = entry?;
+ if entry.path().is_file()
+ && entry.path().extension().and_then(|v| v.to_str()) == Some("json")
+ {
+ std::fs::remove_file(entry.path())?;
+ }
+ }
+ }
+ let mut count = 0;
+ for thread in threads {
+ validate_thread_id(&thread.id)?;
+ save_thread(root, thread)?;
+ count += 1;
+ }
+ Ok(count)
+}
+
+fn thread_path(root: &Path, id: &str) -> PathBuf {
+ root.join("workspace/threads").join(format!("{id}.json"))
+}
+
+fn validate_thread_id(id: &str) -> Result<()> {
+ Uuid::parse_str(id).with_context(|| "invalid thread id")?;
+ Ok(())
+}
+
+fn clean_title(title: &str) -> String {
+ let cleaned = title
+ .trim()
+ .chars()
+ .filter(|c| !c.is_control())
+ .take(120)
+ .collect::();
+ if cleaned.is_empty() {
+ "New conversation".to_string()
+ } else {
+ cleaned
+ }
+}
+
+fn safe_filename(name: &str) -> bool {
+ !name.is_empty()
+ && name.len() <= 120
+ && !name.contains("..")
+ && !name.contains('/')
+ && !name.contains('\\')
+ && name
+ .chars()
+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ' '))
+}
+
+fn read_json_value(path: &Path) -> Result {
+ let text = std::fs::read_to_string(path)?;
+ Ok(serde_json::from_str(&text)?)
+}
+
+fn read_json_typed Deserialize<'de>>(path: &Path) -> Result {
+ let text = std::fs::read_to_string(path)?;
+ Ok(serde_json::from_str(&text)?)
+}
+
+fn write_json_pretty(path: &Path, value: &T) -> Result<()> {
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ std::fs::write(path, serde_json::to_vec_pretty(value)?)?;
+ Ok(())
+}
diff --git a/tools/pm-hwscan.sh b/tools/pm-hwscan.sh
new file mode 100644
index 0000000000000000000000000000000000000000..0146bb80b6147918de0490eb86b521c454014d21
--- /dev/null
+++ b/tools/pm-hwscan.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+set -euo pipefail
+OUT=/opt/JackAILocal/diagnostics/hardware.json
+mkdir -p /opt/JackAILocal/diagnostics
+RAM_GB=$(awk '/MemTotal/ {printf "%.1f", $2/1024/1024}' /proc/meminfo 2>/dev/null || echo 8)
+CPU_THREADS=$(nproc 2>/dev/null || echo 1)
+GPU_NAME="unknown"; GPU_VENDOR="unknown"; VRAM_GB=0
+if command -v nvidia-smi >/dev/null 2>&1; then
+ GPU_VENDOR=nvidia
+ GPU_NAME=$(nvidia-smi --query-gpu=name --format=csv,noheader | head -1 || echo nvidia)
+ VRAM_GB=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits | awk 'NR==1 {printf "%.1f", $1/1024}' || echo 0)
+elif command -v lspci >/dev/null 2>&1; then
+ GPU_NAME=$(lspci | grep -Ei 'vga|3d|display' | head -1 | sed 's/.*: //')
+ echo "$GPU_NAME" | grep -qi nvidia && GPU_VENDOR=nvidia || true
+ echo "$GPU_NAME" | grep -Eqi 'amd|radeon' && GPU_VENDOR=amd || true
+ echo "$GPU_NAME" | grep -qi intel && GPU_VENDOR=intel || true
+fi
+cat > "$OUT" <&2; exit 1; }
+command -v python3 >/dev/null 2>&1 || { echo "python3 is required to install voice assets." >&2; exit 1; }
+command -v curl >/dev/null 2>&1 || { echo "curl is required to install voice assets." >&2; exit 1; }
+
+case "$(uname -s)" in
+ Darwin) PLATFORM=macos ;;
+ Linux) PLATFORM=linux ;;
+ *) echo "Unsupported platform: $(uname -s)" >&2; exit 1 ;;
+esac
+case "$(uname -m)" in
+ x86_64|amd64) ARCH=x86_64 ;;
+ arm64|aarch64) ARCH=aarch64 ;;
+ *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
+esac
+
+ASSET_KEY="${PLATFORM}_${ARCH}"
+if [ "$ASSET_KEY" = "linux_aarch64" ]; then
+ echo "No verified Piper Linux aarch64 asset is configured." >&2
+ exit 1
+fi
+
+read_json() {
+ python3 - "$CONFIG" "$1" <<'PY'
+import json, sys
+value = json.load(open(sys.argv[1], encoding="utf-8"))
+for key in sys.argv[2].split("."):
+ value = value[key]
+print(value)
+PY
+}
+
+sha256_file() {
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$1" | awk '{print $1}'
+ else
+ shasum -a 256 "$1" | awk '{print $1}'
+ fi
+}
+
+download_verified() {
+ url="$1"; expected="$2"; destination="$3"
+ if [ -f "$destination" ] && [ "$(sha256_file "$destination")" = "$expected" ]; then return 0; fi
+ rm -f "$destination"
+ echo "Downloading $url"
+ curl -fL "$url" -o "$destination"
+ actual="$(sha256_file "$destination")"
+ [ "$actual" = "$expected" ] || { rm -f "$destination"; echo "SHA256 verification failed for $url" >&2; exit 1; }
+}
+
+CACHE="$TARGET_ROOT/.jackailocal-builder/voice-cache"
+PIPER_DIR="$TARGET_ROOT/backends/piper/$PLATFORM"
+WHISPER_DIR="$TARGET_ROOT/backends/whisper.cpp/$PLATFORM"
+WHISPER_MODEL_DIR="$TARGET_ROOT/models/whisper"
+PIPER_MODEL_DIR="$TARGET_ROOT/models/piper"
+mkdir -p "$CACHE" "$PIPER_DIR" "$WHISPER_MODEL_DIR" "$PIPER_MODEL_DIR"
+
+[ -x "$WHISPER_DIR/whisper-cli" ] || {
+ echo "Required whisper.cpp binary is missing: $WHISPER_DIR/whisper-cli" >&2
+ echo "Run factory/unix/build-whisper-backend.sh on the target platform before packaging." >&2
+ exit 1
+}
+
+PIPER_ARCHIVE="$CACHE/piper.tar.gz"
+download_verified "$(read_json "$ASSET_KEY.piper.archive_url")" "$(read_json "$ASSET_KEY.piper.archive_sha256")" "$PIPER_ARCHIVE"
+EXTRACT="$CACHE/piper-extract"
+rm -rf "$EXTRACT"
+mkdir -p "$EXTRACT"
+tar -xzf "$PIPER_ARCHIVE" -C "$EXTRACT"
+PIPER_BINARY="$(find "$EXTRACT" -type f -name piper -perm -111 | head -n 1)"
+[ -n "$PIPER_BINARY" ] || { echo "Piper binary was not found in $PIPER_ARCHIVE" >&2; exit 1; }
+cp -R "$(dirname "$PIPER_BINARY")/." "$PIPER_DIR/"
+chmod +x "$PIPER_DIR/piper"
+
+# Model files are platform-independent and live under the "shared" section.
+download_verified "$(read_json shared.whisper.model_url)" "$(read_json shared.whisper.model_sha256)" "$WHISPER_MODEL_DIR/ggml-base.bin"
+download_verified "$(read_json shared.piper.voice_url)" "$(read_json shared.piper.voice_sha256)" "$PIPER_MODEL_DIR/en_US-libritts_r-medium.onnx"
+download_verified "$(read_json shared.piper.voice_config_url)" "$(read_json shared.piper.voice_config_sha256)" "$PIPER_MODEL_DIR/en_US-libritts_r-medium.onnx.json"
+
+echo "Voice assets installed and verified under $TARGET_ROOT"
diff --git a/updates/Create-UpdatePackage.ps1 b/updates/Create-UpdatePackage.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..d7dffbbe9afb751924f9041e69342cd0d8376978
--- /dev/null
+++ b/updates/Create-UpdatePackage.ps1
@@ -0,0 +1,62 @@
+# Vendor-side tool. Builds a signed offline update package that customers can
+# apply from a USB drive or local folder without any internet access.
+#
+# The package layout is:
+# \
+# update-manifest.json signed manifest (product, version, file hashes)
+# update-manifest.json.sig RSA SHA-256 signature
+# payload\ the actual replacement files
+#
+# Example:
+# .\Create-UpdatePackage.ps1 -Version "v16" -Files "bin\jackailocald.exe","webui\app-v15.js" -OutputDir "E:\update-v16"
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory=$true)][string]$Version,
+ [Parameter(Mandatory=$true)][string[]]$Files,
+ [Parameter(Mandatory=$true)][string]$OutputDir,
+ [string]$SourceRoot = "",
+ [string]$Channel = "stable",
+ [string]$PrivateKeyXmlPath = ""
+)
+$ErrorActionPreference = "Stop"
+
+$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
+if (-not $SourceRoot) { $SourceRoot = (Resolve-Path (Join-Path $scriptRoot "..")).Path }
+$SourceRoot = (Resolve-Path $SourceRoot).Path
+
+New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
+$payloadDir = Join-Path $OutputDir "payload"
+New-Item -ItemType Directory -Force -Path $payloadDir | Out-Null
+
+$entries = @()
+foreach ($relative in $Files) {
+ $source = Join-Path $SourceRoot $relative
+ if (!(Test-Path $source -PathType Leaf)) { throw "Update file not found in source root: $source" }
+ $dest = Join-Path $payloadDir $relative
+ New-Item -ItemType Directory -Force -Path (Split-Path $dest) | Out-Null
+ Copy-Item $source $dest -Force
+ $hash = (Get-FileHash -Algorithm SHA256 $dest).Hash.ToLowerInvariant()
+ $entries += [pscustomobject]@{
+ path = ($relative -replace "\\", "/")
+ sha256 = $hash
+ }
+}
+
+$manifest = [pscustomobject]@{
+ product = "JackAILocal"
+ version = $Version
+ channel = $Channel
+ created = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
+ files = $entries
+}
+$manifestPath = Join-Path $OutputDir "update-manifest.json"
+$manifest | ConvertTo-Json -Depth 5 | Set-Content -Encoding utf8 $manifestPath
+
+$signArgs = @{ ManifestPath = $manifestPath }
+if ($PrivateKeyXmlPath) { $signArgs.PrivateKeyXmlPath = $PrivateKeyXmlPath }
+& (Join-Path $scriptRoot "Sign-Manifest.ps1") @signArgs
+
+Write-Host ""
+Write-Host "Offline update package created: $OutputDir" -ForegroundColor Green
+Write-Host "Ship the whole folder (manifest + .sig + payload) to the customer."
+Write-Host "The customer applies it with: updates\JackAILocal-Update-Offline.ps1 -PackagePath -Apply"
diff --git a/updates/Find-And-Apply-Update.ps1 b/updates/Find-And-Apply-Update.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..9d2ddb9ee0e0faae0b2c99aa57878e9aa68e38c9
--- /dev/null
+++ b/updates/Find-And-Apply-Update.ps1
@@ -0,0 +1,43 @@
+# Customer-friendly wrapper around JackAILocal-Update-Offline.ps1.
+# Finds a signed update package on removable drives or in the local "update"
+# folder, verifies it (dry run), then asks for confirmation before applying.
+[CmdletBinding()]
+param()
+$ErrorActionPreference = "Stop"
+$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+
+$candidates = @()
+foreach ($disk in (Get-CimInstance Win32_LogicalDisk | Where-Object { $_.DriveType -eq 2 })) {
+ $driveRoot = $disk.DeviceID + "\"
+ if (Test-Path (Join-Path $driveRoot "update-manifest.json")) {
+ $candidates += Get-Item $driveRoot
+ }
+ $candidates += Get-ChildItem -Path $driveRoot -Directory -ErrorAction SilentlyContinue |
+ Where-Object { Test-Path (Join-Path $_.FullName "update-manifest.json") }
+}
+$localUpdate = Join-Path $Root "update"
+if (Test-Path (Join-Path $localUpdate "update-manifest.json")) {
+ $candidates += Get-Item $localUpdate
+}
+
+if (-not $candidates) {
+ Write-Host ""
+ Write-Host "Aucun package de mise a jour trouve. / No update package found." -ForegroundColor Yellow
+ Write-Host "Inserez la cle USB de mise a jour, ou placez le package dans le dossier 'update'."
+ Write-Host "Insert the update USB key, or put the package in the 'update' folder."
+ exit 1
+}
+
+$package = $candidates | Select-Object -First 1
+Write-Host ("Package trouve / Package found: {0}" -f $package.FullName) -ForegroundColor Cyan
+Write-Host ""
+
+# Dry run: verifies the signature and every checksum without touching files.
+& (Join-Path $PSScriptRoot "JackAILocal-Update-Offline.ps1") -PackagePath $package.FullName
+
+$answer = Read-Host "Appliquer cette mise a jour ? / Apply this update? (OUI/YES)"
+if ($answer -in @("OUI", "YES", "oui", "yes", "Oui", "Yes")) {
+ & (Join-Path $PSScriptRoot "JackAILocal-Update-Offline.ps1") -PackagePath $package.FullName -Apply
+} else {
+ Write-Host "Annule. / Cancelled."
+}
diff --git a/updates/Generate-UpdateKeys.ps1 b/updates/Generate-UpdateKeys.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..ad3ee67354da5028eb2bfb6f1c28ff089a5b151b
--- /dev/null
+++ b/updates/Generate-UpdateKeys.ps1
@@ -0,0 +1,37 @@
+[CmdletBinding()]
+param(
+ [string]$ConfigDir = ""
+)
+$ErrorActionPreference = "Stop"
+
+if ([string]::IsNullOrWhiteSpace($ConfigDir)) {
+ $scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
+ if ([string]::IsNullOrWhiteSpace($scriptRoot)) {
+ $scriptRoot = "."
+ }
+ $ConfigDir = (Join-Path $scriptRoot "..\config")
+}
+
+if (-not (Test-Path $ConfigDir)) {
+ New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null
+}
+
+$publicKeyPath = Join-Path $ConfigDir "update-public-key.xml"
+$privateKeyPath = Join-Path $ConfigDir "update-private-key.xml"
+
+if (Test-Path $publicKeyPath) {
+ Write-Host "Keys already exist. Skipping generation."
+ exit 0
+}
+
+Write-Host "Generating new RSA key pair for update manifest signing..."
+$rsa = [System.Security.Cryptography.RSA]::Create(2048)
+$privateKeyXml = $rsa.ToXmlString($true)
+$publicKeyXml = $rsa.ToXmlString($false)
+
+$publicKeyXml | Set-Content -Path $publicKeyPath -Encoding UTF8
+$privateKeyXml | Set-Content -Path $privateKeyPath -Encoding UTF8
+
+Write-Host "Keys generated successfully."
+Write-Host "Public Key: $publicKeyPath"
+Write-Host "Private Key (KEEP SECRET): $privateKeyPath"
diff --git a/updates/JackAILocal-Update-Offline.ps1 b/updates/JackAILocal-Update-Offline.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..e65f369c4e0d8fdfbb4bb4cfdf520be86e0b59d7
--- /dev/null
+++ b/updates/JackAILocal-Update-Offline.ps1
@@ -0,0 +1,137 @@
+# Customer-side tool. Applies a signed offline update package produced by
+# Create-UpdatePackage.ps1, with no internet access required. Designed for
+# USB-delivered updates on air-gapped machines.
+#
+# Safety properties:
+# - The manifest signature is verified against config\update-public-key.xml
+# before anything is touched.
+# - Every payload file must match its SHA-256 hash from the signed manifest.
+# - Replaced files are backed up first; -Rollback restores the last backup.
+# - Paths are confined to the installation root (no path traversal).
+#
+# Usage:
+# .\JackAILocal-Update-Offline.ps1 -PackagePath D:\update-v16 (dry run)
+# .\JackAILocal-Update-Offline.ps1 -PackagePath D:\update-v16 -Apply
+# .\JackAILocal-Update-Offline.ps1 -Rollback
+[CmdletBinding()]
+param(
+ [string]$PackagePath = "",
+ [switch]$Apply,
+ [switch]$Force,
+ [switch]$Rollback
+)
+$ErrorActionPreference = "Stop"
+$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+$backupRoot = Join-Path $Root ".jackailocal\updates\backups"
+
+function Get-InstalledVersion {
+ $productPath = Join-Path $Root "manifest\product.json"
+ if (Test-Path $productPath) {
+ try { return (Get-Content $productPath -Raw | ConvertFrom-Json).version } catch { return "" }
+ }
+ return ""
+}
+
+if ($Rollback) {
+ if (!(Test-Path $backupRoot)) { throw "No update backups found under $backupRoot" }
+ $latest = Get-ChildItem $backupRoot -Directory | Sort-Object Name -Descending | Select-Object -First 1
+ if (-not $latest) { throw "No update backups found under $backupRoot" }
+ Write-Host "Rolling back from backup: $($latest.FullName)"
+ Get-ChildItem $latest.FullName -Recurse -File | ForEach-Object {
+ $relative = $_.FullName.Substring($latest.FullName.Length).TrimStart("\")
+ $dest = Join-Path $Root $relative
+ New-Item -ItemType Directory -Force -Path (Split-Path $dest) | Out-Null
+ Copy-Item $_.FullName $dest -Force
+ Write-Host "Restored: $relative"
+ }
+ Write-Host "Rollback complete. Restart JackAILocal." -ForegroundColor Green
+ exit 0
+}
+
+if (-not $PackagePath) { throw "Provide -PackagePath or -Rollback." }
+$PackagePath = (Resolve-Path $PackagePath).Path
+$manifestPath = Join-Path $PackagePath "update-manifest.json"
+$sigPath = "$manifestPath.sig"
+$payloadDir = Join-Path $PackagePath "payload"
+$pubKeyPath = Join-Path $Root "config\update-public-key.xml"
+
+foreach ($required in @($manifestPath, $sigPath, $pubKeyPath)) {
+ if (!(Test-Path $required)) { throw "Required file not found: $required" }
+}
+if (!(Test-Path $payloadDir)) { throw "Update package has no payload folder: $payloadDir" }
+
+& (Join-Path $PSScriptRoot "Verify-SignedManifest.ps1") -ManifestPath $manifestPath -SignaturePath $sigPath -PublicKeyXmlPath $pubKeyPath
+
+$manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json
+if ($manifest.product -ne "JackAILocal") { throw "Wrong product in update manifest: $($manifest.product)" }
+
+$installed = Get-InstalledVersion
+Write-Host "Installed version : $installed"
+Write-Host "Update version : $($manifest.version)"
+if ($installed -eq $manifest.version -and -not $Force) {
+ throw "This version is already installed. Use -Force to reapply."
+}
+
+# Verify every payload file against the signed manifest before touching anything.
+$verified = @()
+foreach ($file in $manifest.files) {
+ $relative = $file.path -replace "/", "\"
+ $sourcePath = [System.IO.Path]::GetFullPath((Join-Path $payloadDir $relative))
+ if (-not $sourcePath.StartsWith($payloadDir, [System.StringComparison]::OrdinalIgnoreCase)) {
+ throw "Security error: path traversal detected in payload path: $($file.path)"
+ }
+ $destPath = [System.IO.Path]::GetFullPath((Join-Path $Root $relative))
+ if (-not $destPath.StartsWith($Root, [System.StringComparison]::OrdinalIgnoreCase)) {
+ throw "Security error: path traversal detected in target path: $($file.path)"
+ }
+ if (!(Test-Path $sourcePath -PathType Leaf)) { throw "Payload file missing from package: $($file.path)" }
+ $hash = (Get-FileHash -Algorithm SHA256 $sourcePath).Hash.ToLowerInvariant()
+ if ($hash -ne $file.sha256.ToLowerInvariant()) { throw "Checksum failed for: $($file.path)" }
+ $verified += [pscustomobject]@{ Relative = $relative; Source = $sourcePath; Dest = $destPath }
+ Write-Host "Verified: $($file.path)"
+}
+
+if (-not $Apply) {
+ Write-Host ""
+ Write-Host "Dry run complete: signature and all checksums are valid." -ForegroundColor Green
+ Write-Host "Run again with -Apply to install update $($manifest.version)."
+ exit 0
+}
+
+# Backup current files (including the product manifest, so -Rollback also
+# restores the previous version number), then apply.
+$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
+$backupDir = Join-Path $backupRoot "$stamp-$($manifest.version)"
+$productPath = Join-Path $Root "manifest\product.json"
+if (Test-Path $productPath -PathType Leaf) {
+ $backupProduct = Join-Path $backupDir "manifest\product.json"
+ New-Item -ItemType Directory -Force -Path (Split-Path $backupProduct) | Out-Null
+ Copy-Item $productPath $backupProduct -Force
+}
+foreach ($entry in $verified) {
+ if (Test-Path $entry.Dest -PathType Leaf) {
+ $backupPath = Join-Path $backupDir $entry.Relative
+ New-Item -ItemType Directory -Force -Path (Split-Path $backupPath) | Out-Null
+ Copy-Item $entry.Dest $backupPath -Force
+ }
+}
+foreach ($entry in $verified) {
+ New-Item -ItemType Directory -Force -Path (Split-Path $entry.Dest) | Out-Null
+ Copy-Item $entry.Source $entry.Dest -Force
+ Write-Host "Updated: $($entry.Relative)"
+}
+
+# Record the new version in the local product manifest.
+if (Test-Path $productPath) {
+ try {
+ $product = Get-Content $productPath -Raw | ConvertFrom-Json
+ $product.version = $manifest.version
+ $product | ConvertTo-Json -Depth 10 | Set-Content -Encoding utf8 $productPath
+ } catch {
+ Write-Warning "Could not record the new version in manifest\product.json: $($_.Exception.Message)"
+ }
+}
+
+Write-Host ""
+Write-Host "Update $($manifest.version) applied. Backup saved to: $backupDir" -ForegroundColor Green
+Write-Host "Restart JackAILocal to use the new version. Use -Rollback to undo."
diff --git a/updates/JackAILocal-Update.ps1 b/updates/JackAILocal-Update.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..6e23a48aabba18cb6a0f1b897023dfea06c0ee5f
--- /dev/null
+++ b/updates/JackAILocal-Update.ps1
@@ -0,0 +1,46 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory=$true)][string]$ManifestUrl,
+ [switch]$Apply
+)
+$ErrorActionPreference = "Stop"
+$Root = Resolve-Path (Join-Path $PSScriptRoot "..")
+$tmp = Join-Path $Root ".jackailocal\updates"
+New-Item -ItemType Directory -Force -Path $tmp | Out-Null
+$manifestPath = Join-Path $tmp "update-manifest.json"
+$sigPath = Join-Path $tmp "update-manifest.json.sig"
+$pubKeyPath = Join-Path $Root "config\update-public-key.xml"
+
+if (!(Test-Path $pubKeyPath)) {
+ throw "Public key for update verification not found at: $pubKeyPath"
+}
+
+# Download manifest and signature
+Invoke-WebRequest -Uri $ManifestUrl -OutFile $manifestPath
+Invoke-WebRequest -Uri "$ManifestUrl.sig" -OutFile $sigPath
+
+# Verify Signature
+& (Join-Path $PSScriptRoot "Verify-SignedManifest.ps1") -ManifestPath $manifestPath -SignaturePath $sigPath -PublicKeyXmlPath $pubKeyPath
+
+$manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json
+if ($manifest.product -ne "JackAILocal") { throw "Wrong product." }
+
+$rootPath = [System.IO.Path]::GetFullPath($Root)
+
+foreach ($file in $manifest.files) {
+ # Avoid Path Traversal Vulnerability
+ $dest = [System.IO.Path]::GetFullPath((Join-Path $rootPath $file.path))
+ if (-not $dest.StartsWith($rootPath, [System.StringComparison]::OrdinalIgnoreCase)) {
+ throw "Security error: Path traversal detected in file path: $($file.path)"
+ }
+
+ $download = Join-Path $tmp ([IO.Path]::GetFileName($file.path))
+ Invoke-WebRequest -Uri $file.url -OutFile $download
+ $h = (Get-FileHash -Algorithm SHA256 $download).Hash.ToLowerInvariant()
+ if ($h -ne $file.sha256.ToLowerInvariant()) { throw "Checksum failed: $($file.path)" }
+ if ($Apply) {
+ New-Item -ItemType Directory -Force -Path (Split-Path $dest) | Out-Null
+ Copy-Item $download $dest -Force
+ }
+}
+if (-not $Apply) { Write-Host "Dry-run update verified. Use -Apply to install." }
diff --git a/updates/Sign-Manifest.ps1 b/updates/Sign-Manifest.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a59b928394fed11a385fa579b021b8ff90243e46
--- /dev/null
+++ b/updates/Sign-Manifest.ps1
@@ -0,0 +1,28 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory=$true)][string]$ManifestPath,
+ [string]$PrivateKeyXmlPath = ""
+)
+$ErrorActionPreference = "Stop"
+
+$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
+if ([string]::IsNullOrWhiteSpace($scriptRoot)) { $scriptRoot = "." }
+
+if ([string]::IsNullOrWhiteSpace($PrivateKeyXmlPath)) {
+ $PrivateKeyXmlPath = Join-Path $scriptRoot "..\config\update-private-key.xml"
+}
+
+if (!(Test-Path $PrivateKeyXmlPath)) {
+ throw "Private key file not found: $PrivateKeyXmlPath"
+}
+
+$manifestFullPath = (Resolve-Path $ManifestPath).Path
+$signaturePath = $manifestFullPath + ".sig"
+
+$bytes = [IO.File]::ReadAllBytes($manifestFullPath)
+$rsa = [System.Security.Cryptography.RSA]::Create()
+$rsa.FromXmlString((Get-Content $PrivateKeyXmlPath -Raw))
+$sig = $rsa.SignData($bytes, [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
+
+[IO.File]::WriteAllBytes($signaturePath, $sig)
+Write-Host "Manifest signed successfully. Signature saved to: $signaturePath"
diff --git a/updates/Verify-SignedManifest.ps1 b/updates/Verify-SignedManifest.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..486f2509e26c2f637e939044705f5db70626cdf7
--- /dev/null
+++ b/updates/Verify-SignedManifest.ps1
@@ -0,0 +1,13 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory=$true)][string]$ManifestPath,
+ [Parameter(Mandatory=$true)][string]$SignaturePath,
+ [Parameter(Mandatory=$true)][string]$PublicKeyXmlPath
+)
+$bytes = [IO.File]::ReadAllBytes((Resolve-Path $ManifestPath))
+$sig = [IO.File]::ReadAllBytes((Resolve-Path $SignaturePath))
+$rsa = [System.Security.Cryptography.RSA]::Create()
+$rsa.FromXmlString((Get-Content $PublicKeyXmlPath -Raw))
+$ok = $rsa.VerifyData($bytes, $sig, [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
+if (-not $ok) { throw "Signature verification failed." }
+Write-Host "Signature OK"
diff --git a/updates/update-manifest.example.json b/updates/update-manifest.example.json
new file mode 100644
index 0000000000000000000000000000000000000000..5fd7444fa774e794d43134c464e9ed8347ea26df
--- /dev/null
+++ b/updates/update-manifest.example.json
@@ -0,0 +1,12 @@
+{
+ "product": "JackAILocal",
+ "version": "0.3.1",
+ "channel": "stable",
+ "files": [
+ {
+ "path": "webui/index.html",
+ "url": "https://updates.example/jackailocal/0.3.1/index.html",
+ "sha256": "REPLACE"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/updates/update-manifest.example.json.sig b/updates/update-manifest.example.json.sig
new file mode 100644
index 0000000000000000000000000000000000000000..4f8fd7bed9272f3e5f8ed0c67b3bfa631314f884
Binary files /dev/null and b/updates/update-manifest.example.json.sig differ
diff --git a/usb-root/README-FIRST.txt b/usb-root/README-FIRST.txt
new file mode 100644
index 0000000000000000000000000000000000000000..56dabe122275030ffe2fe77db5c3e71a1a88582e
--- /dev/null
+++ b/usb-root/README-FIRST.txt
@@ -0,0 +1,14 @@
+JackAILocal
+
+Windows portable mode:
+1. Open this drive.
+2. Double-click START-HERE.cmd.
+3. Wait for the local AI window to open.
+
+Linux boot mode:
+1. Shut down the computer.
+2. Insert the drive.
+3. Open the boot menu.
+4. Choose the USB/SSD drive.
+
+Models must be prepared before shipment or before offline use.
diff --git a/usb-root/START-HERE.cmd b/usb-root/START-HERE.cmd
new file mode 100644
index 0000000000000000000000000000000000000000..1ff185755ea82e389539a7620a53aa2cafc94b42
--- /dev/null
+++ b/usb-root/START-HERE.cmd
@@ -0,0 +1,17 @@
+@echo off
+setlocal
+cd /d "%~dp0"
+where powershell.exe >nul 2>nul
+if errorlevel 1 (
+ echo PowerShell was not found. JackAILocal cannot start.
+ pause
+ exit /b 1
+)
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0windows\Start-JackAILocal.ps1"
+if errorlevel 1 (
+ echo.
+ echo JackAILocal did not start correctly.
+ echo Check .jackailocal\logs or run windows\Preflight-Windows.ps1
+ pause
+ exit /b 1
+)
diff --git a/usb-root/STOP-JACKAILOCAL.cmd b/usb-root/STOP-JACKAILOCAL.cmd
new file mode 100644
index 0000000000000000000000000000000000000000..8d5dce3e49f37bbb9346b76f0030ff258b20a5a6
--- /dev/null
+++ b/usb-root/STOP-JACKAILOCAL.cmd
@@ -0,0 +1,5 @@
+@echo off
+setlocal
+cd /d "%~dp0"
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0windows\Stop-JackAILocal.ps1"
+pause
diff --git a/usb-root/autorun.inf b/usb-root/autorun.inf
new file mode 100644
index 0000000000000000000000000000000000000000..cd84a20e2271180748bb74586dc8e4214a6baa02
--- /dev/null
+++ b/usb-root/autorun.inf
@@ -0,0 +1,5 @@
+[AutoRun]
+label=JackAILocal
+icon=branding\icon.ico
+open=START-HERE.cmd
+action=Start JackAILocal
diff --git a/webui/app-v15.js b/webui/app-v15.js
new file mode 100644
index 0000000000000000000000000000000000000000..27ff65fe33f29d2c40cbf8c8a792c5529f109199
--- /dev/null
+++ b/webui/app-v15.js
@@ -0,0 +1,873 @@
+"use strict";
+
+const $ = (id) => document.getElementById(id);
+const API_BASE = window.location.protocol === "file:" ? "http://127.0.0.1:4891" : "";
+const pairFromUrl = new URLSearchParams(window.location.search).get("pair");
+if (pairFromUrl) localStorage.setItem("jackailocal.phoneToken", pairFromUrl);
+
+const state = {
+ models: [],
+ status: {},
+ features: {},
+ threads: [],
+ currentThread: null,
+ apiOk: false,
+ currentView: "dashboard",
+ language: localStorage.getItem("jackailocal.language") || "en",
+ scoutImageBase64: "",
+ voice: {},
+ audioContext: null,
+ audioStream: null,
+ audioProcessor: null,
+ audioSource: null,
+ audioChunks: [],
+ audioSampleRate: 44100,
+};
+
+const i18n = {
+ en: {
+ brandSubtitle: "Offline local AI", statusStarting: "Starting", statusLocalScan: "Local scan...",
+ navDashboard: "Dashboard", navChat: "Chat", navScout: "SCOUT Vision", navVoice: "Voice",
+ navFieldManual: "Field Manual", navModels: "Models", navDocuments: "Documents",
+ navTransfers: "Import / Export", navPhone: "Phone Access", navBenchmark: "Benchmark",
+ navSettings: "Settings", navSupport: "Support", networkMode: "Network mode",
+ loopbackOnly: "Local access only", eyebrow: "Portable private AI", refreshButton: "Refresh",
+ supportButton: "Support", heroKicker: "Ready for offline work",
+ heroTitle: "Local AI, loaded from your JackAILocal drive.",
+ heroSubtitle: "Runtime, models, conversations, documents, and UI stay on this device. No cloud fallback is used.",
+ startChatButton: "Start chat", checkModelsButton: "Check models", runtimeCard: "Runtime",
+ hardwareCard: "Hardware", modelCard: "Recommended model", privacyCard: "Privacy",
+ autoSelectHint: "Selected from installed local models", offlineLocked: "Offline locked",
+ localOnlyHint: "Loopback by default", setupTitle: "Preparation state", checkRuntimeTitle: "Local API",
+ checkRuntimeText: "jackailocald must be running.", checkModelsTitle: "Installed model",
+ checkModelsText: "At least one compatible local model must be installed.", checkSecurityTitle: "Security mode",
+ checkSecurityText: "Shell tools remain disabled and LAN access is explicit.",
+ quickActionsTitle: "Quick actions", runBenchmarkQuick: "Run benchmark", openDocsQuick: "Open documents",
+ createSupportQuick: "Create support bundle", chat: "Chat", dashboard: "Dashboard", scout: "SCOUT Vision",
+ voice: "Voice", "field-manual": "Field Manual", models: "Models", documents: "Documents",
+ transfers: "Import / Export", phone: "Phone Access", benchmark: "Benchmark", settings: "Settings",
+ support: "Support", threadsTitle: "Threads", newThreadButton: "New", threadFilterPlaceholder: "Filter threads",
+ newConversation: "New conversation", renameButton: "Rename", deleteButton: "Delete", modelLabel: "Model",
+ clearButton: "New thread", promptPlaceholder: "Write your request here. Enter = send, Shift+Enter = new line.",
+ sendButton: "Send", chatHelperTitle: "Assistant mode",
+ chatHelperText: "Use Auto unless you need a specific installed model. Threads are stored on this JackAILocal drive.",
+ offlineNoteTitle: "Offline note", offlineNoteText: "Answers come only from reachable local backends.",
+ scoutTitle: "SCOUT Vision", scoutSubtitle: "Analyze an image with an installed local vision model.",
+ scoutPromptPlaceholder: "Describe the image and extract readable text.", analyzeButton: "Analyze locally",
+ scoutResultTitle: "Analysis result", voiceTitle: "Voice Mode",
+ voiceSubtitle: "Local speech-to-text with whisper.cpp and text-to-speech with Piper.",
+ sttTitle: "Speech to text", recordButton: "Record", stopButton: "Stop", transcriptPlaceholder: "Transcript",
+ sendToChatButton: "Send to chat", ttsTitle: "Text to speech", ttsPlaceholder: "Text to speak",
+ speakButton: "Speak locally", fieldManualTitle: "Field Manual",
+ fieldManualSubtitle: "Offline reference cards with an Ask AI action.", askAiButton: "Ask AI about this",
+ modelsTitle: "Models", modelsSubtitle: "Availability is read from installed local model files and Ollama.",
+ agentTitle: "LLM Config Agent",
+ agentSubtitle: "A real installed local LLM can review hardware, model selection, and client configuration. Preferred model: gemma4:12b.",
+ agentModelLabel: "Agent model",
+ agentNotesPlaceholder: "Client constraints, use case, budget, privacy needs...",
+ agentRunButton: "Run local agent review",
+ agentRunning: "Local agent review running...",
+ addModelTitle: "Add a model to the catalog",
+ addModelSubtitle: "This registers a model reference. It does not claim the model is installed.",
+ addModelIdPlaceholder: "Ollama model reference, example: qwen3.5:4b", addModelLabelPlaceholder: "display name",
+ addButton: "Add", documentsTitle: "Documents", documentsSubtitle: "Local storage in workspace/documents.",
+ createNoteTitle: "Create a note", docNamePlaceholder: "document-name.txt",
+ docContentPlaceholder: "Text, notes, procedures, prompts...", saveLocalButton: "Save locally",
+ localDocsTitle: "Local documents", transfersTitle: "Encrypted Import / Export",
+ transfersSubtitle: "Transfer threads and documents with an AES-256-GCM encrypted pack.",
+ exportTitle: "Export", importTitle: "Import", passphrasePlaceholder: "Passphrase, minimum 8 characters",
+ includeThreads: "Include threads", includeDocuments: "Include documents", includeSettings: "Include safe settings",
+ exportButton: "Export encrypted pack", importMode: "Import mode", importButton: "Import encrypted pack",
+ phoneTitle: "Phone Access", phoneSubtitle: "Explicit local-network access with a pairing token. Disabled by default.",
+ enablePhoneButton: "Enable Phone Access", disablePhoneButton: "Disable Phone Access",
+ restartRuntimeButton: "Restart runtime",
+ phoneRestartNote: "A runtime restart is required after changing this mode.",
+ benchmarkTitle: "Benchmark", benchmarkSubtitle: "Short real inference test against the selected local backend.",
+ runTestButton: "Run test", settingsTitle: "Settings", settingsSubtitle: "Options stay simple. Dangerous tools remain disabled.",
+ modeTitle: "Mode", settingsModeHint: "Simple mode is the default for non-technical users.",
+ simpleMode: "Simple", simpleModeDesc: "Minimal choices and automatic local model selection.",
+ advancedMode: "Advanced", advancedModeDesc: "Shows more controls without enabling shell or system tools.",
+ offlineLock: "Offline lock", offlineLockDesc: "Keeps JackAILocal local-first.",
+ toolsLock: "Block shell/system tools", toolsLockDesc: "Prevents agent-style system actions.",
+ saveButton: "Save", activeFeaturesTitle: "Active features",
+ activeFeaturesSubtitle: "Capabilities are detected from the running local API.",
+ licenseTitle: "License", licenseSubtitle: "License status of this installation. Verified locally, no internet required.",
+ licenseStateLicensed: "Licensed", licenseStateUnlicensed: "No license installed",
+ licenseStateExpired: "License expired", licenseStateInvalid: "License invalid",
+ licenseStateNoKey: "Verification key missing", licenseEdition: "Edition",
+ licenseCustomer: "Licensed to", licenseExpires: "Expires", licensePerpetual: "Perpetual",
+ legalDocsHint: "Legal documents are in the legal/ and licenses/ folders of this installation.",
+ supportTitle: "Support", supportSubtitle: "Local diagnostics without conversations or documents.",
+ prepareReportButton: "Create report", auto: "Auto", runtimeUnavailable: "Runtime unavailable",
+ startWithCommand: "Start with START-HERE.cmd", apiNotResponding: "The local API is not responding:",
+ noModelsTitle: "No installed models", noModelsBody: "Use the builder to install a compatible local model.",
+ modelCatalogError: "Cannot load model catalog:", responsePending: "Generating local response...",
+ localError: "Local error:", backendHint: "Check that a local backend and model are installed.",
+ chatCleared: "New local thread created.", adding: "Adding...", saving: "Saving...",
+ noLocalDocuments: "No local documents.", apiUnavailable: "API unavailable.", benchmarkRunning: "Benchmark running...",
+ benchmarkFailed: "Benchmark failed:", savingSettings: "Saving...", preparing: "Preparing...",
+ readyMessage: "JackAILocal is ready. Create a thread and use Auto to select an installed model.",
+ recommended: "recommended", installed: "installed", unavailable: "not installed", bytes: "bytes",
+ unknown: "unknown", apiReady: "Ready", apiOffline: "Not running", pass: "Ready", fail: "Missing",
+ locked: "Locked", recording: "Recording...", processing: "Processing locally...", available: "Available",
+ notAvailable: "Not available", confirmDelete: "Delete this thread?", renamePrompt: "Thread name",
+ fileRequired: "Choose a file first.", imageRequired: "Choose an image first.", importComplete: "Import complete.",
+ on: "On", off: "Off",
+ eulaTitle: "End User License Agreement", eulaAcceptButton: "I accept",
+ eulaLoading: "Loading the license agreement...",
+ eulaFallback: "The license agreement could not be loaded from the local API. The full text is available in the legal/ folder of this installation (EULA_EN.md, EULA_FR.md). By continuing you accept its terms.",
+ installLicenseButton: "Install license file", licenseInstalling: "Installing license...",
+ },
+ fr: {
+ brandSubtitle: "IA locale hors ligne", statusStarting: "Démarrage", statusLocalScan: "Analyse locale...",
+ navDashboard: "Tableau", navChat: "Chat", navScout: "Vision SCOUT", navVoice: "Voix",
+ navFieldManual: "Manuel terrain", navModels: "Modèles", navDocuments: "Documents",
+ navTransfers: "Import / Export", navPhone: "Accès téléphone", navBenchmark: "Benchmark",
+ navSettings: "Réglages", navSupport: "Support", networkMode: "Mode réseau",
+ loopbackOnly: "Accès local seulement", eyebrow: "IA privée portable", refreshButton: "Rafraîchir",
+ supportButton: "Support", heroKicker: "Prêt pour le travail hors ligne",
+ heroTitle: "IA locale, chargée depuis votre support JackAILocal.",
+ heroSubtitle: "Runtime, modèles, conversations, documents et UI restent sur cet appareil. Aucun repli cloud n'est utilisé.",
+ startChatButton: "Démarrer le chat", checkModelsButton: "Vérifier les modèles", runtimeCard: "Runtime",
+ hardwareCard: "Matériel", modelCard: "Modèle recommandé", privacyCard: "Confidentialité",
+ autoSelectHint: "Sélectionné parmi les modèles locaux installés", offlineLocked: "Hors ligne verrouillé",
+ localOnlyHint: "Loopback par défaut", setupTitle: "État de préparation", checkRuntimeTitle: "API locale",
+ checkRuntimeText: "jackailocald doit être démarré.", checkModelsTitle: "Modèle installé",
+ checkModelsText: "Au moins un modèle local compatible doit être installé.", checkSecurityTitle: "Mode sécurité",
+ checkSecurityText: "Les outils shell restent désactivés et l'accès LAN est explicite.",
+ quickActionsTitle: "Actions rapides", runBenchmarkQuick: "Lancer benchmark", openDocsQuick: "Ouvrir documents",
+ createSupportQuick: "Créer bundle support", chat: "Chat", dashboard: "Tableau", scout: "Vision SCOUT",
+ voice: "Voix", "field-manual": "Manuel terrain", models: "Modèles", documents: "Documents",
+ transfers: "Import / Export", phone: "Accès téléphone", benchmark: "Benchmark", settings: "Réglages",
+ support: "Support", threadsTitle: "Fils", newThreadButton: "Nouveau", threadFilterPlaceholder: "Filtrer les fils",
+ newConversation: "Nouvelle conversation", renameButton: "Renommer", deleteButton: "Supprimer", modelLabel: "Modèle",
+ clearButton: "Nouveau fil", promptPlaceholder: "Écrivez votre demande. Entrée = envoyer, Maj+Entrée = nouvelle ligne.",
+ sendButton: "Envoyer", chatHelperTitle: "Mode assistant",
+ chatHelperText: "Utilisez Auto sauf si vous voulez un modèle installé précis. Les fils sont stockés sur ce support JackAILocal.",
+ offlineNoteTitle: "Note hors ligne", offlineNoteText: "Les réponses viennent uniquement des backends locaux accessibles.",
+ scoutTitle: "Vision SCOUT", scoutSubtitle: "Analysez une image avec un modèle vision local installé.",
+ scoutPromptPlaceholder: "Décrivez l'image et extrayez le texte lisible.", analyzeButton: "Analyser localement",
+ scoutResultTitle: "Résultat d'analyse", voiceTitle: "Mode voix",
+ voiceSubtitle: "Reconnaissance vocale locale avec whisper.cpp et synthèse avec Piper.",
+ sttTitle: "Parole vers texte", recordButton: "Enregistrer", stopButton: "Arrêter", transcriptPlaceholder: "Transcription",
+ sendToChatButton: "Envoyer au chat", ttsTitle: "Texte vers parole", ttsPlaceholder: "Texte à prononcer",
+ speakButton: "Prononcer localement", fieldManualTitle: "Manuel terrain",
+ fieldManualSubtitle: "Cartes de référence hors ligne avec action Ask AI.", askAiButton: "Demander à l'IA",
+ modelsTitle: "Modèles", modelsSubtitle: "La disponibilité est lue depuis Ollama et les fichiers locaux installés.",
+ agentTitle: "Agent LLM config",
+ agentSubtitle: "Un vrai LLM local installe peut revoir le materiel, la selection des modeles et la configuration client. Modele prefere : gemma4:12b.",
+ agentModelLabel: "Modele agent",
+ agentNotesPlaceholder: "Contraintes client, usage, budget, besoins de confidentialite...",
+ agentRunButton: "Lancer la revue agent locale",
+ agentRunning: "Revue agent locale en cours...",
+ addModelTitle: "Ajouter un modèle au catalogue",
+ addModelSubtitle: "Cette action inscrit une référence. Elle ne prétend pas que le modèle est installé.",
+ addModelIdPlaceholder: "Référence Ollama, exemple : qwen3.5:4b", addModelLabelPlaceholder: "nom affiché",
+ addButton: "Ajouter", documentsTitle: "Documents", documentsSubtitle: "Stockage local dans workspace/documents.",
+ createNoteTitle: "Créer une note", docNamePlaceholder: "nom-du-document.txt",
+ docContentPlaceholder: "Texte, notes, procédures, prompts...", saveLocalButton: "Sauver localement",
+ localDocsTitle: "Documents locaux", transfersTitle: "Import / Export chiffré",
+ transfersSubtitle: "Transférez fils et documents avec un pack chiffré AES-256-GCM.",
+ exportTitle: "Exporter", importTitle: "Importer", passphrasePlaceholder: "Phrase secrète, minimum 8 caractères",
+ includeThreads: "Inclure les fils", includeDocuments: "Inclure les documents", includeSettings: "Inclure les réglages sûrs",
+ exportButton: "Exporter le pack chiffré", importMode: "Mode d'import", importButton: "Importer le pack chiffré",
+ phoneTitle: "Accès téléphone", phoneSubtitle: "Accès réseau local explicite avec jeton de pairage. Désactivé par défaut.",
+ enablePhoneButton: "Activer l'accès téléphone", disablePhoneButton: "Désactiver l'accès téléphone",
+ restartRuntimeButton: "Redémarrer le runtime",
+ phoneRestartNote: "Un redémarrage du runtime est requis après ce changement.",
+ benchmarkTitle: "Benchmark", benchmarkSubtitle: "Test réel court contre le backend local sélectionné.",
+ runTestButton: "Lancer test", settingsTitle: "Réglages", settingsSubtitle: "Les options restent simples. Les outils dangereux restent désactivés.",
+ modeTitle: "Mode", settingsModeHint: "Le mode simple est le défaut pour les utilisateurs non techniques.",
+ simpleMode: "Simple", simpleModeDesc: "Choix minimaux et sélection automatique du modèle local.",
+ advancedMode: "Avancé", advancedModeDesc: "Affiche plus de contrôles sans activer le shell ni les outils système.",
+ offlineLock: "Verrou hors ligne", offlineLockDesc: "Garde JackAILocal local-first.",
+ toolsLock: "Bloquer shell/outils système", toolsLockDesc: "Empêche les actions système de type agent.",
+ saveButton: "Enregistrer", activeFeaturesTitle: "Fonctions actives",
+ activeFeaturesSubtitle: "Les capacités sont détectées depuis l'API locale.",
+ licenseTitle: "Licence", licenseSubtitle: "État de la licence de cette installation. Vérifiée localement, sans Internet.",
+ licenseStateLicensed: "Sous licence", licenseStateUnlicensed: "Aucune licence installée",
+ licenseStateExpired: "Licence expirée", licenseStateInvalid: "Licence invalide",
+ licenseStateNoKey: "Clé de vérification absente", licenseEdition: "Édition",
+ licenseCustomer: "Licencié à", licenseExpires: "Expire", licensePerpetual: "Perpétuelle",
+ legalDocsHint: "Les documents légaux sont dans les dossiers legal/ et licenses/ de cette installation.",
+ supportTitle: "Support", supportSubtitle: "Diagnostic local sans conversations ni documents.",
+ prepareReportButton: "Créer rapport", auto: "Auto", runtimeUnavailable: "Runtime non disponible",
+ startWithCommand: "Démarrer avec START-HERE.cmd", apiNotResponding: "L'API locale ne répond pas :",
+ noModelsTitle: "Aucun modèle installé", noModelsBody: "Utilisez le builder pour installer un modèle local compatible.",
+ modelCatalogError: "Impossible de charger le catalogue :", responsePending: "Réponse locale en cours...",
+ localError: "Erreur locale :", backendHint: "Vérifiez qu'un backend local et un modèle sont installés.",
+ chatCleared: "Nouveau fil local créé.", adding: "Ajout...", saving: "Sauvegarde...",
+ noLocalDocuments: "Aucun document local.", apiUnavailable: "API non disponible.", benchmarkRunning: "Benchmark en cours...",
+ benchmarkFailed: "Benchmark échoué :", savingSettings: "Enregistrement...", preparing: "Préparation...",
+ readyMessage: "JackAILocal est prêt. Créez un fil et utilisez Auto pour sélectionner un modèle installé.",
+ recommended: "recommandé", installed: "installé", unavailable: "non installé", bytes: "octets",
+ unknown: "inconnu", apiReady: "Prêt", apiOffline: "Non démarré", pass: "Prêt", fail: "Manquant",
+ locked: "Verrouillé", recording: "Enregistrement...", processing: "Traitement local...", available: "Disponible",
+ notAvailable: "Non disponible", confirmDelete: "Supprimer ce fil ?", renamePrompt: "Nom du fil",
+ fileRequired: "Choisissez d'abord un fichier.", imageRequired: "Choisissez d'abord une image.", importComplete: "Import terminé.",
+ on: "Actif", off: "Inactif",
+ eulaTitle: "Contrat de licence utilisateur final", eulaAcceptButton: "J'accepte",
+ eulaLoading: "Chargement du contrat de licence...",
+ eulaFallback: "Le contrat de licence n'a pas pu être chargé depuis l'API locale. Le texte complet est disponible dans le dossier legal/ de cette installation (EULA_FR.md, EULA_EN.md). En continuant, vous en acceptez les termes.",
+ installLicenseButton: "Installer le fichier de licence", licenseInstalling: "Installation de la licence...",
+ },
+};
+
+function t(key) { return i18n[state.language]?.[key] || i18n.en[key] || key; }
+function apiUrl(path) { return /^https?:\/\//i.test(path) ? path : `${API_BASE}${path}`; }
+function escapeHtml(value) {
+ return String(value ?? "").replace(/[&<>'"]/g, (character) => ({
+ "&": "&", "<": "<", ">": ">", "'": "'", "\"": """,
+ }[character]));
+}
+function contentText(value) { return typeof value === "string" ? value : JSON.stringify(value, null, 2); }
+function viewTitleKey(view) { return view; }
+
+async function requestJson(url, options = {}) {
+ const token = localStorage.getItem("jackailocal.phoneToken");
+ const response = await fetch(apiUrl(url), {
+ headers: {
+ "Content-Type": "application/json",
+ ...(token ? { "X-JackAILocal-Token": token } : {}),
+ ...(options.headers || {}),
+ },
+ ...options,
+ });
+ const text = await response.text();
+ let payload = {};
+ try { payload = text ? JSON.parse(text) : {}; } catch { payload = { raw: text }; }
+ if (!response.ok) throw new Error(payload.error || payload.message || `${response.status} ${response.statusText}`);
+ return payload;
+}
+const getJson = (url) => requestJson(url);
+const postJson = (url, body) => requestJson(url, { method: "POST", body: JSON.stringify(body || {}) });
+const patchJson = (url, body) => requestJson(url, { method: "PATCH", body: JSON.stringify(body || {}) });
+const deleteJson = (url) => requestJson(url, { method: "DELETE" });
+
+function applyLanguage(language) {
+ state.language = language;
+ localStorage.setItem("jackailocal.language", language);
+ document.documentElement.lang = language;
+ document.querySelectorAll("[data-i18n]").forEach((element) => { element.textContent = t(element.dataset.i18n); });
+ document.querySelectorAll("[data-i18n-placeholder]").forEach((element) => { element.placeholder = t(element.dataset.i18nPlaceholder); });
+ document.querySelectorAll(".lang-btn").forEach((button) => button.classList.toggle("active", button.dataset.lang === language));
+ $("viewTitle").textContent = t(viewTitleKey(state.currentView));
+ renderModels();
+ renderThreads();
+ renderFeatures();
+ renderLicense();
+}
+
+function switchView(name) {
+ state.currentView = name;
+ document.querySelectorAll(".nav").forEach((button) => button.classList.toggle("active", button.dataset.view === name));
+ document.querySelectorAll(".view").forEach((view) => view.classList.remove("active"));
+ $(`view-${name}`)?.classList.add("active");
+ $("viewTitle").textContent = t(viewTitleKey(name));
+}
+
+function showError(message) {
+ $("errorBanner").textContent = message;
+ $("errorBanner").classList.remove("hidden");
+}
+function clearError() { $("errorBanner").classList.add("hidden"); $("errorBanner").textContent = ""; }
+function setStatus(kind, title, subtitle) {
+ $("statusBox").className = `status-card ${kind === "ok" ? "state-ok" : kind === "error" ? "state-error" : "state-loading"}`;
+ $("runtimeStatus").textContent = title;
+ $("hardwareLine").textContent = subtitle || "";
+}
+function updateCheck(id, ok) { $(id)?.classList.toggle("ok", Boolean(ok)); $(id)?.classList.toggle("error", !ok); }
+function formatHardware(hardware = {}) { return `RAM ${hardware.ram_gb ?? "?"} GB · VRAM ${hardware.vram_gb ?? "?"} GB · CPU ${hardware.cpu_threads ?? "?"}`; }
+function formatGpu(hardware = {}) { return `${hardware.gpu_vendor || t("unknown")}${hardware.gpu_name ? ` · ${hardware.gpu_name}` : ""}`; }
+
+async function loadStatus() {
+ try {
+ state.status = await getJson("/api/status");
+ state.apiOk = true;
+ setStatus("ok", `${t("apiReady")} · ${state.status.backend}`, `${formatHardware(state.status.hardware)} · ${state.status.bind}`);
+ clearError();
+ } catch (error) {
+ state.apiOk = false;
+ state.status = {};
+ setStatus("error", t("runtimeUnavailable"), t("startWithCommand"));
+ showError(`${t("apiNotResponding")} ${error.message}`);
+ }
+ renderDashboard();
+}
+
+function selectRecommendedModel() {
+ const hardware = state.status.hardware || {};
+ return state.models.filter((model) => model.available)
+ .filter((model) => Number(hardware.ram_gb ?? 0) >= Number(model.min_ram_gb ?? 0))
+ .filter((model) => Number(hardware.vram_gb ?? 0) >= Number(model.min_vram_gb ?? 0))
+ .filter((model) => (model.features || []).includes("chat"))
+ .at(-1) || null;
+}
+
+function renderDashboard() {
+ const recommended = selectRecommendedModel();
+ $("runtimeMetric").textContent = state.apiOk ? t("apiReady") : t("apiOffline");
+ $("backendMetric").textContent = state.status.backend || "unavailable";
+ $("hardwareMetric").textContent = formatHardware(state.status.hardware || {});
+ $("gpuMetric").textContent = formatGpu(state.status.hardware || {});
+ $("recommendedMetric").textContent = recommended?.label || recommended?.id || t("auto");
+ updateCheck("checkRuntime", state.apiOk);
+ updateCheck("checkModels", state.models.some((model) => model.available));
+ updateCheck("checkSecurity", state.features.shell_tools === false);
+ const bind = state.status.bind || "127.0.0.1:4891";
+ document.querySelector(".security-pill").textContent = bind;
+ document.querySelector(".mini-copy").textContent = bind.startsWith("127.") ? t("loopbackOnly") : t("navPhone");
+}
+
+async function loadModels() {
+ try {
+ const payload = await getJson("/api/models");
+ state.models = payload.models || [];
+ renderModels();
+ } catch (error) {
+ state.models = [];
+ renderModels();
+ showError(`${t("modelCatalogError")} ${error.message}`);
+ }
+}
+
+function renderModels() {
+ const recommended = selectRecommendedModel();
+ $("modelSelect").innerHTML = `${escapeHtml(t("auto"))} ` + state.models.map((model) => (
+ `${escapeHtml(model.label || model.id)}${model.available ? "" : ` · ${escapeHtml(t("unavailable"))}`} `
+ )).join("");
+ const agentModels = state.models
+ .filter((model) => model.available)
+ .filter((model) => Number(model.params_b ?? 999) <= 32)
+ .filter((model) => (model.features || []).includes("chat"));
+ const preferredAgent = agentModels.find((model) => model.id === "gemma_config_agent_12b" || model.ollama === "gemma4:12b");
+ $("agentModelSelect").innerHTML = `${escapeHtml(t("auto"))} ` + agentModels.map((model) => {
+ const preferred = preferredAgent?.id === model.id ? " - Gemma 4 12B" : "";
+ return `${escapeHtml(model.label || model.id)}${escapeHtml(preferred)} `;
+ }).join("");
+ if (preferredAgent) $("agentModelSelect").value = preferredAgent.id;
+ $("modelChips").innerHTML = state.models.filter((model) => model.available).map((model) => (
+ `${escapeHtml(model.label || model.id)} `
+ )).join("") || `${escapeHtml(t("noModelsTitle"))} `;
+ $("modelCards").innerHTML = state.models.length ? state.models.map((model) => {
+ const availableClass = model.available ? "availability-on" : "availability-off";
+ const availableText = model.available ? t("installed") : t("unavailable");
+ const features = (model.features || []).map((feature) => `${escapeHtml(feature)} `).join("");
+ return `
+ ${recommended?.id === model.id ? `${escapeHtml(t("recommended"))} ` : ""}
+ ${escapeHtml(model.label || model.id)}
+ ${escapeHtml(model.id)}
+ ${escapeHtml(model.notes || model.description || "")}
+ ${escapeHtml(availableText)} RAM ${escapeHtml(model.min_ram_gb ?? 0)}+ GB VRAM ${escapeHtml(model.min_vram_gb ?? 0)}+ GB
+ ${features}
+ Ollama: ${escapeHtml(model.ollama || "n/a")}
+ `;
+ }).join("") : `${escapeHtml(t("noModelsTitle"))} ${escapeHtml(t("noModelsBody"))}
`;
+ renderDashboard();
+}
+
+async function loadFeatures() {
+ try { state.features = await getJson("/api/features"); } catch { state.features = {}; }
+ renderFeatures();
+}
+
+async function loadLicense() {
+ try { state.license = await getJson("/api/license"); } catch { state.license = null; }
+ renderLicense();
+}
+function renderLicense() {
+ const target = $("licenseInfo");
+ if (!target) return;
+ const payload = state.license;
+ const warning = $("licenseWarning");
+ if (!payload) {
+ target.innerHTML = `${escapeHtml(t("apiUnavailable"))}
`;
+ warning?.classList.add("hidden");
+ return;
+ }
+ const stateKeys = {
+ licensed: "licenseStateLicensed",
+ unlicensed: "licenseStateUnlicensed",
+ expired: "licenseStateExpired",
+ invalid: "licenseStateInvalid",
+ no_public_key: "licenseStateNoKey",
+ };
+ const stateLabel = t(stateKeys[payload.state] || "licenseStateUnlicensed");
+ if (warning) {
+ const showWarning = payload.state !== "licensed" && payload.state !== "no_public_key";
+ warning.textContent = showWarning ? stateLabel : "";
+ warning.classList.toggle("hidden", !showWarning);
+ }
+ const rows = [
+ `${escapeHtml(stateLabel)} ${escapeHtml(payload.licensed ? t("on") : t("off"))}
`,
+ ];
+ const info = payload.license;
+ if (info) {
+ rows.push(`${escapeHtml(t("licenseEdition"))} ${escapeHtml(info.edition || "")}
`);
+ rows.push(`${escapeHtml(t("licenseCustomer"))} ${escapeHtml(info.customer_name || "")}
`);
+ const expires = info.expires_at ? new Date(info.expires_at).toLocaleDateString() : t("licensePerpetual");
+ rows.push(`${escapeHtml(t("licenseExpires"))} ${escapeHtml(expires)}
`);
+ }
+ target.innerHTML = rows.join("");
+}
+
+async function runAgentReview() {
+ const recommended = selectRecommendedModel();
+ $("agentOut").textContent = t("agentRunning");
+ const payload = await postJson("/api/agent/recommend", {
+ language: state.language,
+ package_goal: "Runtime WebUI client configuration review",
+ hardware: state.status.hardware || {},
+ default_model: recommended || null,
+ candidate_models: state.models,
+ content_packs: null,
+ current_config: {
+ runtime_status: state.status,
+ features: state.features,
+ selected_chat_model: $("modelSelect").value,
+ phone_access: state.status.phone_access || null,
+ },
+ customer_notes: $("agentNotes").value.trim(),
+ agent_model: $("agentModelSelect").value,
+ max_params_b: 32,
+ agent_max_tokens: 192,
+ agent_timeout_seconds: 180,
+ });
+ const parsed = payload.agent_json && typeof payload.agent_json === "object" ? payload.agent_json : null;
+ $("agentOut").textContent = JSON.stringify({
+ agent_model: payload.agent_model,
+ selected_model: parsed?.selected_model_ref || parsed?.selected_model_id || null,
+ confidence: parsed?.confidence || null,
+ summary: parsed?.human_summary || payload.raw_content,
+ risk_flags: parsed?.risk_flags || [],
+ next_steps: parsed?.next_steps || [],
+ raw: parsed ? undefined : payload.raw_content,
+ }, null, 2);
+}
+function renderFeatures() {
+ const entries = Object.entries(state.features || {});
+ $("featureList").innerHTML = entries.length ? entries.map(([key, value]) => (
+ `${escapeHtml(key)} ${escapeHtml(value ? t("on") : t("off"))}
`
+ )).join("") : `${escapeHtml(t("apiUnavailable"))}
`;
+}
+
+async function loadThreads(filter = "") {
+ try {
+ const payload = await getJson(`/api/threads${filter ? `?q=${encodeURIComponent(filter)}` : ""}`);
+ state.threads = payload.threads || [];
+ renderThreads();
+ if (!state.currentThread && state.threads.length) await selectThread(state.threads[0].id);
+ } catch (error) { $("threadList").innerHTML = `${escapeHtml(error.message)}
`; }
+}
+function renderThreads() {
+ $("threadList").innerHTML = state.threads.length ? state.threads.map((thread) => (
+ `
+ ${escapeHtml(thread.title)} ${escapeHtml(thread.preview || `${thread.message_count} messages`)}
+ `
+ )).join("") : `${escapeHtml(t("newConversation"))}
`;
+ document.querySelectorAll("[data-thread-id]").forEach((button) => button.addEventListener("click", () => selectThread(button.dataset.threadId)));
+}
+async function createThread() {
+ const payload = await postJson("/api/threads", { title: t("newConversation"), model: $("modelSelect").value });
+ state.currentThread = payload.thread;
+ await loadThreads($("threadFilter").value.trim());
+ renderChat();
+ return state.currentThread;
+}
+async function selectThread(id) {
+ const payload = await getJson(`/api/threads/${encodeURIComponent(id)}`);
+ state.currentThread = payload.thread;
+ if (state.currentThread.model && [...$("modelSelect").options].some((option) => option.value === state.currentThread.model && !option.disabled)) {
+ $("modelSelect").value = state.currentThread.model;
+ }
+ renderThreads();
+ renderChat();
+}
+function renderChat() {
+ $("chatLog").innerHTML = "";
+ $("activeThreadTitle").textContent = state.currentThread?.title || t("newConversation");
+ const messages = state.currentThread?.messages || [];
+ if (!messages.length) addMessage("system", t("readyMessage"));
+ messages.forEach((message) => addMessage(message.role, contentText(message.content)));
+}
+function addMessage(role, content) {
+ const wrapper = document.createElement("div");
+ wrapper.className = `msg ${role}`;
+ const bubble = document.createElement("div");
+ bubble.className = "bubble";
+ bubble.textContent = content;
+ wrapper.appendChild(bubble);
+ $("chatLog").appendChild(wrapper);
+ $("chatLog").scrollTop = $("chatLog").scrollHeight;
+ return bubble;
+}
+async function sendTextToThread(text) {
+ if (!text.trim()) return;
+ if (!state.currentThread) await createThread();
+ addMessage("user", text.trim());
+ const pending = addMessage("assistant", t("responsePending"));
+ $("sendBtn").disabled = true;
+ try {
+ const payload = await postJson(`/api/threads/${encodeURIComponent(state.currentThread.id)}/messages`, {
+ content: text.trim(), model: $("modelSelect").value,
+ });
+ state.currentThread = payload.thread;
+ renderChat();
+ await loadThreads($("threadFilter").value.trim());
+ } catch (error) {
+ pending.textContent = `${t("localError")} ${error.message}\n\n${t("backendHint")}`;
+ } finally { $("sendBtn").disabled = false; }
+}
+async function sendPrompt() {
+ const prompt = $("prompt").value;
+ if (!prompt.trim()) return;
+ $("prompt").value = "";
+ await sendTextToThread(prompt);
+}
+
+async function loadDocs() {
+ try {
+ const payload = await getJson("/api/documents");
+ const documents = payload.documents || [];
+ $("docList").innerHTML = documents.length ? documents.map((doc) => (
+ `${escapeHtml(doc.name)} ${escapeHtml(doc.content.length)} ${escapeHtml(t("bytes"))}
`
+ )).join("") : `${escapeHtml(t("noLocalDocuments"))}
`;
+ } catch { $("docList").innerHTML = `${escapeHtml(t("apiUnavailable"))}
`; }
+}
+
+async function loadVoiceStatus() {
+ try {
+ state.voice = await getJson("/api/voice/status");
+ $("sttStatus").textContent = `${state.voice.stt.available ? t("available") : t("notAvailable")} · whisper.cpp`;
+ $("ttsStatus").textContent = `${state.voice.tts.available ? t("available") : t("notAvailable")} · Piper`;
+ $("startRecording").disabled = !state.voice.stt.available;
+ $("speakText").disabled = !state.voice.tts.available;
+ } catch (error) { $("voiceOut").textContent = error.message; }
+}
+
+async function startRecording() {
+ try {
+ state.audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ state.audioContext = new AudioContext();
+ state.audioSampleRate = state.audioContext.sampleRate;
+ state.audioSource = state.audioContext.createMediaStreamSource(state.audioStream);
+ state.audioProcessor = state.audioContext.createScriptProcessor(4096, 1, 1);
+ state.audioChunks = [];
+ state.audioProcessor.onaudioprocess = (event) => state.audioChunks.push(new Float32Array(event.inputBuffer.getChannelData(0)));
+ state.audioSource.connect(state.audioProcessor);
+ state.audioProcessor.connect(state.audioContext.destination);
+ $("startRecording").disabled = true;
+ $("stopRecording").disabled = false;
+ $("voiceOut").textContent = t("recording");
+ } catch (error) { $("voiceOut").textContent = error.message; }
+}
+async function stopRecording() {
+ $("stopRecording").disabled = true;
+ state.audioProcessor?.disconnect();
+ state.audioSource?.disconnect();
+ state.audioStream?.getTracks().forEach((track) => track.stop());
+ await state.audioContext?.close();
+ $("voiceOut").textContent = t("processing");
+ try {
+ const wav = encodeWav(state.audioChunks, state.audioSampleRate);
+ const payload = await postJson("/api/voice/transcribe", {
+ audio_base64: arrayBufferToBase64(wav), format: "wav", language: state.language,
+ });
+ $("voiceTranscript").value = payload.text || "";
+ $("voiceOut").textContent = JSON.stringify(payload, null, 2);
+ } catch (error) { $("voiceOut").textContent = error.message; }
+ await loadVoiceStatus();
+}
+
+async function loadFieldManual() {
+ try {
+ const payload = await getJson("/api/field-manual");
+ $("fieldManualWarning").textContent = state.language === "fr" ? payload.warning_fr : payload.warning_en;
+ $("fieldManualWarning").classList.remove("hidden");
+ $("fieldManualCards").innerHTML = (payload.cards || []).map((card) => {
+ const title = state.language === "fr" ? card.title_fr : card.title_en;
+ const summary = state.language === "fr" ? card.summary_fr : card.summary_en;
+ const steps = state.language === "fr" ? card.steps_fr : card.steps_en;
+ const warnings = state.language === "fr" ? card.warnings_fr : card.warnings_en;
+ return `${escapeHtml(title)} ${escapeHtml(summary)}
+ ${steps.map((step) => `${escapeHtml(step)} `).join("")}
+ ${warnings.map((warning) => `${escapeHtml(warning)} `).join("")}
+ ${escapeHtml(t("askAiButton"))} `;
+ }).join("");
+ document.querySelectorAll(".ask-manual").forEach((button) => button.addEventListener("click", async () => {
+ const card = payload.cards.find((item) => item.id === button.dataset.cardId);
+ const title = state.language === "fr" ? card.title_fr : card.title_en;
+ const summary = state.language === "fr" ? card.summary_fr : card.summary_en;
+ const steps = state.language === "fr" ? card.steps_fr : card.steps_en;
+ const warnings = state.language === "fr" ? card.warnings_fr : card.warnings_en;
+ const prompt = `${title}\n\n${summary}\n\n${steps.join("\n")}\n\nWarnings:\n${warnings.join("\n")}\n\nAnswer my question using this local reference card, remain cautious, and state when professional help is required.`;
+ switchView("chat");
+ await sendTextToThread(prompt);
+ }));
+ } catch (error) { $("fieldManualCards").innerHTML = `${escapeHtml(error.message)}
`; }
+}
+
+async function loadPhone() {
+ try {
+ const status = await getJson("/api/phone");
+ const display = { ...status, qr_svg: status.qr_svg ? "[QR generated]" : null };
+ $("phoneState").textContent = JSON.stringify(display, null, 2);
+ $("phoneQr").innerHTML = status.qr_svg || "";
+ $("enablePhone").disabled = !status.available || status.enabled;
+ $("disablePhone").disabled = !status.enabled;
+ } catch (error) { $("phoneState").textContent = error.message; }
+}
+
+async function createSupportBundle(outputId) {
+ $(outputId).textContent = t("preparing");
+ try {
+ const payload = await postJson("/api/support/bundle", {});
+ downloadBase64(payload.filename, payload.data_base64, payload.mime_type);
+ $(outputId).textContent = JSON.stringify({ ...payload, data_base64: "[downloaded]" }, null, 2);
+ } catch (error) { $(outputId).textContent = error.message; }
+}
+
+async function refreshAll() {
+ await loadStatus();
+ await Promise.all([loadModels(), loadFeatures(), loadLicense(), loadDocs(), loadThreads(), loadVoiceStatus(), loadFieldManual(), loadPhone()]);
+}
+
+function bindEvents() {
+ document.querySelectorAll(".nav").forEach((button) => button.addEventListener("click", () => switchView(button.dataset.view)));
+ document.querySelectorAll("[data-action-view]").forEach((button) => button.addEventListener("click", () => switchView(button.dataset.actionView)));
+ document.querySelectorAll(".lang-btn").forEach((button) => button.addEventListener("click", () => { applyLanguage(button.dataset.lang); loadFieldManual(); }));
+ $("refreshAll").addEventListener("click", refreshAll);
+ $("openSupport").addEventListener("click", () => switchView("support"));
+ $("sendBtn").addEventListener("click", sendPrompt);
+ $("prompt").addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); sendPrompt(); } });
+ $("clearChat").addEventListener("click", async () => {
+ try {
+ await createThread();
+ addMessage("system", t("chatCleared"));
+ } catch (error) { showError(error.message); }
+ });
+ $("newThread").addEventListener("click", async () => {
+ try { await createThread(); } catch (error) { showError(error.message); }
+ });
+ $("threadFilter").addEventListener("input", () => loadThreads($("threadFilter").value.trim()));
+ $("renameThread").addEventListener("click", async () => {
+ if (!state.currentThread) return;
+ const title = window.prompt(t("renamePrompt"), state.currentThread.title);
+ if (!title) return;
+ try {
+ const payload = await patchJson(`/api/threads/${encodeURIComponent(state.currentThread.id)}`, { title });
+ state.currentThread = payload.thread;
+ await loadThreads($("threadFilter").value.trim());
+ renderChat();
+ } catch (error) { showError(error.message); }
+ });
+ $("deleteThread").addEventListener("click", async () => {
+ if (!state.currentThread || !window.confirm(t("confirmDelete"))) return;
+ try {
+ await deleteJson(`/api/threads/${encodeURIComponent(state.currentThread.id)}`);
+ state.currentThread = null;
+ await loadThreads($("threadFilter").value.trim());
+ if (!state.currentThread) renderChat();
+ } catch (error) { showError(error.message); }
+ });
+ $("refreshModels").addEventListener("click", loadModels);
+ $("runAgentReview").addEventListener("click", async () => {
+ const button = $("runAgentReview");
+ button.disabled = true;
+ try { await runAgentReview(); }
+ catch (error) { $("agentOut").textContent = error.message; }
+ finally { button.disabled = false; }
+ });
+ $("reloadDocs").addEventListener("click", loadDocs);
+ $("supportBundleQuick").addEventListener("click", () => createSupportBundle("dashboardOut"));
+ $("supportBundle").addEventListener("click", () => createSupportBundle("supportOut"));
+ $("addModelBtn").addEventListener("click", async () => {
+ $("addModelOut").textContent = t("adding");
+ try {
+ $("addModelOut").textContent = JSON.stringify(await postJson("/api/models/add", { id: $("addModelId").value.trim(), label: $("addModelLabel").value.trim() }), null, 2);
+ await loadModels();
+ } catch (error) { $("addModelOut").textContent = error.message; }
+ });
+ $("saveDoc").addEventListener("click", async () => {
+ $("docOut").textContent = t("saving");
+ try {
+ $("docOut").textContent = JSON.stringify(await postJson("/api/documents", { name: $("docName").value.trim(), content: $("docContent").value }), null, 2);
+ await loadDocs();
+ } catch (error) { $("docOut").textContent = error.message; }
+ });
+ $("runBenchmark").addEventListener("click", async () => {
+ const button = $("runBenchmark");
+ button.disabled = true;
+ $("benchmarkOut").textContent = t("benchmarkRunning");
+ try { $("benchmarkOut").textContent = JSON.stringify(await postJson("/api/benchmark/run", { model: $("modelSelect").value }), null, 2); }
+ catch (error) { $("benchmarkOut").textContent = `${t("benchmarkFailed")} ${error.message}`; }
+ finally { button.disabled = false; }
+ });
+ $("saveSettings").addEventListener("click", async () => {
+ $("settingsOut").textContent = t("savingSettings");
+ try {
+ $("settingsOut").textContent = JSON.stringify(await postJson("/api/settings", {
+ mode: document.querySelector("input[name=mode]:checked").value,
+ offline_lock: $("offlineLock").checked, tools_lock: $("toolsLock").checked,
+ }), null, 2);
+ } catch (error) { $("settingsOut").textContent = error.message; }
+ });
+ $("scoutImage").addEventListener("change", async () => {
+ const file = $("scoutImage").files[0];
+ if (!file) return;
+ state.scoutImageBase64 = await fileToDataUrl(file);
+ $("scoutPreview").src = state.scoutImageBase64;
+ $("scoutPreview").classList.remove("hidden");
+ });
+ $("runScout").addEventListener("click", async () => {
+ if (!state.scoutImageBase64) { $("scoutOut").textContent = t("imageRequired"); return; }
+ const button = $("runScout");
+ button.disabled = true;
+ $("scoutOut").textContent = t("processing");
+ try {
+ const payload = await postJson("/api/vision/analyze", { image_base64: state.scoutImageBase64, prompt: $("scoutPrompt").value, model: null });
+ $("scoutOut").textContent = payload.content || JSON.stringify(payload, null, 2);
+ } catch (error) { $("scoutOut").textContent = error.message; }
+ finally { button.disabled = false; }
+ });
+ $("refreshVoice").addEventListener("click", loadVoiceStatus);
+ $("startRecording").addEventListener("click", startRecording);
+ $("stopRecording").addEventListener("click", stopRecording);
+ $("sendTranscript").addEventListener("click", () => { $("prompt").value = $("voiceTranscript").value; switchView("chat"); });
+ $("speakText").addEventListener("click", async () => {
+ $("voiceOut").textContent = t("processing");
+ try {
+ const payload = await postJson("/api/voice/synthesize", { text: $("ttsText").value });
+ $("ttsAudio").src = `data:${payload.mime_type};base64,${payload.audio_base64}`;
+ $("ttsAudio").classList.remove("hidden");
+ await $("ttsAudio").play();
+ $("voiceOut").textContent = JSON.stringify({ ...payload, audio_base64: "[audio loaded]" }, null, 2);
+ } catch (error) { $("voiceOut").textContent = error.message; }
+ });
+ $("exportPack").addEventListener("click", async () => {
+ const button = $("exportPack");
+ button.disabled = true;
+ $("transferOut").textContent = t("preparing");
+ try {
+ const payload = await postJson("/api/packs/export", {
+ passphrase: $("exportPassphrase").value, include_threads: $("exportThreads").checked,
+ include_documents: $("exportDocuments").checked, include_settings: $("exportSettings").checked,
+ });
+ downloadBase64(payload.filename, payload.data_base64, payload.mime_type);
+ $("transferOut").textContent = JSON.stringify({ ...payload, data_base64: "[downloaded]" }, null, 2);
+ } catch (error) { $("transferOut").textContent = error.message; }
+ finally { button.disabled = false; }
+ });
+ $("importPack").addEventListener("click", async () => {
+ const file = $("importPackFile").files[0];
+ if (!file) { $("transferOut").textContent = t("fileRequired"); return; }
+ const button = $("importPack");
+ button.disabled = true;
+ $("transferOut").textContent = t("processing");
+ try {
+ const data = arrayBufferToBase64(await file.arrayBuffer());
+ const payload = await postJson("/api/packs/import", { passphrase: $("importPassphrase").value, data_base64: data, mode: $("importMode").value });
+ $("transferOut").textContent = JSON.stringify(payload, null, 2);
+ state.currentThread = null;
+ await Promise.all([loadThreads(), loadDocs()]);
+ } catch (error) { $("transferOut").textContent = error.message; }
+ finally { button.disabled = false; }
+ });
+ $("refreshPhone").addEventListener("click", loadPhone);
+ $("enablePhone").addEventListener("click", async () => {
+ try {
+ await postJson("/api/phone", { enabled: true });
+ await loadPhone();
+ } catch (error) {
+ $("phoneState").textContent = error.message;
+ showError(error.message);
+ }
+ });
+ $("disablePhone").addEventListener("click", async () => {
+ try {
+ await postJson("/api/phone", { enabled: false });
+ await loadPhone();
+ } catch (error) {
+ $("phoneState").textContent = error.message;
+ showError(error.message);
+ }
+ });
+ $("restartRuntime").addEventListener("click", async () => {
+ $("phoneState").textContent = t("processing");
+ try {
+ await postJson("/api/runtime/restart", {});
+ window.setTimeout(() => window.location.reload(), 2200);
+ } catch (error) { $("phoneState").textContent = error.message; }
+ });
+ $("installLicense").addEventListener("click", async () => {
+ const button = $("installLicense");
+ const message = $("licenseMessage");
+ const file = $("licenseFile").files[0];
+ if (!file) { message.textContent = t("fileRequired"); return; }
+ button.disabled = true;
+ message.textContent = t("licenseInstalling");
+ try {
+ const content = await file.text();
+ await postJson("/api/license", { content });
+ message.textContent = "";
+ $("licenseFile").value = "";
+ await loadLicense();
+ } catch (error) { message.textContent = error.message; }
+ finally { button.disabled = false; }
+ });
+ $("eulaAccept").addEventListener("click", () => {
+ localStorage.setItem("jackailocal.eulaAccepted", new Date().toISOString());
+ $("eulaOverlay").classList.add("hidden");
+ });
+}
+
+async function showEulaIfNeeded() {
+ if (localStorage.getItem("jackailocal.eulaAccepted")) return;
+ const overlay = $("eulaOverlay");
+ if (!overlay) return;
+ overlay.classList.remove("hidden");
+ $("eulaText").textContent = t("eulaLoading");
+ try {
+ const payload = await getJson(`/api/legal/eula?lang=${encodeURIComponent(state.language)}`);
+ $("eulaText").textContent = payload.markdown || t("eulaFallback");
+ } catch {
+ $("eulaText").textContent = t("eulaFallback");
+ }
+}
+
+function fileToDataUrl(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); }); }
+function arrayBufferToBase64(buffer) {
+ const bytes = new Uint8Array(buffer); let binary = "";
+ for (let index = 0; index < bytes.length; index += 0x8000) binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
+ return btoa(binary);
+}
+function downloadBase64(filename, base64, mimeType) {
+ const link = document.createElement("a");
+ link.href = `data:${mimeType || "application/octet-stream"};base64,${base64}`;
+ link.download = filename;
+ document.body.appendChild(link); link.click(); link.remove();
+}
+function encodeWav(chunks, sampleRate) {
+ const length = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
+ const samples = new Float32Array(length); let offset = 0;
+ chunks.forEach((chunk) => { samples.set(chunk, offset); offset += chunk.length; });
+ const buffer = new ArrayBuffer(44 + samples.length * 2);
+ const view = new DataView(buffer);
+ const write = (at, text) => [...text].forEach((char, index) => view.setUint8(at + index, char.charCodeAt(0)));
+ write(0, "RIFF"); view.setUint32(4, 36 + samples.length * 2, true); write(8, "WAVE");
+ write(12, "fmt "); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true);
+ view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true);
+ write(36, "data"); view.setUint32(40, samples.length * 2, true);
+ samples.forEach((sample, index) => view.setInt16(44 + index * 2, Math.max(-1, Math.min(1, sample)) * 0x7fff, true));
+ return buffer;
+}
+
+(async function init() {
+ bindEvents();
+ applyLanguage(state.language);
+ setStatus("loading", t("statusStarting"), t("statusLocalScan"));
+ showEulaIfNeeded();
+ await refreshAll();
+ renderChat();
+ setInterval(loadStatus, 15000);
+})();
diff --git a/webui/index.html b/webui/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..33544193fd3c90e7b8b382bb29698ca7fd442cc7
--- /dev/null
+++ b/webui/index.html
@@ -0,0 +1,408 @@
+
+
+
+
+
+
+ JackAILocal
+
+
+
+
+
+
+
+
+
+
Portable private AI
+
Dashboard
+
+
+ Refresh
+ Support
+
+
+
+
+
+
+
+
+
Ready for offline work
+
Local AI, loaded from your USB drive.
+
JackAILocal runs the launcher, runtime, models, documents, and UI locally. No cloud is required after preparation.
+
+
+ Start chat
+ Check models
+
+
+
+
+
+ Runtime
+ ...
+ ...
+
+
+ Hardware
+ ...
+ ...
+
+
+ Recommended model
+ Auto
+ Selected by local policy
+
+
+ Privacy
+ Offline locked
+ Loopback only
+
+
+
+
+
+
Preparation state
+
+
Local API jackailocald must be running.
+
Model catalog The USB must contain configured model weights.
+
Security mode Shell tools and LAN exposure remain disabled.
+
+
+
+
Quick actions
+
+ Run benchmark
+ Open documents
+ Create support bundle
+
+
+
+
+
+
+
+
+
+
+
+
New conversation
+
+ Rename
+ Delete
+
+
+
+
+
+
+ Model Auto
+ Clear
+ Send
+
+
+
+
+ Assistant mode
+ Use Auto unless you need a specific installed model. Threads are stored on this JackAILocal drive.
+
+ Offline note Answers come from local models. Do not expect cloud frontier quality.
+
+
+
+
+
+
+
+
+
+
+
+
Analyze locally
+
+
+
+
+
+
+
+
+
+
Speech to text
+
+
+ Record
+ Stop
+
+
+
Send to chat
+
+
+
Text to speech
+
+
+
Speak locally
+
+
+
+
+
+
+
+
+
+
+
+
+
LLM Config Agent
+
A real installed local LLM can review hardware, model selection, and client configuration. Preferred model: gemma4:12b.
+
+ Agent model Auto
+
+ Run local agent review
+
+
+
+
+
Add a model to the catalog
+
This registers a model. It does not download weights and it does not bypass builder validation.
+
+
+
+ Add
+
+
+
+
+
+
+
+
+
+
Create a note
+
+
+
Save locally
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Enable Phone Access
+ Disable Phone Access
+ Restart runtime
+
+
A runtime restart is required after changing this mode.
+
+
+
+
+
+
+
+
+
+
+
+
+
Active features
+
Runtime capabilities are read from the local API when it is running.
+
+
+
+
License
+
License status of this installation. Verified locally, no internet required.
+
+
+
+
Install license file
+
+
+
Legal documents are in the legal/ and licenses/ folders of this installation.
+
+
+
+
+
+
+
+
+
+
+
End User License Agreement
+
+
+ I accept
+
+
+
+
+
+
+
diff --git a/webui/styles.css b/webui/styles.css
new file mode 100644
index 0000000000000000000000000000000000000000..78885a58dfd5c4c018b2a41274f263163dc8f398
--- /dev/null
+++ b/webui/styles.css
@@ -0,0 +1,535 @@
+:root {
+ --bg: #070a0f;
+ --bg-soft: #0b111a;
+ --panel: rgba(17, 24, 39, 0.88);
+ --panel-solid: #111827;
+ --panel-strong: #182235;
+ --line: rgba(148, 163, 184, 0.20);
+ --line-strong: rgba(148, 163, 184, 0.34);
+ --text: #edf2f7;
+ --muted: #9aa8bc;
+ --muted-2: #718096;
+ --accent: #bfff4f;
+ --accent-2: #6ee7f9;
+ --danger: #fb7185;
+ --ok: #34d399;
+ --warn: #fbbf24;
+ --button: #f8fafc;
+ --button-text: #0f172a;
+ --shadow: 0 24px 80px rgba(0, 0, 0, 0.34);
+ --radius-lg: 28px;
+ --radius-md: 18px;
+ --radius-sm: 12px;
+}
+
+* { box-sizing: border-box; }
+html, body { min-height: 100%; }
+body {
+ margin: 0;
+ color: var(--text);
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
+ background:
+ radial-gradient(circle at 12% 0%, rgba(191, 255, 79, 0.14), transparent 27%),
+ radial-gradient(circle at 88% 9%, rgba(110, 231, 249, 0.10), transparent 30%),
+ linear-gradient(145deg, #05070b 0%, #0b111a 55%, #06080d 100%);
+}
+
+button, input, textarea, select { font: inherit; }
+*, *::before, *::after { box-sizing: border-box; }
+button { user-select: none; }
+
+.app-shell {
+ display: grid;
+ grid-template-columns: 304px minmax(0, 1fr);
+ min-height: 100vh;
+}
+
+.sidebar {
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 20px;
+ border-right: 1px solid var(--line);
+ background: rgba(7, 10, 15, 0.72);
+ backdrop-filter: blur(18px);
+}
+
+.brand-card {
+ display: flex;
+ align-items: center;
+ gap: 13px;
+ padding: 12px;
+ border: 1px solid var(--line);
+ border-radius: 22px;
+ background: linear-gradient(180deg, rgba(255,255,255,0.055), rgba(255,255,255,0.025));
+}
+
+.brand-mark {
+ width: 48px;
+ height: 48px;
+ border-radius: 17px;
+ display: grid;
+ place-items: center;
+ font-size: 24px;
+ font-weight: 950;
+ color: #0a0f16;
+ background: linear-gradient(135deg, var(--accent), #ffffff 62%, var(--accent-2));
+ box-shadow: 0 12px 30px rgba(191,255,79,0.16);
+}
+
+.brand-name { font-weight: 900; letter-spacing: 0.2px; }
+.brand-subtitle, .muted { color: var(--muted); }
+.small, small { font-size: 12px; }
+
+.language-switch {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+}
+
+.lang-btn, .nav, button, .security-pill {
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+}
+
+.lang-btn {
+ padding: 9px 10px;
+ color: var(--muted);
+ background: rgba(15, 23, 42, 0.76);
+ cursor: pointer;
+ font-weight: 800;
+}
+
+.lang-btn.active {
+ color: var(--button-text);
+ background: var(--button);
+}
+
+.status-card {
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: var(--panel);
+}
+
+.status-header { display: flex; align-items: center; gap: 9px; }
+.status-dot {
+ width: 11px;
+ height: 11px;
+ border-radius: 99px;
+ background: var(--warn);
+ box-shadow: 0 0 18px rgba(251, 191, 36, 0.7);
+}
+.state-ok .status-dot { background: var(--ok); box-shadow: 0 0 18px rgba(52, 211, 153, 0.7); }
+.state-error .status-dot { background: var(--danger); box-shadow: 0 0 18px rgba(251, 113, 133, 0.7); }
+.status-title { font-weight: 850; }
+.status-subtitle { margin-top: 6px; color: var(--muted); font-size: 12px; line-height: 1.35; }
+
+.nav-list { display: grid; gap: 7px; }
+.nav {
+ appearance: none;
+ display: grid;
+ grid-template-columns: 24px 1fr;
+ align-items: center;
+ gap: 9px;
+ width: 100%;
+ padding: 12px 13px;
+ text-align: left;
+ background: transparent;
+ color: #dbe7f3;
+ cursor: pointer;
+ font-weight: 780;
+}
+.nav span:first-child { color: var(--muted); text-align: center; }
+.nav:hover { background: rgba(148, 163, 184, 0.08); }
+.nav.active {
+ background: linear-gradient(90deg, rgba(191,255,79,0.15), rgba(110,231,249,0.04));
+ border-color: rgba(191,255,79,0.34);
+ box-shadow: inset 3px 0 0 var(--accent);
+}
+
+.sidebar-footer { margin-top: auto; }
+.mini-label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.14em; }
+.security-pill {
+ display: inline-flex;
+ margin-top: 7px;
+ padding: 6px 10px;
+ background: rgba(52, 211, 153, 0.10);
+ border-color: rgba(52, 211, 153, 0.28);
+ color: #bbf7d0;
+ font-weight: 800;
+}
+.mini-copy { margin-top: 6px; color: var(--muted); font-size: 12px; }
+
+.main { min-width: 0; padding: 24px; overflow: auto; }
+.app-topbar {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 18px;
+ margin-bottom: 18px;
+}
+.eyebrow, .hero-kicker {
+ color: var(--accent);
+ font-size: 12px;
+ font-weight: 900;
+ text-transform: uppercase;
+ letter-spacing: 0.13em;
+}
+#viewTitle { margin: 4px 0 0; font-size: clamp(30px, 4vw, 46px); letter-spacing: -0.04em; }
+.top-actions { display: flex; gap: 10px; }
+
+.view { display: none; animation: enter 0.16s ease-out; }
+.view.active { display: block; }
+@keyframes enter { from { opacity: 0.75; transform: translateY(6px); } to { opacity: 1; transform: none; } }
+
+.banner {
+ margin-bottom: 16px;
+ padding: 13px 15px;
+ border-radius: 16px;
+ border: 1px solid rgba(251,113,133,0.42);
+ background: rgba(127, 29, 29, 0.22);
+ color: #fecdd3;
+ line-height: 1.45;
+}
+.hidden { display: none !important; }
+
+.hero-panel, .panel, .metric-card {
+ border: 1px solid var(--line);
+ background: var(--panel);
+ box-shadow: var(--shadow);
+}
+.hero-panel {
+ display: flex;
+ justify-content: space-between;
+ align-items: stretch;
+ gap: 24px;
+ padding: clamp(22px, 4vw, 34px);
+ border-radius: var(--radius-lg);
+ background:
+ linear-gradient(135deg, rgba(191,255,79,0.13), transparent 42%),
+ linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.025));
+}
+.hero-panel h2 { margin: 8px 0 8px; font-size: clamp(30px, 4vw, 54px); line-height: 0.96; letter-spacing: -0.055em; max-width: 850px; }
+.hero-panel p { max-width: 760px; margin: 0; color: var(--muted); line-height: 1.55; }
+.hero-actions { display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap; }
+
+.metric-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 14px;
+ margin: 16px 0;
+}
+.metric-card { padding: 16px; border-radius: var(--radius-md); }
+.metric-label { display: block; color: var(--muted); font-size: 12px; margin-bottom: 10px; }
+.metric-card strong { display: block; font-size: 21px; line-height: 1.1; }
+.metric-card small { display: block; margin-top: 7px; color: var(--muted); }
+
+.split-hero, .split, .chat-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(320px, 0.48fr);
+ gap: 14px;
+}
+.panel { padding: 18px; border-radius: var(--radius-md); }
+.panel h2, .section-header h2 { margin: 0 0 7px; font-size: 22px; letter-spacing: -0.02em; }
+.panel p, .section-header p { margin: 0; color: var(--muted); line-height: 1.5; }
+
+.checklist { display: grid; gap: 10px; }
+.check-row {
+ display: grid;
+ grid-template-columns: 13px 1fr;
+ gap: 12px;
+ align-items: start;
+ padding: 12px;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: rgba(15, 23, 42, 0.42);
+}
+.check-row > span { width: 11px; height: 11px; margin-top: 5px; border-radius: 50%; background: var(--warn); box-shadow: 0 0 14px rgba(251,191,36,0.6); }
+.check-row.ok > span { background: var(--ok); box-shadow: 0 0 14px rgba(52,211,153,0.6); }
+.check-row.error > span { background: var(--danger); box-shadow: 0 0 14px rgba(251,113,133,0.6); }
+.check-row strong { display: block; }
+.check-row small { display: block; color: var(--muted); margin-top: 3px; }
+
+.quick-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
+
+.section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 20px;
+ margin-bottom: 16px;
+}
+
+button {
+ border: 0;
+ padding: 11px 15px;
+ border-radius: 13px;
+ cursor: pointer;
+ font-weight: 900;
+ transition: transform 0.11s ease, filter 0.11s ease, border-color 0.11s ease;
+}
+button:hover { transform: translateY(-1px); filter: brightness(1.04); }
+button:active { transform: translateY(0); }
+button:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
+button:focus-visible, .nav:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.primary { color: #061016; background: linear-gradient(135deg, var(--accent), #ffffff); }
+.secondary { color: var(--text); background: rgba(148, 163, 184, 0.10); border: 1px solid var(--line); }
+
+input:not([type="checkbox"]):not([type="radio"]), textarea, select {
+ width: 100%;
+ color: var(--text);
+ background: rgba(3, 7, 18, 0.72);
+ border: 1px solid var(--line-strong);
+ border-radius: 13px;
+ padding: 11px 12px;
+ outline: none;
+}
+input[type="checkbox"], input[type="radio"] {
+ width: 18px;
+ height: 18px;
+ min-width: 18px;
+ margin: 2px 0 0;
+ flex: 0 0 auto;
+ accent-color: var(--accent);
+}
+input:focus, textarea:focus, select:focus { border-color: rgba(191,255,79,0.55); box-shadow: 0 0 0 3px rgba(191,255,79,0.09); }
+textarea { resize: vertical; }
+
+.chat-layout { grid-template-columns: minmax(0, 1fr) 320px; align-items: start; }
+.chat-panel, .side-panel {
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ background: var(--panel);
+ box-shadow: var(--shadow);
+}
+.chat-panel { overflow: hidden; }
+.chat-log {
+ height: calc(100vh - 285px);
+ min-height: 420px;
+ padding: 20px;
+ overflow: auto;
+ background: rgba(2, 6, 23, 0.35);
+}
+.msg { display: flex; margin: 12px 0; }
+.msg.user { justify-content: flex-end; }
+.bubble {
+ max-width: min(820px, 84%);
+ padding: 13px 15px;
+ border-radius: 17px;
+ line-height: 1.52;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.user .bubble { color: #ffffff; background: linear-gradient(135deg, #2558a8, #1d4ed8); border-bottom-right-radius: 6px; }
+.assistant .bubble { background: rgba(24, 34, 53, 0.96); border: 1px solid var(--line); border-bottom-left-radius: 6px; }
+.system .bubble { background: rgba(191,255,79,0.09); border: 1px solid rgba(191,255,79,0.24); color: #eaffb3; }
+
+.composer-card { padding: 14px; border-top: 1px solid var(--line); }
+.composer-card textarea { min-height: 96px; }
+.composer-actions { display: grid; grid-template-columns: minmax(150px, 1fr) auto auto; gap: 10px; align-items: end; margin-top: 10px; }
+.select-label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; }
+
+.side-panel { padding: 18px; position: sticky; top: 24px; }
+.side-panel h2 { margin: 0 0 8px; }
+.side-panel p { color: var(--muted); line-height: 1.45; }
+.chip-list { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0; }
+.chip, .pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ padding: 5px 9px;
+ color: #dbe7f3;
+ background: rgba(15, 23, 42, 0.68);
+ font-size: 12px;
+ font-weight: 760;
+}
+.chip.recommended { border-color: rgba(191,255,79,0.44); color: #eaff99; }
+.hint-box { display: grid; gap: 5px; margin-top: 16px; padding: 13px; border: 1px solid rgba(110,231,249,0.22); border-radius: 16px; background: rgba(110,231,249,0.075); }
+.hint-box span { color: var(--muted); line-height: 1.4; }
+
+.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(265px, 1fr)); gap: 14px; margin-bottom: 16px; }
+.model-card { position: relative; min-height: 220px; }
+.model-card.recommended { border-color: rgba(191,255,79,0.45); }
+.recommended-badge { position: absolute; right: 14px; top: 14px; color: #eaff99; border: 1px solid rgba(191,255,79,0.36); border-radius: 999px; padding: 4px 8px; font-size: 11px; font-weight: 900; background: rgba(191,255,79,0.08); }
+.model-title { padding-right: 112px; }
+.model-card .desc { min-height: 42px; color: var(--muted); }
+.model-meta { display: flex; flex-wrap: wrap; gap: 7px; margin: 12px 0; }
+.progress-track { height: 7px; border-radius: 999px; overflow: hidden; background: rgba(148,163,184,0.14); margin: 14px 0 8px; }
+.progress-bar { height: 100%; width: 60%; background: linear-gradient(90deg, var(--accent), var(--accent-2)); border-radius: inherit; }
+
+.form-grid { display: grid; grid-template-columns: 1fr 1fr auto; gap: 10px; }
+.doc-textarea { min-height: 220px; margin: 10px 0; }
+.doc-list { display: grid; gap: 9px; }
+.doc-item { padding: 13px; border: 1px solid var(--line); border-radius: 14px; background: rgba(15,23,42,0.44); }
+.settings-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.55fr);
+ gap: 14px;
+ align-items: start;
+}
+.settings-panel { min-height: 0; }
+.option-stack { display: grid; gap: 10px; margin: 16px 0; }
+.option-card {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: rgba(15, 23, 42, 0.42);
+ cursor: pointer;
+}
+.option-card:hover { border-color: rgba(191,255,79,0.30); background: rgba(15, 23, 42, 0.62); }
+.option-copy { display: grid; gap: 4px; min-width: 0; }
+.option-copy strong { display: block; color: var(--text); }
+.option-copy small { display: block; color: var(--muted); line-height: 1.35; }
+.settings-actions { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; }
+.feature-list { display: grid; gap: 8px; margin-top: 16px; }
+.feature-row { display: flex; justify-content: space-between; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--line); align-items: center; }
+.feature-row span:first-child { color: var(--muted); word-break: break-word; }
+
+.chat-workspace {
+ display: grid;
+ grid-template-columns: 260px minmax(0, 1fr) 300px;
+ gap: 14px;
+ align-items: start;
+}
+.thread-panel {
+ min-height: calc(100vh - 150px);
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ background: var(--panel);
+ box-shadow: var(--shadow);
+}
+.thread-header, .chat-thread-bar, .button-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+.thread-header h2 { margin: 0; }
+.thread-list { display: grid; gap: 8px; margin-top: 12px; max-height: calc(100vh - 260px); overflow: auto; }
+.thread-item {
+ width: 100%;
+ display: grid;
+ gap: 4px;
+ padding: 11px;
+ text-align: left;
+ color: var(--text);
+ background: rgba(15, 23, 42, 0.42);
+ border: 1px solid var(--line);
+}
+.thread-item.active { border-color: rgba(191,255,79,0.45); background: rgba(191,255,79,0.08); }
+.thread-item small { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.chat-thread-bar { padding: 12px 14px; border-bottom: 1px solid var(--line); }
+.compact { padding: 7px 10px; font-size: 12px; }
+.danger-button { color: #fecdd3; border-color: rgba(251,113,133,0.34); }
+.image-preview { width: 100%; max-height: 420px; object-fit: contain; margin: 12px 0; border: 1px solid var(--line); border-radius: 14px; background: #020617; }
+.audio-player { width: 100%; margin: 14px 0; }
+.qr-panel { display: grid; place-items: center; min-height: 280px; background: #ffffff; }
+.qr-panel svg { width: min(280px, 100%); height: auto; }
+.compact-stack { margin: 12px 0; }
+.compact-stack .option-card { padding: 10px 12px; }
+.field-card .field-steps { padding-left: 20px; color: var(--muted); line-height: 1.5; }
+.field-card .warning-list { color: #fbbf24; }
+.availability-on { color: #bbf7d0; border-color: rgba(52,211,153,0.35); }
+.availability-off { color: #fecdd3; border-color: rgba(251,113,133,0.35); }
+.security-pill.availability-off { background: rgba(251, 113, 133, 0.10); }
+.license-install { display: grid; gap: 10px; margin: 16px 0 12px; }
+
+.eula-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background: rgba(2, 6, 23, 0.82);
+ backdrop-filter: blur(8px);
+}
+.eula-modal {
+ width: min(720px, 100%);
+ max-height: min(82vh, 760px);
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+.eula-modal h2 { margin: 0; }
+.eula-text {
+ flex: 1;
+ min-height: 200px;
+ overflow: auto;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: rgba(2, 6, 23, 0.55);
+ color: var(--text);
+ white-space: pre-wrap;
+ line-height: 1.55;
+ font-size: 13px;
+}
+.eula-actions { display: flex; justify-content: flex-end; }
+
+.terminal, pre {
+ color: #dbe7f3;
+ background: rgba(2, 6, 23, 0.72);
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ padding: 14px;
+ white-space: pre-wrap;
+ overflow: auto;
+ line-height: 1.45;
+}
+.small-terminal { min-height: 60px; max-height: 220px; }
+code { color: #eaff99; }
+
+@media (max-width: 1120px) {
+ .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .chat-layout, .chat-workspace, .split-hero, .split, .settings-grid { grid-template-columns: 1fr; }
+ .side-panel { position: static; }
+ .thread-panel { min-height: auto; }
+ .thread-list { max-height: 240px; }
+}
+
+@media (max-width: 820px) {
+ .app-shell { display: block; }
+ .sidebar { position: relative; height: auto; }
+ .main { padding: 16px; }
+ .app-topbar, .hero-panel, .section-header { display: block; }
+ .top-actions, .hero-actions { margin-top: 12px; }
+ .composer-actions, .form-grid { grid-template-columns: 1fr; }
+ .chat-log { height: 420px; min-height: 320px; }
+ .metric-grid { grid-template-columns: 1fr; }
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ --bg: #f5f7fb;
+ --bg-soft: #ffffff;
+ --panel: rgba(255, 255, 255, 0.90);
+ --panel-solid: #ffffff;
+ --panel-strong: #eef2f7;
+ --line: rgba(15, 23, 42, 0.13);
+ --line-strong: rgba(15, 23, 42, 0.22);
+ --text: #111827;
+ --muted: #64748b;
+ --muted-2: #7b8797;
+ --button: #111827;
+ --button-text: #ffffff;
+ --shadow: 0 24px 70px rgba(15, 23, 42, 0.10);
+ }
+ body { background: linear-gradient(145deg, #f7fafc, #eef4ff 60%, #f8fafc); }
+ .sidebar { background: rgba(255,255,255,0.76); }
+ input:not([type="checkbox"]):not([type="radio"]), textarea, select, .terminal, pre, .eula-text { background: rgba(255,255,255,0.86); color: var(--text); }
+ .assistant .bubble { background: #f1f5f9; }
+ .system .bubble { color: #3f4d08; }
+ .security-pill { color: #065f46; }
+ .nav { color: #1f2937; }
+}
diff --git a/windows/Add-Model.ps1 b/windows/Add-Model.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..79bdf41511460b748c3877c01e83310136e01b16
--- /dev/null
+++ b/windows/Add-Model.ps1
@@ -0,0 +1,14 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory=$true)][string]$Model,
+ [ValidateSet("ollama","gguf")][string]$Backend="ollama"
+)
+$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+if ($Backend -eq "ollama") {
+ $OllamaExe = Join-Path $Root "backends\ollama\windows\ollama.exe"
+ if (!(Test-Path $OllamaExe)) { throw "Missing Ollama binary." }
+ $env:OLLAMA_MODELS = Join-Path $Root "models\ollama"
+ & $OllamaExe pull $Model
+ exit $LASTEXITCODE
+}
+throw "GGUF add requires a signed model pack or a URL in manifest/model-sources.json."
diff --git a/windows/Install-Voice.ps1 b/windows/Install-Voice.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..91841b61d6f8aa16f2017cede9a94ed192f2dc34
--- /dev/null
+++ b/windows/Install-Voice.ps1
@@ -0,0 +1,68 @@
+[CmdletBinding()]
+param(
+ [string]$TargetRoot = ""
+)
+
+$ErrorActionPreference = "Stop"
+if (-not $TargetRoot) { $TargetRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path }
+if (!(Test-Path $TargetRoot)) { throw "Target root does not exist: $TargetRoot" }
+$TargetRoot = (Resolve-Path $TargetRoot).Path
+$assetConfigPath = Join-Path $TargetRoot "config\voice-assets.json"
+if (!(Test-Path $assetConfigPath)) { throw "Voice asset configuration is missing: $assetConfigPath" }
+$assetConfig = Get-Content -Raw $assetConfigPath | ConvertFrom-Json
+$assets = $assetConfig.windows_x64
+$shared = $assetConfig.shared
+
+$cache = Join-Path $TargetRoot ".jackailocal-builder\voice-cache"
+$whisperDir = Join-Path $TargetRoot "backends\whisper.cpp\windows"
+$piperDir = Join-Path $TargetRoot "backends\piper\windows"
+$whisperModelDir = Join-Path $TargetRoot "models\whisper"
+$piperModelDir = Join-Path $TargetRoot "models\piper"
+New-Item -ItemType Directory -Force -Path $cache,$whisperDir,$piperDir,$whisperModelDir,$piperModelDir | Out-Null
+
+function Download-Verified([string]$Url, [string]$Sha256, [string]$Destination) {
+ if (Test-Path $Destination) {
+ $existing = (Get-FileHash -Algorithm SHA256 -Path $Destination).Hash.ToLowerInvariant()
+ if ($existing -eq $Sha256.ToLowerInvariant()) { return }
+ Remove-Item -LiteralPath $Destination -Force
+ }
+ Write-Host "Downloading $Url" -ForegroundColor Cyan
+ Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing
+ $actual = (Get-FileHash -Algorithm SHA256 -Path $Destination).Hash.ToLowerInvariant()
+ if ($actual -ne $Sha256.ToLowerInvariant()) {
+ Remove-Item -LiteralPath $Destination -Force
+ throw "SHA256 verification failed for $Url. Expected $Sha256, got $actual."
+ }
+}
+
+function Copy-ArchiveContents([string]$Archive, [string]$Destination, [string]$RequiredFile) {
+ $extract = Join-Path $cache ([IO.Path]::GetFileNameWithoutExtension($Archive))
+ if (Test-Path $extract) { Remove-Item -LiteralPath $extract -Recurse -Force }
+ New-Item -ItemType Directory -Force -Path $extract | Out-Null
+ Expand-Archive -Path $Archive -DestinationPath $extract -Force
+ $required = Get-ChildItem -Path $extract -Recurse -File -Filter $RequiredFile | Select-Object -First 1
+ if (-not $required) { throw "Required file $RequiredFile was not found in $Archive." }
+ $sourceDir = Split-Path $required.FullName -Parent
+ Copy-Item -Path (Join-Path $sourceDir "*") -Destination $Destination -Recurse -Force
+}
+
+$whisperArchive = Join-Path $cache "whisper-bin-x64.zip"
+$piperArchive = Join-Path $cache "piper-windows-amd64.zip"
+$whisperModel = Join-Path $whisperModelDir "ggml-base.bin"
+$piperVoice = Join-Path $piperModelDir "en_US-libritts_r-medium.onnx"
+$piperVoiceConfig = Join-Path $piperModelDir "en_US-libritts_r-medium.onnx.json"
+
+if (!(Test-Path (Join-Path $whisperDir "whisper-cli.exe"))) {
+ Download-Verified $assets.whisper.archive_url $assets.whisper.archive_sha256 $whisperArchive
+ Copy-ArchiveContents $whisperArchive $whisperDir "whisper-cli.exe"
+}
+if (!(Test-Path (Join-Path $piperDir "piper.exe"))) {
+ Download-Verified $assets.piper.archive_url $assets.piper.archive_sha256 $piperArchive
+ Copy-ArchiveContents $piperArchive $piperDir "piper.exe"
+}
+
+Download-Verified $shared.whisper.model_url $shared.whisper.model_sha256 $whisperModel
+Download-Verified $shared.piper.voice_url $shared.piper.voice_sha256 $piperVoice
+Download-Verified $shared.piper.voice_config_url $shared.piper.voice_config_sha256 $piperVoiceConfig
+
+Write-Host "Voice assets installed and verified under $TargetRoot" -ForegroundColor Green
diff --git a/windows/Integrity-Check.ps1 b/windows/Integrity-Check.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..93b07f886d962d6e26d79071fc212449af64433d
--- /dev/null
+++ b/windows/Integrity-Check.ps1
@@ -0,0 +1,24 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [ValidateSet("Warn","Fail")][string]$Mode="Fail"
+)
+$manifest = Join-Path $Root "manifest\sha256-manifest.json"
+if (!(Test-Path $manifest)) {
+ if ($Mode -eq "Fail") { throw "Missing sha256 manifest." }
+ Write-Warning "Missing sha256 manifest."
+ exit 0
+}
+$data = Get-Content $manifest -Raw | ConvertFrom-Json
+$bad = @()
+foreach ($entry in $data.files) {
+ $p = Join-Path $Root $entry.path
+ if (!(Test-Path $p)) { $bad += "missing: $($entry.path)"; continue }
+ $h = (Get-FileHash -Algorithm SHA256 $p).Hash.ToLowerInvariant()
+ if ($h -ne $entry.sha256.ToLowerInvariant()) { $bad += "hash mismatch: $($entry.path)" }
+}
+if ($bad.Count -gt 0) {
+ $msg = "Integrity check failed:`n" + ($bad -join "`n")
+ if ($Mode -eq "Fail") { throw $msg }
+ Write-Warning $msg
+}
diff --git a/windows/JackAILocal-USB-Builder.ps1 b/windows/JackAILocal-USB-Builder.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..6d73f974bdbd6888bab7fb717423ecdbb6f1c28c
--- /dev/null
+++ b/windows/JackAILocal-USB-Builder.ps1
@@ -0,0 +1,629 @@
+[CmdletBinding()]
+param(
+ [string]$ManifestPath = "",
+ [string]$TargetDrive = "",
+ [ValidateSet("USB", "SSD", "LocalFolder")][string]$TargetMode = "USB",
+ [ValidateSet("DefaultOnly", "RecommendedSet", "AllAllowed")][string]$ModelInstallMode = "RecommendedSet",
+ [switch]$SkipModelDownload,
+ [switch]$CleanTarget
+)
+
+$ErrorActionPreference = "Stop"
+
+# Force console and script output to UTF-8 to prevent Python decoding crashes
+[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
+$OutputEncoding = [System.Text.Encoding]::UTF8
+
+function Write-Title([string]$Text) {
+ Write-Host ""
+ Write-Host "============================================================" -ForegroundColor DarkGray
+ Write-Host $Text -ForegroundColor Cyan
+ Write-Host "============================================================" -ForegroundColor DarkGray
+}
+
+function Ensure-Directory([string]$Path) {
+ New-Item -ItemType Directory -Force -Path $Path | Out-Null
+}
+
+function Resolve-PackageRoot {
+ $dir = $PSScriptRoot
+ while ($dir -and (Test-Path $dir)) {
+ if (Test-Path (Join-Path $dir "config")) {
+ return (Resolve-Path $dir).Path
+ }
+ $parent = Split-Path -Parent $dir
+ if ($parent -eq $dir) { break }
+ $dir = $parent
+ }
+ return (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+}
+
+function Verify-ManifestSignature([string]$ManifestFile) {
+ $packageRoot = Resolve-PackageRoot
+ $fullManifestPath = [System.IO.Path]::GetFullPath($ManifestFile)
+ $fullPackageRoot = [System.IO.Path]::GetFullPath($packageRoot)
+ if ($fullManifestPath.StartsWith($fullPackageRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
+ Write-Host "Manifest is local to the package. Skipping signature check."
+ return
+ }
+
+ $sigPath = $ManifestFile + ".sig"
+ if (!(Test-Path $sigPath)) {
+ throw "Security error: Manifest is loaded from outside the package root ($ManifestFile), but no signature file was found at $sigPath. For security, external manifests must be signed."
+ }
+
+ $pubKeyXmlPath = Join-Path $packageRoot "config\update-public-key.xml"
+ if (!(Test-Path $pubKeyXmlPath)) {
+ throw "Security error: Public key not found at $pubKeyXmlPath. Cannot verify signature."
+ }
+
+ $bytes = [IO.File]::ReadAllBytes($fullManifestPath)
+ $sig = [IO.File]::ReadAllBytes((Resolve-Path $sigPath).Path)
+ $rsa = [System.Security.Cryptography.RSA]::Create()
+ $rsa.FromXmlString((Get-Content $pubKeyXmlPath -Raw))
+ $ok = $rsa.VerifyData($bytes, $sig, [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
+ if (-not $ok) {
+ throw "Security error: Signature verification failed for external manifest $ManifestFile."
+ }
+ Write-Host "External manifest signature verified successfully." -ForegroundColor Green
+}
+
+
+function Get-FreeGb([string]$TargetPath) {
+ $fullPath = [System.IO.Path]::GetFullPath($TargetPath)
+ $root = [System.IO.Path]::GetPathRoot($fullPath)
+ if ([string]::IsNullOrWhiteSpace($root)) { throw "Could not determine a drive for target path: $TargetPath" }
+ $drive = [System.IO.DriveInfo]::new($root)
+ return [math]::Round($drive.AvailableFreeSpace / 1GB, 1)
+}
+
+function Assert-SafeTarget([string]$SourceRoot, [string]$TargetRoot) {
+ $source = [System.IO.Path]::GetFullPath($SourceRoot).TrimEnd("\", "/")
+ $target = [System.IO.Path]::GetFullPath($TargetRoot).TrimEnd("\", "/")
+ $separator = [System.IO.Path]::DirectorySeparatorChar
+ $comparison = [System.StringComparison]::OrdinalIgnoreCase
+ if ($source.Equals($target, $comparison) -or
+ $source.StartsWith($target + $separator, $comparison) -or
+ $target.StartsWith($source + $separator, $comparison)) {
+ throw "Unsafe target path: source package '$source' and target '$target' overlap. Choose a separate folder or drive."
+ }
+}
+
+function Get-DriveCandidates([string]$Mode) {
+ $logical = Get-CimInstance Win32_LogicalDisk | Where-Object {
+ $_.DeviceID -ne $env:SystemDrive -and $_.Size -gt 0 -and (
+ ($Mode -eq "USB" -and $_.DriveType -eq 2) -or
+ ($Mode -eq "SSD" -and $_.DriveType -in @(2, 3))
+ )
+ }
+ $logical | ForEach-Object {
+ [pscustomobject]@{
+ Drive = "$($_.DeviceID)\"
+ Label = $_.VolumeName
+ Type = if ($_.DriveType -eq 2) { "Removable" } else { "Fixed/External" }
+ SizeGb = [math]::Round($_.Size / 1GB, 1)
+ FreeGb = [math]::Round($_.FreeSpace / 1GB, 1)
+ }
+ }
+}
+
+function Select-TargetDrive([string]$ExistingTarget, [string]$Mode) {
+ if ($ExistingTarget) {
+ $fullPath = [System.IO.Path]::GetFullPath($ExistingTarget)
+ if ($Mode -eq "LocalFolder") {
+ Ensure-Directory $fullPath
+ return (Resolve-Path $fullPath).Path
+ }
+ if (!(Test-Path $fullPath)) { throw "Target drive does not exist: $fullPath" }
+ $resolved = (Resolve-Path $fullPath).Path
+ $root = [System.IO.Path]::GetPathRoot($resolved)
+ if ($resolved.TrimEnd("\") -ne $root.TrimEnd("\")) {
+ throw "$Mode target must be a drive root such as E:\, not a subfolder: $resolved"
+ }
+ if ($root.TrimEnd("\") -eq $env:SystemDrive.TrimEnd("\")) {
+ throw "The Windows system drive cannot be used as a USB/SSD target."
+ }
+ if (-not $resolved.EndsWith("\")) { $resolved += "\" }
+ return $resolved
+ }
+
+ if ($Mode -eq "LocalFolder") {
+ $defaultFolder = Join-Path $env:LOCALAPPDATA "JackAILocal"
+ Ensure-Directory $defaultFolder
+ return (Resolve-Path $defaultFolder).Path
+ }
+
+ $candidates = @(Get-DriveCandidates -Mode $Mode)
+ if ($candidates.Count -eq 0) {
+ throw "No compatible $Mode drive was detected. Connect the target drive and run the builder again."
+ }
+
+ Write-Host "Detected $Mode target drives:" -ForegroundColor Yellow
+ for ($i = 0; $i -lt $candidates.Count; $i++) {
+ $d = $candidates[$i]
+ Write-Host ("[{0}] {1} {2} Label='{3}' Size={4}GB Free={5}GB" -f ($i + 1), $d.Drive, $d.Type, $d.Label, $d.SizeGb, $d.FreeGb)
+ }
+
+ if ($candidates.Count -eq 1) {
+ $answer = Read-Host "Use $($candidates[0].Drive) as the JackAILocal USB target? Type Y to continue"
+ if ($answer -notin @("Y", "y", "YES", "yes")) { throw "Cancelled by user." }
+ return $candidates[0].Drive
+ }
+
+ $selection = Read-Host "Type the number of the target drive"
+ $index = 0
+ if (-not [int]::TryParse($selection, [ref]$index)) { throw "Invalid drive selection." }
+ if ($index -lt 1 -or $index -gt $candidates.Count) { throw "Invalid drive selection." }
+ $chosen = $candidates[$index - 1].Drive
+ $confirm = Read-Host "Confirm target drive $chosen. Type BUILD to continue"
+ if ($confirm -ne "BUILD") { throw "Cancelled by user." }
+ return $chosen
+}
+
+function Find-Manifest([string]$Root, [string]$ProvidedPath) {
+ if ($ProvidedPath) {
+ if (!(Test-Path $ProvidedPath)) { throw "Manifest path does not exist: $ProvidedPath" }
+ return (Resolve-Path $ProvidedPath).Path
+ }
+
+ $searchRoots = New-Object System.Collections.Generic.List[string]
+ $searchRoots.Add($Root)
+
+ $manifestInput = Join-Path $Root "manifest-input"
+ if (Test-Path $manifestInput) { $searchRoots.Add($manifestInput) }
+
+ $downloads = Join-Path $env:USERPROFILE "Downloads"
+ if (Test-Path $downloads) { $searchRoots.Add($downloads) }
+
+ $patterns = @("*build-manifest*.json", "*build-manifest*.zip", "jackailocal-build-manifest*.zip")
+ $candidates = @()
+
+ foreach ($searchRoot in $searchRoots) {
+ foreach ($pattern in $patterns) {
+ try {
+ $recursive = ($searchRoot -eq $Root -or $searchRoot -eq $manifestInput)
+ if ($recursive) {
+ $candidates += Get-ChildItem -Path $searchRoot -Recurse -File -Filter $pattern -ErrorAction SilentlyContinue
+ } else {
+ $candidates += Get-ChildItem -Path $searchRoot -File -Filter $pattern -ErrorAction SilentlyContinue
+ }
+ } catch {}
+ }
+ }
+
+ $candidates = @($candidates | Sort-Object FullName -Unique | Sort-Object LastWriteTime -Descending)
+
+ if ($candidates.Count -eq 0) {
+ Write-Host "No Gradio build manifest ZIP/JSON was found." -ForegroundColor Yellow
+ Write-Host "Looked in:" -ForegroundColor Yellow
+ foreach ($searchRoot in $searchRoots) { Write-Host " $searchRoot" }
+ Write-Host "The builder will use the default Standard offline assistant profile."
+ return ""
+ }
+
+ Write-Host "Detected build manifest files:" -ForegroundColor Yellow
+ for ($i = 0; $i -lt $candidates.Count; $i++) {
+ Write-Host ("[{0}] {1}" -f ($i + 1), $candidates[$i].FullName)
+ }
+
+ if ($candidates.Count -eq 1) {
+ Write-Host "Using detected manifest: $($candidates[0].Name)"
+ return $candidates[0].FullName
+ }
+
+ $selection = Read-Host "Type the number of the manifest to use, or press Enter for the newest"
+ if ([string]::IsNullOrWhiteSpace($selection)) { return $candidates[0].FullName }
+ $index = 0
+ if (-not [int]::TryParse($selection, [ref]$index)) { throw "Invalid manifest selection." }
+ if ($index -lt 1 -or $index -gt $candidates.Count) { throw "Invalid manifest selection." }
+ return $candidates[$index - 1].FullName
+}
+
+function Read-BuildManifest([string]$Root, [string]$ManifestFile) {
+ if (-not $ManifestFile) {
+ return [ordered]@{
+ product = "JackAILocal"
+ package_goal = "Standard offline assistant"
+ hardware = @{ os = "windows"; ram_gb = 16; vram_gb = 0; gpu_name = "Default profile"; usb_storage_gb = 128 }
+ default_model = @{ id = "qwen35_smart_9b"; model_ref = "qwen3.5:9b"; provider = "ollama"; params_b = 9 }
+ allowed_models = @(
+ @{ id = "qwen35_fast_2b"; model_ref = "qwen3.5:2b"; provider = "ollama"; params_b = 2 },
+ @{ id = "qwen35_balanced_4b"; model_ref = "qwen3.5:4b"; provider = "ollama"; params_b = 4 },
+ @{ id = "qwen35_smart_9b"; model_ref = "qwen3.5:9b"; provider = "ollama"; params_b = 9 }
+ )
+ content_packs = @()
+ build_id = "local-default"
+ }
+ }
+
+ Verify-ManifestSignature -ManifestFile $ManifestFile
+
+ $extension = [IO.Path]::GetExtension($ManifestFile).ToLowerInvariant()
+ if ($extension -eq ".zip") {
+ $extractDir = Join-Path $Root ".jackailocal-builder\manifest"
+ if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
+ Ensure-Directory $extractDir
+ Expand-Archive -Path $ManifestFile -DestinationPath $extractDir -Force
+ $json = Get-ChildItem -Path $extractDir -Recurse -File -Filter "*.json" | Where-Object { $_.Name -like "*manifest*" -or $_.FullName -like "*build-manifest*" } | Select-Object -First 1
+ if (-not $json) { throw "No JSON build manifest was found inside $ManifestFile" }
+ return Get-Content -Raw -Path $json.FullName | ConvertFrom-Json
+ }
+
+ return Get-Content -Raw -Path $ManifestFile | ConvertFrom-Json
+}
+
+function Copy-RuntimePayload([string]$SourceRoot, [string]$TargetRoot, [bool]$Clean) {
+ if ($Clean) {
+ if ($env:AUTO_CONFIRM -ne "true") {
+ $confirm = Read-Host "Clean existing JackAILocal files on $TargetRoot? Type CLEAN to continue"
+ if ($confirm -ne "CLEAN") { throw "Clean target was requested but not confirmed." }
+ }
+ $paths = @("webui", "windows", "macos", "unix", "profiler", "config", "manifest", "models", "backends", "bin", "docs", "updates", "content", "content-packs", "licenses", "legal", "workspace", "diagnostics", ".jackailocal", "README-FIRST.txt", "START-HERE.cmd", "START-DESKTOP.cmd", "STOP-JACKAILOCAL.cmd", "START-HERE.command", "STOP-JACKAILOCAL.command", "autorun.inf")
+ foreach ($path in $paths) {
+ $target = Join-Path $TargetRoot $path
+ if (Test-Path $target) { Remove-Item $target -Recurse -Force }
+ }
+ }
+
+ $items = @(
+ "START-HERE.cmd", "START-DESKTOP.cmd", "STOP-JACKAILOCAL.cmd", "UPDATE-FROM-USB.cmd", "START-HERE.command", "STOP-JACKAILOCAL.command", "README-FIRST.txt", "autorun.inf",
+ "webui", "windows", "macos", "unix", "profiler", "config", "manifest", "models", "backends", "bin", "docs", "updates", "content", "content-packs", "licenses", "legal"
+ )
+
+ foreach ($item in $items) {
+ $src = Join-Path $SourceRoot $item
+ $dst = Join-Path $TargetRoot $item
+ if (Test-Path $src) {
+ if ((Get-Item $src).PSIsContainer) {
+ $sizeGb = [math]::Round(((Get-ChildItem $src -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum) / 1GB, 2)
+ if ($sizeGb -ge 1) {
+ Write-Host ("Copying {0} ({1} GB)... this step can take several minutes." -f $item, $sizeGb)
+ } else {
+ Write-Host ("Copying {0}..." -f $item)
+ }
+ Ensure-Directory $dst
+ Copy-Item -Path (Join-Path $src "*") -Destination $dst -Recurse -Force -ErrorAction SilentlyContinue
+ } else {
+ Copy-Item -Path $src -Destination $dst -Force
+ }
+ }
+ }
+ Write-Host "Runtime payload copy finished."
+
+ Ensure-Directory (Join-Path $TargetRoot "bin")
+ Ensure-Directory (Join-Path $TargetRoot "backends\ollama\windows")
+ Ensure-Directory (Join-Path $TargetRoot "backends\llama.cpp\windows")
+ Ensure-Directory (Join-Path $TargetRoot "models\ollama")
+ Ensure-Directory (Join-Path $TargetRoot "models\gguf")
+ Ensure-Directory (Join-Path $TargetRoot "models\hf")
+ Ensure-Directory (Join-Path $TargetRoot "workspace\documents")
+ Ensure-Directory (Join-Path $TargetRoot "workspace\threads")
+ Ensure-Directory (Join-Path $TargetRoot "workspace\exports")
+ Ensure-Directory (Join-Path $TargetRoot ".jackailocal\logs")
+
+ $privateKey = Join-Path $TargetRoot "config\update-private-key.xml"
+ if (Test-Path $privateKey) { Remove-Item -LiteralPath $privateKey -Force }
+}
+
+function Find-OllamaExe([string]$Root) {
+ $local = Join-Path $Root "backends\ollama\windows\ollama.exe"
+ if (Test-Path $local) { return (Resolve-Path $local).Path }
+ $package = Get-ChildItem -Path $Root -Recurse -File -Filter "ollama.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
+ if ($package) { return $package.FullName }
+ $cmd = Get-Command ollama.exe -ErrorAction SilentlyContinue
+ if ($cmd) { return $cmd.Source }
+ return ""
+}
+
+function Start-OllamaServer([string]$OllamaExe, [string]$ModelDir, [string]$LogDir) {
+ $builderHost = "127.0.0.1:11437"
+ $builderUrl = "http://$builderHost"
+ $busy = Get-NetTCPConnection -LocalAddress 127.0.0.1 -LocalPort 11437 -State Listen -ErrorAction SilentlyContinue
+ if ($busy) {
+ throw "Builder Ollama port 11437 is already in use. Stop that process before running the builder."
+ }
+
+ $env:OLLAMA_HOST = $builderHost
+ $env:OLLAMA_MODELS = $ModelDir
+ $env:OLLAMA_NO_CLOUD = "true"
+
+ $process = Start-Process -FilePath $OllamaExe `
+ -ArgumentList "serve" `
+ -WorkingDirectory (Split-Path -Parent $OllamaExe) `
+ -WindowStyle Hidden `
+ -PassThru `
+ -RedirectStandardOutput (Join-Path $LogDir "ollama-builder.out.log") `
+ -RedirectStandardError (Join-Path $LogDir "ollama-builder.err.log")
+
+ for ($i = 0; $i -lt 40; $i++) {
+ Start-Sleep -Milliseconds 500
+ try {
+ Invoke-RestMethod -Uri "$builderUrl/api/tags" -TimeoutSec 2 | Out-Null
+ return $process
+ } catch {}
+ }
+
+ throw "Ollama server did not become ready."
+}
+
+function Get-ModelsToInstall($Manifest, [string]$Mode) {
+ $models = New-Object System.Collections.Generic.List[string]
+
+ function Add-ModelRef($candidate) {
+ if ($null -eq $candidate) { return }
+ $provider = [string]$candidate.provider
+ $ref = [string]$candidate.model_ref
+ if ($provider -eq "ollama" -and -not [string]::IsNullOrWhiteSpace($ref)) {
+ if (-not $models.Contains($ref)) { $models.Add($ref) }
+ }
+ }
+
+ function Add-ModelString([string]$ref) {
+ if (-not [string]::IsNullOrWhiteSpace($ref)) {
+ if (-not $models.Contains($ref)) { $models.Add($ref) }
+ }
+ }
+
+ if ($Manifest.backend_plan -and $Manifest.backend_plan.model_install_plan -and $Manifest.backend_plan.model_install_plan.ollama) {
+ foreach ($ref in @($Manifest.backend_plan.model_install_plan.ollama)) {
+ Add-ModelString ([string]$ref)
+ }
+ }
+
+ Add-ModelRef $Manifest.default_model
+ Add-ModelRef $Manifest.agent_model
+
+ if ($Mode -in @("RecommendedSet", "AllAllowed")) {
+ foreach ($m in @($Manifest.allowed_models)) {
+ if ($Mode -eq "RecommendedSet") {
+ $params = 0
+ try { $params = [double]$m.params_b } catch { $params = 0 }
+ if ($params -le 9) { Add-ModelRef $m }
+ } else {
+ Add-ModelRef $m
+ }
+ }
+ }
+
+ return @($models)
+}
+
+function Pull-OllamaModels([string]$TargetRoot, $Manifest, [string]$Mode, [bool]$Skip) {
+ $modelDir = Join-Path $TargetRoot "models\ollama"
+ Ensure-Directory $modelDir
+
+ $models = @(Get-ModelsToInstall -Manifest $Manifest -Mode $Mode)
+ if ($models.Count -eq 0) {
+ Write-Host "No Ollama model was selected for installation."
+ return
+ }
+
+ $planPath = Join-Path $TargetRoot "manifest\models-to-install.txt"
+ $models | Set-Content -Encoding UTF8 $planPath
+
+ if ($Skip) {
+ Write-Host "Model download skipped. Planned models were written to manifest\models-to-install.txt" -ForegroundColor Yellow
+ return
+ }
+
+ $ollamaExe = Find-OllamaExe $TargetRoot
+ if (-not $ollamaExe) {
+ throw "ollama.exe was not found. This builder package must include ollama.exe, or Ollama must already be installed on this PC."
+ }
+
+ $ollamaDest = Join-Path $TargetRoot "backends\ollama\windows\ollama.exe"
+ if ($ollamaExe -ne $ollamaDest) {
+ Ensure-Directory (Split-Path $ollamaDest)
+ Copy-Item $ollamaExe $ollamaDest -Force
+ $ollamaExe = $ollamaDest
+ }
+
+ $logDir = Join-Path $TargetRoot ".jackailocal\logs"
+ Ensure-Directory $logDir
+ $server = Start-OllamaServer -OllamaExe $ollamaExe -ModelDir $modelDir -LogDir $logDir
+
+ try {
+ foreach ($model in $models) {
+ Write-Host "Downloading model to USB: $model" -ForegroundColor Cyan
+ $env:OLLAMA_HOST = "127.0.0.1:11437"
+ $env:OLLAMA_MODELS = $modelDir
+ & $ollamaExe pull $model
+ if ($LASTEXITCODE -ne 0) { throw "Ollama pull failed for model: $model" }
+ }
+
+ # Optional LoRA adapters: white-label customers ship fine-tuned models.
+ $loraManifest = Join-Path $TargetRoot "manifest\lora-adapters.json"
+ $loraScript = Join-Path (Resolve-PackageRoot) "factory\powershell\Install-LoraAdapters.ps1"
+ if ((Test-Path $loraManifest) -and (Test-Path $loraScript)) {
+ Write-Host "Installing LoRA adapters from manifest\lora-adapters.json" -ForegroundColor Cyan
+ & $loraScript -DriveRoot $TargetRoot -OllamaExe $ollamaExe
+ }
+ } finally {
+ if ($server -and -not $server.HasExited) {
+ Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue
+ }
+ }
+
+ Write-Host "Models installed under $modelDir"
+}
+
+function Write-TargetManifest([string]$TargetRoot, $Manifest) {
+ $manifestDir = Join-Path $TargetRoot "manifest"
+ Ensure-Directory $manifestDir
+ $Manifest | ConvertTo-Json -Depth 50 | Set-Content -Encoding UTF8 (Join-Path $manifestDir "build-manifest.json")
+
+ $hashManifest = @()
+ Get-ChildItem -Path $TargetRoot -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object {
+ $rel = $_.FullName.Substring($TargetRoot.Length).TrimStart("\", "/") -replace "\\", "/"
+ if ($rel -eq "manifest/sha256-manifest.json") { return }
+ if ($rel -like ".jackailocal/*") { return }
+ if ($rel -like "workspace/*") { return }
+ if ($rel -like "diagnostics/*") { return }
+
+ # Skip hidden/system files and common Windows system lockouts
+ if ($_.Attributes -match "Hidden" -or $_.Attributes -match "System") { return }
+ if ($rel -eq "DumpStack.log" -or $rel -like "System Volume Information/*" -or $rel -like "`$RECYCLE.BIN/*") { return }
+
+ try {
+ $hash = (Get-FileHash -Algorithm SHA256 -Path $_.FullName -ErrorAction Stop).Hash.ToLowerInvariant()
+ $hashManifest += [ordered]@{ path = $rel; sha256 = $hash; bytes = $_.Length }
+ } catch {
+ Write-Host "Warning: Skipping locked or inaccessible file: $rel" -ForegroundColor Yellow
+ }
+ }
+ [ordered]@{ files = $hashManifest } | ConvertTo-Json -Depth 5 | Set-Content -Encoding UTF8 (Join-Path $manifestDir "sha256-manifest.json")
+}
+
+function Ensure-LaunchFiles([string]$TargetRoot) {
+ $startPath = Join-Path $TargetRoot "START-HERE.cmd"
+ if (!(Test-Path $startPath)) {
+ @'
+@echo off
+cd /d "%~dp0"
+powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0windows\Start-JackAILocal.ps1"
+pause
+'@ | Set-Content -Encoding ASCII $startPath
+ }
+
+ $readmePath = Join-Path $TargetRoot "README-FIRST.txt"
+ @'
+JackAILocal
+
+=== FRANCAIS ===
+
+1. Ouvrez ce lecteur.
+2. Double-cliquez sur START-HERE.cmd.
+3. Attendez que l'interface locale s'ouvre.
+4. Internet n'est pas requis une fois les modeles installes sur cette cle.
+
+Ne supprimez pas les dossiers models, backends, config, manifest, webui, windows, bin, legal ou licenses.
+
+Documents importants : legal\EULA_FR.md (contrat de licence), legal\PRIVACY_FR.md (confidentialite),
+licenses\THIRD_PARTY_NOTICES.md (composants tiers).
+
+=== ENGLISH ===
+
+1. Open this drive.
+2. Double-click START-HERE.cmd.
+3. Wait for the local interface to open.
+4. Internet is not required after the models have been installed on this key.
+
+Do not delete the models, backends, config, manifest, webui, windows, bin, legal, or licenses folders.
+
+Important documents: legal\EULA_EN.md (license agreement), legal\PRIVACY_EN.md (privacy),
+licenses\THIRD_PARTY_NOTICES.md (third-party components).
+'@ | Set-Content -Encoding UTF8 $readmePath
+}
+
+function Assert-TargetReady([string]$TargetRoot) {
+ $requiredFiles = @(
+ "bin\jackailocald.exe",
+ "backends\ollama\windows\ollama.exe",
+ "backends\whisper.cpp\windows\whisper-cli.exe",
+ "backends\piper\windows\piper.exe",
+ "models\whisper\ggml-base.bin",
+ "models\piper\en_US-libritts_r-medium.onnx",
+ "models\piper\en_US-libritts_r-medium.onnx.json",
+ "content\field-manual\cards.json",
+ "content-packs\prompts\general-assistant.json",
+ "webui\index.html",
+ "webui\app-v15.js",
+ "config\jackailocal.windows.toml",
+ "legal\EULA_EN.md",
+ "legal\EULA_FR.md",
+ "legal\PRIVACY_EN.md",
+ "legal\PRIVACY_FR.md",
+ "licenses\THIRD_PARTY_NOTICES.md"
+ )
+ foreach ($relative in $requiredFiles) {
+ $path = Join-Path $TargetRoot $relative
+ if (!(Test-Path $path -PathType Leaf)) { throw "Target validation failed. Required file is missing: $path" }
+ }
+ $modelFiles = @(Get-ChildItem -Path (Join-Path $TargetRoot "models\ollama") -Recurse -File -ErrorAction SilentlyContinue |
+ Where-Object { $_.Name -notlike "*.txt" -and $_.Length -gt 0 })
+ if ($modelFiles.Count -eq 0) {
+ throw "Target validation failed. No real Ollama model files were found under models\ollama."
+ }
+ $planPath = Join-Path $TargetRoot "manifest\models-to-install.txt"
+ if (Test-Path $planPath) {
+ foreach ($model in (Get-Content -Path $planPath | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })) {
+ $parts = $model.Trim().Split(":", 2)
+ $name = $parts[0]
+ $tag = if ($parts.Count -gt 1 -and $parts[1]) { $parts[1] } else { "latest" }
+ $segments = $name.Split("/", [System.StringSplitOptions]::RemoveEmptyEntries)
+ if ($segments.Count -eq 1) {
+ $manifestPath = Join-Path $TargetRoot ("models\ollama\manifests\registry.ollama.ai\library\{0}\{1}" -f $segments[0], $tag)
+ } elseif ($segments.Count -eq 2) {
+ $manifestPath = Join-Path $TargetRoot ("models\ollama\manifests\registry.ollama.ai\{0}\{1}\{2}" -f $segments[0], $segments[1], $tag)
+ } else {
+ throw "Target validation failed. Unsupported Ollama model reference in plan: $model"
+ }
+ if (!(Test-Path $manifestPath -PathType Leaf)) {
+ throw "Target validation failed. Planned Ollama model is missing from target store: $model"
+ }
+ }
+ }
+}
+
+try {
+ Write-Title "JackAILocal Builder"
+ Write-Host "This wizard prepares a real JackAILocal $TargetMode target for a non-technical user."
+ Write-Host "It does not require the user to understand models, backends, RAM, VRAM, or quantization."
+
+ $root = Resolve-PackageRoot
+ $daemonSource = Join-Path $root "bin\jackailocald.exe"
+ if (!(Test-Path $daemonSource)) {
+ throw "Required runtime binary is missing: $daemonSource. Build and stage jackailocald.exe before creating a target."
+ }
+ $ollamaSource = Find-OllamaExe $root
+ if (-not $ollamaSource) {
+ throw "Ollama was not found in the package or on this computer. Install or stage ollama.exe before creating a target."
+ }
+
+ $logRoot = Join-Path $root ".jackailocal-builder\logs"
+ Ensure-Directory $logRoot
+ $logFile = Join-Path $logRoot ("builder-{0}.log" -f (Get-Date -Format "yyyyMMdd-HHmmss"))
+ Start-Transcript -Path $logFile -Force | Out-Null
+
+ $manifestFile = Find-Manifest -Root $root -ProvidedPath $ManifestPath
+ $manifest = Read-BuildManifest -Root $root -ManifestFile $manifestFile
+
+ $target = Select-TargetDrive -ExistingTarget $TargetDrive -Mode $TargetMode
+ Assert-SafeTarget -SourceRoot $root -TargetRoot $target
+ $freeGb = Get-FreeGb $target
+ if ($freeGb -lt 16) { throw "The target drive has only $freeGb GB free. Use a larger USB/SSD." }
+
+ Write-Title "Build summary"
+ Write-Host "Target mode: $TargetMode"
+ Write-Host "Target path: $target"
+ Write-Host "Package goal: $($manifest.package_goal)"
+ Write-Host "Default model: $($manifest.default_model.model_ref)"
+ Write-Host "Install mode: $ModelInstallMode"
+ Write-Host "Skip model download: $SkipModelDownload"
+
+ if ($env:AUTO_CONFIRM -ne "true") {
+ $confirm = Read-Host "Type YES to build this JackAILocal $TargetMode target"
+ if ($confirm -ne "YES") { throw "Cancelled by user." }
+ }
+
+ Copy-RuntimePayload -SourceRoot $root -TargetRoot $target -Clean ([bool]$CleanTarget)
+ Write-TargetManifest -TargetRoot $target -Manifest $manifest
+ Pull-OllamaModels -TargetRoot $target -Manifest $manifest -Mode $ModelInstallMode -Skip ([bool]$SkipModelDownload)
+ & (Join-Path $target "windows\Install-Voice.ps1") -TargetRoot $target
+ Ensure-LaunchFiles -TargetRoot $target
+ Assert-TargetReady -TargetRoot $target
+ Write-TargetManifest -TargetRoot $target -Manifest $manifest
+
+ Write-Title "Build complete"
+ Write-Host "The target is ready for use." -ForegroundColor Green
+ Write-Host ("Client launch file: {0}" -f (Join-Path $target "START-HERE.cmd"))
+ Write-Host "Build log: $logFile"
+ Stop-Transcript | Out-Null
+ exit 0
+} catch {
+ Write-Host ""
+ Write-Host "JackAILocal Builder failed:" -ForegroundColor Red
+ Write-Host $_.Exception.Message -ForegroundColor Red
+ try { Stop-Transcript | Out-Null } catch {}
+ exit 1
+}
diff --git a/windows/Preflight-Windows.ps1 b/windows/Preflight-Windows.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..07bec6f43ca9257c1a6eb2621045eb2c5cb6e69b
--- /dev/null
+++ b/windows/Preflight-Windows.ps1
@@ -0,0 +1,19 @@
+[CmdletBinding()]
+param()
+$ErrorActionPreference = "Continue"
+$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+$checks = @()
+function Add-Check($Name, $Ok, $Detail) { $script:checks += [ordered]@{ name=$Name; ok=[bool]$Ok; detail=$Detail } }
+Add-Check "root" (Test-Path $Root) $Root
+Add-Check "START-HERE.cmd" (Test-Path (Join-Path $Root "START-HERE.cmd")) "required client launcher"
+Add-Check "jackailocald.exe" (Test-Path (Join-Path $Root "bin\jackailocald.exe")) "compile Rust or copy binary"
+Add-Check "ollama.exe" (Test-Path (Join-Path $Root "backends\ollama\windows\ollama.exe")) "required for default backend"
+Add-Check "llama-server.exe" (Test-Path (Join-Path $Root "backends\llama.cpp\windows\llama-server.exe")) "optional fallback"
+Add-Check "models ollama" ((Test-Path (Join-Path $Root "models\ollama\manifests")) -or (Test-Path (Join-Path $Root "models\ollama\blobs"))) "models should be preloaded"
+Add-Check "webui" (Test-Path (Join-Path $Root "webui\index.html")) "UI files"
+Add-Check "model catalog" (Test-Path (Join-Path $Root "config\model-catalog.json")) "catalog"
+Add-Check "manifest" (Test-Path (Join-Path $Root "manifest\sha256-manifest.json")) "integrity manifest"
+$checks | ConvertTo-Json -Depth 4
+$failed = $checks | Where-Object { -not $_.ok }
+if ($failed.Count -gt 0) { exit 1 }
+exit 0
diff --git a/windows/Start-JackAILocal.ps1 b/windows/Start-JackAILocal.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..544740e278964e079249a418da70c879a0f07ecd
--- /dev/null
+++ b/windows/Start-JackAILocal.ps1
@@ -0,0 +1,148 @@
+[CmdletBinding()]
+param(
+ [switch]$NoBrowser,
+ [switch]$AppMode
+)
+$ErrorActionPreference = "Stop"
+$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+$DiagDir = Join-Path $Root "diagnostics"
+$LogDir = Join-Path $Root ".jackailocal\logs"
+$CacheDir = Join-Path $Root ".jackailocal\cache"
+$RunDir = Join-Path $Root ".jackailocal\run"
+New-Item -ItemType Directory -Force -Path $DiagDir,$LogDir,$CacheDir,$RunDir | Out-Null
+
+& (Join-Path $PSScriptRoot "hwscan-windows.ps1") -OutputPath (Join-Path $DiagDir "hardware.json") | Out-Null
+& (Join-Path $PSScriptRoot "Integrity-Check.ps1") -Root $Root -Mode Fail | Out-Null
+
+$env:JACKAILOCAL_ROOT = $Root
+$env:JACKAILOCAL_CACHE = $CacheDir
+
+$OllamaExe = Join-Path $Root "backends\ollama\windows\ollama.exe"
+$DaemonExe = Join-Path $Root "bin\jackailocald.exe"
+
+if (!(Test-Path $DaemonExe)) { throw "Missing $DaemonExe. Run factory build first." }
+
+$ollamaPidFile = Join-Path $RunDir "ollama.pid"
+$daemonPidFile = Join-Path $RunDir "jackailocald.pid"
+
+$OllamaPort = "11434"
+$OllamaHost = "127.0.0.1:11434"
+$OllamaUrl = "http://127.0.0.1:11434"
+$ollamaRunning = Get-NetTCPConnection -LocalAddress 127.0.0.1 -LocalPort 11434 -State Listen -ErrorAction SilentlyContinue
+$reuseOllama = $false
+
+if ($ollamaRunning) {
+ # Query the running instance to check if it has the models from the USB
+ try {
+ $response = Invoke-RestMethod -Uri "$OllamaUrl/api/tags" -TimeoutSec 2
+ $manifestPath = Join-Path $Root "manifest\build-manifest.json"
+ $defaultModelRef = "qwen3.5:4b" # fallback default
+ if (Test-Path $manifestPath) {
+ $manifest = Get-Content -Raw $manifestPath | ConvertFrom-Json
+ if ($manifest.default_model.model_ref) {
+ $defaultModelRef = $manifest.default_model.model_ref
+ }
+ }
+
+ $found = $false
+ foreach ($m in $response.models) {
+ if ($m.name -eq $defaultModelRef -or $m.name.StartsWith($defaultModelRef + ":") -or $m.name -eq $defaultModelRef.Replace(":", "-")) {
+ $found = $true
+ break
+ }
+ }
+ if ($found) {
+ Write-Host "Ollama is already running on port 11434 and contains our preloaded models. Reusing instance."
+ $reuseOllama = $true
+ }
+ } catch {}
+}
+
+if (-not $reuseOllama) {
+ if ($ollamaRunning) {
+ Write-Host "Host Ollama is running but does not have our preloaded models. Launching isolated instance on port 11435."
+ $OllamaPort = "11435"
+ $OllamaHost = "127.0.0.1:11435"
+ $OllamaUrl = "http://127.0.0.1:11435"
+ }
+
+ if (!(Test-Path $OllamaExe)) { throw "Missing $OllamaExe. Run backend installer first." }
+
+ # Isolate Ollama environment completely to the USB/local folder root
+ $env:OLLAMA_HOST = $OllamaHost
+ $env:OLLAMA_MODELS = Join-Path $Root "models\ollama"
+ $env:OLLAMA_NO_CLOUD = "true"
+ $env:OLLAMA_TMPDIR = $CacheDir
+ $env:HOME = $Root
+ $env:USERPROFILE = $Root
+
+ # Also set for the Gradio agent decision engine
+ $env:OLLAMA_AGENT_ENDPOINT = "http://127.0.0.1:$($OllamaPort)/v1"
+
+ $ollamaProcess = Start-Process -FilePath $OllamaExe `
+ -ArgumentList "serve" `
+ -WindowStyle Hidden `
+ -WorkingDirectory (Split-Path $OllamaExe) `
+ -PassThru `
+ -RedirectStandardOutput (Join-Path $LogDir "ollama.out.log") `
+ -RedirectStandardError (Join-Path $LogDir "ollama.err.log")
+ $ollamaProcess.Id | Set-Content -Encoding ASCII $ollamaPidFile
+
+ for ($i = 0; $i -lt 30; $i++) {
+ Start-Sleep -Milliseconds 500
+ try {
+ Invoke-RestMethod -Uri "$OllamaUrl/api/tags" -TimeoutSec 2 | Out-Null
+ break
+ } catch {
+ if ($i -eq 29) { throw "Ollama did not become ready on port $OllamaPort. See $LogDir." }
+ }
+ }
+} else {
+ # Reusing running host instance on 11434
+ $env:OLLAMA_HOST = $OllamaHost
+ $env:OLLAMA_AGENT_ENDPOINT = "http://127.0.0.1:$($OllamaPort)/v1"
+}
+
+$daemonRunning = Get-NetTCPConnection -LocalAddress 127.0.0.1 -LocalPort 4891 -State Listen -ErrorAction SilentlyContinue
+if ($daemonRunning -and !(Test-Path $daemonPidFile)) {
+ throw "Port 4891 is already in use by another process. Stop that process before launching JackAILocal."
+}
+if (-not $daemonRunning) {
+ $daemonProcess = Start-Process -FilePath $DaemonExe `
+ -ArgumentList "serve --config config\jackailocal.windows.toml" `
+ -WindowStyle Hidden `
+ -WorkingDirectory $Root `
+ -PassThru `
+ -RedirectStandardOutput (Join-Path $LogDir "jackailocald.out.log") `
+ -RedirectStandardError (Join-Path $LogDir "jackailocald.err.log")
+ $daemonProcess.Id | Set-Content -Encoding ASCII $daemonPidFile
+ for ($i = 0; $i -lt 30; $i++) {
+ Start-Sleep -Milliseconds 500
+ try {
+ Invoke-RestMethod -Uri "http://127.0.0.1:4891/health" -TimeoutSec 2 | Out-Null
+ break
+ } catch {
+ if ($i -eq 29) { throw "JackAILocal runtime did not become ready. See $LogDir." }
+ }
+ }
+}
+
+$Url = "http://127.0.0.1:4891"
+if ($NoBrowser) { return }
+
+if ($AppMode) {
+ $browserCandidates = @(
+ (Join-Path ${env:ProgramFiles(x86)} "Microsoft\Edge\Application\msedge.exe"),
+ (Join-Path $env:ProgramFiles "Microsoft\Edge\Application\msedge.exe"),
+ (Join-Path $env:LOCALAPPDATA "Microsoft\Edge\Application\msedge.exe"),
+ (Join-Path $env:ProgramFiles "Google\Chrome\Application\chrome.exe"),
+ (Join-Path ${env:ProgramFiles(x86)} "Google\Chrome\Application\chrome.exe")
+ )
+ $appBrowser = $browserCandidates | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
+ if ($appBrowser) {
+ Start-Process -FilePath $appBrowser -ArgumentList "--app=$Url", "--start-maximized"
+ return
+ }
+}
+
+Start-Process $Url
diff --git a/windows/Start-LlamaCppFallback.ps1 b/windows/Start-LlamaCppFallback.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a011167d41f84f6c65aa1459fb79be420c151041
--- /dev/null
+++ b/windows/Start-LlamaCppFallback.ps1
@@ -0,0 +1,7 @@
+$ErrorActionPreference = 'Stop'
+$Root = Split-Path -Parent $PSScriptRoot
+$Exe = "$Root\backends\llama.cpp\windows\llama-server.exe"
+$Model = "$Root\models\gguf\qwen3.5-4b-q4_k_m.gguf"
+if (!(Test-Path $Exe)) { throw "Missing llama-server.exe" }
+if (!(Test-Path $Model)) { throw "Missing GGUF model $Model" }
+Start-Process -FilePath $Exe -ArgumentList "-m `"$Model`" --host 127.0.0.1 --port 8080 -c 8192" -WorkingDirectory $Root -WindowStyle Hidden -RedirectStandardOutput "$Root\logs\llamacpp.out.log" -RedirectStandardError "$Root\logs\llamacpp.err.log"
diff --git a/windows/Stop-JackAILocal.ps1 b/windows/Stop-JackAILocal.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..39b13a45efa3f15684bda0bc07c41afa68747cc3
--- /dev/null
+++ b/windows/Stop-JackAILocal.ps1
@@ -0,0 +1,25 @@
+[CmdletBinding()]
+param()
+$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+$RunDir = Join-Path $Root ".jackailocal\run"
+
+function Stop-PidFile([string]$Path) {
+ if (!(Test-Path $Path)) { return }
+ $pidText = (Get-Content -Raw -Path $Path).Trim()
+ $pidValue = 0
+ if ([int]::TryParse($pidText, [ref]$pidValue)) {
+ $process = Get-Process -Id $pidValue -ErrorAction SilentlyContinue
+ if ($process) {
+ Stop-Process -Id $pidValue -Force -ErrorAction SilentlyContinue
+ }
+ }
+ Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
+}
+
+Stop-PidFile (Join-Path $RunDir "jackailocald.pid")
+Stop-PidFile (Join-Path $RunDir "ollama.pid")
+
+Get-Process jackailocald,ollama,llama-server -ErrorAction SilentlyContinue |
+ Where-Object { $_.Path -and $_.Path.StartsWith($Root, [System.StringComparison]::OrdinalIgnoreCase) } |
+ Stop-Process -Force -ErrorAction SilentlyContinue
+Write-Host "JackAILocal stopped."
diff --git a/windows/hwscan-windows.ps1 b/windows/hwscan-windows.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..0fb095624728f9f7283a0a94fd67995abe0d005a
--- /dev/null
+++ b/windows/hwscan-windows.ps1
@@ -0,0 +1,25 @@
+[CmdletBinding()]
+param([string]$OutputPath = "diagnostics\hardware.json")
+$ErrorActionPreference = "SilentlyContinue"
+$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
+$memBytes = (Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory
+$gpus = Get-CimInstance Win32_VideoController | ForEach-Object {
+ [pscustomobject]@{ name=$_.Name; vram_gb=[math]::Round(($_.AdapterRAM / 1GB),1); driver=$_.DriverVersion }
+}
+$drive = Get-Item (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
+$profile = [ordered]@{
+ collected_at = (Get-Date).ToString("o")
+ os = "windows"
+ cpu_name = $cpu.Name
+ cpu_threads = $cpu.NumberOfLogicalProcessors
+ ram_gb = [math]::Round($memBytes / 1GB, 1)
+ gpus = $gpus
+ vram_gb = [double](($gpus | Measure-Object -Property vram_gb -Maximum).Maximum)
+ whichllm = $null
+}
+if (Get-Command whichllm -ErrorAction SilentlyContinue) {
+ try { $profile.whichllm = (whichllm --top 10 --json | ConvertFrom-Json) } catch { $profile.whichllm_error = $_.Exception.Message }
+}
+New-Item -ItemType Directory -Force -Path (Split-Path $OutputPath) | Out-Null
+$profile | ConvertTo-Json -Depth 20 | Set-Content -Encoding UTF8 $OutputPath
+$profile | ConvertTo-Json -Depth 20
diff --git a/workspace/exports/JackAILocal-support-20260604-194958.zip b/workspace/exports/JackAILocal-support-20260604-194958.zip
new file mode 100644
index 0000000000000000000000000000000000000000..18fe7778f941484fdb44f582b9a84582e81ba13b
Binary files /dev/null and b/workspace/exports/JackAILocal-support-20260604-194958.zip differ
diff --git a/workspace/exports/JackAILocal-support-20260604-205705.zip b/workspace/exports/JackAILocal-support-20260604-205705.zip
new file mode 100644
index 0000000000000000000000000000000000000000..567518f2240e1c8a947fdfa3cba4ae1a9019e844
Binary files /dev/null and b/workspace/exports/JackAILocal-support-20260604-205705.zip differ
diff --git a/workspace/exports/JackAILocal-support-20260604-205806.zip b/workspace/exports/JackAILocal-support-20260604-205806.zip
new file mode 100644
index 0000000000000000000000000000000000000000..567518f2240e1c8a947fdfa3cba4ae1a9019e844
Binary files /dev/null and b/workspace/exports/JackAILocal-support-20260604-205806.zip differ
diff --git a/workspace/settings/settings.json b/workspace/settings/settings.json
new file mode 100644
index 0000000000000000000000000000000000000000..06379432f6d1b386d0a5c1862e2081d4207149b2
--- /dev/null
+++ b/workspace/settings/settings.json
@@ -0,0 +1,7 @@
+{
+ "mode": "simple",
+ "offline_lock": true,
+ "phone_access_enabled": false,
+ "phone_access_token": "P-OHrbfuDSu4ue9wGqJzjMUxkklhDF5w",
+ "tools_lock": true
+}
\ No newline at end of file
diff --git a/workspace/test-manifest-qwen2b.json b/workspace/test-manifest-qwen2b.json
new file mode 100644
index 0000000000000000000000000000000000000000..1c839e553617237e8eaddf37861702d7a8cece26
--- /dev/null
+++ b/workspace/test-manifest-qwen2b.json
@@ -0,0 +1,19 @@
+{
+ "product": "JackAILocal",
+ "mode": "local-folder-real-test",
+ "selector_version": "v15-real-builder",
+ "build_id": "local-test-qwen2b",
+ "created_at": "2026-06-04T00:00:00Z",
+ "package_goal": "Real local-folder acceptance test",
+ "hardware": { "os": "windows", "cpu_threads": 8, "ram_gb": 16, "vram_gb": 0, "gpu_name": "local", "usb_storage_gb": 64 },
+ "default_model": { "id": "qwen35_fast_2b", "label_en": "Fast 2B", "provider": "ollama", "model_ref": "qwen3.5:2b", "params_b": 2 },
+ "allowed_models": [
+ { "id": "qwen35_fast_2b", "label_en": "Fast 2B", "provider": "ollama", "model_ref": "qwen3.5:2b", "params_b": 2 }
+ ],
+ "content_packs": [
+ { "id": "field_manual_core", "label_en": "Field Manual Core", "recommended": true, "min_storage_gb": 32 },
+ { "id": "starter_prompt_packs", "label_en": "Starter Prompt Packs", "recommended": true, "min_storage_gb": 32 }
+ ],
+ "backends": ["ollama"],
+ "max_params_b": 32
+}