From 382c5dbdcedcfd68d7ee3d25676027be5aa91cba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 14:01:36 +0000 Subject: [PATCH] =?UTF-8?q?Interface=20en=20anglais,=20par-dessus=20une=20?= =?UTF-8?q?source=20fran=C3=A7aise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'amont tient une table de clés FR/EN et marque chaque texte d'un data-i18n. Reproduire ça ici demanderait de réécrire tous les écrans d'un coup, avec le risque d'en casser un pour une clé oubliée — et un « settings.memory.title » affiché en production. Chemin additif : la source RESTE le français, et « English » applique un dictionnaire sur le texte affiché (correspondance exacte, nœud par nœud). Une chaîne absente du dictionnaire reste en français ; le dictionnaire s'enrichit sans toucher au reste de l'interface. Jamais traduits : le fil de discussion (#chat), le code, les zones de saisie, et tout ce qui porte data-no-i18n. Un observateur couvre les panneaux rendus en JS après le chargement. Revenir au français recharge la page — le texte d'origine a été remplacé dans le DOM, c'est le moyen sûr de le retrouver intact. Couverture de départ : navigation des réglages, intitulés de sections, libellés de lignes, boutons et interrupteurs. Sélecteur dans Apparence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY --- README.md | 6 + internal/loki/ui/index.html | 249 +++++++++++++++++++++++++++ internal/loki/ui/src/index.tmpl.html | 9 + internal/loki/ui/src/js/28-i18n.js | 240 ++++++++++++++++++++++++++ 4 files changed, 504 insertions(+) create mode 100644 internal/loki/ui/src/js/28-i18n.js diff --git a/README.md b/README.md index 4c87ace..0f02690 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,12 @@ Héritées d'AJEAN : la seule clé — de quoi remonter le conteneur ailleurs. C'est la sauvegarde de l'amont sans son relais : ici rien ne part sur un service tiers, le fichier reste chez toi. +- **Interface en anglais** (*Réglages → Apparence → Langue*) : la source reste + française et « English » applique un dictionnaire sur la coque — navigation, + intitulés, boutons, interrupteurs. Ce qui n'y figure pas **reste en + français** plutôt que d'afficher une clé technique, et le fil de discussion + n'est jamais touché : c'est ton contenu. Le dictionnaire s'enrichit sans + toucher au reste de l'interface (`ui/src/js/28-i18n.js`). - **Presets** de configuration par modèle, bench, auto-détection GPU. - **Échantillonnage réglable par preset** : température, `top_p`, `top_k`, `min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index f8333bb..0f1cf31 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -2860,6 +2860,15 @@ html[data-files="1"] #files-btn{color:var(--accent)}
Apparence
+ + @@ -10915,4 +10924,244 @@ if(pushSupported() && window.isSecureContext){ navigator.serviceWorker.register('/sw.js', {scope:'/'}).then(r=>{ _swReg=r; }).catch(()=>{}); } document.addEventListener('DOMContentLoaded', ()=>{ pushRefresh(); }); +// 28-i18n.js — interface en anglais, par-dessus une source française. +// +// L'amont (AJEAN) a un vrai système de clés : chaque texte de l'UI porte un +// data-i18n et vit dans une table FR/EN. Traduire Loki de la même façon +// demanderait de réécrire tous ses écrans d'un coup, avec le risque d'en casser +// un pour une clé oubliée. On prend l'autre chemin, additif : la source RESTE le +// français, et passer en anglais applique un DICTIONNAIRE sur le texte affiché. +// +// Conséquences, assumées : +// - une chaîne absente du dictionnaire reste en français (dégradation douce, +// jamais de « settings.memory.title » à l'écran) ; +// - le dictionnaire s'enrichit sans toucher au reste de l'interface ; +// - la correspondance est EXACTE (texte complet d'un nœud, une fois les +// espaces retirés) : aucune substitution partielle, donc pas de mot traduit +// au milieu d'une phrase qui ne l'est pas. +// +// Ce qui n'est JAMAIS traduit : le fil de discussion (#chat) — c'est ton +// contenu et celui du modèle —, le code (pre/code), les zones de saisie, et +// tout ce qui porte data-no-i18n. + +const LANG_KEY = 'loki-lang'; +function uiLang(){ try{ return localStorage.getItem(LANG_KEY) || 'fr'; }catch(e){ return 'fr'; } } + +// Dictionnaire français → anglais. Couvre la « coque » : navigation des +// réglages, intitulés de sections, libellés de lignes, boutons, interrupteurs. +// Les longues bulles d'aide restent en français pour l'instant — elles sont +// nombreuses et leur traduction n'apporte rien tant que la coque ne l'est pas. +const I18N_EN = { + // Navigation des réglages + 'Paramètres': 'Settings', + 'Intelligence artificielle': 'Artificial intelligence', + 'Moteur': 'Engine', + 'Application': 'Application', + 'Projets': 'Projects', + 'Config active': 'Active config', + 'Presets': 'Presets', + 'Mode agent': 'Agent mode', + 'Tâches planifiées': 'Scheduled tasks', + 'System prompt': 'System prompt', + 'Moteur llama.cpp': 'llama.cpp engine', + 'Journal du moteur': 'Engine log', + 'Dictée': 'Dictation', + 'Identité': 'Identity', + 'Apparence': 'Appearance', + 'Accès OpenAI': 'OpenAI access', + 'Actions': 'Actions', + // Sections + 'Mémoire': 'Memory', + 'Accès internet': 'Internet access', + 'Contrôle du navigateur': 'Browser control', + 'Serveurs MCP': 'MCP servers', + 'Service du moteur': 'Engine service', + 'Modèle': 'Model', + 'Reconnaissance': 'Recognition', + 'Affichage': 'Display', + 'Endpoint compatible OpenAI': 'OpenAI-compatible endpoint', + 'Clé API': 'API key', + 'Connexion': 'Connection', + 'Format': 'Format', + 'Portée': 'Scope', + 'Contenu': 'Content', + 'Nom': 'Name', + 'Type': 'Type', + 'Consigne': 'Instruction', + 'Script': 'Script', + 'Preset': 'Preset', + 'Fréquence': 'Frequency', + 'Accès': 'Access', + 'Cartes graphiques': 'Graphics cards', + 'Réglages': 'Settings', + 'Échantillonnage': 'Sampling', + 'Sauvegarde chiffrée': 'Encrypted backup', + 'Ton avatar': 'Your avatar', + 'Avatar de Loki': "Loki's avatar", + 'À exécuter': 'To run', + 'Modèle utilisé': 'Model used', + 'Projet visé': 'Target project', + 'Toutes les': 'Every', + 'Accès à la mémoire': 'Memory access', + 'Accès au web': 'Web access', + 'Tâche active': 'Task enabled', + 'Fichier': 'File', + 'Destination': 'Destination', + 'Contexte': 'Context', + 'Vision': 'Vision', + 'Vérifier': 'Check', + // Interrupteurs + 'activer le mode agent': 'enable agent mode', + "me prévenir quand c'est prêt": 'notify me when it is ready', + 'chiffrer la mémoire sur le disque': 'encrypt memory on disk', + "donner internet à l'IA": 'give the AI internet access', + "laisser l'IA piloter un navigateur": 'let the AI drive a browser', + 'compactage automatique du contexte': 'automatic context compaction', + 'suspendre toutes les tâches': 'suspend all tasks', + 'masquer le raisonnement': 'hide reasoning', + "masquer les appels d'outils": 'hide tool calls', + 'garder les bulles repliées': 'keep bubbles collapsed', + 'masquer la vitesse de génération': 'hide generation speed', + 'mode': 'mode', + // Boutons + 'Libérer la VRAM': 'Free VRAM', + 'libérer la VRAM': 'free VRAM', + 'Chat': 'Chat', + 'Code': 'Code', + 'compacter': 'compact', + 'fermer': 'close', + 'relancer': 'restart', + 'Tester la connexion': 'Test connection', + 'supprimer': 'delete', + 'annuler': 'cancel', + 'Annuler': 'Cancel', + 'Confirmer': 'Confirm', + 'enregistrer': 'save', + 'Enregistrer': 'Save', + '+ ajouter un projet': '+ add a project', + '+ ajouter une tâche': '+ add a task', + '+ ajouter un serveur': '+ add a server', + '+ ajouter un point': '+ add an entry', + '+ ajouter': '+ add', + 'exporter': 'export', + 'importer': 'import', + 'retirer': 'remove', + 'catalogue': 'catalog', + 'renommer': 'rename', + 'déplacer': 'move', + 'vérifier les mises à jour': 'check for updates', + '↥ Vérifier les mises à jour': '↥ Check for updates', + 'mettre à jour le moteur': 'update the engine', + "revenir au moteur de l'image": 'go back to the image engine', + 'démarrer': 'start', + 'redémarrer': 'restart', + 'arrêter': 'stop', + 'Télécharger ce modèle': 'Download this model', + 'Annuler le téléchargement': 'Cancel download', + 'Retirer le jeton': 'Remove token', + 'Tester le micro (3 s)': 'Test the mic (3 s)', + 'tester maintenant': 'test now', + 'afficher': 'show', + 'générer': 'generate', + 'définir…': 'set…', + '⤓ exporter…': '⤓ export…', + '⤓ télécharger': '⤓ download', + 'bench': 'bench', + 'Installer & compiler': 'Install & build', + 'voir plus': 'show more', + 'voir moins': 'show less', + 'copier': 'copy', + 'copié': 'copied', + 'réponse': 'response', + 'modifications': 'changes', + 'écriture en cours': 'writing', + 'exécution en cours…': 'running…', + // Divers visibles + 'Nouvelle discussion': 'New chat', + 'Discussions': 'Chats', + 'Langue': 'Language', + 'Français': 'French', + 'Anglais': 'English', +}; + +// Sélecteurs dont le CONTENU n'est jamais traduit : ton texte et celui du +// modèle, le code, les champs de saisie. +const I18N_SKIP = '#chat, pre, code, textarea, input, [data-no-i18n]'; + +function i18nText(fr){ + const key = (fr || '').trim(); + if (!key) return null; + const en = I18N_EN[key]; + if (en === undefined) return null; + // On rend la traduction en conservant l'espacement d'origine autour du texte + // (un « ' ' + label » dans le HTML garde son espace). + const lead = fr.slice(0, fr.length - fr.trimStart().length); + const tail = fr.slice(fr.trimEnd().length); + return lead + en + tail; +} + +// Traduit les nœuds texte et quelques attributs sous `root`. +function i18nApply(root){ + if (uiLang() !== 'en' || !root) return; + const el = root.nodeType === 1 ? root : root.parentElement; + if (el && el.closest && el.closest(I18N_SKIP)) return; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(n){ + if (!n.nodeValue || !n.nodeValue.trim()) return NodeFilter.FILTER_REJECT; + if (n.parentElement && n.parentElement.closest(I18N_SKIP)) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + } + }); + const todo = []; + for (let n = walker.nextNode(); n; n = walker.nextNode()) todo.push(n); + for (const n of todo){ + const tr = i18nText(n.nodeValue); + if (tr !== null) n.nodeValue = tr; + } + // Attributs visibles (infobulles, champs vides, accessibilité). + const scope = root.nodeType === 1 ? root : document.body; + const all = scope.querySelectorAll ? [scope, ...scope.querySelectorAll('[placeholder],[title],[aria-label]')] : []; + for (const e of all){ + if (!e.getAttribute) continue; + for (const attr of ['placeholder', 'title', 'aria-label']){ + const v = e.getAttribute(attr); + if (!v) continue; + const tr = i18nText(v); + if (tr !== null) e.setAttribute(attr, tr); + } + } +} + +// Les panneaux sont rendus en JS bien après le chargement : un observateur +// traduit ce qui apparaît. Il ignore #chat, donc le fil ne passe jamais dessous. +function i18nWatch(){ + if (uiLang() !== 'en') return; + const obs = new MutationObserver(muts => { + for (const m of muts){ + for (const n of m.addedNodes){ + if (n.nodeType === 1 || n.nodeType === 3) i18nApply(n); + } + } + }); + obs.observe(document.body, {childList: true, subtree: true}); +} + +// setUiLang : bascule la langue. Vers l'anglais, on applique tout de suite ; +// vers le français, on RECHARGE — le texte français d'origine a été remplacé +// dans le DOM, le recharger est le moyen le plus sûr de le retrouver intact. +function setUiLang(lang){ + const next = lang === 'en' ? 'en' : 'fr'; + const prev = uiLang(); + try{ localStorage.setItem(LANG_KEY, next); }catch(e){} + if (next === prev) return; + if (next === 'en'){ i18nApply(document.body); i18nWatch(); } + else location.reload(); +} + +document.addEventListener('DOMContentLoaded', () => { + const sel = document.getElementById('ui-lang'); + if (sel) sel.value = uiLang(); + i18nApply(document.body); + i18nWatch(); +}); diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index bda1ddd..fa04693 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -659,6 +659,15 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
Apparence
+ + diff --git a/internal/loki/ui/src/js/28-i18n.js b/internal/loki/ui/src/js/28-i18n.js new file mode 100644 index 0000000..cd77cf6 --- /dev/null +++ b/internal/loki/ui/src/js/28-i18n.js @@ -0,0 +1,240 @@ +// 28-i18n.js — interface en anglais, par-dessus une source française. +// +// L'amont (AJEAN) a un vrai système de clés : chaque texte de l'UI porte un +// data-i18n et vit dans une table FR/EN. Traduire Loki de la même façon +// demanderait de réécrire tous ses écrans d'un coup, avec le risque d'en casser +// un pour une clé oubliée. On prend l'autre chemin, additif : la source RESTE le +// français, et passer en anglais applique un DICTIONNAIRE sur le texte affiché. +// +// Conséquences, assumées : +// - une chaîne absente du dictionnaire reste en français (dégradation douce, +// jamais de « settings.memory.title » à l'écran) ; +// - le dictionnaire s'enrichit sans toucher au reste de l'interface ; +// - la correspondance est EXACTE (texte complet d'un nœud, une fois les +// espaces retirés) : aucune substitution partielle, donc pas de mot traduit +// au milieu d'une phrase qui ne l'est pas. +// +// Ce qui n'est JAMAIS traduit : le fil de discussion (#chat) — c'est ton +// contenu et celui du modèle —, le code (pre/code), les zones de saisie, et +// tout ce qui porte data-no-i18n. + +const LANG_KEY = 'loki-lang'; +function uiLang(){ try{ return localStorage.getItem(LANG_KEY) || 'fr'; }catch(e){ return 'fr'; } } + +// Dictionnaire français → anglais. Couvre la « coque » : navigation des +// réglages, intitulés de sections, libellés de lignes, boutons, interrupteurs. +// Les longues bulles d'aide restent en français pour l'instant — elles sont +// nombreuses et leur traduction n'apporte rien tant que la coque ne l'est pas. +const I18N_EN = { + // Navigation des réglages + 'Paramètres': 'Settings', + 'Intelligence artificielle': 'Artificial intelligence', + 'Moteur': 'Engine', + 'Application': 'Application', + 'Projets': 'Projects', + 'Config active': 'Active config', + 'Presets': 'Presets', + 'Mode agent': 'Agent mode', + 'Tâches planifiées': 'Scheduled tasks', + 'System prompt': 'System prompt', + 'Moteur llama.cpp': 'llama.cpp engine', + 'Journal du moteur': 'Engine log', + 'Dictée': 'Dictation', + 'Identité': 'Identity', + 'Apparence': 'Appearance', + 'Accès OpenAI': 'OpenAI access', + 'Actions': 'Actions', + // Sections + 'Mémoire': 'Memory', + 'Accès internet': 'Internet access', + 'Contrôle du navigateur': 'Browser control', + 'Serveurs MCP': 'MCP servers', + 'Service du moteur': 'Engine service', + 'Modèle': 'Model', + 'Reconnaissance': 'Recognition', + 'Affichage': 'Display', + 'Endpoint compatible OpenAI': 'OpenAI-compatible endpoint', + 'Clé API': 'API key', + 'Connexion': 'Connection', + 'Format': 'Format', + 'Portée': 'Scope', + 'Contenu': 'Content', + 'Nom': 'Name', + 'Type': 'Type', + 'Consigne': 'Instruction', + 'Script': 'Script', + 'Preset': 'Preset', + 'Fréquence': 'Frequency', + 'Accès': 'Access', + 'Cartes graphiques': 'Graphics cards', + 'Réglages': 'Settings', + 'Échantillonnage': 'Sampling', + 'Sauvegarde chiffrée': 'Encrypted backup', + 'Ton avatar': 'Your avatar', + 'Avatar de Loki': "Loki's avatar", + 'À exécuter': 'To run', + 'Modèle utilisé': 'Model used', + 'Projet visé': 'Target project', + 'Toutes les': 'Every', + 'Accès à la mémoire': 'Memory access', + 'Accès au web': 'Web access', + 'Tâche active': 'Task enabled', + 'Fichier': 'File', + 'Destination': 'Destination', + 'Contexte': 'Context', + 'Vision': 'Vision', + 'Vérifier': 'Check', + // Interrupteurs + 'activer le mode agent': 'enable agent mode', + "me prévenir quand c'est prêt": 'notify me when it is ready', + 'chiffrer la mémoire sur le disque': 'encrypt memory on disk', + "donner internet à l'IA": 'give the AI internet access', + "laisser l'IA piloter un navigateur": 'let the AI drive a browser', + 'compactage automatique du contexte': 'automatic context compaction', + 'suspendre toutes les tâches': 'suspend all tasks', + 'masquer le raisonnement': 'hide reasoning', + "masquer les appels d'outils": 'hide tool calls', + 'garder les bulles repliées': 'keep bubbles collapsed', + 'masquer la vitesse de génération': 'hide generation speed', + 'mode': 'mode', + // Boutons + 'Libérer la VRAM': 'Free VRAM', + 'libérer la VRAM': 'free VRAM', + 'Chat': 'Chat', + 'Code': 'Code', + 'compacter': 'compact', + 'fermer': 'close', + 'relancer': 'restart', + 'Tester la connexion': 'Test connection', + 'supprimer': 'delete', + 'annuler': 'cancel', + 'Annuler': 'Cancel', + 'Confirmer': 'Confirm', + 'enregistrer': 'save', + 'Enregistrer': 'Save', + '+ ajouter un projet': '+ add a project', + '+ ajouter une tâche': '+ add a task', + '+ ajouter un serveur': '+ add a server', + '+ ajouter un point': '+ add an entry', + '+ ajouter': '+ add', + 'exporter': 'export', + 'importer': 'import', + 'retirer': 'remove', + 'catalogue': 'catalog', + 'renommer': 'rename', + 'déplacer': 'move', + 'vérifier les mises à jour': 'check for updates', + '↥ Vérifier les mises à jour': '↥ Check for updates', + 'mettre à jour le moteur': 'update the engine', + "revenir au moteur de l'image": 'go back to the image engine', + 'démarrer': 'start', + 'redémarrer': 'restart', + 'arrêter': 'stop', + 'Télécharger ce modèle': 'Download this model', + 'Annuler le téléchargement': 'Cancel download', + 'Retirer le jeton': 'Remove token', + 'Tester le micro (3 s)': 'Test the mic (3 s)', + 'tester maintenant': 'test now', + 'afficher': 'show', + 'générer': 'generate', + 'définir…': 'set…', + '⤓ exporter…': '⤓ export…', + '⤓ télécharger': '⤓ download', + 'bench': 'bench', + 'Installer & compiler': 'Install & build', + 'voir plus': 'show more', + 'voir moins': 'show less', + 'copier': 'copy', + 'copié': 'copied', + 'réponse': 'response', + 'modifications': 'changes', + 'écriture en cours': 'writing', + 'exécution en cours…': 'running…', + // Divers visibles + 'Nouvelle discussion': 'New chat', + 'Discussions': 'Chats', + 'Langue': 'Language', + 'Français': 'French', + 'Anglais': 'English', +}; + +// Sélecteurs dont le CONTENU n'est jamais traduit : ton texte et celui du +// modèle, le code, les champs de saisie. +const I18N_SKIP = '#chat, pre, code, textarea, input, [data-no-i18n]'; + +function i18nText(fr){ + const key = (fr || '').trim(); + if (!key) return null; + const en = I18N_EN[key]; + if (en === undefined) return null; + // On rend la traduction en conservant l'espacement d'origine autour du texte + // (un « ' ' + label » dans le HTML garde son espace). + const lead = fr.slice(0, fr.length - fr.trimStart().length); + const tail = fr.slice(fr.trimEnd().length); + return lead + en + tail; +} + +// Traduit les nœuds texte et quelques attributs sous `root`. +function i18nApply(root){ + if (uiLang() !== 'en' || !root) return; + const el = root.nodeType === 1 ? root : root.parentElement; + if (el && el.closest && el.closest(I18N_SKIP)) return; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(n){ + if (!n.nodeValue || !n.nodeValue.trim()) return NodeFilter.FILTER_REJECT; + if (n.parentElement && n.parentElement.closest(I18N_SKIP)) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + } + }); + const todo = []; + for (let n = walker.nextNode(); n; n = walker.nextNode()) todo.push(n); + for (const n of todo){ + const tr = i18nText(n.nodeValue); + if (tr !== null) n.nodeValue = tr; + } + // Attributs visibles (infobulles, champs vides, accessibilité). + const scope = root.nodeType === 1 ? root : document.body; + const all = scope.querySelectorAll ? [scope, ...scope.querySelectorAll('[placeholder],[title],[aria-label]')] : []; + for (const e of all){ + if (!e.getAttribute) continue; + for (const attr of ['placeholder', 'title', 'aria-label']){ + const v = e.getAttribute(attr); + if (!v) continue; + const tr = i18nText(v); + if (tr !== null) e.setAttribute(attr, tr); + } + } +} + +// Les panneaux sont rendus en JS bien après le chargement : un observateur +// traduit ce qui apparaît. Il ignore #chat, donc le fil ne passe jamais dessous. +function i18nWatch(){ + if (uiLang() !== 'en') return; + const obs = new MutationObserver(muts => { + for (const m of muts){ + for (const n of m.addedNodes){ + if (n.nodeType === 1 || n.nodeType === 3) i18nApply(n); + } + } + }); + obs.observe(document.body, {childList: true, subtree: true}); +} + +// setUiLang : bascule la langue. Vers l'anglais, on applique tout de suite ; +// vers le français, on RECHARGE — le texte français d'origine a été remplacé +// dans le DOM, le recharger est le moyen le plus sûr de le retrouver intact. +function setUiLang(lang){ + const next = lang === 'en' ? 'en' : 'fr'; + const prev = uiLang(); + try{ localStorage.setItem(LANG_KEY, next); }catch(e){} + if (next === prev) return; + if (next === 'en'){ i18nApply(document.body); i18nWatch(); } + else location.reload(); +} + +document.addEventListener('DOMContentLoaded', () => { + const sel = document.getElementById('ui-lang'); + if (sel) sel.value = uiLang(); + i18nApply(document.body); + i18nWatch(); +});