${escapeHtml(model.label || model.id)}
${escapeHtml(model.notes || model.description || "")}
"use strict";
const $ = (id) => document.getElementById(id);
const API_BASE = window.location.protocol === "file:" ? "http://127.0.0.1:4891" : "";
const pairFromUrl = new URLSearchParams(window.location.search).get("pair");
if (pairFromUrl) localStorage.setItem("jackailocal.phoneToken", pairFromUrl);
const state = {
models: [],
status: {},
features: {},
threads: [],
currentThread: null,
apiOk: false,
currentView: "dashboard",
language: localStorage.getItem("jackailocal.language") || "en",
scoutImageBase64: "",
voice: {},
audioContext: null,
audioStream: null,
audioProcessor: null,
audioSource: null,
audioChunks: [],
audioSampleRate: 44100,
};
const i18n = {
en: {
brandSubtitle: "Offline local AI", statusStarting: "Starting", statusLocalScan: "Local scan...",
navDashboard: "Dashboard", navChat: "Chat", navScout: "SCOUT Vision", navVoice: "Voice",
navFieldManual: "Field Manual", navModels: "Models", navDocuments: "Documents",
navTransfers: "Import / Export", navPhone: "Phone Access", navBenchmark: "Benchmark",
navSettings: "Settings", navSupport: "Support", networkMode: "Network mode",
loopbackOnly: "Local access only", eyebrow: "Portable private AI", refreshButton: "Refresh",
supportButton: "Support", heroKicker: "Ready for offline work",
heroTitle: "Local AI, loaded from your JackAILocal drive.",
heroSubtitle: "Runtime, models, conversations, documents, and UI stay on this device. No cloud fallback is used.",
startChatButton: "Start chat", checkModelsButton: "Check models", runtimeCard: "Runtime",
hardwareCard: "Hardware", modelCard: "Recommended model", privacyCard: "Privacy",
autoSelectHint: "Selected from installed local models", offlineLocked: "Offline locked",
localOnlyHint: "Loopback by default", setupTitle: "Preparation state", checkRuntimeTitle: "Local API",
checkRuntimeText: "jackailocald must be running.", checkModelsTitle: "Installed model",
checkModelsText: "At least one compatible local model must be installed.", checkSecurityTitle: "Security mode",
checkSecurityText: "Shell tools remain disabled and LAN access is explicit.",
quickActionsTitle: "Quick actions", runBenchmarkQuick: "Run benchmark", openDocsQuick: "Open documents",
createSupportQuick: "Create support bundle", chat: "Chat", dashboard: "Dashboard", scout: "SCOUT Vision",
voice: "Voice", "field-manual": "Field Manual", models: "Models", documents: "Documents",
transfers: "Import / Export", phone: "Phone Access", benchmark: "Benchmark", settings: "Settings",
support: "Support", threadsTitle: "Threads", newThreadButton: "New", threadFilterPlaceholder: "Filter threads",
newConversation: "New conversation", renameButton: "Rename", deleteButton: "Delete", modelLabel: "Model",
clearButton: "New thread", promptPlaceholder: "Write your request here. Enter = send, Shift+Enter = new line.",
sendButton: "Send", chatHelperTitle: "Assistant mode",
chatHelperText: "Use Auto unless you need a specific installed model. Threads are stored on this JackAILocal drive.",
offlineNoteTitle: "Offline note", offlineNoteText: "Answers come only from reachable local backends.",
scoutTitle: "SCOUT Vision", scoutSubtitle: "Analyze an image with an installed local vision model.",
scoutPromptPlaceholder: "Describe the image and extract readable text.", analyzeButton: "Analyze locally",
scoutResultTitle: "Analysis result", voiceTitle: "Voice Mode",
voiceSubtitle: "Local speech-to-text with whisper.cpp and text-to-speech with Piper.",
sttTitle: "Speech to text", recordButton: "Record", stopButton: "Stop", transcriptPlaceholder: "Transcript",
sendToChatButton: "Send to chat", ttsTitle: "Text to speech", ttsPlaceholder: "Text to speak",
speakButton: "Speak locally", fieldManualTitle: "Field Manual",
fieldManualSubtitle: "Offline reference cards with an Ask AI action.", askAiButton: "Ask AI about this",
modelsTitle: "Models", modelsSubtitle: "Availability is read from installed local model files and Ollama.",
agentTitle: "LLM Config Agent",
agentSubtitle: "A real installed local LLM can review hardware, model selection, and client configuration. Preferred model: gemma4:12b.",
agentModelLabel: "Agent model",
agentNotesPlaceholder: "Client constraints, use case, budget, privacy needs...",
agentRunButton: "Run local agent review",
agentRunning: "Local agent review running...",
addModelTitle: "Add a model to the catalog",
addModelSubtitle: "This registers a model reference. It does not claim the model is installed.",
addModelIdPlaceholder: "Ollama model reference, example: qwen3.5:4b", addModelLabelPlaceholder: "display name",
addButton: "Add", documentsTitle: "Documents", documentsSubtitle: "Local storage in workspace/documents.",
createNoteTitle: "Create a note", docNamePlaceholder: "document-name.txt",
docContentPlaceholder: "Text, notes, procedures, prompts...", saveLocalButton: "Save locally",
localDocsTitle: "Local documents", transfersTitle: "Encrypted Import / Export",
transfersSubtitle: "Transfer threads and documents with an AES-256-GCM encrypted pack.",
exportTitle: "Export", importTitle: "Import", passphrasePlaceholder: "Passphrase, minimum 8 characters",
includeThreads: "Include threads", includeDocuments: "Include documents", includeSettings: "Include safe settings",
exportButton: "Export encrypted pack", importMode: "Import mode", importButton: "Import encrypted pack",
phoneTitle: "Phone Access", phoneSubtitle: "Explicit local-network access with a pairing token. Disabled by default.",
enablePhoneButton: "Enable Phone Access", disablePhoneButton: "Disable Phone Access",
restartRuntimeButton: "Restart runtime",
phoneRestartNote: "A runtime restart is required after changing this mode.",
benchmarkTitle: "Benchmark", benchmarkSubtitle: "Short real inference test against the selected local backend.",
runTestButton: "Run test", settingsTitle: "Settings", settingsSubtitle: "Options stay simple. Dangerous tools remain disabled.",
modeTitle: "Mode", settingsModeHint: "Simple mode is the default for non-technical users.",
simpleMode: "Simple", simpleModeDesc: "Minimal choices and automatic local model selection.",
advancedMode: "Advanced", advancedModeDesc: "Shows more controls without enabling shell or system tools.",
offlineLock: "Offline lock", offlineLockDesc: "Keeps JackAILocal local-first.",
toolsLock: "Block shell/system tools", toolsLockDesc: "Prevents agent-style system actions.",
saveButton: "Save", activeFeaturesTitle: "Active features",
activeFeaturesSubtitle: "Capabilities are detected from the running local API.",
licenseTitle: "License", licenseSubtitle: "License status of this installation. Verified locally, no internet required.",
licenseStateLicensed: "Licensed", licenseStateUnlicensed: "No license installed",
licenseStateExpired: "License expired", licenseStateInvalid: "License invalid",
licenseStateNoKey: "Verification key missing", licenseEdition: "Edition",
licenseCustomer: "Licensed to", licenseExpires: "Expires", licensePerpetual: "Perpetual",
legalDocsHint: "Legal documents are in the legal/ and licenses/ folders of this installation.",
supportTitle: "Support", supportSubtitle: "Local diagnostics without conversations or documents.",
prepareReportButton: "Create report", auto: "Auto", runtimeUnavailable: "Runtime unavailable",
startWithCommand: "Start with START-HERE.cmd", apiNotResponding: "The local API is not responding:",
noModelsTitle: "No installed models", noModelsBody: "Use the builder to install a compatible local model.",
modelCatalogError: "Cannot load model catalog:", responsePending: "Generating local response...",
localError: "Local error:", backendHint: "Check that a local backend and model are installed.",
chatCleared: "New local thread created.", adding: "Adding...", saving: "Saving...",
noLocalDocuments: "No local documents.", apiUnavailable: "API unavailable.", benchmarkRunning: "Benchmark running...",
benchmarkFailed: "Benchmark failed:", savingSettings: "Saving...", preparing: "Preparing...",
readyMessage: "JackAILocal is ready. Create a thread and use Auto to select an installed model.",
recommended: "recommended", installed: "installed", unavailable: "not installed", bytes: "bytes",
unknown: "unknown", apiReady: "Ready", apiOffline: "Not running", pass: "Ready", fail: "Missing",
locked: "Locked", recording: "Recording...", processing: "Processing locally...", available: "Available",
notAvailable: "Not available", confirmDelete: "Delete this thread?", renamePrompt: "Thread name",
fileRequired: "Choose a file first.", imageRequired: "Choose an image first.", importComplete: "Import complete.",
on: "On", off: "Off",
eulaTitle: "End User License Agreement", eulaAcceptButton: "I accept",
eulaLoading: "Loading the license agreement...",
eulaFallback: "The license agreement could not be loaded from the local API. The full text is available in the legal/ folder of this installation (EULA_EN.md, EULA_FR.md). By continuing you accept its terms.",
installLicenseButton: "Install license file", licenseInstalling: "Installing license...",
},
fr: {
brandSubtitle: "IA locale hors ligne", statusStarting: "Démarrage", statusLocalScan: "Analyse locale...",
navDashboard: "Tableau", navChat: "Chat", navScout: "Vision SCOUT", navVoice: "Voix",
navFieldManual: "Manuel terrain", navModels: "Modèles", navDocuments: "Documents",
navTransfers: "Import / Export", navPhone: "Accès téléphone", navBenchmark: "Benchmark",
navSettings: "Réglages", navSupport: "Support", networkMode: "Mode réseau",
loopbackOnly: "Accès local seulement", eyebrow: "IA privée portable", refreshButton: "Rafraîchir",
supportButton: "Support", heroKicker: "Prêt pour le travail hors ligne",
heroTitle: "IA locale, chargée depuis votre support JackAILocal.",
heroSubtitle: "Runtime, modèles, conversations, documents et UI restent sur cet appareil. Aucun repli cloud n'est utilisé.",
startChatButton: "Démarrer le chat", checkModelsButton: "Vérifier les modèles", runtimeCard: "Runtime",
hardwareCard: "Matériel", modelCard: "Modèle recommandé", privacyCard: "Confidentialité",
autoSelectHint: "Sélectionné parmi les modèles locaux installés", offlineLocked: "Hors ligne verrouillé",
localOnlyHint: "Loopback par défaut", setupTitle: "État de préparation", checkRuntimeTitle: "API locale",
checkRuntimeText: "jackailocald doit être démarré.", checkModelsTitle: "Modèle installé",
checkModelsText: "Au moins un modèle local compatible doit être installé.", checkSecurityTitle: "Mode sécurité",
checkSecurityText: "Les outils shell restent désactivés et l'accès LAN est explicite.",
quickActionsTitle: "Actions rapides", runBenchmarkQuick: "Lancer benchmark", openDocsQuick: "Ouvrir documents",
createSupportQuick: "Créer bundle support", chat: "Chat", dashboard: "Tableau", scout: "Vision SCOUT",
voice: "Voix", "field-manual": "Manuel terrain", models: "Modèles", documents: "Documents",
transfers: "Import / Export", phone: "Accès téléphone", benchmark: "Benchmark", settings: "Réglages",
support: "Support", threadsTitle: "Fils", newThreadButton: "Nouveau", threadFilterPlaceholder: "Filtrer les fils",
newConversation: "Nouvelle conversation", renameButton: "Renommer", deleteButton: "Supprimer", modelLabel: "Modèle",
clearButton: "Nouveau fil", promptPlaceholder: "Écrivez votre demande. Entrée = envoyer, Maj+Entrée = nouvelle ligne.",
sendButton: "Envoyer", chatHelperTitle: "Mode assistant",
chatHelperText: "Utilisez Auto sauf si vous voulez un modèle installé précis. Les fils sont stockés sur ce support JackAILocal.",
offlineNoteTitle: "Note hors ligne", offlineNoteText: "Les réponses viennent uniquement des backends locaux accessibles.",
scoutTitle: "Vision SCOUT", scoutSubtitle: "Analysez une image avec un modèle vision local installé.",
scoutPromptPlaceholder: "Décrivez l'image et extrayez le texte lisible.", analyzeButton: "Analyser localement",
scoutResultTitle: "Résultat d'analyse", voiceTitle: "Mode voix",
voiceSubtitle: "Reconnaissance vocale locale avec whisper.cpp et synthèse avec Piper.",
sttTitle: "Parole vers texte", recordButton: "Enregistrer", stopButton: "Arrêter", transcriptPlaceholder: "Transcription",
sendToChatButton: "Envoyer au chat", ttsTitle: "Texte vers parole", ttsPlaceholder: "Texte à prononcer",
speakButton: "Prononcer localement", fieldManualTitle: "Manuel terrain",
fieldManualSubtitle: "Cartes de référence hors ligne avec action Ask AI.", askAiButton: "Demander à l'IA",
modelsTitle: "Modèles", modelsSubtitle: "La disponibilité est lue depuis Ollama et les fichiers locaux installés.",
agentTitle: "Agent LLM config",
agentSubtitle: "Un vrai LLM local installe peut revoir le materiel, la selection des modeles et la configuration client. Modele prefere : gemma4:12b.",
agentModelLabel: "Modele agent",
agentNotesPlaceholder: "Contraintes client, usage, budget, besoins de confidentialite...",
agentRunButton: "Lancer la revue agent locale",
agentRunning: "Revue agent locale en cours...",
addModelTitle: "Ajouter un modèle au catalogue",
addModelSubtitle: "Cette action inscrit une référence. Elle ne prétend pas que le modèle est installé.",
addModelIdPlaceholder: "Référence Ollama, exemple : qwen3.5:4b", addModelLabelPlaceholder: "nom affiché",
addButton: "Ajouter", documentsTitle: "Documents", documentsSubtitle: "Stockage local dans workspace/documents.",
createNoteTitle: "Créer une note", docNamePlaceholder: "nom-du-document.txt",
docContentPlaceholder: "Texte, notes, procédures, prompts...", saveLocalButton: "Sauver localement",
localDocsTitle: "Documents locaux", transfersTitle: "Import / Export chiffré",
transfersSubtitle: "Transférez fils et documents avec un pack chiffré AES-256-GCM.",
exportTitle: "Exporter", importTitle: "Importer", passphrasePlaceholder: "Phrase secrète, minimum 8 caractères",
includeThreads: "Inclure les fils", includeDocuments: "Inclure les documents", includeSettings: "Inclure les réglages sûrs",
exportButton: "Exporter le pack chiffré", importMode: "Mode d'import", importButton: "Importer le pack chiffré",
phoneTitle: "Accès téléphone", phoneSubtitle: "Accès réseau local explicite avec jeton de pairage. Désactivé par défaut.",
enablePhoneButton: "Activer l'accès téléphone", disablePhoneButton: "Désactiver l'accès téléphone",
restartRuntimeButton: "Redémarrer le runtime",
phoneRestartNote: "Un redémarrage du runtime est requis après ce changement.",
benchmarkTitle: "Benchmark", benchmarkSubtitle: "Test réel court contre le backend local sélectionné.",
runTestButton: "Lancer test", settingsTitle: "Réglages", settingsSubtitle: "Les options restent simples. Les outils dangereux restent désactivés.",
modeTitle: "Mode", settingsModeHint: "Le mode simple est le défaut pour les utilisateurs non techniques.",
simpleMode: "Simple", simpleModeDesc: "Choix minimaux et sélection automatique du modèle local.",
advancedMode: "Avancé", advancedModeDesc: "Affiche plus de contrôles sans activer le shell ni les outils système.",
offlineLock: "Verrou hors ligne", offlineLockDesc: "Garde JackAILocal local-first.",
toolsLock: "Bloquer shell/outils système", toolsLockDesc: "Empêche les actions système de type agent.",
saveButton: "Enregistrer", activeFeaturesTitle: "Fonctions actives",
activeFeaturesSubtitle: "Les capacités sont détectées depuis l'API locale.",
licenseTitle: "Licence", licenseSubtitle: "État de la licence de cette installation. Vérifiée localement, sans Internet.",
licenseStateLicensed: "Sous licence", licenseStateUnlicensed: "Aucune licence installée",
licenseStateExpired: "Licence expirée", licenseStateInvalid: "Licence invalide",
licenseStateNoKey: "Clé de vérification absente", licenseEdition: "Édition",
licenseCustomer: "Licencié à", licenseExpires: "Expire", licensePerpetual: "Perpétuelle",
legalDocsHint: "Les documents légaux sont dans les dossiers legal/ et licenses/ de cette installation.",
supportTitle: "Support", supportSubtitle: "Diagnostic local sans conversations ni documents.",
prepareReportButton: "Créer rapport", auto: "Auto", runtimeUnavailable: "Runtime non disponible",
startWithCommand: "Démarrer avec START-HERE.cmd", apiNotResponding: "L'API locale ne répond pas :",
noModelsTitle: "Aucun modèle installé", noModelsBody: "Utilisez le builder pour installer un modèle local compatible.",
modelCatalogError: "Impossible de charger le catalogue :", responsePending: "Réponse locale en cours...",
localError: "Erreur locale :", backendHint: "Vérifiez qu'un backend local et un modèle sont installés.",
chatCleared: "Nouveau fil local créé.", adding: "Ajout...", saving: "Sauvegarde...",
noLocalDocuments: "Aucun document local.", apiUnavailable: "API non disponible.", benchmarkRunning: "Benchmark en cours...",
benchmarkFailed: "Benchmark échoué :", savingSettings: "Enregistrement...", preparing: "Préparation...",
readyMessage: "JackAILocal est prêt. Créez un fil et utilisez Auto pour sélectionner un modèle installé.",
recommended: "recommandé", installed: "installé", unavailable: "non installé", bytes: "octets",
unknown: "inconnu", apiReady: "Prêt", apiOffline: "Non démarré", pass: "Prêt", fail: "Manquant",
locked: "Verrouillé", recording: "Enregistrement...", processing: "Traitement local...", available: "Disponible",
notAvailable: "Non disponible", confirmDelete: "Supprimer ce fil ?", renamePrompt: "Nom du fil",
fileRequired: "Choisissez d'abord un fichier.", imageRequired: "Choisissez d'abord une image.", importComplete: "Import terminé.",
on: "Actif", off: "Inactif",
eulaTitle: "Contrat de licence utilisateur final", eulaAcceptButton: "J'accepte",
eulaLoading: "Chargement du contrat de licence...",
eulaFallback: "Le contrat de licence n'a pas pu être chargé depuis l'API locale. Le texte complet est disponible dans le dossier legal/ de cette installation (EULA_FR.md, EULA_EN.md). En continuant, vous en acceptez les termes.",
installLicenseButton: "Installer le fichier de licence", licenseInstalling: "Installation de la licence...",
},
};
function t(key) { return i18n[state.language]?.[key] || i18n.en[key] || key; }
function apiUrl(path) { return /^https?:\/\//i.test(path) ? path : `${API_BASE}${path}`; }
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>'"]/g, (character) => ({
"&": "&", "<": "<", ">": ">", "'": "'", "\"": """,
}[character]));
}
function contentText(value) { return typeof value === "string" ? value : JSON.stringify(value, null, 2); }
function viewTitleKey(view) { return view; }
async function requestJson(url, options = {}) {
const token = localStorage.getItem("jackailocal.phoneToken");
const response = await fetch(apiUrl(url), {
headers: {
"Content-Type": "application/json",
...(token ? { "X-JackAILocal-Token": token } : {}),
...(options.headers || {}),
},
...options,
});
const text = await response.text();
let payload = {};
try { payload = text ? JSON.parse(text) : {}; } catch { payload = { raw: text }; }
if (!response.ok) throw new Error(payload.error || payload.message || `${response.status} ${response.statusText}`);
return payload;
}
const getJson = (url) => requestJson(url);
const postJson = (url, body) => requestJson(url, { method: "POST", body: JSON.stringify(body || {}) });
const patchJson = (url, body) => requestJson(url, { method: "PATCH", body: JSON.stringify(body || {}) });
const deleteJson = (url) => requestJson(url, { method: "DELETE" });
function applyLanguage(language) {
state.language = language;
localStorage.setItem("jackailocal.language", language);
document.documentElement.lang = language;
document.querySelectorAll("[data-i18n]").forEach((element) => { element.textContent = t(element.dataset.i18n); });
document.querySelectorAll("[data-i18n-placeholder]").forEach((element) => { element.placeholder = t(element.dataset.i18nPlaceholder); });
document.querySelectorAll(".lang-btn").forEach((button) => button.classList.toggle("active", button.dataset.lang === language));
$("viewTitle").textContent = t(viewTitleKey(state.currentView));
renderModels();
renderThreads();
renderFeatures();
renderLicense();
}
function switchView(name) {
state.currentView = name;
document.querySelectorAll(".nav").forEach((button) => button.classList.toggle("active", button.dataset.view === name));
document.querySelectorAll(".view").forEach((view) => view.classList.remove("active"));
$(`view-${name}`)?.classList.add("active");
$("viewTitle").textContent = t(viewTitleKey(name));
}
function showError(message) {
$("errorBanner").textContent = message;
$("errorBanner").classList.remove("hidden");
}
function clearError() { $("errorBanner").classList.add("hidden"); $("errorBanner").textContent = ""; }
function setStatus(kind, title, subtitle) {
$("statusBox").className = `status-card ${kind === "ok" ? "state-ok" : kind === "error" ? "state-error" : "state-loading"}`;
$("runtimeStatus").textContent = title;
$("hardwareLine").textContent = subtitle || "";
}
function updateCheck(id, ok) { $(id)?.classList.toggle("ok", Boolean(ok)); $(id)?.classList.toggle("error", !ok); }
function formatHardware(hardware = {}) { return `RAM ${hardware.ram_gb ?? "?"} GB · VRAM ${hardware.vram_gb ?? "?"} GB · CPU ${hardware.cpu_threads ?? "?"}`; }
function formatGpu(hardware = {}) { return `${hardware.gpu_vendor || t("unknown")}${hardware.gpu_name ? ` · ${hardware.gpu_name}` : ""}`; }
async function loadStatus() {
try {
state.status = await getJson("/api/status");
state.apiOk = true;
setStatus("ok", `${t("apiReady")} · ${state.status.backend}`, `${formatHardware(state.status.hardware)} · ${state.status.bind}`);
clearError();
} catch (error) {
state.apiOk = false;
state.status = {};
setStatus("error", t("runtimeUnavailable"), t("startWithCommand"));
showError(`${t("apiNotResponding")} ${error.message}`);
}
renderDashboard();
}
function selectRecommendedModel() {
const hardware = state.status.hardware || {};
return state.models.filter((model) => model.available)
.filter((model) => Number(hardware.ram_gb ?? 0) >= Number(model.min_ram_gb ?? 0))
.filter((model) => Number(hardware.vram_gb ?? 0) >= Number(model.min_vram_gb ?? 0))
.filter((model) => (model.features || []).includes("chat"))
.at(-1) || null;
}
function renderDashboard() {
const recommended = selectRecommendedModel();
$("runtimeMetric").textContent = state.apiOk ? t("apiReady") : t("apiOffline");
$("backendMetric").textContent = state.status.backend || "unavailable";
$("hardwareMetric").textContent = formatHardware(state.status.hardware || {});
$("gpuMetric").textContent = formatGpu(state.status.hardware || {});
$("recommendedMetric").textContent = recommended?.label || recommended?.id || t("auto");
updateCheck("checkRuntime", state.apiOk);
updateCheck("checkModels", state.models.some((model) => model.available));
updateCheck("checkSecurity", state.features.shell_tools === false);
const bind = state.status.bind || "127.0.0.1:4891";
document.querySelector(".security-pill").textContent = bind;
document.querySelector(".mini-copy").textContent = bind.startsWith("127.") ? t("loopbackOnly") : t("navPhone");
}
async function loadModels() {
try {
const payload = await getJson("/api/models");
state.models = payload.models || [];
renderModels();
} catch (error) {
state.models = [];
renderModels();
showError(`${t("modelCatalogError")} ${error.message}`);
}
}
function renderModels() {
const recommended = selectRecommendedModel();
$("modelSelect").innerHTML = `` + state.models.map((model) => (
``
)).join("");
const agentModels = state.models
.filter((model) => model.available)
.filter((model) => Number(model.params_b ?? 999) <= 32)
.filter((model) => (model.features || []).includes("chat"));
const preferredAgent = agentModels.find((model) => model.id === "gemma_config_agent_12b" || model.ollama === "gemma4:12b");
$("agentModelSelect").innerHTML = `` + agentModels.map((model) => {
const preferred = preferredAgent?.id === model.id ? " - Gemma 4 12B" : "";
return ``;
}).join("");
if (preferredAgent) $("agentModelSelect").value = preferredAgent.id;
$("modelChips").innerHTML = state.models.filter((model) => model.available).map((model) => (
`${escapeHtml(model.label || model.id)}`
)).join("") || `${escapeHtml(t("noModelsTitle"))}`;
$("modelCards").innerHTML = state.models.length ? state.models.map((model) => {
const availableClass = model.available ? "availability-on" : "availability-off";
const availableText = model.available ? t("installed") : t("unavailable");
const features = (model.features || []).map((feature) => `${escapeHtml(feature)}`).join("");
return ` ${escapeHtml(model.notes || model.description || "")}${escapeHtml(model.label || model.id)}
${escapeHtml(t("noModelsBody"))}
${escapeHtml(summary)}