"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 = `` + state.models.map((model) => ( `` )).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 = `` + agentModels.map((model) => { const preferred = preferredAgent?.id === model.id ? " - Gemma 4 12B" : ""; return ``; }).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)}+ GBVRAM ${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) => ( `` )).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) => `
  1. ${escapeHtml(step)}
  2. `).join("")}
`; }).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); })();