mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: add internationalization (i18n) to web dashboard — English + Chinese (#9453)
Add a lightweight i18n system to the web dashboard with English (default) and Chinese language support. A language switcher with flag icons is placed in the header bar, allowing users to toggle between languages. The choice persists to localStorage. Implementation: - src/i18n/ — types, translation files (en.ts, zh.ts), React context + hook - LanguageSwitcher component shows the *other* language's flag as the toggle - I18nProvider wraps the app in main.tsx - All 8 pages + OAuth components updated to use t() translation calls - Zero new dependencies — pure React context + localStorage
This commit is contained in:
parent
19199cd38d
commit
a2ea237db2
19 changed files with 1715 additions and 977 deletions
|
|
@ -1,5 +1,4 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import { formatTokenCount } from "@/lib/format";
|
||||
import {
|
||||
BarChart3,
|
||||
Cpu,
|
||||
|
|
@ -10,6 +9,7 @@ import { api } from "@/lib/api";
|
|||
import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
const PERIODS = [
|
||||
{ label: "7d", days: 7 },
|
||||
|
|
@ -19,7 +19,11 @@ const PERIODS = [
|
|||
|
||||
const CHART_HEIGHT_PX = 160;
|
||||
|
||||
const formatTokens = formatTokenCount;
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function formatDate(day: string): string {
|
||||
try {
|
||||
|
|
@ -56,6 +60,7 @@ function SummaryCard({
|
|||
}
|
||||
|
||||
function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||
const { t } = useI18n();
|
||||
if (daily.length === 0) return null;
|
||||
|
||||
const maxTokens = Math.max(...daily.map((d) => d.input_tokens + d.output_tokens), 1);
|
||||
|
|
@ -65,16 +70,16 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Daily Token Usage</CardTitle>
|
||||
<CardTitle className="text-base">{t.analytics.dailyTokenUsage}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-2.5 w-2.5 bg-[#ffe6cb]" />
|
||||
Input
|
||||
<div className="h-2.5 w-2.5 rounded-sm bg-[#ffe6cb]" />
|
||||
{t.analytics.input}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-2.5 w-2.5 bg-emerald-500" />
|
||||
Output
|
||||
<div className="h-2.5 w-2.5 rounded-sm bg-emerald-500" />
|
||||
{t.analytics.output}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
|
@ -92,11 +97,11 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
>
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
|
||||
<div className="bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
|
||||
<div className="rounded-md bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
|
||||
<div className="font-medium">{formatDate(d.day)}</div>
|
||||
<div>Input: {formatTokens(d.input_tokens)}</div>
|
||||
<div>Output: {formatTokens(d.output_tokens)}</div>
|
||||
<div>Total: {formatTokens(total)}</div>
|
||||
<div>{t.analytics.input}: {formatTokens(d.input_tokens)}</div>
|
||||
<div>{t.analytics.output}: {formatTokens(d.output_tokens)}</div>
|
||||
<div>{t.analytics.total}: {formatTokens(total)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Input bar */}
|
||||
|
|
@ -127,6 +132,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
}
|
||||
|
||||
function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||
const { t } = useI18n();
|
||||
if (daily.length === 0) return null;
|
||||
|
||||
const sorted = [...daily].reverse();
|
||||
|
|
@ -136,7 +142,7 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Daily Breakdown</CardTitle>
|
||||
<CardTitle className="text-base">{t.analytics.dailyBreakdown}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -144,10 +150,10 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||
<th className="text-left py-2 pr-4 font-medium">Date</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Sessions</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Input</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">Output</th>
|
||||
<th className="text-left py-2 pr-4 font-medium">{t.analytics.date}</th>
|
||||
<th className="text-right py-2 px-4 font-medium">{t.sessions.title}</th>
|
||||
<th className="text-right py-2 px-4 font-medium">{t.analytics.input}</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">{t.analytics.output}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -174,6 +180,7 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
}
|
||||
|
||||
function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
||||
const { t } = useI18n();
|
||||
if (models.length === 0) return null;
|
||||
|
||||
const sorted = [...models].sort(
|
||||
|
|
@ -185,7 +192,7 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
|||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Per-Model Breakdown</CardTitle>
|
||||
<CardTitle className="text-base">{t.analytics.perModelBreakdown}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -193,9 +200,9 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
|||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||
<th className="text-left py-2 pr-4 font-medium">Model</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Sessions</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">Tokens</th>
|
||||
<th className="text-left py-2 pr-4 font-medium">{t.analytics.model}</th>
|
||||
<th className="text-right py-2 px-4 font-medium">{t.sessions.title}</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">{t.analytics.tokens}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -225,6 +232,7 @@ export default function AnalyticsPage() {
|
|||
const [data, setData] = useState<AnalyticsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
|
|
@ -244,7 +252,7 @@ export default function AnalyticsPage() {
|
|||
<div className="flex flex-col gap-6">
|
||||
{/* Period selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground font-medium">Period:</span>
|
||||
<span className="text-sm text-muted-foreground font-medium">{t.analytics.period}</span>
|
||||
{PERIODS.map((p) => (
|
||||
<Button
|
||||
key={p.label}
|
||||
|
|
@ -278,21 +286,21 @@ export default function AnalyticsPage() {
|
|||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<SummaryCard
|
||||
icon={Hash}
|
||||
label="Total Tokens"
|
||||
label={t.analytics.totalTokens}
|
||||
value={formatTokens(data.totals.total_input + data.totals.total_output)}
|
||||
sub={`${formatTokens(data.totals.total_input)} in / ${formatTokens(data.totals.total_output)} out`}
|
||||
sub={t.analytics.inOut.replace("{input}", formatTokens(data.totals.total_input)).replace("{output}", formatTokens(data.totals.total_output))}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={BarChart3}
|
||||
label="Total Sessions"
|
||||
label={t.analytics.totalSessions}
|
||||
value={String(data.totals.total_sessions)}
|
||||
sub={`~${(data.totals.total_sessions / days).toFixed(1)}/day avg`}
|
||||
sub={`~${(data.totals.total_sessions / days).toFixed(1)}${t.analytics.perDayAvg}`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={TrendingUp}
|
||||
label="API Calls"
|
||||
label={t.analytics.apiCalls}
|
||||
value={String(data.daily.reduce((sum, d) => sum + d.sessions, 0))}
|
||||
sub={`across ${data.by_model.length} models`}
|
||||
sub={t.analytics.acrossModels.replace("{count}", String(data.by_model.length))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -310,8 +318,8 @@ export default function AnalyticsPage() {
|
|||
<CardContent className="py-12">
|
||||
<div className="flex flex-col items-center text-muted-foreground">
|
||||
<BarChart3 className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm font-medium">No usage data for this period</p>
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">Start a session to see analytics here</p>
|
||||
<p className="text-sm font-medium">{t.analytics.noUsageData}</p>
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">{t.analytics.startSession}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue