fix(i18n): preserve remaining Vietnamese localization (#7935)

* fix(i18n): preserve remaining Vietnamese localization

* chore(quality): rebaseline file-size cap for 9 dashboard components (i18n wiring)

Restoring the Vietnamese localization on 9 dashboard components (useTranslations
wiring + t()/tc() call-site swaps for previously hardcoded strings) grows each
file by a small, irreducible amount. Bumps the frozen file-size-baseline.json
caps to match, with a justification entry per the project's own ratchet policy.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(i18n): wire weekday localization + add missing qwen CLI description

Two gaps left by this PR's own new contract tests, caught while
reconciling the branch against the release tip:

- CostOverviewTab.tsx added formatWeekdayLabel() but never called it;
  the Weekly Usage Pattern chart still showed raw English day
  abbreviations regardless of locale. Now maps weeklyPattern rows
  through it before handing them to WeeklyPatternCard.
- cliTools.toolDescriptions was missing an entry for "qwen" (a
  baseUrlSupport:"full" tool) in both en.json and vi.json, failing
  the PR's own cli-catalog-display-contract.test.ts.

Covered by the PR's existing tests/unit/dashboard-localization-contract.test.ts
and tests/unit/cli-catalog-display-contract.test.ts (both now pass).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* i18n(vi): backfill the 4 proxySubscription keys #7299 added to en.json

#7299 (proxy subscriptions) merged while this branch was rebasing, adding
settings.proxySubscriptionsTab and settings.proxySubscription.error.{LOCAL_CORE_ENDPOINT_INVALID,
NEEDS_CORE_NOT_CONFIGURED,NO_USABLE_NODES} to en.json. This PR's own
i18n-vi-completeness contract asserts full en↔vi key parity, so the merge of the
current release tip surfaced them as missing. Adds the Vietnamese translations,
keeping the SS/VMess/Trojan/VLESS/SOCKS5 technical terms verbatim.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: nguyenha935 <208228297+nguyenha935@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: nguyenha935 <nguyenha935@users.noreply.github.com>
This commit is contained in:
nguyenha935
2026-07-21 23:41:02 +07:00
committed by GitHub
parent eab59d4048
commit 4012bac41d
207 changed files with 12021 additions and 5221 deletions

View File

@@ -13,22 +13,97 @@
import { usePathname } from "next/navigation";
import Link from "next/link";
import { useTranslations } from "next-intl";
const PATH_LABELS = {
dashboard: "Dashboard",
providers: "Providers",
combos: "Combos",
settings: "Settings",
logs: "Logs",
"audit-log": "Audit Log",
console: "Console",
logger: "Logger",
translator: "Translator",
playground: "Playground",
add: "Add",
edit: "Edit",
keys: "API Keys",
models: "Models",
dashboard: "dashboard",
providers: "providers",
combos: "combos",
settings: "settings",
general: "general",
appearance: "appearance",
ai: "ai",
routing: "routing",
resilience: "resilience",
advanced: "advanced",
"access-tokens": "accessTokens",
"feature-flags": "featureFlags",
logs: "logs",
"audit-log": "auditLog",
console: "console",
logger: "logger",
translator: "translator",
playground: "playground",
add: "add",
edit: "edit",
keys: "apiKeys",
models: "models",
"cli-code": "cliCode",
"cli-agents": "cliAgents",
"acp-agents": "acpAgents",
endpoint: "endpoint",
"api-manager": "apiManager",
context: "context",
compression: "compression",
services: "services",
analytics: "analytics",
costs: "costs",
health: "health",
runtime: "runtime",
webhooks: "webhooks",
home: "home",
activity: "activity",
"agent-skills": "agentSkills",
"combo-health": "comboHealth",
evals: "evals",
search: "search",
utilization: "utilization",
"api-endpoints": "apiEndpoints",
audit: "audit",
a2a: "a2a",
mcp: "mcp",
batch: "batch",
files: "files",
media: "media",
cache: "cache",
changelog: "changelog",
chaos: "chaos",
"cloud-agents": "cloudAgents",
live: "live",
studio: "studio",
aggressive: "aggressive",
caveman: "caveman",
ccr: "ccr",
headroom: "headroom",
lite: "lite",
llmlingua: "llmlingua",
omniglyph: "omniglyph",
rtk: "rtk",
"session-dedup": "sessionDedup",
ultra: "ultra",
budget: "budget",
pricing: "pricing",
"quota-share": "quotaShare",
discovery: "discovery",
"free-provider-rankings": "freeProviderRankings",
"free-tiers": "freeTiers",
gamification: "gamification",
leaderboard: "leaderboard",
limits: "limits",
profile: "profile",
plugins: "plugins",
"provider-stats": "providerStats",
new: "new",
quota: "quota",
relay: "relay",
"search-tools": "searchTools",
security: "security",
sidebar: "sidebar",
tokens: "tokens",
tools: "tools",
"agent-bridge": "agentBridge",
"traffic-inspector": "trafficInspector",
usage: "usage",
};
/**
@@ -36,24 +111,26 @@ const PATH_LABELS = {
* @param {string} segment
* @returns {string}
*/
function getLabel(segment) {
return PATH_LABELS[segment] || segment.charAt(0).toUpperCase() + segment.slice(1);
function getLabel(segment, t) {
const key = PATH_LABELS[segment];
return key ? t(key) : segment.charAt(0).toUpperCase() + segment.slice(1);
}
export default function Breadcrumbs() {
const pathname = usePathname();
const t = useTranslations("breadcrumbs");
if (!pathname || pathname === "/dashboard") return null;
const segments = pathname.split("/").filter(Boolean);
const crumbs = segments.map((seg, idx) => ({
label: getLabel(seg),
label: getLabel(seg, t),
href: "/" + segments.slice(0, idx + 1).join("/"),
isLast: idx === segments.length - 1,
}));
return (
<nav
aria-label="Breadcrumb"
aria-label={t("ariaLabel")}
style={{
display: "flex",
alignItems: "center",

View File

@@ -9,9 +9,12 @@ const variants = {
"bg-white dark:bg-white/10 border border-black/10 dark:border-white/10 text-text-main hover:bg-black/5 dark:hover:bg-white/5",
outline: "border border-black/15 dark:border-white/15 text-text-main hover:bg-black/5",
ghost: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5 hover:text-text-main",
warning: "bg-amber-500 text-white hover:bg-amber-600 shadow-sm",
danger: "bg-red-500 text-white hover:bg-red-600 shadow-sm",
};
export type ButtonVariant = keyof typeof variants;
const sizes = {
sm: "h-7 px-3 text-xs rounded-control",
md: "h-9 px-4 text-sm rounded-control",
@@ -20,7 +23,7 @@ const sizes = {
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children?: React.ReactNode;
variant?: keyof typeof variants;
variant?: ButtonVariant;
size?: keyof typeof sizes;
icon?: string;
iconRight?: string;

View File

@@ -12,20 +12,22 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
// #6147 — user-facing labels renamed from "Cloud …" to "Remote Settings Sync"
// wording (this feature syncs the operator's own settings to their own remote
// store — it is not a cloud/telemetry service). Internal state keys, the
// `cloud_*` material icons and the cloudSync.* wiring are intentionally kept.
const STATUS_CONFIG = {
connected: { icon: "cloud_done", color: "text-green-500", label: "Synced" },
syncing: { icon: "cloud_sync", color: "text-blue-400 animate-pulse", label: "Syncing..." },
disconnected: { icon: "cloud_off", color: "text-amber-500", label: "Sync Off" },
error: { icon: "cloud_off", color: "text-red-400", label: "Sync Error" },
disabled: { icon: "cloud_off", color: "text-text-muted/50", label: "Disabled" },
connected: { icon: "cloud_done", color: "text-green-500", labelKey: "synced" },
syncing: { icon: "cloud_sync", color: "text-blue-400 animate-pulse", labelKey: "syncing" },
disconnected: { icon: "cloud_off", color: "text-amber-500", labelKey: "off" },
error: { icon: "cloud_off", color: "text-red-400", labelKey: "error" },
disabled: { icon: "cloud_off", color: "text-text-muted/50", labelKey: "disabled" },
};
export default function CloudSyncStatus({ collapsed = false }) {
const t = useTranslations("cloudSyncStatus");
const [status, setStatus] = useState("disabled");
const [lastSync, setLastSync] = useState(null);
const mountedRef = useRef(true);
@@ -77,6 +79,7 @@ export default function CloudSyncStatus({ collapsed = false }) {
if (status === "disabled") return null;
const config = STATUS_CONFIG[status];
const label = t(config.labelKey);
return (
<button
@@ -84,10 +87,13 @@ export default function CloudSyncStatus({ collapsed = false }) {
className="flex items-center gap-2 px-3 py-1.5 text-xs rounded-lg hover:bg-white/5 transition-colors cursor-pointer w-full"
title={
lastSync
? `Remote settings sync ${status === "connected" ? "connected" : "disconnected"} — Last sync: ${lastSync.toLocaleTimeString()}`
: config.label
? t("lastSync", {
status: status === "connected" ? t("connected") : t("disconnected"),
time: lastSync.toLocaleTimeString(),
})
: label
}
aria-label={`Remote settings sync status: ${config.label}`}
aria-label={t("statusLabel", { status: label })}
>
<span className={`material-symbols-outlined text-[16px] ${config.color}`} aria-hidden="true">
{config.icon}
@@ -96,7 +102,7 @@ export default function CloudSyncStatus({ collapsed = false }) {
<span
className={`truncate ${status === "connected" ? "text-green-500" : "text-text-muted"}`}
>
{config.label}
{label}
</span>
)}
</button>

View File

@@ -15,8 +15,10 @@
*/
import { useState, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
export default function ColumnToggle({ columns = [], visible = {}, onToggle }) {
const t = useTranslations("common");
const [open, setOpen] = useState(false);
const ref = useRef(null);
@@ -34,7 +36,7 @@ export default function ColumnToggle({ columns = [], visible = {}, onToggle }) {
<div ref={ref} style={{ position: "relative" }}>
<button
onClick={() => setOpen(!open)}
title="Toggle columns"
title={t("toggleColumns")}
style={{
padding: "6px 10px",
borderRadius: "6px",
@@ -49,7 +51,7 @@ export default function ColumnToggle({ columns = [], visible = {}, onToggle }) {
}}
>
<span style={{ fontSize: "14px" }}></span>
Columns
{t("columns")}
</button>
{open && (

View File

@@ -1,6 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
/**
* Console Log Viewer — Real-time application log viewer.
@@ -45,6 +45,7 @@ const LEVEL_BG: Record<string, string> = {
const POLL_INTERVAL = 5000; // 5 seconds
export default function ConsoleLogViewer() {
const locale = useLocale();
const t = useTranslations("loggers");
const tv = useTranslations("logs.consoleViewer");
const [logs, setLogs] = useState<LogEntry[]>([]);
@@ -79,9 +80,12 @@ export default function ConsoleLogViewer() {
// Initial fetch + polling
useEffect(() => {
fetchLogs();
const initialFetch = setTimeout(() => void fetchLogs(), 0);
const interval = setInterval(fetchLogs, POLL_INTERVAL);
return () => clearInterval(interval);
return () => {
clearTimeout(initialFetch);
clearInterval(interval);
};
}, [fetchLogs]);
// Auto-scroll to bottom on new logs
@@ -107,7 +111,7 @@ export default function ConsoleLogViewer() {
const formatTime = (ts: string) => {
try {
const d = new Date(ts);
return d.toLocaleTimeString("en-US", {
return d.toLocaleTimeString(locale, {
hour12: false,
hour: "2-digit",
minute: "2-digit",
@@ -153,7 +157,7 @@ export default function ConsoleLogViewer() {
<select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
aria-label="Filter by log level"
aria-label={tv("filterByLevel")}
className="px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] focus:outline-2 focus:outline-[var(--color-accent)]"
>
<option value="all">{t("allLevels")}</option>
@@ -166,17 +170,17 @@ export default function ConsoleLogViewer() {
{/* Search */}
<input
type="text"
placeholder="Search logs..."
placeholder={tv("searchPlaceholder")}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
aria-label="Search log entries"
aria-label={tv("searchAria")}
className="flex-1 min-w-[200px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
/>
{/* Auto-scroll toggle */}
<button
onClick={() => setAutoScroll(!autoScroll)}
title={autoScroll ? "Disable auto-scroll" : "Enable auto-scroll"}
title={autoScroll ? tv("disableAutoScroll") : tv("enableAutoScroll")}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
autoScroll
? "bg-cyan-500/15 text-cyan-400 border-cyan-500/30"
@@ -186,7 +190,7 @@ export default function ConsoleLogViewer() {
<span className="material-symbols-outlined text-[16px] align-middle mr-1">
{autoScroll ? "vertical_align_bottom" : "lock"}
</span>
Auto-scroll
{tv("autoScroll")}
</button>
{/* Refresh */}
@@ -201,13 +205,13 @@ export default function ConsoleLogViewer() {
{/* Status */}
<div className="flex items-center gap-2 ml-auto text-xs text-[var(--color-text-muted)]">
<span className="inline-block w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span>{filteredLogs.length} entries</span>
<span>{tv("entryCount", { count: filteredLogs.length })}</span>
<span className="text-[var(--color-text-muted)]/50"></span>
<span>Last 1h</span>
<span>{tv("lastHour")}</span>
{lastUpdated && (
<>
<span className="text-[var(--color-text-muted)]/50"></span>
<span>Updated {lastUpdated.toLocaleTimeString()}</span>
<span>{tv("updatedAt", { time: lastUpdated.toLocaleTimeString(locale) })}</span>
</>
)}
</div>
@@ -221,9 +225,7 @@ export default function ConsoleLogViewer() {
>
<span className="material-symbols-outlined text-[16px] align-middle mr-2">error</span>
{error}
<span className="text-xs ml-2 opacity-70">
Make sure the application is writing logs to file (APP_LOG_TO_FILE=true)
</span>
<span className="text-xs ml-2 opacity-70"> {tv("fileLoggingRequired")}</span>
</div>
)}
@@ -233,7 +235,7 @@ export default function ConsoleLogViewer() {
className="rounded-xl border border-[var(--color-border)] bg-[#0d1117] overflow-auto font-mono text-xs leading-relaxed"
style={{ maxHeight: "calc(100vh - 340px)", minHeight: "400px" }}
role="log"
aria-label="Application console logs"
aria-label={tv("consoleAria")}
aria-live="polite"
>
{/* Header bar */}
@@ -241,7 +243,9 @@ export default function ConsoleLogViewer() {
<div className="w-3 h-3 rounded-full bg-[#FF5F56]" />
<div className="w-3 h-3 rounded-full bg-[#FFBD2E]" />
<div className="w-3 h-3 rounded-full bg-[#27C93F]" />
<span className="ml-3 text-[#8b949e] text-[11px]">OmniRoute Application Console</span>
<span className="ml-3 text-[#8b949e] text-[11px]">
OmniRoute {tv("applicationConsole")}
</span>
</div>
{/* Log entries */}
@@ -252,9 +256,7 @@ export default function ConsoleLogViewer() {
terminal
</span>
<p>{t("noLogEntries")}</p>
<p className="text-[10px] mt-1 opacity-60">
Ensure APP_LOG_TO_FILE=true is set in your .env file
</p>
<p className="text-[10px] mt-1 opacity-60">{tv("emptyFileLoggingHint")}</p>
</div>
) : (
filteredLogs.map((entry, idx) => {
@@ -316,7 +318,7 @@ export default function ConsoleLogViewer() {
<span className="material-symbols-outlined text-[24px] animate-spin block mb-2">
progress_activity
</span>
Loading logs...
{t("loadingLogs")}
</div>
)}
</div>

View File

@@ -35,6 +35,7 @@ export default function EmptyState({
}: EmptyStateProps) {
const t = useTranslations("common");
const resolvedTitle = title ?? t("nothingHere");
const usesMaterialSymbol = /^[a-z][a-z0-9_]*$/.test(icon);
return (
<div
style={{
@@ -57,7 +58,13 @@ export default function EmptyState({
role="img"
aria-hidden="true"
>
{icon}
{usesMaterialSymbol ? (
<span className="material-symbols-outlined" style={{ fontSize: "inherit" }}>
{icon}
</span>
) : (
icon
)}
</div>
<h3
style={{

View File

@@ -21,16 +21,18 @@
*/
import { useState, useCallback } from "react";
import { useTranslations } from "next-intl";
export default function FilterBar({
searchValue = "",
onSearchChange,
placeholder = "Search...",
placeholder,
filters = [],
activeFilters = {},
onFilterChange,
children,
}) {
const t = useTranslations("common");
const [expandedFilter, setExpandedFilter] = useState(null);
const handleClear = useCallback(() => {
@@ -57,7 +59,7 @@ export default function FilterBar({
type="text"
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={placeholder}
placeholder={placeholder || t("search")}
style={{
width: "100%",
padding: "8px 12px 8px 32px",
@@ -139,7 +141,7 @@ export default function FilterBar({
borderRadius: "4px",
}}
>
All
{t("all")}
</button>
{(filter.options || []).map((opt) => (
<button
@@ -186,7 +188,7 @@ export default function FilterBar({
cursor: "pointer",
}}
>
Clear
{t("clear")}
</button>
)}

View File

@@ -248,11 +248,11 @@ export default function Header({
type="button"
onClick={onOpenCommandPalette}
className="hidden md:inline-flex items-center gap-2 px-2.5 py-1.5 rounded-lg border border-black/10 dark:border-white/10 bg-bg-subtle text-text-muted hover:text-text-main hover:bg-black/[0.04] dark:hover:bg-white/[0.04] transition-colors"
title="Quick navigation (⌘K / Ctrl+K)"
aria-label="Open quick navigation"
title={t("quickNavigationTitle")}
aria-label={t("openQuickNavigation")}
>
<span className="material-symbols-outlined text-[16px]">search</span>
<span className="text-xs">Quick nav</span>
<span className="text-xs">{t("quickNavigation")}</span>
<kbd className="hidden lg:inline-flex font-mono text-[10px] px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10">
{isMac ? "⌘K" : "Ctrl+K"}
</kbd>
@@ -261,7 +261,7 @@ export default function Header({
type="button"
onClick={onOpenCommandPalette}
className="md:hidden p-2 rounded-lg text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
aria-label="Open quick navigation"
aria-label={t("openQuickNavigation")}
>
<span className="material-symbols-outlined">search</span>
</button>
@@ -275,6 +275,7 @@ export default function Header({
onClick={handleLogout}
className="flex items-center justify-center p-2 rounded-lg text-text-muted hover:text-red-500 hover:bg-red-500/10 transition-all"
title={t("logout")}
aria-label={t("logout")}
>
<span className="material-symbols-outlined">logout</span>
</button>

View File

@@ -1,6 +1,7 @@
"use client";
import { useId, useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/shared/utils/cn";
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> {
@@ -29,6 +30,7 @@ export default function Input({
onKeyUp: externalOnKeyUp,
...props
}: InputProps) {
const t = useTranslations("common");
const generatedId = useId();
const inputId = externalId || generatedId;
const errorId = error ? `${inputId}-error` : undefined;
@@ -134,7 +136,7 @@ export default function Input({
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
keyboard_capslock
</span>
Caps Lock is on
{t("capsLockOn")}
</p>
)}
{error && (

View File

@@ -1,8 +1,9 @@
"use client";
import { useEffect, useRef, useId } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/shared/utils/cn";
import Button from "./Button";
import Button, { type ButtonVariant } from "./Button";
// #6265 — preset for content-heavy modals: caps height on the OUTERMOST dialog
// wrapper only (single scroll owner) and keeps the inner body plain (no
@@ -27,6 +28,18 @@ interface ModalProps {
maxWidth?: string;
}
interface ConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void | Promise<void>;
title?: React.ReactNode;
message: React.ReactNode;
confirmText?: React.ReactNode;
cancelText?: React.ReactNode;
variant?: ButtonVariant;
loading?: boolean;
}
export default function Modal({
isOpen,
onClose,
@@ -40,6 +53,7 @@ export default function Modal({
bodyClassName,
compactHeader = false,
}: ModalProps) {
const t = useTranslations("common");
const titleId = useId();
const dialogRef = useRef(null);
@@ -186,7 +200,7 @@ export default function Modal({
{showCloseButton && (
<button
onClick={onClose}
aria-label="Close"
aria-label={t("close")}
className="p-1.5 rounded-lg text-text-muted hover:bg-black/5 dark:hover:bg-white/5 transition-colors shrink-0"
>
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
@@ -218,26 +232,31 @@ export function ConfirmModal({
isOpen,
onClose,
onConfirm,
title = "Confirm",
title,
message,
confirmText = "Confirm",
cancelText = "Cancel",
confirmText,
cancelText,
variant = "danger",
loading = false,
}) {
}: ConfirmModalProps) {
const t = useTranslations("common");
const resolvedTitle = title ?? t("confirmTitle");
const resolvedConfirmText = confirmText ?? t("confirmAction");
const resolvedCancelText = cancelText ?? t("cancel");
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={title}
title={resolvedTitle}
size="sm"
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={loading}>
{cancelText}
{resolvedCancelText}
</Button>
<Button variant={variant as any} onClick={onConfirm} loading={loading}>
{confirmText}
<Button variant={variant} onClick={onConfirm} loading={loading}>
{resolvedConfirmText}
</Button>
</>
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import Card from "./Card";
import Button from "./Button";
import DistributeProxiesButton from "./DistributeProxiesButton";
@@ -92,13 +93,16 @@ export default function NoAuthAccountCard({
generateAccountId,
generateApiKey,
dataKey = "fingerprints",
description = "Ready to use — no signup needed. Add accounts for rate-limit rotation.",
addLabel = "Add Account",
description,
addLabel,
enabled = true,
savingEnabled = false,
onEnabledChange,
providerProxyControl,
}: NoAuthAccountCardProps) {
const t = useTranslations("noAuthProvider");
const resolvedDescription = description || t("accountDescription");
const resolvedAddLabel = addLabel || t("addAccount");
const [connections, setConnections] = useState<Connection[]>([]);
const [loading, setLoading] = useState(true);
const [adding, setAdding] = useState(false);
@@ -144,8 +148,12 @@ export default function NoAuthAccountCard({
}, []);
useEffect(() => {
void fetchConnections();
void fetchSavedProxies();
const loadTimer = window.setTimeout(() => {
void fetchConnections();
void fetchSavedProxies();
}, 0);
return () => window.clearTimeout(loadTimer);
}, [fetchConnections, fetchSavedProxies]);
useEffect(() => {
@@ -176,14 +184,14 @@ export default function NoAuthAccountCard({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: providerId,
name: `${providerName} Account 1`,
name: t("accountName", { provider: providerName, number: 1 }),
...(apiKey ? { apiKey } : {}),
providerSpecificData: { [dataKey]: [accountId] },
}),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData?.error || `Failed to create connection (${res.status})`);
throw new Error(errData?.error || t("createConnectionFailed"));
}
} else {
const updated = [...allAccountIds, accountId];
@@ -194,7 +202,7 @@ export default function NoAuthAccountCard({
providerSpecificData: { [dataKey]: updated },
}),
});
if (!res.ok) throw new Error("Failed to update connection");
if (!res.ok) throw new Error(t("updateConnectionFailed"));
}
await fetchConnections();
} catch (err) {
@@ -302,11 +310,11 @@ export default function NoAuthAccountCard({
if (!conn || allAccountIds.length === 0) return;
const proxiesRes = await fetch("/api/settings/proxies");
if (!proxiesRes.ok) throw new Error("Failed to fetch proxies");
if (!proxiesRes.ok) throw new Error(t("fetchProxiesFailed"));
const proxiesData = await proxiesRes.json();
const savedProxies = (proxiesData?.items || []).filter((p: any) => p.status === "active");
if (savedProxies.length === 0) {
throw new Error("No saved proxies found. Add proxies in Settings → Proxy first.");
throw new Error(t("noSavedProxiesError"));
}
// #5217 (Gap 1): distribute stores by-id references too, so editing a pool
@@ -323,7 +331,7 @@ export default function NoAuthAccountCard({
providerSpecificData: { accountProxies: updatedProxies },
}),
});
if (!res.ok) throw new Error("Failed to update connection");
if (!res.ok) throw new Error(t("updateConnectionFailed"));
await fetchConnections();
};
@@ -336,8 +344,8 @@ export default function NoAuthAccountCard({
<span className="material-symbols-outlined text-[20px]">lock_open</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">No authentication required</p>
<p className="text-xs text-text-muted">{description}</p>
<p className="text-sm font-medium">{t("title")}</p>
<p className="text-xs text-text-muted">{resolvedDescription}</p>
</div>
</div>
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto sm:flex-nowrap">
@@ -354,7 +362,7 @@ export default function NoAuthAccountCard({
<div className="border-t border-border pt-3 mt-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">
Accounts ({loading ? "..." : allAccountIds.length})
{t("accounts", { count: loading ? "..." : allAccountIds.length })}
</span>
<div className="flex items-center justify-end gap-2">
{!loading && allAccountIds.length > 0 && (
@@ -365,14 +373,14 @@ export default function NoAuthAccountCard({
/>
)}
<Button size="sm" icon="add" onClick={handleAddAccount} disabled={adding || !enabled}>
{adding ? "Adding..." : addLabel}
{adding ? t("adding") : resolvedAddLabel}
</Button>
</div>
</div>
{!loading && allAccountIds.length === 0 && (
<p className="text-xs text-text-muted py-2">
Using auto-generated account. Click &quot;{addLabel}&quot; for rate-limit rotation.
{t("autoGeneratedAccount", { addLabel: resolvedAddLabel })}
</p>
)}
@@ -405,9 +413,11 @@ export default function NoAuthAccountCard({
title={
proxy
? `Proxy: ${proxy.type}://${proxy.host}:${proxy.port}`
: "Configure proxy"
: t("configureProxy")
}
aria-label={
proxy ? t("proxyConfigured", { host: proxy.host }) : t("configureProxy")
}
aria-label={proxy ? `Proxy configured: ${proxy.host}` : "Configure proxy"}
>
<span
className="material-symbols-outlined text-[16px]"
@@ -420,7 +430,7 @@ export default function NoAuthAccountCard({
type="button"
onClick={() => handleRemoveAccount(id)}
className="shrink-0 rounded p-1 text-text-muted opacity-0 transition-colors hover:bg-red-500/10 hover:text-red-500 group-hover:opacity-100"
aria-label="Remove account"
aria-label={t("removeAccount")}
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
@@ -437,7 +447,9 @@ export default function NoAuthAccountCard({
className="w-80 max-w-full rounded-lg border border-black/10 bg-surface p-4 shadow-lg dark:border-white/10"
>
<p className="mb-3 text-sm font-medium">
Proxy for Account {allAccountIds.indexOf(proxyAccountId) + 1}
{t("proxyForAccount", {
number: allAccountIds.indexOf(proxyAccountId) + 1,
})}
</p>
<div className="space-y-3">
{/* #5217 (Gap 1): pick a pre-saved Proxy Pool entry by reference,
@@ -452,7 +464,7 @@ export default function NoAuthAccountCard({
: "text-text-muted hover:text-text-main"
}`}
>
Saved
{t("saved")}
</button>
<button
type="button"
@@ -463,7 +475,7 @@ export default function NoAuthAccountCard({
: "text-text-muted hover:text-text-main"
}`}
>
Custom
{t("custom")}
</button>
</div>
@@ -474,9 +486,7 @@ export default function NoAuthAccountCard({
className="w-full rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
>
<option value="">
{savedProxies.length === 0
? "No saved proxies — add one in Settings → Proxy"
: "Direct (no proxy)"}
{savedProxies.length === 0 ? t("noSavedProxies") : t("directConnection")}
</option>
{savedProxies.map((p) => (
<option key={p.id} value={p.id}>
@@ -502,14 +512,14 @@ export default function NoAuthAccountCard({
type="text"
value={proxyHost}
onChange={(e) => setProxyHost(e.target.value)}
placeholder="Host"
placeholder={t("host")}
className="flex-1 rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
/>
<input
type="text"
value={proxyPort}
onChange={(e) => setProxyPort(e.target.value)}
placeholder="Port"
placeholder={t("port")}
className="w-16 rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
/>
</div>
@@ -517,14 +527,14 @@ export default function NoAuthAccountCard({
type="text"
value={proxyUsername}
onChange={(e) => setProxyUsername(e.target.value)}
placeholder="Username (optional)"
placeholder={t("usernameOptional")}
className="w-full rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
/>
<input
type="password"
value={proxyPassword}
onChange={(e) => setProxyPassword(e.target.value)}
placeholder="Password (optional)"
placeholder={t("passwordOptional")}
className="w-full rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
/>
</>
@@ -534,14 +544,14 @@ export default function NoAuthAccountCard({
onClick={() => setProxyAccountId(null)}
className="rounded-md px-3 py-1.5 text-xs text-text-muted transition-colors hover:bg-black/5 hover:text-text-main dark:hover:bg-white/5"
>
Cancel
{t("cancel")}
</button>
<button
onClick={handleSaveProxy}
disabled={savingProxy}
className="rounded-md bg-primary/10 px-3 py-1.5 text-xs text-primary transition-colors hover:bg-primary/20 disabled:opacity-50"
>
{savingProxy ? "Saving..." : "Save"}
{savingProxy ? t("saving") : t("save")}
</button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import type { ReactNode } from "react";
import Card from "./Card";
import NoAuthProviderToggle from "./NoAuthProviderToggle";
@@ -17,6 +18,8 @@ export default function NoAuthProviderCard({
onEnabledChange,
providerProxyControl,
}: NoAuthProviderCardProps) {
const t = useTranslations("noAuthProvider");
return (
<Card>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
@@ -25,10 +28,8 @@ export default function NoAuthProviderCard({
<span className="material-symbols-outlined text-[20px]">lock_open</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">No authentication required</p>
<p className="text-xs text-text-muted">
This provider is ready to use immediately no signup or API key needed.
</p>
<p className="text-sm font-medium">{t("title")}</p>
<p className="text-xs text-text-muted">{t("description")}</p>
</div>
</div>
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto sm:flex-nowrap">

View File

@@ -11,6 +11,7 @@
import { useNotificationStore } from "@/store/notificationStore";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
const ICONS = {
success: "✓",
@@ -67,6 +68,7 @@ const COLORS = {
};
function Toast({ notification, onDismiss }) {
const t = useTranslations("common");
const [isExiting, setIsExiting] = useState(false);
const handleDismiss = () => {
@@ -142,7 +144,7 @@ function Toast({ notification, onDismiss }) {
e.stopPropagation();
handleDismiss();
}}
aria-label="Dismiss notification"
aria-label={t("dismissNotification")}
style={{
background: "none",
border: "none",

View File

@@ -11,8 +11,8 @@
* 1. Theme-aware static SVGs (`THEMED_SVGS`, e.g. arena-light/dark for lmarena)
* 2. Try /providers/{id}.svg (local SVG assets — fastest, cached separately from JS bundle)
* 3. Try @lobehub/icons direct React components (no @lobehub/ui peer runtime)
* 4. Fall back to thesvg.org CDN (external SVG)
* 5. Fall back to /providers/{id}.png (legacy static assets)
* 4. Try /providers/{id}.png (legacy static assets)
* 5. Fall back to thesvg.org CDN (external SVG)
* 6. Fall back to a generic AI icon
*
* Usage:
@@ -284,6 +284,12 @@ const THEMED_SVGS: Record<string, { light: string; dark: string }> = {
},
};
const PROVIDER_ICON_ALIASES: Record<string, string> = {
"opencode-go": "opencode",
"opencode-zen": "opencode",
"poe-web": "poe",
};
const ProviderIcon = memo(function ProviderIcon({
providerId,
size = 24,
@@ -296,7 +302,7 @@ const ProviderIcon = memo(function ProviderIcon({
fallbackColor,
}: ProviderIconProps) {
const { isDark } = useTheme();
const normalizedId = providerId.toLowerCase();
const normalizedId = PROVIDER_ICON_ALIASES[providerId.toLowerCase()] || providerId.toLowerCase();
const localSvgId = LOCAL_SVG_ALIASES[normalizedId] || normalizedId;
const lobeIcon = getLobeProviderIcon(normalizedId, type);
const themedSvg = THEMED_SVGS[normalizedId];
@@ -417,27 +423,7 @@ const ProviderIcon = memo(function ProviderIcon({
);
}
// Tier 4: thesvg.org CDN — external SVG fallback for unknown providers
if (!theSvgFailed) {
return (
<span
className={className}
style={{ display: "inline-flex", alignItems: "center", ...style }}
>
{/* eslint-disable-next-line @next/next/no-img-element -- external SVG from thesvg.org, not a static/known asset */}
<img
src={`https://thesvg.org/icons/${normalizedId}/default.svg`}
alt={providerId}
width={size}
height={size}
style={{ objectFit: "contain", flex: "none" }}
onError={() => setFailedAssets((current) => ({ ...current, [theSvgKey]: true }))}
/>
</span>
);
}
// Tier 5: Local PNG — last resort before generic icon
// Tier 4: Known local PNG — avoid a failing external request when a bundled asset exists
if (hasPng && !pngFailed) {
return (
<span
@@ -457,6 +443,26 @@ const ProviderIcon = memo(function ProviderIcon({
);
}
// Tier 5: thesvg.org CDN — external SVG fallback for unknown providers
if (!theSvgFailed) {
return (
<span
className={className}
style={{ display: "inline-flex", alignItems: "center", ...style }}
>
{/* eslint-disable-next-line @next/next/no-img-element -- external SVG from thesvg.org, not a static/known asset */}
<img
src={`https://thesvg.org/icons/${normalizedId}/default.svg`}
alt={providerId}
width={size}
height={size}
style={{ objectFit: "contain", flex: "none" }}
onError={() => setFailedAssets((current) => ({ ...current, [theSvgKey]: true }))}
/>
</span>
);
}
// Tier 6: Generic AI icon
return (
<span className={className} style={{ display: "inline-flex", alignItems: "center", ...style }}>

View File

@@ -1,6 +1,7 @@
"use client";
import { useId } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/shared/utils/cn";
interface SelectOption {
@@ -22,7 +23,7 @@ export default function Select({
options = [],
value,
onChange,
placeholder = "Select an option",
placeholder,
error,
hint,
disabled = false,
@@ -33,6 +34,7 @@ export default function Select({
children,
...props
}: SelectProps) {
const t = useTranslations("common");
const generatedId = useId();
const selectId = externalId || generatedId;
const errorId = error ? `${selectId}-error` : undefined;
@@ -72,9 +74,9 @@ export default function Select({
)}
{...props}
>
{!children && placeholder && (
{!children && (placeholder ?? t("selectOption")) && (
<option value="" disabled className="bg-surface text-text-muted">
{placeholder}
{placeholder ?? t("selectOption")}
</option>
)}
{!children &&

View File

@@ -537,7 +537,7 @@ export default function Sidebar({
)}
<nav
aria-label="Main navigation"
aria-label={t("mainNavigation")}
className={cn(
"min-h-0 flex-1 overflow-y-auto py-1 custom-scrollbar",
collapsed ? "px-2 space-y-0.5" : "px-3"

View File

@@ -1,5 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import { useTheme } from "@/shared/hooks/useTheme";
import { cn } from "@/shared/utils/cn";
@@ -10,7 +11,9 @@ export default function ThemeToggle({
className?: any;
variant?: string;
}) {
const { theme, toggleTheme, isDark } = useTheme();
const { toggleTheme, isDark } = useTheme();
const t = useTranslations("header");
const toggleLabel = isDark ? t("switchToLightMode") : t("switchToDarkMode");
const variants = {
default: cn(
@@ -36,8 +39,8 @@ export default function ThemeToggle({
<button
onClick={toggleTheme}
className={cn(variants[variant], className)}
aria-label={`Switch to ${isDark ? "light" : "dark"} mode`}
title={`Switch to ${isDark ? "light" : "dark"} mode`}
aria-label={toggleLabel}
title={toggleLabel}
>
<span
className={cn(

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import Card from "./Card";
import { CardSkeleton } from "./Loading";
import { fmtCompact as fmt, fmtFull, fmtCost } from "@/shared/utils/formatting";
@@ -31,6 +31,7 @@ import {
// ============================================================================
export default function UsageAnalytics() {
const locale = useLocale();
const t = useTranslations("analytics");
const tCommon = useTranslations("common");
const [range, setRange] = useState("30d");
@@ -87,7 +88,8 @@ export default function UsageAnalytics() {
}, [range, customStart, customEnd, selectedApiKeys, tCommon]);
useEffect(() => {
fetchAnalytics();
const timer = window.setTimeout(() => void fetchAnalytics(), 0);
return () => window.clearTimeout(timer);
}, [fetchAnalytics]);
const handleRangeSelect = useCallback((value: string) => {
@@ -111,7 +113,7 @@ export default function UsageAnalytics() {
if (range !== "custom" || !customStart || !customEnd) return null;
const fmt = (iso: string) => {
const d = new Date(iso);
return d.toLocaleDateString(undefined, {
return d.toLocaleDateString(locale, {
month: "short",
day: "numeric",
hour: "2-digit",
@@ -119,7 +121,7 @@ export default function UsageAnalytics() {
});
};
return `${fmt(customStart)}${fmt(customEnd)}`;
}, [range, customStart, customEnd]);
}, [range, customStart, customEnd, locale]);
const ranges = [
{ value: "1d", label: t("period1D") },
@@ -144,8 +146,13 @@ export default function UsageAnalytics() {
const wp = analytics?.weeklyPattern || [];
if (!wp.length) return "—";
const max = wp.reduce((a, b) => (a.avgTokens > b.avgTokens ? a : b), wp[0]);
return max.avgTokens > 0 ? max.day : "—";
}, [analytics]);
if (max.avgTokens <= 0) return "—";
const weekdayIndex = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(max.day);
if (weekdayIndex < 0) return max.day;
return new Intl.DateTimeFormat(locale, { weekday: "short" }).format(
new Date(2024, 0, 7 + weekdayIndex)
);
}, [analytics, locale]);
const providerCount = useMemo(() => {
return (analytics?.byProvider || []).length;

View File

@@ -133,7 +133,13 @@ export function CompactStatGrid({ sections }: { sections: CompactStatSection[] }
export function ActivityHeatmap({ activityMap }) {
const t = useTranslations("analytics");
const locale = useLocale();
const scrollRef = useRef<HTMLDivElement>(null);
const monthFormatter = useMemo(() => createDateFormatter(locale, { month: "short" }), [locale]);
const weekdayFormatter = useMemo(
() => createDateFormatter(locale, { weekday: "short" }),
[locale]
);
const cells = useMemo(() => {
const today = new Date();
@@ -184,27 +190,18 @@ export function ActivityHeatmap({ activityMap }) {
if (firstDay) {
const m = new Date(firstDay.date).getMonth();
if (m !== lastMonth) {
const monthNames = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
labels.push({ weekIdx, label: monthNames[m] });
labels.push({ weekIdx, label: monthFormatter.format(new Date(2024, m, 1)) });
lastMonth = m;
}
}
});
return labels;
}, [weeks]);
}, [monthFormatter, weeks]);
const weekdayLabels = useMemo(
() => [1, 3, 5].map((day) => weekdayFormatter.format(new Date(2024, 0, 7 + day))),
[weekdayFormatter]
);
function getCellColor(value) {
if (!value || value === 0) return "bg-white/[0.04]";
@@ -222,9 +219,13 @@ export function ActivityHeatmap({ activityMap }) {
{t("overview")}
</h3>
<span className="text-xs text-text-muted">
{Object.keys(activityMap || {}).length} active days ·{" "}
{fmt(Object.values(activityMap || {}).reduce((a: number, b: number) => a + b, 0))} tokens
· 365 days
{t("activitySummary", {
active: Object.keys(activityMap || {}).length,
tokens: fmt(
Object.values(activityMap || {}).reduce((a: number, b: number) => a + b, 0)
),
days: 365,
})}
</span>
</div>
@@ -249,11 +250,11 @@ export function ActivityHeatmap({ activityMap }) {
<div className="flex gap-[3px]">
<div className="flex flex-col gap-[3px] shrink-0 text-[10px] text-text-muted pr-1 sticky left-0 z-10 bg-surface">
<span className="h-[10px]"></span>
<span className="h-[10px] leading-[10px]">Mon</span>
<span className="h-[10px] leading-[10px]">{weekdayLabels[0]}</span>
<span className="h-[10px]"></span>
<span className="h-[10px] leading-[10px]">Wed</span>
<span className="h-[10px] leading-[10px]">{weekdayLabels[1]}</span>
<span className="h-[10px]"></span>
<span className="h-[10px] leading-[10px]">Fri</span>
<span className="h-[10px] leading-[10px]">{weekdayLabels[2]}</span>
<span className="h-[10px]"></span>
</div>
@@ -262,7 +263,11 @@ export function ActivityHeatmap({ activityMap }) {
{week.map((day, di) => (
<div
key={di}
title={day ? `${day.date}: ${fmtFull(day.value)} tokens` : ""}
title={
day
? t("activityCellTitle", { date: day.date, tokens: fmtFull(day.value) })
: ""
}
className={`w-[10px] h-[10px] rounded-[2px] ${day ? getCellColor(day.value) : "bg-transparent"}`}
/>
))}
@@ -273,13 +278,13 @@ export function ActivityHeatmap({ activityMap }) {
</div>
<div className="flex items-center gap-1 mt-2 ml-6 text-[10px] text-text-muted">
<span>Less</span>
<span>{t("activityLess")}</span>
<div className="w-[10px] h-[10px] rounded-[2px] bg-white/[0.04]" />
<div className="w-[10px] h-[10px] rounded-[2px] bg-primary/20" />
<div className="w-[10px] h-[10px] rounded-[2px] bg-primary/40" />
<div className="w-[10px] h-[10px] rounded-[2px] bg-primary/60" />
<div className="w-[10px] h-[10px] rounded-[2px] bg-primary/90" />
<span>More</span>
<span>{t("activityMore")}</span>
</div>
</Card>
);
@@ -334,7 +339,7 @@ export function ApiKeyTable({ byApiKey }) {
return (
<Card className="p-4 flex-1">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
API Key Breakdown
{t("chartApiKeyBreakdown")}
</h3>
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
</Card>
@@ -345,7 +350,7 @@ export function ApiKeyTable({ byApiKey }) {
<Card className="overflow-hidden">
<div className="p-4 border-b border-border flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">
API Key Breakdown
{t("chartApiKeyBreakdown")}
</h3>
<input
type="text"
@@ -363,7 +368,8 @@ export function ApiKeyTable({ byApiKey }) {
className="px-4 py-2.5 text-left cursor-pointer group"
onClick={() => toggleSort("apiKeyName")}
>
API Key <SortIndicator active={sortBy === "apiKeyName"} sortOrder={sortOrder} />
{t("chartApiKey")}{" "}
<SortIndicator active={sortBy === "apiKeyName"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
@@ -444,6 +450,7 @@ export function ApiKeyTable({ byApiKey }) {
}
export function MostActiveDay7d({ activityMap }) {
const t = useTranslations("analytics");
const locale = useLocale();
const weekdayFormatter = useMemo(
() => createDateFormatter(locale, { weekday: "long" }),
@@ -485,7 +492,7 @@ export function MostActiveDay7d({ activityMap }) {
className="text-xs font-semibold uppercase tracking-wider mb-2"
style={{ color: "var(--color-text-muted)" }}
>
Most Active Day
{t("mostActiveDay")}
</h3>
{data ? (
<>
@@ -493,12 +500,12 @@ export function MostActiveDay7d({ activityMap }) {
{data.weekday}
</span>
<span className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
{data.label} · {fmt(data.tokens)} tokens
{t("datedTokenCount", { date: data.label, tokens: fmt(data.tokens) })}
</span>
</>
) : (
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
No data in the last 7 days
{t("noDataLast7Days")}
</span>
)}
</Card>
@@ -561,7 +568,7 @@ export function WeeklySquares7d({ activityMap }) {
style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}
>
<div
title={`${d.dateLabel}: ${fmtFull(d.val)} tokens`}
title={t("activityCellTitle", { date: d.dateLabel, tokens: fmtFull(d.val) })}
style={{
width: 36,
height: 36,
@@ -750,6 +757,7 @@ function getServiceTierCostClass(serviceTier) {
export function ServiceTierBreakdown({ byServiceTier, summary }) {
const t = useTranslations("costs") as TranslationFn;
const tAnalytics = useTranslations("analytics");
const data = useMemo(() => byServiceTier || [], [byServiceTier]);
const totalRequests = Number(summary?.totalRequests || 0);
const totalCost = Number(summary?.totalCost || 0);
@@ -807,7 +815,10 @@ export function ServiceTierBreakdown({ byServiceTier, summary }) {
<div>
<div className="text-sm font-semibold text-text-main">{tierLabel}</div>
<div className="text-xs text-text-muted">
{fmtFull(tier.requests)} requests · {fmt(tier.totalTokens)} tokens
{tAnalytics("requestTokenSummary", {
requests: fmtFull(tier.requests),
tokens: fmt(tier.totalTokens),
})}
{usageSavingsText}
</div>
</div>

View File

@@ -103,14 +103,14 @@ export function AccountDonut({ byAccount }) {
return (
<Card className="p-4 flex-1">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
By Account
{t("chartByAccount")}
</h3>
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
</Card>
);
}
return <CompactDonutCard pieData={pieData} title="By Account" formatter={fmt} />;
return <CompactDonutCard pieData={pieData} title={t("chartByAccount")} formatter={fmt} />;
}
// ── ApiKeyDonut (Recharts) ─────────────────────────────────────────────────
@@ -123,17 +123,17 @@ export function ApiKeyDonut({ byApiKey }) {
const pieData = useMemo(() => {
return data.slice(0, 8).map((item, i) => ({
name: maskApiKeyLabel(item.apiKeyName, item.apiKeyId),
fullName: item.apiKeyName || item.apiKeyId || "unknown",
fullName: item.apiKeyName || item.apiKeyId || t("unknownApiKey"),
value: item.totalTokens,
fill: getModelColor(i),
}));
}, [data]);
}, [data, t]);
if (!hasData) {
return (
<Card className="p-4 flex-1">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
By API Key
{t("chartByApiKey")}
</h3>
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
</Card>
@@ -143,7 +143,7 @@ export function ApiKeyDonut({ byApiKey }) {
return (
<CompactDonutCard
pieData={pieData}
title="By API Key"
title={t("chartByApiKey")}
formatter={fmt}
getLegendKey={(seg, i) => `${seg.fullName}-${i}`}
getLegendTitle={(seg) => seg.fullName}

View File

@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
import type { CliCatalogEntry } from "@/shared/schemas/cliCatalog";
import type { ToolBatchStatus } from "@/shared/types/cliBatchStatus";
import CliStatusBadge from "@/app/(dashboard)/dashboard/cli-code/components/CliStatusBadge";
import { useTheme } from "@/shared/hooks/useTheme";
import { cn } from "@/shared/utils/cn";
export interface CliToolCardProps {
@@ -22,19 +23,23 @@ export default function CliToolCard({
hasActiveProviders,
}: CliToolCardProps) {
const t = useTranslations("cliCommon");
const tTools = useTranslations("cliTools");
const { isDark } = useTheme();
const installed = batchStatus?.detection.installed ?? false;
const configStatus = batchStatus?.config.status ?? null;
const version = batchStatus?.detection.version ?? "not found";
const version = batchStatus?.detection.version ?? t("card.versionNotFound");
const endpoint = batchStatus?.config.endpoint ?? null;
const imageSrc =
tool.image || (isDark ? tool.imageDark || tool.imageLight : tool.imageLight || tool.imageDark);
const showInstallChips = !installed && tool.configType !== "guide";
const title = (
<div className="flex items-center gap-2.5">
{/* Icon / image */}
{tool.image ? (
{imageSrc ? (
<Image
src={tool.image}
src={imageSrc}
alt={tool.name}
width={32}
height={32}
@@ -58,7 +63,9 @@ export default function CliToolCard({
{version}
</span>
</div>
<p className="text-xs text-text-muted line-clamp-1 mt-0.5">{tool.description}</p>
<p className="text-xs text-text-muted line-clamp-1 mt-0.5">
{tTools(`toolDescriptions.${tool.id}`)}
</p>
</div>
<span className="material-symbols-outlined text-[18px] text-text-muted flex-shrink-0">
chevron_right
@@ -124,10 +131,10 @@ export default function CliToolCard({
{showInstallChips && (
<>
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] font-medium rounded-full bg-black/5 dark:bg-white/5 text-text-muted">
📋 Manual config
📋 {t("card.manualConfig")}
</span>
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] font-medium rounded-full bg-black/5 dark:bg-white/5 text-text-muted">
Install
{t("card.installGuide")}
</span>
</>
)}

View File

@@ -6,10 +6,6 @@
// (parent owns the state + persistence). All mutations go through the pure
// `compressionPipelineModel` so invariants (valid intensity, non-empty pipeline) hold.
//
// Hydration note: this lives under the combos screen, which deliberately uses NO
// `useTranslations` (an earlier redesign failed to hydrate on the production build with a
// page-level `useTranslations`). Strings are literal English to match `CompressionHub`.
import {
DndContext,
closestCenter,
@@ -26,6 +22,7 @@ import {
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useTranslations } from "next-intl";
import {
allowedIntensities,
addLayer,
@@ -57,6 +54,7 @@ function SortableRow(props: {
onPatch: (patch: Partial<PipelineStep>) => void;
onRemove: () => void;
}) {
const t = useTranslations("contextCombos");
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.id,
});
@@ -74,7 +72,7 @@ function SortableRow(props: {
>
<button
type="button"
aria-label="Drag to reorder step"
aria-label={t("dragToReorder")}
data-testid={`pipeline-drag-${props.index}`}
className="cursor-grab rounded-lg border border-border px-2 py-2 text-sm text-text-muted"
{...attributes}
@@ -83,7 +81,7 @@ function SortableRow(props: {
</button>
<select
aria-label="Engine"
aria-label={t("engine")}
value={props.step.engine}
onChange={(event) => props.onPatch({ engine: event.target.value })}
className={SELECT_CLASS}
@@ -95,7 +93,7 @@ function SortableRow(props: {
))}
</select>
<select
aria-label="Intensity"
aria-label={t("intensity")}
value={props.step.intensity ?? ""}
onChange={(event) => props.onPatch({ intensity: event.target.value })}
className={SELECT_CLASS}
@@ -113,13 +111,14 @@ function SortableRow(props: {
data-testid={`pipeline-remove-${props.index}`}
className="rounded-lg border border-border px-3 py-2 text-sm text-text-main disabled:opacity-50"
>
Remove
{t("removeStep")}
</button>
</div>
);
}
export function CompressionPipelineEditor({ steps, onChange, engineIntensities }: Props) {
const t = useTranslations("contextCombos");
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
@@ -141,14 +140,14 @@ export function CompressionPipelineEditor({ steps, onChange, engineIntensities }
return (
<div className="space-y-3" data-testid="compression-pipeline-editor">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-main">Pipeline</h3>
<h3 className="text-sm font-semibold text-text-main">{t("pipeline")}</h3>
<button
type="button"
data-testid="pipeline-add-step"
onClick={() => onChange(addLayer(steps, { engine: firstEngine }, engineIntensities))}
className="rounded-lg border border-border px-3 py-1.5 text-xs text-text-main"
>
Add step
{t("addStep")}
</button>
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import type { EngineConfigField } from "@omniroute/open-sse/services/compression/engines/types";
import { EngineConfigForm } from "@/shared/components/compression/EngineConfigForm";
@@ -63,10 +64,9 @@ interface PreviewResult {
// ── Default preview sample ────────────────────────────────────────────────
const PREVIEW_SAMPLE =
"The quick brown fox jumps over the lazy dog. " +
"This is a sample message used to preview compression. " +
"It contains enough text to show meaningful token savings.";
const ENGINE_ICON_ALIASES: Record<string, string> = {
brain: "psychology",
};
// ── Sub-components ────────────────────────────────────────────────────────
@@ -79,7 +79,11 @@ function StatCard({ label, value }: { label: string; value: string }) {
);
}
function renderDiffSegment(segment: PreviewDiffSegment, index: number) {
function renderDiffSegment(
segment: PreviewDiffSegment,
index: number,
translateLabel: (label: string) => string
) {
const label = segment.type ?? "change";
const text =
segment.value ??
@@ -93,7 +97,7 @@ function renderDiffSegment(segment: PreviewDiffSegment, index: number) {
return (
<div key={`${label}-${index}`} className="rounded border border-border bg-background p-2">
<span className="mr-2 rounded bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-text-muted">
{label}
{translateLabel(label)}
</span>
<span className="whitespace-pre-wrap break-words text-text">{text}</span>
</div>
@@ -103,6 +107,8 @@ function renderDiffSegment(segment: PreviewDiffSegment, index: number) {
// ── Main component ────────────────────────────────────────────────────────
export function EngineConfigPage({ engineId }: { engineId: string }) {
const locale = useLocale();
const t = useTranslations("compressionEngineConfig");
// ── Data state ──────────────────────────────────────────────────────────
const [engine, setEngine] = useState<EngineEntry | null>(null);
const [configState, setConfigState] = useState<Record<string, unknown>>({});
@@ -111,7 +117,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
const [loading, setLoading] = useState(true);
// ── Preview state ───────────────────────────────────────────────────────
const [previewText, setPreviewText] = useState(PREVIEW_SAMPLE);
const [previewText, setPreviewText] = useState(() => t("previewSample"));
const [preview, setPreview] = useState<PreviewResult | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
@@ -147,7 +153,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
if (enginesData) {
foundEngine = enginesData.engines?.find((e) => e.id === engineId) ?? null;
} else {
setLoadError("Failed to load engine information.");
setLoadError(t("loadFailed"));
}
// Detailed config lives in the engine's settings sub-object (when it has one);
@@ -174,7 +180,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
return () => {
cancelled = true;
};
}, [engineId]);
}, [engineId, t]);
// ── Handlers ─────────────────────────────────────────────────────────────
@@ -200,10 +206,10 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
body: JSON.stringify({ [subKey]: detail }),
});
if (!res.ok) {
setSaveError("Failed to save configuration.");
setSaveError(t("saveFailed"));
}
} catch {
setSaveError("Failed to save configuration.");
setSaveError(t("saveFailed"));
} finally {
setSaving(false);
}
@@ -226,10 +232,10 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
const data = (await res.json()) as PreviewResult;
setPreview(data);
} else {
setPreviewError("Preview failed.");
setPreviewError(t("previewFailed"));
}
} catch {
setPreviewError("Preview failed.");
setPreviewError(t("previewFailed"));
} finally {
setPreviewLoading(false);
}
@@ -239,20 +245,48 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
if (loading) {
return (
<div className="flex items-center justify-center p-12 text-text-muted text-sm">Loading</div>
<div className="flex items-center justify-center p-12 text-text-muted text-sm">
{t("loading")}
</div>
);
}
if (!engine) {
return (
<div className="p-6 text-sm text-text-muted">
{loadError ?? `Engine "${engineId}" not found.`}
{loadError ?? t("engineNotFound", { engine: engineId })}
</div>
);
}
const subtitle = engine.metadata?.description ?? engine.description;
const visibleConfigSchema = engine.configSchema.filter((field) => field.key !== "enabled");
const engineNameKey = `engines.${engineId}.name`;
const engineDescriptionKey = `engines.${engineId}.description`;
const engineName = t.has(engineNameKey) ? t(engineNameKey) : engine.name;
const rawSubtitle = engine.metadata?.description ?? engine.description;
const subtitle = t.has(engineDescriptionKey) ? t(engineDescriptionKey) : rawSubtitle;
const visibleConfigSchema = engine.configSchema
.filter((field) => field.key !== "enabled")
.map((field) => {
const engineFieldPrefix = `engineFields.${engineId}.${field.key}`;
const fieldPrefix = `fields.${field.key}`;
const labelKey = t.has(`${engineFieldPrefix}.label`)
? `${engineFieldPrefix}.label`
: `${fieldPrefix}.label`;
const descriptionKey = t.has(`${engineFieldPrefix}.description`)
? `${engineFieldPrefix}.description`
: `${fieldPrefix}.description`;
return {
...field,
label: t.has(labelKey) ? t(labelKey) : field.label,
description:
field.description && t.has(descriptionKey) ? t(descriptionKey) : field.description,
options: field.options?.map((option) => {
const optionKey = `options.${field.key}.${option.value}`;
return { ...option, label: t.has(optionKey) ? t(optionKey) : option.label };
}),
};
});
// Only engines with a dedicated settings sub-object can persist their detail here.
const persistable = Boolean(SETTINGS_SUBOBJECT[engineId]);
@@ -266,10 +300,10 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
className="material-symbols-outlined text-[28px] leading-none text-text-muted"
aria-hidden="true"
>
{engine.icon}
{ENGINE_ICON_ALIASES[engine.icon] || engine.icon}
</span>
)}
<h1 className="text-2xl font-bold text-text">{engine.name}</h1>
<h1 className="text-2xl font-bold text-text">{engineName}</h1>
</div>
{subtitle && <p className="text-sm text-text-muted">{subtitle}</p>}
</div>
@@ -283,17 +317,17 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
{/* ── Panel pointer (on/off + level live there now) ── */}
<div className="flex flex-col gap-1 rounded-lg border border-border bg-surface p-4">
<p className="text-xs text-text-muted" data-testid="panel-pointer-notice">
Turn this layer on/off and set its level in{" "}
{t("panelPointerPrefix")}{" "}
<a href="/dashboard/context/settings" className="underline hover:text-text">
Compression Settings
{t("compressionSettings")}
</a>
. This page edits its detailed configuration only.
{t("panelPointerSuffix")}
</p>
</div>
{/* ── Config form ── */}
<div className="flex flex-col gap-3 rounded-lg border border-border bg-surface p-4">
<h2 className="text-sm font-semibold text-text">Configuration</h2>
<h2 className="text-sm font-semibold text-text">{t("configuration")}</h2>
{visibleConfigSchema.length > 0 ? (
<EngineConfigForm
schema={visibleConfigSchema}
@@ -301,7 +335,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
onChange={setConfigState}
/>
) : (
<p className="text-sm text-text-muted">No additional configuration.</p>
<p className="text-sm text-text-muted">{t("noAdditionalConfiguration")}</p>
)}
<div className="flex items-center gap-3 pt-1">
{persistable ? (
@@ -310,12 +344,11 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
disabled={saving}
className="px-4 py-1.5 rounded bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
{saving ? t("saving") : t("save")}
</button>
) : (
<p className="text-xs text-text-muted" data-testid="no-detail-store-notice">
This layer is configured by the global settings; there is no per-engine override to
save here yet.
{t("globalSettingsOnly")}
</p>
)}
{saveError && <p className="text-xs text-destructive">{saveError}</p>}
@@ -324,12 +357,12 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
{/* ── Live preview ── */}
<div className="flex flex-col gap-3 rounded-lg border border-border bg-surface p-4">
<h2 className="text-sm font-semibold text-text">Preview</h2>
<h2 className="text-sm font-semibold text-text">{t("preview")}</h2>
<textarea
className="border border-border rounded px-3 py-2 text-sm text-text bg-background resize-y min-h-[80px]"
value={previewText}
onChange={(e) => setPreviewText(e.target.value)}
aria-label="Preview input"
aria-label={t("previewInput")}
/>
<div className="flex items-center gap-3">
<button
@@ -337,7 +370,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
disabled={previewLoading}
className="px-4 py-1.5 rounded bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50"
>
{previewLoading ? "Processing..." : "Preview"}
{previewLoading ? t("processing") : t("preview")}
</button>
</div>
{previewError && <p className="text-xs text-destructive">{previewError}</p>}
@@ -345,19 +378,22 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
<div className="flex flex-col gap-3 pt-1 text-sm">
<div className="flex flex-wrap gap-4">
<span className="text-text-muted">
Original tokens: <strong className="text-text">{preview.originalTokens}</strong>
{t("originalTokens")}:{" "}
<strong className="text-text">{preview.originalTokens}</strong>
</span>
<span className="text-text-muted">
Compressed tokens: <strong className="text-text">{preview.compressedTokens}</strong>
{t("compressedTokens")}:{" "}
<strong className="text-text">{preview.compressedTokens}</strong>
</span>
<span className="text-text-muted">
Savings: <strong className="text-primary">{preview.savingsPct.toFixed(1)}%</strong>
{t("savings")}:{" "}
<strong className="text-primary">{preview.savingsPct.toFixed(1)}%</strong>
</span>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="flex flex-col gap-1">
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-muted">
Original
{t("original")}
</h3>
<pre className="max-h-72 overflow-auto rounded border border-border bg-background p-3 whitespace-pre-wrap break-words text-text">
{preview.original ?? ""}
@@ -365,7 +401,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
</div>
<div className="flex flex-col gap-1">
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-muted">
Compressed
{t("compressed")}
</h3>
<pre className="max-h-72 overflow-auto rounded border border-border bg-background p-3 whitespace-pre-wrap break-words text-text">
{preview.compressed ?? ""}
@@ -375,10 +411,15 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
{preview.diff && preview.diff.length > 0 && (
<div className="flex flex-col gap-2" data-testid="compression-preview-diff">
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-muted">
Diff
{t("diff")}
</h3>
<div className="flex max-h-72 flex-col gap-2 overflow-auto rounded border border-border p-2">
{preview.diff.map(renderDiffSegment)}
{preview.diff.map((segment, index) =>
renderDiffSegment(segment, index, (label) => {
const key = `diffLabels.${label}`;
return t.has(key) ? t(key) : label;
})
)}
</div>
</div>
)}
@@ -388,20 +429,23 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
{/* ── Analytics strip ── */}
<div className="flex flex-col gap-3 rounded-lg border border-border bg-surface p-4">
<h2 className="text-sm font-semibold text-text">Last 7 days</h2>
<h2 className="text-sm font-semibold text-text">{t("last7Days")}</h2>
{analytics && analytics.runs === 0 ? (
<p className="text-sm text-text-muted">No data yet</p>
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
) : analytics ? (
<div className="grid grid-cols-3 gap-3">
<StatCard label="Runs" value={analytics.runs.toLocaleString()} />
<StatCard label="Tokens saved" value={analytics.tokensSaved.toLocaleString()} />
<StatCard label={t("runs")} value={analytics.runs.toLocaleString(locale)} />
<StatCard
label="Average savings"
label={t("tokensSaved")}
value={analytics.tokensSaved.toLocaleString(locale)}
/>
<StatCard
label={t("averageSavings")}
value={`${analytics.avgSavingsPercent.toFixed(1)}%`}
/>
</div>
) : (
<p className="text-sm text-text-muted">No data yet</p>
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
)}
</div>
</div>