jackailocal / webui /app-v15.js
jackboy70's picture
Deploy: accurate lite-builder note
f25362a
Raw
History Blame Contribute Delete
52 kB
"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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", "\"": "&quot;",
}[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);
})();