| "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 = `<option value="auto">${escapeHtml(t("auto"))}</option>` + state.models.map((model) => ( |
| `<option value="${escapeHtml(model.id)}" ${model.available ? "" : "disabled"}>${escapeHtml(model.label || model.id)}${model.available ? "" : ` · ${escapeHtml(t("unavailable"))}`}</option>` |
| )).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 = `<option value="auto">${escapeHtml(t("auto"))}</option>` + agentModels.map((model) => { |
| const preferred = preferredAgent?.id === model.id ? " - Gemma 4 12B" : ""; |
| return `<option value="${escapeHtml(model.id)}">${escapeHtml(model.label || model.id)}${escapeHtml(preferred)}</option>`; |
| }).join(""); |
| if (preferredAgent) $("agentModelSelect").value = preferredAgent.id; |
| $("modelChips").innerHTML = state.models.filter((model) => model.available).map((model) => ( |
| `<span class="chip ${recommended?.id === model.id ? "recommended" : ""}">${escapeHtml(model.label || model.id)}</span>` |
| )).join("") || `<span class="chip">${escapeHtml(t("noModelsTitle"))}</span>`; |
| $("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) => `<span class="pill">${escapeHtml(feature)}</span>`).join(""); |
| return `<article class="panel model-card ${recommended?.id === model.id ? "recommended" : ""}"> |
| ${recommended?.id === model.id ? `<span class="recommended-badge">${escapeHtml(t("recommended"))}</span>` : ""} |
| <h2 class="model-title">${escapeHtml(model.label || model.id)}</h2> |
| <div class="muted small">${escapeHtml(model.id)}</div> |
| <p class="desc">${escapeHtml(model.notes || model.description || "")}</p> |
| <div class="model-meta"><span class="pill ${availableClass}">${escapeHtml(availableText)}</span><span class="pill">RAM ${escapeHtml(model.min_ram_gb ?? 0)}+ GB</span><span class="pill">VRAM ${escapeHtml(model.min_vram_gb ?? 0)}+ GB</span></div> |
| <div class="model-meta">${features}</div> |
| <div class="muted small">Ollama: ${escapeHtml(model.ollama || "n/a")}</div> |
| </article>`; |
| }).join("") : `<div class="panel"><h2>${escapeHtml(t("noModelsTitle"))}</h2><p>${escapeHtml(t("noModelsBody"))}</p></div>`; |
| 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 = `<div class="muted">${escapeHtml(t("apiUnavailable"))}</div>`; |
| 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 = [ |
| `<div class="feature-row"><span>${escapeHtml(stateLabel)}</span><span class="pill ${payload.licensed ? "availability-on" : "availability-off"}">${escapeHtml(payload.licensed ? t("on") : t("off"))}</span></div>`, |
| ]; |
| const info = payload.license; |
| if (info) { |
| rows.push(`<div class="feature-row"><span>${escapeHtml(t("licenseEdition"))}</span><span>${escapeHtml(info.edition || "")}</span></div>`); |
| rows.push(`<div class="feature-row"><span>${escapeHtml(t("licenseCustomer"))}</span><span>${escapeHtml(info.customer_name || "")}</span></div>`); |
| const expires = info.expires_at ? new Date(info.expires_at).toLocaleDateString() : t("licensePerpetual"); |
| rows.push(`<div class="feature-row"><span>${escapeHtml(t("licenseExpires"))}</span><span>${escapeHtml(expires)}</span></div>`); |
| } |
| 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]) => ( |
| `<div class="feature-row"><span>${escapeHtml(key)}</span><span class="pill ${value ? "availability-on" : "availability-off"}">${escapeHtml(value ? t("on") : t("off"))}</span></div>` |
| )).join("") : `<div class="muted">${escapeHtml(t("apiUnavailable"))}</div>`; |
| } |
|
|
| 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 = `<div class="muted">${escapeHtml(error.message)}</div>`; } |
| } |
| function renderThreads() { |
| $("threadList").innerHTML = state.threads.length ? state.threads.map((thread) => ( |
| `<button class="thread-item ${state.currentThread?.id === thread.id ? "active" : ""}" data-thread-id="${escapeHtml(thread.id)}"> |
| <strong>${escapeHtml(thread.title)}</strong><small>${escapeHtml(thread.preview || `${thread.message_count} messages`)}</small> |
| </button>` |
| )).join("") : `<div class="muted">${escapeHtml(t("newConversation"))}</div>`; |
| 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) => ( |
| `<div class="doc-item"><strong>${escapeHtml(doc.name)}</strong><div class="muted small">${escapeHtml(doc.content.length)} ${escapeHtml(t("bytes"))}</div></div>` |
| )).join("") : `<div class="muted">${escapeHtml(t("noLocalDocuments"))}</div>`; |
| } catch { $("docList").innerHTML = `<div class="muted">${escapeHtml(t("apiUnavailable"))}</div>`; } |
| } |
|
|
| 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 `<article class="panel field-card"><h2>${escapeHtml(title)}</h2><p>${escapeHtml(summary)}</p> |
| <ol class="field-steps">${steps.map((step) => `<li>${escapeHtml(step)}</li>`).join("")}</ol> |
| <ul class="field-steps warning-list">${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join("")}</ul> |
| <button class="secondary ask-manual" data-card-id="${escapeHtml(card.id)}">${escapeHtml(t("askAiButton"))}</button></article>`; |
| }).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 = `<div class="panel">${escapeHtml(error.message)}</div>`; } |
| } |
|
|
| 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); |
| })(); |
|
|