Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions web/src/components/LanguageSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,33 @@
import { useI18n } from "@/i18n/context";
import type { Locale } from "@/i18n/types";

/**
* Compact language toggle — shows a clickable flag that switches between
* English and Chinese. Persists choice to localStorage.
* Compact language toggle — cycles through English → French → Chinese.
* Persists choice to localStorage.
*/
const CYCLE: Record<Locale, Locale> = {
en: "fr",
fr: "zh",
zh: "en",
};

const FLAGS: Record<Locale, string> = {
en: "🇬🇧",
fr: "🇫🇷",
zh: "🇨🇳",
};

const LABELS: Record<Locale, string> = {
en: "EN",
fr: "FR",
zh: "中文",
};

export function LanguageSwitcher() {
const { locale, setLocale, t } = useI18n();

const toggle = () => setLocale(locale === "en" ? "zh" : "en");
const next = CYCLE[locale];
const toggle = () => setLocale(next);

return (
<button
Expand All @@ -17,10 +37,10 @@ export function LanguageSwitcher() {
title={t.language.switchTo}
aria-label={t.language.switchTo}
>
{/* Show the *other* language's flag as the clickable target */}
<span className="text-base leading-none">{locale === "en" ? "🇨🇳" : "🇬🇧"}</span>
{/* Show the *next* language's flag as the clickable target */}
<span className="text-base leading-none">{FLAGS[next]}</span>
<span className="hidden sm:inline font-display tracking-wide uppercase text-[0.65rem]">
{locale === "en" ? "中文" : "EN"}
{LABELS[next]}
</span>
</button>
);
Expand Down
5 changes: 3 additions & 2 deletions web/src/i18n/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { createContext, useContext, useState, useCallback, type ReactNode } from
import type { Locale, Translations } from "./types";
import { en } from "./en";
import { zh } from "./zh";
import { fr } from "./fr";

const TRANSLATIONS: Record<Locale, Translations> = { en, zh };
const TRANSLATIONS: Record<Locale, Translations> = { en, zh, fr };
const STORAGE_KEY = "hermes-locale";

function getInitialLocale(): Locale {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "en" || stored === "zh") return stored;
if (stored === "en" || stored === "zh" || stored === "fr") return stored;
} catch {
// SSR or privacy mode
}
Expand Down
2 changes: 1 addition & 1 deletion web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,6 @@ export const en: Translations = {
},

language: {
switchTo: "Switch to Chinese",
switchTo: "Switch to French",
},
};
278 changes: 278 additions & 0 deletions web/src/i18n/fr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import type { Translations } from "./types";

export const fr: Translations = {
common: {
save: "Enregistrer",
saving: "Enregistrement...",
cancel: "Annuler",
close: "Fermer",
delete: "Supprimer",
refresh: "Actualiser",
retry: "Réessayer",
search: "Rechercher...",
loading: "Chargement...",
create: "Créer",
creating: "Création...",
set: "Définir",
replace: "Remplacer",
clear: "Effacer",
live: "En direct",
off: "Désactivé",
enabled: "activé",
disabled: "désactivé",
active: "actif",
inactive: "inactif",
unknown: "inconnu",
untitled: "Sans titre",
none: "Aucun",
form: "Formulaire",
noResults: "Aucun résultat",
of: "sur",
page: "Page",
msgs: "msgs",
tools: "outils",
match: "correspondance",
other: "Autre",
configured: "configuré",
removed: "supprimé",
failedToToggle: "Échec du basculement",
failedToRemove: "Échec de la suppression",
failedToReveal: "Échec de l'affichage",
collapse: "Réduire",
expand: "Développer",
general: "Général",
messaging: "Messagerie",
},

app: {
brand: "Hermes Agent",
brandShort: "HA",
webUi: "Interface web",
footer: {
name: "Hermes Agent",
org: "Nous Research",
},
nav: {
status: "Statut",
sessions: "Sessions",
analytics: "Statistiques",
logs: "Journaux",
cron: "Cron",
skills: "Compétences",
config: "Config",
keys: "Clés",
},
},

status: {
agent: "Agent",
gateway: "Passerelle",
activeSessions: "Sessions actives",
recentSessions: "Sessions récentes",
connectedPlatforms: "Plateformes connectées",
running: "En cours",
starting: "Démarrage",
failed: "Échec",
stopped: "Arrêté",
connected: "Connecté",
disconnected: "Déconnecté",
error: "Erreur",
notRunning: "Non démarré",
startFailed: "Échec du démarrage",
pid: "PID",
runningRemote: "En cours (distant)",
noneRunning: "Aucune",
gatewayFailedToStart: "Échec du démarrage de la passerelle",
lastUpdate: "Dernière mise à jour",
platformError: "erreur",
platformDisconnected: "déconnecté",
},

sessions: {
title: "Sessions",
searchPlaceholder: "Rechercher dans les messages...",
noSessions: "Aucune session pour le moment",
noMatch: "Aucune session ne correspond à votre recherche",
startConversation: "Démarrez une conversation pour la voir ici",
noMessages: "Aucun message",
untitledSession: "Session sans titre",
deleteSession: "Supprimer la session",
previousPage: "Page précédente",
nextPage: "Page suivante",
roles: {
user: "Utilisateur",
assistant: "Assistant",
system: "Système",
tool: "Outil",
},
},

analytics: {
period: "Période :",
totalTokens: "Total de tokens",
totalSessions: "Total de sessions",
apiCalls: "Appels API",
dailyTokenUsage: "Utilisation quotidienne de tokens",
dailyBreakdown: "Répartition quotidienne",
perModelBreakdown: "Répartition par modèle",
input: "Entrée",
output: "Sortie",
total: "Total",
noUsageData: "Aucune donnée d'utilisation pour cette période",
startSession: "Démarrez une session pour voir les statistiques",
date: "Date",
model: "Modèle",
tokens: "Tokens",
perDayAvg: "/jour en moyenne",
acrossModels: "sur {count} modèles",
inOut: "{input} entrée / {output} sortie",
},

logs: {
title: "Journaux",
autoRefresh: "Actualisation auto",
file: "Fichier",
level: "Niveau",
component: "Composant",
lines: "Lignes",
noLogLines: "Aucune ligne de journal trouvée",
},

cron: {
newJob: "Nouvelle tâche Cron",
nameOptional: "Nom (facultatif)",
namePlaceholder: "ex. Résumé quotidien",
prompt: "Invite",
promptPlaceholder: "Que doit faire l'agent à chaque exécution ?",
schedule: "Planification (expression cron)",
schedulePlaceholder: "0 9 * * *",
deliverTo: "Livrer à",
scheduledJobs: "Tâches planifiées",
noJobs: "Aucune tâche cron configurée. Créez-en une ci-dessus.",
last: "Dernière",
next: "Prochaine",
pause: "Pause",
resume: "Reprendre",
triggerNow: "Déclencher maintenant",
delivery: {
local: "Local",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
email: "E-mail",
},
},

skills: {
title: "Compétences",
searchPlaceholder: "Rechercher compétences et outils...",
enabledOf: "{enabled}/{total} activées",
all: "Toutes",
noSkills: "Aucune compétence trouvée. Les compétences sont chargées depuis ~/.hermes/skills/",
noSkillsMatch: "Aucune compétence ne correspond à votre recherche ou filtre.",
skillCount: "{count} compétence{s}",
resultCount: "{count} résultat{s}",
noDescription: "Aucune description disponible.",
toolsets: "Jeux d'outils",
toolsetLabel: "jeu d'outils {name}",
noToolsetsMatch: "Aucun jeu d'outils ne correspond à la recherche.",
setupNeeded: "Configuration requise",
disabledForCli: "Désactivé pour la CLI",
more: "+{count} autre{s}",
},

config: {
configPath: "~/.hermes/config.yaml",
exportConfig: "Exporter la config en JSON",
importConfig: "Importer la config depuis JSON",
resetDefaults: "Réinitialiser aux valeurs par défaut",
rawYaml: "Configuration YAML brute",
searchResults: "Résultats de recherche",
fields: "champ{s}",
noFieldsMatch: 'Aucun champ ne correspond à "{query}"',
configSaved: "Configuration enregistrée",
yamlConfigSaved: "Configuration YAML enregistrée",
failedToSave: "Échec de l'enregistrement",
failedToSaveYaml: "Échec de l'enregistrement YAML",
failedToLoadRaw: "Échec du chargement de la configuration brute",
configImported: "Configuration importée — vérifiez et enregistrez",
invalidJson: "Fichier JSON invalide",
categories: {
general: "Général",
agent: "Agent",
terminal: "Terminal",
display: "Affichage",
delegation: "Délégation",
memory: "Mémoire",
compression: "Compression",
security: "Sécurité",
browser: "Navigateur",
voice: "Voix",
tts: "Synthèse vocale",
stt: "Reconnaissance vocale",
logging: "Journalisation",
discord: "Discord",
auxiliary: "Auxiliaire",
},
},

env: {
description: "Gérez les clés API et les secrets stockés dans",
changesNote: "Les modifications sont enregistrées immédiatement. Les sessions actives récupèrent automatiquement les nouvelles clés.",
hideAdvanced: "Masquer avancé",
showAdvanced: "Afficher avancé",
llmProviders: "Fournisseurs LLM",
providersConfigured: "{configured}/{total} fournisseurs configurés",
getKey: "Obtenir une clé",
notConfigured: "{count} non configuré(s)",
notSet: "Non défini",
keysCount: "{count} clé{s}",
enterValue: "Saisir une valeur...",
replaceCurrentValue: "Remplacer la valeur actuelle ({preview})",
showValue: "Afficher la valeur réelle",
hideValue: "Masquer la valeur",
},

oauth: {
title: "Connexions fournisseurs (OAuth)",
providerLogins: "Connexions fournisseurs (OAuth)",
description: "{connected}/{total} fournisseurs OAuth connectés. Les flux de connexion passent actuellement par la CLI ; cliquez sur Copier la commande et collez-la dans un terminal pour la configurer.",
connected: "Connecté",
expired: "Expiré",
notConnected: "Non connecté. Exécutez {command} dans un terminal.",
runInTerminal: "dans un terminal.",
noProviders: "Aucun fournisseur compatible OAuth détecté.",
login: "Connexion",
disconnect: "Déconnexion",
managedExternally: "Géré en externe",
copied: "Copié ✓",
cli: "CLI",
copyCliCommand: "Copier la commande CLI (pour usage externe / secours)",
connect: "Connecter",
sessionExpires: "La session expire dans {time}",
initiatingLogin: "Initialisation du flux de connexion…",
exchangingCode: "Échange du code contre des tokens…",
connectedClosing: "Connecté ! Fermeture…",
loginFailed: "Échec de la connexion.",
sessionExpired: "Session expirée. Cliquez sur Réessayer pour vous reconnecter.",
reOpenAuth: "Rouvrir la page d'authentification",
reOpenVerification: "Rouvrir la page de vérification",
submitCode: "Envoyer le code",
pasteCode: "Collez le code d'autorisation (le suffixe #state est accepté)",
waitingAuth: "En attente de votre autorisation dans le navigateur…",
enterCodePrompt: "Un nouvel onglet s'est ouvert. Saisissez ce code si demandé :",
pkceStep1: "Un nouvel onglet s'est ouvert sur claude.ai. Connectez-vous et cliquez sur Autoriser.",
pkceStep2: "Copiez le code d'autorisation affiché après l'autorisation.",
pkceStep3: "Collez-le ci-dessous et envoyez.",
flowLabels: {
pkce: "Connexion navigateur (PKCE)",
device_code: "Code appareil",
external: "CLI externe",
},
expiresIn: "expire dans {time}",
},

language: {
switchTo: "Passer à l'anglais",
},
};
2 changes: 1 addition & 1 deletion web/src/i18n/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type Locale = "en" | "zh";
export type Locale = "en" | "zh" | "fr";

export interface Translations {
// ── Common ──
Expand Down
2 changes: 1 addition & 1 deletion web/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,6 @@ export const zh: Translations = {
},

language: {
switchTo: "切换到英文",
switchTo: "切换到英语",
},
};