- Warnings ({result.warnings.length})
+ {t("playgroundWarningCount", { count: result.warnings.length })}
{result.warnings.map((w, i) => (
@@ -288,7 +297,7 @@ export default function ComboPlaygroundClient() {
- Errors ({result.errors.length})
+ {t("playgroundErrorCount", { count: result.errors.length })}
{result.errors.map((e, i) => (
@@ -307,7 +316,9 @@ export default function ComboPlaygroundClient() {
- Select a combo and click Simulate Route to see the routing path.
+ {t.rich("playgroundEmptyHint", {
+ strong: (chunks) => {chunks} ,
+ })}
@@ -317,9 +328,9 @@ export default function ComboPlaygroundClient() {
- No combos configured yet.{" "}
+ {t("playgroundNoCombosYet")}{" "}
- Create one first
+ {t("playgroundCreateFirst")}
.
diff --git a/src/app/(dashboard)/dashboard/compression/studio/CompareView.tsx b/src/app/(dashboard)/dashboard/compression/studio/CompareView.tsx
index 985e1800ef..246b67b888 100644
--- a/src/app/(dashboard)/dashboard/compression/studio/CompareView.tsx
+++ b/src/app/(dashboard)/dashboard/compression/studio/CompareView.tsx
@@ -1,17 +1,33 @@
"use client";
import { useState } from "react";
+import { useTranslations } from "next-intl";
-interface Row { engine: string; meanSavingsPercent: number; meanRetention: number; totalCompressedTokens: number; }
-interface VerifyResult { id: string; verdict: string | null; usdCost: number; skippedCapped: boolean; }
-export interface CompareViewProps { text: string; }
+interface Row {
+ engine: string;
+ meanSavingsPercent: number;
+ meanRetention: number;
+ totalCompressedTokens: number;
+}
+interface VerifyResult {
+ id: string;
+ verdict: string | null;
+ usdCost: number;
+ skippedCapped: boolean;
+}
+export interface CompareViewProps {
+ text: string;
+}
async function runFidelityCheck(
- rows: Row[], text: string, opts: { provider: string; judgeModel: string; capUsd: number }
+ rows: Row[],
+ text: string,
+ opts: { provider: string; judgeModel: string; capUsd: number }
): Promise<{ verdicts: Record
; spent: number; capped: boolean } | null> {
const items = await Promise.all(
rows.map(async (r) => {
const res = await fetch("/api/compression/preview", {
- method: "POST", headers: { "Content-Type": "application/json" },
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: text }], engineId: r.engine }),
});
const d = await res.json();
@@ -19,51 +35,145 @@ async function runFidelityCheck(
})
);
const vres = await fetch("/api/compression/compare/verify", {
- method: "POST", headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ items, provider: opts.provider, judgeModel: opts.judgeModel, costCapUsd: opts.capUsd }),
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ items,
+ provider: opts.provider,
+ judgeModel: opts.judgeModel,
+ costCapUsd: opts.capUsd,
+ }),
});
const vdata = await vres.json();
if (vres.ok && Array.isArray(vdata.results)) {
const map: Record = {};
for (const v of vdata.results) map[v.id] = v;
- return { verdicts: map, spent: typeof vdata.totalUsd === "number" ? vdata.totalUsd : 0, capped: Boolean(vdata.capped) };
+ return {
+ verdicts: map,
+ spent: typeof vdata.totalUsd === "number" ? vdata.totalUsd : 0,
+ capped: Boolean(vdata.capped),
+ };
}
return null;
}
interface VerifyControlsProps {
- provider: string; onProvider: (v: string) => void;
- judgeModel: string; onJudgeModel: (v: string) => void;
- capUsd: number; onCapUsd: (v: number) => void;
- verifying: boolean; onVerify: () => void;
- spent: number | null; capped: boolean;
+ provider: string;
+ onProvider: (v: string) => void;
+ judgeModel: string;
+ onJudgeModel: (v: string) => void;
+ capUsd: number;
+ onCapUsd: (v: number) => void;
+ verifying: boolean;
+ onVerify: () => void;
+ spent: number | null;
+ capped: boolean;
}
-function VerifyControls({ provider, onProvider, judgeModel, onJudgeModel, capUsd, onCapUsd, verifying, onVerify, spent, capped }: VerifyControlsProps) {
+function VerifyControls({
+ provider,
+ onProvider,
+ judgeModel,
+ onJudgeModel,
+ capUsd,
+ onCapUsd,
+ verifying,
+ onVerify,
+ spent,
+ capped,
+}: VerifyControlsProps) {
+ const t = useTranslations("compressionStudio");
return (
<>
- provider
- onProvider(e.target.value)} />
+
+ {t("provider")}
+ onProvider(e.target.value)}
+ />
- juiz (modelo)
- onJudgeModel(e.target.value)} placeholder="ex: claude-haiku" />
+
+ {t("judgeModel")}
+ onJudgeModel(e.target.value)}
+ placeholder={t("judgeModelPlaceholder")}
+ />
- cap USD
- onCapUsd(Number(e.target.value))} />
+
+ {t("maxCostUsd")}
+ onCapUsd(Number(e.target.value))}
+ />
-
- {verifying ? "Verificando..." : "⚖ Verificar todas"}
+
+ {verifying ? t("verifying") : `⚖ ${t("verifyAll")}`}
{spent !== null && (
- gasto ${spent.toFixed(3)} / ${capUsd.toFixed(2)}{capped ? " · cap atingido" : ""}
+
+ {t("spent", { spent: spent.toFixed(3), cap: capUsd.toFixed(2) })}
+ {capped ? ` · ${t("capReached")}` : ""}
+
)}
>
);
}
+function ComparisonTable({
+ rows,
+ verdicts,
+}: {
+ rows: Row[];
+ verdicts: Record;
+}) {
+ const t = useTranslations("compressionStudio");
+ return (
+
+
+
+ {t("engine")}
+ {t("savings")}
+ {t("retention")}
+ {t("outputTokensShort")}
+ {t("fidelity")}
+
+
+
+ {rows.map((row) => {
+ const verdict = verdicts[row.engine];
+ return (
+
+ {row.engine}
+ −{row.meanSavingsPercent.toFixed(0)}%
+ {Math.round(row.meanRetention * 100)}%
+ {row.totalCompressedTokens}
+
+ {verdict ? (verdict.skippedCapped ? "—(cap)" : (verdict.verdict ?? "?")) : ""}
+
+
+ );
+ })}
+
+
+ );
+}
+
export function CompareView({ text }: CompareViewProps) {
+ const t = useTranslations("compressionStudio");
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false);
const [verdicts, setVerdicts] = useState>({});
@@ -80,12 +190,15 @@ export function CompareView({ text }: CompareViewProps) {
setSpent(null);
try {
const res = await fetch("/api/compression/compare", {
- method: "POST", headers: { "Content-Type": "application/json" },
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: text }] }),
});
const data = await res.json();
if (res.ok) setRows(Array.isArray(data.rows) ? data.rows : []);
- } finally { setLoading(false); }
+ } finally {
+ setLoading(false);
+ }
};
const verifyAll = async () => {
@@ -93,45 +206,43 @@ export function CompareView({ text }: CompareViewProps) {
setVerifying(true);
try {
const out = await runFidelityCheck(rows, text, { provider, judgeModel, capUsd });
- if (out) { setVerdicts(out.verdicts); setSpent(out.spent); setCapped(out.capped); }
- } finally { setVerifying(false); }
+ if (out) {
+ setVerdicts(out.verdicts);
+ setSpent(out.spent);
+ setCapped(out.capped);
+ }
+ } finally {
+ setVerifying(false);
+ }
};
return (
-
- {loading ? "Rodando..." : "Carregar A/B"}
+
+ {loading ? t("running") : t("loadAb")}
{rows.length > 0 && (
)}
-
-
- Engine Savings Retention Out tok Fidelity
-
-
- {rows.map((r) => {
- const v = verdicts[r.engine];
- return (
-
- {r.engine}
- −{r.meanSavingsPercent.toFixed(0)}%
- {Math.round(r.meanRetention * 100)}%
- {r.totalCompressedTokens}
- {v ? (v.skippedCapped ? "—(cap)" : v.verdict ?? "?") : ""}
-
- );
- })}
-
-
+
);
}
diff --git a/src/app/(dashboard)/dashboard/compression/studio/CompressionCockpit.tsx b/src/app/(dashboard)/dashboard/compression/studio/CompressionCockpit.tsx
index bdd2d633e6..35e8d5bdf0 100644
--- a/src/app/(dashboard)/dashboard/compression/studio/CompressionCockpit.tsx
+++ b/src/app/(dashboard)/dashboard/compression/studio/CompressionCockpit.tsx
@@ -1,6 +1,7 @@
"use client";
import { useMemo, useState } from "react";
+import { useTranslations } from "next-intl";
import type { NodeTypes } from "@xyflow/react";
import { FlowCanvas } from "@/shared/components/flow/FlowCanvas";
import { EngineNode } from "./nodes/EngineNode";
@@ -91,14 +92,15 @@ function ViewButton({
// ── Empty state ───────────────────────────────────────────────────────────
function EmptyState() {
+ const t = useTranslations("compressionStudio");
return (
⌁
-
No compression run available.
-
Live data arrives via the WS compression channel.
+
{t("noRun")}
+
{t("liveDataHint")}
);
}
@@ -129,6 +131,7 @@ export interface CompressionCockpitProps {
* component WS-free and unit-testable with a static run.
*/
export function CompressionCockpit({ run: runProp }: CompressionCockpitProps) {
+ const t = useTranslations("compressionStudio");
// Canvas (A2, ReactFlow) is the default; Waterfall (A1) is a plain-div list view of the same run.
const [view, setView] = useState("canvas");
@@ -193,29 +196,29 @@ export function CompressionCockpit({ run: runProp }: CompressionCockpitProps) {
/>
{/* View toggle: ReactFlow canvas (A2) ↔ waterfall list (A1) */}
-
+
setView("canvas")}
/>
setView("waterfall")}
/>
- {fmt(run.originalTokens)} → {fmt(run.compressedTokens)} tok
+ {fmt(run.originalTokens)} → {fmt(run.compressedTokens)} {t("tokenShort")}
−{run.savingsPercent.toFixed(1)}%
{isComplete && view === "canvas" && (
- replay done
+ {t("replayDone")}
)}
@@ -244,14 +247,14 @@ export function CompressionCockpit({ run: runProp }: CompressionCockpitProps) {
- {isPlaying ? "⏸ Pause" : "▷ Replay"}
+ {isPlaying ? `⏸ ${t("pause")}` : `▷ ${t("replay")}`}
⟳
@@ -262,7 +265,10 @@ export function CompressionCockpit({ run: runProp }: CompressionCockpitProps) {
{displayRun && currentFrame && (
- step {displayRun?.steps.length ?? 0}/{run.steps.length}
+ {t("stepProgress", {
+ current: displayRun?.steps.length ?? 0,
+ total: run.steps.length,
+ })}
)}
diff --git a/src/app/(dashboard)/dashboard/compression/studio/DiffPane.tsx b/src/app/(dashboard)/dashboard/compression/studio/DiffPane.tsx
index bc3e0a0a13..ee709491d7 100644
--- a/src/app/(dashboard)/dashboard/compression/studio/DiffPane.tsx
+++ b/src/app/(dashboard)/dashboard/compression/studio/DiffPane.tsx
@@ -1,22 +1,44 @@
"use client";
+import { useTranslations } from "next-intl";
import type { DiffSegment } from "./compressionFlowModel";
-export interface DiffPaneProps { segments: DiffSegment[]; preservedBlocks: Array<{ kind: string; preview: string }>; }
-const SEG_CLASS: Record = { same: "opacity-90", removed: "bg-red-500/20 line-through", added: "bg-green-500/20" };
+export interface DiffPaneProps {
+ segments: DiffSegment[];
+ preservedBlocks: Array<{ kind: string; preview: string }>;
+}
+const SEG_CLASS: Record = {
+ same: "opacity-90",
+ removed: "bg-red-500/20 line-through",
+ added: "bg-green-500/20",
+};
export function DiffPane({ segments, preservedBlocks }: DiffPaneProps) {
+ const t = useTranslations("compressionStudio");
return (
- inline
-
- split (em breve)
+ {t("inlineView")}
+
+ {t("splitComingSoon")}
- {segments.map((seg, i) => ({seg.text} ))}
+ {segments.map((seg, i) => (
+
+ {seg.text}
+
+ ))}
{preservedBlocks.length > 0 && (
- {preservedBlocks.map((b, i) => ({b.kind}: {b.preview.slice(0, 40)} ))}
+ {preservedBlocks.map((b, i) => (
+
+ {b.kind}: {b.preview.slice(0, 40)}
+
+ ))}
)}
diff --git a/src/app/(dashboard)/dashboard/compression/studio/EncoderComparisonTable.tsx b/src/app/(dashboard)/dashboard/compression/studio/EncoderComparisonTable.tsx
index aa2cae6927..1eadf237ad 100644
--- a/src/app/(dashboard)/dashboard/compression/studio/EncoderComparisonTable.tsx
+++ b/src/app/(dashboard)/dashboard/compression/studio/EncoderComparisonTable.tsx
@@ -1,9 +1,11 @@
"use client";
+import { useTranslations } from "next-intl";
import type { EncoderComparison } from "./compressionFlowModel";
const fmt = (n: number) => n.toLocaleString("en-US");
export function EncoderComparisonTable({ comparison }: { comparison: EncoderComparison }) {
+ const t = useTranslations("compressionStudio");
if (!comparison || comparison.arraysCompared === 0) return null;
const rows: Array<{
key: "gcf" | "toon" | "json";
@@ -18,17 +20,17 @@ export function EncoderComparisonTable({ comparison }: { comparison: EncoderComp
return (
- Encoder A/B — {comparison.arraysCompared} array(s){" "}
+ {t("encoderComparison", { count: comparison.arraysCompared })}{" "}
- vencedor: {comparison.winner}
+ {t("encoderWinner", { winner: comparison.winner })}
- encoder
- bytes
- tokens (cl100k)
+ {t("encoder")}
+ {t("bytes")}
+ {t("tokensCl100k")}
diff --git a/src/app/(dashboard)/dashboard/compression/studio/PlayView.tsx b/src/app/(dashboard)/dashboard/compression/studio/PlayView.tsx
index 0537d861b3..5a7810e7e7 100644
--- a/src/app/(dashboard)/dashboard/compression/studio/PlayView.tsx
+++ b/src/app/(dashboard)/dashboard/compression/studio/PlayView.tsx
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
+import { useTranslations } from "next-intl";
import { usePreviewCompression, type Lane, type PreviewBatch } from "@/hooks/usePreviewCompression";
import { WaterfallInspector } from "./WaterfallInspector";
import { DiffPane } from "./DiffPane";
@@ -14,10 +15,10 @@ export interface PlayViewProps {
laneEngines?: readonly string[];
}
-function laneStatus(l: Lane): string {
+function laneStatus(l: Lane, t: ReturnType): string {
const rejected = l.run?.steps?.find((s) => s.rejected);
- if (rejected) return `⚠ rejeitado: ${rejected.rejectReason ?? ""}`;
- return l.error ? "⚠ erro" : l.run ? `−${l.run.savingsPercent}%` : "—";
+ if (rejected) return `⚠ ${t("laneRejected", { reason: rejected.rejectReason ?? "" })}`;
+ return l.error ? `⚠ ${t("error")}` : l.run ? `−${l.run.savingsPercent}%` : "—";
}
function resolveActiveDiff(batch: PreviewBatch | null, selectedLane: string | null) {
@@ -26,6 +27,7 @@ function resolveActiveDiff(batch: PreviewBatch | null, selectedLane: string | nu
}
function LaneList({ lanes, onSelect }: { lanes: Lane[]; onSelect: (e: string) => void }) {
+ const t = useTranslations("compressionStudio");
return (
<>
{lanes.map((l) => (
@@ -36,7 +38,7 @@ function LaneList({ lanes, onSelect }: { lanes: Lane[]; onSelect: (e: string) =>
className="flex w-full items-center justify-between border-b py-1 text-left font-mono text-xs"
>
{l.engine}
- {laneStatus(l)}
+ {laneStatus(l, t)}
))}
>
@@ -44,6 +46,7 @@ function LaneList({ lanes, onSelect }: { lanes: Lane[]; onSelect: (e: string) =>
}
export function PlayView({ text, onText, laneEngines = LANE_ENGINES }: PlayViewProps) {
+ const t = useTranslations("compressionStudio");
const [active, setActive] = useState(["rtk", "caveman"]);
const [fuzzyDedup, setFuzzyDedup] = useState(false);
const [selectedLane, setSelectedLane] = useState(null);
@@ -99,7 +102,7 @@ export function PlayView({ text, onText, laneEngines = LANE_ENGINES }: PlayViewP
{batch?.combined && (
- Fluxo combinado — {active.join(" → ")}{" "}
+ {t("combinedFlow")} — {active.join(" → ")}{" "}
@@ -107,7 +110,7 @@ export function PlayView({ text, onText, laneEngines = LANE_ENGINES }: PlayViewP
)}
{(() => {
@@ -119,14 +122,16 @@ export function PlayView({ text, onText, laneEngines = LANE_ENGINES }: PlayViewP
})()}
{activeDiff && (
- Diff — {selectedLane ?? "combinado"}
+
+ {t("diff")} — {selectedLane ?? t("combined")}
+
)}
{batch?.heatmap && (
- Saliency heatmap — {batch.heatmap.mode}
+ {t("saliencyHeatmap")} — {batch.heatmap.mode}
diff --git a/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx b/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx
index 0cadf7a356..26c60994d0 100644
--- a/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx
+++ b/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx
@@ -1,36 +1,148 @@
"use client";
-export const LANE_ENGINES = ["session-dedup", "ccr", "lite", "rtk", "ionizer", "headroom", "caveman", "aggressive", "ultra"] as const;
-export interface PlaygroundInputProps { text: string; onText: (t: string) => void; active: string[]; onToggleActive: (engine: string) => void; onRun: () => void; loading: boolean; fidelityGate: boolean; onToggleFidelity: () => void; fuzzyDedup: boolean; onToggleFuzzy: () => void; riskGate: boolean; onToggleRisk: () => void; quantumLock: boolean; onToggleQuantum: () => void; heatmap: "ultra" | "universal" | false; onToggleHeatmap: () => void; }
-export function PlaygroundInput({ text, onText, active, onToggleActive, onRun, loading, fidelityGate, onToggleFidelity, fuzzyDedup, onToggleFuzzy, riskGate, onToggleRisk, quantumLock, onToggleQuantum, heatmap, onToggleHeatmap }: PlaygroundInputProps) {
+import type { ReactNode } from "react";
+import { useTranslations } from "next-intl";
+export const LANE_ENGINES = [
+ "session-dedup",
+ "ccr",
+ "lite",
+ "rtk",
+ "ionizer",
+ "headroom",
+ "caveman",
+ "aggressive",
+ "ultra",
+] as const;
+export interface PlaygroundInputProps {
+ text: string;
+ onText: (t: string) => void;
+ active: string[];
+ onToggleActive: (engine: string) => void;
+ onRun: () => void;
+ loading: boolean;
+ fidelityGate: boolean;
+ onToggleFidelity: () => void;
+ fuzzyDedup: boolean;
+ onToggleFuzzy: () => void;
+ riskGate: boolean;
+ onToggleRisk: () => void;
+ quantumLock: boolean;
+ onToggleQuantum: () => void;
+ heatmap: "ultra" | "universal" | false;
+ onToggleHeatmap: () => void;
+}
+
+function EngineOptions({
+ active,
+ onToggleActive,
+}: {
+ active: string[];
+ onToggleActive: (engine: string) => void;
+}) {
+ const t = useTranslations("compressionStudio");
return (
-
@@ -103,19 +107,22 @@ export interface WaterfallInspectorProps {
* Plain divs — no ReactFlow.
*/
export function WaterfallInspector({ run, className = "" }: WaterfallInspectorProps) {
+ const t = useTranslations("compressionStudio");
const maxTokens = run.originalTokens;
return (
{/* Header — INPUT */}
-
⌁ INPUT
+
⌁ {t("input")}
-
{fmt(run.originalTokens)} tokens
+
+ {t("tokenCount", { count: run.originalTokens })}
+
{/* Steps */}
@@ -125,7 +132,7 @@ export function WaterfallInspector({ run, className = "" }: WaterfallInspectorPr
{/* Footer — OUTPUT */}
-
✦ OUTPUT
+
✦ {t("output")}
- {fmt(run.compressedTokens)} tokens
+ {t("tokenCount", { count: run.compressedTokens })}
- {fmt(run.originalTokens)} → {fmt(run.compressedTokens)} tok
+ {fmt(run.originalTokens)} → {fmt(run.compressedTokens)} {t("tokenShort")}
−{run.savingsPercent.toFixed(1)}%
{run.comboId &&
{run.comboId} }
diff --git a/src/app/(dashboard)/dashboard/compression/studio/page.tsx b/src/app/(dashboard)/dashboard/compression/studio/page.tsx
index f8ab875dcc..036ee26a39 100644
--- a/src/app/(dashboard)/dashboard/compression/studio/page.tsx
+++ b/src/app/(dashboard)/dashboard/compression/studio/page.tsx
@@ -1,15 +1,25 @@
"use client";
import { useState } from "react";
+import { useTranslations } from "next-intl";
import { PlayView } from "./PlayView";
import { CompareView } from "./CompareView";
export default function CompressionStudioPage() {
+ const t = useTranslations("compressionStudio");
const [tab, setTab] = useState<"play" | "compare">("play");
const [text, setText] = useState("");
return (
- setTab("play")}>Play
- setTab("compare")}>Compare
+ setTab("play")}>
+ {t("playTab")}
+
+ setTab("compare")}
+ >
+ {t("compareTab")}
+
{tab === "play" ?
:
}
diff --git a/src/app/(dashboard)/dashboard/context/CompressionStylesTile.tsx b/src/app/(dashboard)/dashboard/context/CompressionStylesTile.tsx
index 98a79a916d..c4b4fed60c 100644
--- a/src/app/(dashboard)/dashboard/context/CompressionStylesTile.tsx
+++ b/src/app/(dashboard)/dashboard/context/CompressionStylesTile.tsx
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
-import { useTranslations } from "next-intl";
+import { useLocale, useTranslations } from "next-intl";
interface Summary {
totalRuns: number;
@@ -23,6 +23,7 @@ const EMPTY: Summary = {
export default function CompressionStylesTile() {
const t = useTranslations("settings");
+ const locale = useLocale();
const [summary, setSummary] = useState
(EMPTY);
useEffect(() => {
@@ -43,18 +44,20 @@ export default function CompressionStylesTile() {
>
{t("compressionStylesTileTitle")}
- {summary.totalTokensSaved.toLocaleString("en-US", { useGrouping: false })}
+ {summary.totalTokensSaved.toLocaleString(locale, { useGrouping: false })}
+
+
+ {t("compressionStylesTileSummary", {
+ tokens: summary.totalTokensSaved,
+ runs: summary.runsWithStyles,
+ })}
- tokens saved · {summary.runsWithStyles} runs styled
{styles.length === 0 ? (
-
No styled runs yet.
+
{t("compressionStylesTileEmpty")}
) : (
styles.map(([id, count]) => (
-
+
{id} · {count}
))
diff --git a/src/app/(dashboard)/dashboard/context/caveman/CavemanContextPageClient.tsx b/src/app/(dashboard)/dashboard/context/caveman/CavemanContextPageClient.tsx
index 3c2353e94e..c059673fd6 100644
--- a/src/app/(dashboard)/dashboard/context/caveman/CavemanContextPageClient.tsx
+++ b/src/app/(dashboard)/dashboard/context/caveman/CavemanContextPageClient.tsx
@@ -216,10 +216,7 @@ export default function CavemanContextPageClient() {
{!masterEnabled && (
info
-
- Token Saver master switch is OFF — these settings will not affect requests until you
- turn it on from Compression Settings or change it here.
-
+
{t("masterDisabledWarning")}
)}
diff --git a/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx b/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx
index 597e07d2fc..f4e2487d28 100644
--- a/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx
+++ b/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx
@@ -2,12 +2,8 @@
// Combos screen = Compression Hub (top) + named-combos manager (below).
//
-// IMPORTANT (hydration): no `useTranslations` here. The earlier combos redesign
-// failed to hydrate on the production build and the only structural difference from
-// the engine pages was a page-level `useTranslations`. Strings are literal English,
-// matching `EngineConfigPage` / `CompressionHub`, both of which hydrate cleanly.
-
import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
import { STACKED_PIPELINE_ENGINE_INTENSITIES } from "@/shared/validation/compressionConfigSchemas";
import { CompressionPipelineEditor } from "@/shared/components/compression/CompressionPipelineEditor";
import { ComboCompressionModeSelect } from "@/shared/components/compression/ComboCompressionModeSelect";
@@ -41,6 +37,7 @@ const EMPTY_PIPELINE: PipelineStep[] = [
const ENGINE_INTENSITIES: Record = STACKED_PIPELINE_ENGINE_INTENSITIES;
function NamedCombosManager() {
+ const t = useTranslations("contextCombos");
const [combos, setCombos] = useState([]);
const [routingCombos, setRoutingCombos] = useState([]);
const [languagePacks, setLanguagePacks] = useState([]);
@@ -118,11 +115,11 @@ function NamedCombosManager() {
const saveCombo = async () => {
const trimmed = name.trim();
if (!trimmed) {
- setError("Enter a combo name before saving.");
+ setError(t("nameRequired"));
return;
}
if (pipeline.length === 0) {
- setError("Add at least one pipeline step before saving.");
+ setError(t("pipelineRequired"));
return;
}
setError(null);
@@ -146,7 +143,7 @@ function NamedCombosManager() {
);
if (!res.ok) {
const body = await res.json().catch(() => null);
- setError(body?.error || `Failed to save combo (HTTP ${res.status}).`);
+ setError(body?.error || t("saveComboFailed", { status: res.status }));
return;
}
const combo = await res.json();
@@ -163,7 +160,7 @@ function NamedCombosManager() {
};
const deleteCombo = async (combo: CompressionCombo) => {
- if (!confirm(`Delete combo "${combo.name}"?`)) return;
+ if (!confirm(t("deleteNamedConfirm", { name: combo.name }))) return;
const res = await fetch(`/api/context/combos/${combo.id}`, { method: "DELETE" });
if (res.ok) refresh();
};
@@ -185,10 +182,8 @@ function NamedCombosManager() {
return (
-
Named combos
-
- Save different pipelines and assign them to specific routing combos.
-
+
{t("namedCombos")}
+
{t("namedCombosDescription")}
@@ -217,7 +212,7 @@ function NamedCombosManager() {
-
Language packs
+
{t("languagePacks")}
{languagePacks.map((pack) => (
@@ -235,30 +230,30 @@ function NamedCombosManager() {
-
Output mode
+ {t("outputMode")}
setOutputMode(event.target.checked)}
/>
- Enabled
+ {t("enabled")}
setOutputModeIntensity(event.target.value)}
className="w-full rounded-lg border border-border bg-bg px-3 py-2 text-sm"
>
- lite
- full
- ultra
+ {t("intensityLite")}
+ {t("intensityFull")}
+ {t("intensityUltra")}
-
Assign to routing
+
{t("assignToRouting")}
{routingCombos.length === 0 ? (
-
No routing combos available.
+
{t("noAssignments")}
) : (
routingCombos.map((combo) => {
const id = combo.id ?? combo.name ?? "";
@@ -299,14 +294,14 @@ function NamedCombosManager() {
disabled={saving}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white"
>
- {editingId ? "Save" : "Create combo"}
+ {editingId ? t("save") : t("createCombo")}
{editingId && (
- Cancel
+ {t("cancel")}
)}
@@ -325,7 +320,7 @@ function NamedCombosManager() {
data-testid={`active-badge-${combo.id}`}
className="rounded-full bg-green-500/10 px-2 py-1 text-xs font-medium text-green-500"
>
- ● Active
+ ● {t("active")}
)}
@@ -341,21 +336,21 @@ function NamedCombosManager() {
))}
- Language packs: {combo.languagePacks.join(", ")}
+ {t("languagePacksList", { packs: combo.languagePacks.join(", ") })}
editCombo(combo)}
className="rounded-lg border border-border px-3 py-1.5 text-xs text-text-main"
>
- Edit
+ {t("editCombo")}
{!combo.isDefault && (
deleteCombo(combo)}
className="rounded-lg border border-danger/40 px-3 py-1.5 text-xs text-danger"
>
- Delete
+ {t("deleteCombo")}
)}
diff --git a/src/app/(dashboard)/dashboard/context/combos/CompressionHub.tsx b/src/app/(dashboard)/dashboard/context/combos/CompressionHub.tsx
index 7265782bb3..4eae132de0 100644
--- a/src/app/(dashboard)/dashboard/context/combos/CompressionHub.tsx
+++ b/src/app/(dashboard)/dashboard/context/combos/CompressionHub.tsx
@@ -2,18 +2,13 @@
// Compression Hub — the single place to understand and control compression.
//
-// IMPORTANT (hydration): this component deliberately does NOT use `useTranslations`.
-// The previous combos redesign failed to hydrate on the production build; the only
-// structural difference from the engine pages (which hydrate fine) was a page-level
-// `useTranslations("contextCombos")`. To stay on the proven-good path, strings here
-// remain literal English text, exactly like `EngineConfigPage`.
-//
// Phase 2: this Hub is now a thin overview. The master toggle, mode selector, and the
// reorderable per-layer pipeline live in the panel at /dashboard/context/settings and
// in the named-combo editor. Here we expose a single active-profile selector
// (Default-from-panel | a named combo) + a read-only preview.
import { useCallback, useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -67,6 +62,7 @@ function Toggle({
// ── Main component ──────────────────────────────────────────────────────────────
export default function CompressionHub() {
+ const t = useTranslations("contextCombos");
const [settings, setSettings] = useState(null);
const [combos, setCombos] = useState([]);
const [loading, setLoading] = useState(true);
@@ -124,21 +120,21 @@ export default function CompressionHub() {
});
if (!res.ok) {
setSettings(settings); // revert
- setError("Failed to save settings.");
+ setError(t("saveSettingsFailed"));
}
} catch {
setSettings(settings);
- setError("Failed to save settings.");
+ setError(t("saveSettingsFailed"));
}
},
- [settings]
+ [settings, t]
);
// ── Derived state ─────────────────────────────────────────────────────────────
if (loading) {
return (
- Loading...
+ {t("loading")}
);
}
@@ -157,10 +153,8 @@ export default function CompressionHub() {
hub
-
Compression Hub
-
- Pick which compression profile runs globally.
-
+
{t("hubTitle")}
+
{t("hubDescription")}
setExplainerOpen((v) => !v)}
className="shrink-0 rounded-lg border border-border px-3 py-1.5 text-xs text-text-main hover:bg-bg"
>
- {explainerOpen ? "Hide explanation" : "How it works"}
+ {explainerOpen ? t("hideExplanation") : t("howItWorks")}
@@ -180,25 +174,30 @@ export default function CompressionHub() {
{explainerOpen && (
- Compression reduces tokens and cost by
- rewriting history before it is sent to the provider while preserving meaning.
+ {t.rich("explanationIntro", {
+ strong: (chunks) => {chunks} ,
+ })}
- Active profile : chooses which compression
- profile runs globally — the panel-derived Default or one of your saved named combos.
+ {t.rich("explanationActiveProfile", {
+ strong: (chunks) => {chunks} ,
+ })}
- Default (from panel) : derived from the
- master switch and per-engine toggles you configure in Compression Settings.
+ {t.rich("explanationDefault", {
+ strong: (chunks) => {chunks} ,
+ })}
- Named combos : saved pipelines you build in
- the named-combo editor. Selecting one makes it the active profile for every request.
+ {t.rich("explanationNamedCombos", {
+ strong: (chunks) => {chunks} ,
+ })}
- Preview : shows which engines the active
- profile runs, in order.
+ {t.rich("explanationPreview", {
+ strong: (chunks) => {chunks} ,
+ })}
@@ -208,11 +207,9 @@ export default function CompressionHub() {
- Active profile
+ {t("activeProfile")}
-
- Pick which compression profile runs globally — the panel-derived Default or a saved named combo.
-
+
{t("activeProfileDescription")}
saveSettings({ activeComboId: e.target.value || null })}
className="rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main"
>
- Default (from panel)
+ {t("defaultFromPanel")}
{combos.map((c) => (
{c.name}
@@ -234,13 +231,13 @@ export default function CompressionHub() {
>
{activeCombo ? (
- Runs: {activePipelineText}
+ {t("runs")} {activePipelineText}
) : (
- Default — configured in{" "}
+ {t("defaultConfiguredPrefix")}{" "}
- Compression Settings
+ {t("compressionSettings")}
.
@@ -250,27 +247,23 @@ export default function CompressionHub() {
{/* ── Provider-delegated compression ── */}
-
Provider-delegated compression
+
{t("providerDelegated")}
-
Context Editing (Claude)
-
- Lets the provider clear old tool-use blocks server-side, without rewriting the message.
-
+
{t("contextEditingClaude")}
+
{t("contextEditingDescription")}
saveSettings({ contextEditing: { enabled: !settings?.contextEditing?.enabled } })
}
- ariaLabel="Context Editing"
+ ariaLabel={t("contextEditingAria")}
/>
info
-
- Currently available for Claude (Anthropic) only. It is a delegated mode: the provider clears old tool-use blocks server-side — we do not rewrite the message. Does not affect other providers.
-
+ {t("contextEditingNote")}
diff --git a/src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient.tsx b/src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient.tsx
index cd7613ea66..81ff3c5ff7 100644
--- a/src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient.tsx
+++ b/src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient.tsx
@@ -5,13 +5,12 @@
// a real before→after (dense text vs the rendered PNG page), the fail-closed gate
// flow, and the enable control (wired to /api/settings/compression, preview engine).
//
-// Engine-facing copy is hardcoded English (matches the catalog convention — engine
-// text stays deterministic, not i18n). The sample in ./sampleData.ts is a REAL render
-// from the omniglyph package, not a mockup.
+// The sample in ./sampleData.ts is a REAL render from the omniglyph package, not a mockup.
//
// Card/Toggle are imported from their direct module paths (not the @/shared/components
// barrel) — the barrel pulls a Node-only module that hangs vitest/jsdom.
import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
import Card from "@/shared/components/Card";
import Toggle from "@/shared/components/Toggle";
import { SAMPLE_BEFORE_TEXT, SAMPLE_PAGE_PNG_DATA_URI, SAMPLE_METRICS } from "./sampleData";
@@ -24,67 +23,56 @@ type EngineMap = Record;
/** The measured fail-closed gate chain, in evaluation order. Every no-op is telemetered
* as `skip:`; the engine only fires when all pass. */
-const GATES: ReadonlyArray<{ label: string; pass: string; why: string }> = [
+const GATES = [
{
- label: "Model",
- pass: "claude-fable-5",
- why: "Only Fable 5 reads dense pages at 100% (measured, n=30). GPT-5.5 and Gemini 2.5-flash are blocked.",
+ id: "model",
},
{
- label: "Transport",
- pass: "direct Anthropic",
- why: "Aggregators resample images and destroy legibility — only the direct route is authoritative.",
+ id: "transport",
},
{
- label: "Format",
- pass: "native Claude",
- why: "The body must be Claude-format (never a system role inside messages).",
+ id: "format",
},
{
- label: "Profitable",
- pass: "dense enough",
- why: "The exact 28px-patch cost gate decides per request; small or sparse text passes through untouched.",
+ id: "profitable",
},
-];
+] as const;
-const ECONOMICS: ReadonlyArray<{ value: string; label: string }> = [
- { value: "~10×", label: "fewer tokens on the converted block" },
- { value: "59–70%", label: "end-to-end savings (measured)" },
- { value: "1456", label: "image tokens for a 1568×728 page (~28k chars)" },
- { value: "100%", label: "reading accuracy on Fable 5 (n=30)" },
-];
+const ECONOMICS = [
+ { value: "~10×", id: "fewerTokens" },
+ { value: "59–70%", id: "savings" },
+ { value: "1456", id: "imageTokens" },
+ { value: "100%", id: "accuracy" },
+] as const;
// Section components are split out of the page component so each function stays
// under the complexity gate's 80-line cap; the page composes them.
function PageHeader() {
+ const t = useTranslations("omniglyph");
return (
OmniGlyph
- Preview
+ {t("preview")}
-
- Context-as-image compression. Renders the system prompt, tool docs and dense history as
- compact PNG pages that Claude Fable 5 reads instead of the text — image tokens are billed by
- dimensions, not characters, so the converted block costs ~10× less. Direct Anthropic route
- only.
-
+
{t("description")}
);
}
function EconomicsCard() {
+ const t = useTranslations("omniglyph");
return (
- The economics
+ {t("economicsTitle")}
{ECONOMICS.map((e) => (
-
+
{e.value}
- {e.label}
+ {t(`economics.${e.id}`)}
))}
@@ -93,21 +81,22 @@ function EconomicsCard() {
}
function BeforeAfterCard() {
+ const t = useTranslations("omniglyph");
return (
-
Before → after
+ {t("beforeAfterTitle")}
- −{SAMPLE_METRICS.savingsPct}% tokens on this block
+ {t("blockSavings", { percent: SAMPLE_METRICS.savingsPct })}
- A real render of {SAMPLE_METRICS.beforeChars} characters of dense tool docs — not a mockup.
+ {t("realRender", { characters: SAMPLE_METRICS.beforeChars })}
- Text · ≈ {SAMPLE_METRICS.textTokens} tokens
+ {t("textTokens", { tokens: SAMPLE_METRICS.textTokens })}
{SAMPLE_BEFORE_TEXT}
@@ -115,13 +104,16 @@ function BeforeAfterCard() {
- Rendered page · ≈ {SAMPLE_METRICS.imageTokens} tokens
+ {t("renderedTokens", { tokens: SAMPLE_METRICS.imageTokens })}
{/* eslint-disable-next-line @next/next/no-img-element -- data URI, no loader needed */}
-
When it fires
+
{t("gatesTitle")}
- Fail-closed: every gate must pass, or the request passes through untouched (each skip is
- telemetered as skip:<reason>).
+ {t.rich("gatesDescription", {
+ code: (chunks) => {chunks},
+ })}
{GATES.map((g, i) => (
-
+
{i + 1}
- {g.label} — {g.pass}
+ {t(`gates.${g.id}.label`)} —{" "}
+
+ {t(`gates.${g.id}.pass`)}
+
- {g.why}
+ {t(`gates.${g.id}.why`)}
))}
@@ -166,21 +163,22 @@ function EnableCard(props: {
status: "" | "saved" | "error";
onToggle: (next: boolean) => void;
}) {
+ const t = useTranslations("omniglyph");
return (
-
Enable the engine
+
{t("enableTitle")}
- Runs last in the stack (after RTK/Caveman clean the text, OmniGlyph images the residual)
- and also standalone via the omniglyph mode. Preview — off by default until
- the end-to-end validation lands.
+ {t.rich("enableDescription", {
+ code: (chunks) => {chunks},
+ })}
{props.status === "saved"
- ? "Saved."
+ ? t("saved")
: props.status === "error"
- ? "Could not save."
+ ? t("saveFailed")
: ""}
@@ -190,7 +188,7 @@ function EnableCard(props: {
checked={props.enabled}
onChange={props.onToggle}
disabled={props.disabled}
- ariaLabel="Enable the OmniGlyph engine"
+ ariaLabel={t("enableAria")}
/>
diff --git a/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx b/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx
index 3d9bbd9362..4d7b506171 100644
--- a/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx
+++ b/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx
@@ -7,10 +7,6 @@
// toggle (its own endpoint / separate store), a read-only derived-pipeline preview,
// and the general settings (auto-trigger tokens + preserve-system-prompt).
//
-// Engine rows use the catalog label/description (hardcoded English) directly — NOT
-// i18n — so they stay deterministic. Human-facing chrome (master, general) keeps the
-// app's i18n via useTranslations("settings").
-
import Link from "next/link";
import { useEffect, useState } from "react";
import { useTranslations, useLocale } from "next-intl";
@@ -33,7 +29,7 @@ import {
DEFAULT_CONTEXT_BUDGET,
type ContextBudgetConfig,
} from "../../../../../../open-sse/services/compression/adaptiveCompression/types.ts";
-import { formatAdaptiveTarget } from "./adaptiveTargetLabel.ts";
+import { getAdaptiveTargetSummary } from "./adaptiveTargetLabel.ts";
type CavemanIntensity = "lite" | "full" | "ultra";
@@ -124,6 +120,26 @@ function LiveZoneToggle({
);
}
+function AdaptiveTargetPreview({ contextBudget }: { contextBudget?: ContextBudgetConfig }) {
+ const t = useTranslations("settings");
+ const target = getAdaptiveTargetSummary(contextBudget ?? DEFAULT_CONTEXT_BUDGET, 200000);
+ return (
+
+ {target.enabled
+ ? t("compressionAdaptiveTarget", {
+ mode: target.mode,
+ policy: target.policy,
+ target: target.target,
+ contextLimit: target.contextLimit,
+ })
+ : t("compressionAdaptiveOff")}
+
+ );
+}
+
export default function CompressionPanel() {
const t = useTranslations("settings");
// D-A6/§7: locale-gated styles (e.g. terse-cjk → zh) are only OFFERED under their locale.
@@ -238,11 +254,12 @@ export default function CompressionPanel() {
const derived = deriveDefaultPlan(config.engines, config.enabled);
const derivedText =
derived.mode === "off"
- ? "off"
+ ? t("compressionDerivedOff")
: derived.stackedPipeline.length > 0
- ? `runs: ${derived.stackedPipeline.map((s) => s.engine).join(" → ")}`
- : `mode: ${derived.mode}`;
-
+ ? t("compressionDerivedRuns", {
+ pipeline: derived.stackedPipeline.map((s) => s.engine).join(" → "),
+ })
+ : t("compressionDerivedMode", { mode: derived.mode });
if (loading) {
return (
@@ -305,16 +322,12 @@ export default function CompressionPanel() {
data-testid="derived-pipeline-preview"
className="mb-4 rounded-md border border-border/60 bg-bg-subtle px-3 py-2 text-xs text-text-muted"
>
- Effective pipeline: {derivedText}
+ {t("compressionEffectivePipeline")} {" "}
+ {derivedText}
{/* Adaptive context-budget — read-only computed target (Phase 4C, D-C1 transparency) */}
-
- {formatAdaptiveTarget(config.contextBudget ?? DEFAULT_CONTEXT_BUDGET, 200000)}
-
+
{/* Engine grid */}
@@ -323,6 +336,8 @@ export default function CompressionPanel() {
const engine = config.engines[id] ?? { enabled: false };
const levels = meta.levels;
const level = engine.level ?? levels?.[0] ?? "";
+ const engineLabel = t(`compressionEngine.${id}.label`);
+ const engineDescription = t(`compressionEngine.${id}.description`);
return (
- {meta.label}
+ {engineLabel}
-
{meta.description}
+
{engineDescription}
{levels.map((lvl) => (
- {lvl}
+ {t(`compressionLevel.${lvl}`)}
))}
@@ -368,7 +383,7 @@ export default function CompressionPanel() {
checked={engine.enabled}
onChange={(enabled) => setEngine(id, { enabled })}
disabled={!config.enabled || saving}
- ariaLabel={meta.label}
+ ariaLabel={engineLabel}
/>
@@ -384,7 +399,7 @@ export default function CompressionPanel() {
{t("compressionSettingsOutputStyles")}
- Inject response-shaping instructions without rewriting provider output. Combine freely.
+ {t("compressionOutputStylesDescription")}
{OUTPUT_STYLE_IDS.filter((id) => {
@@ -393,6 +408,8 @@ export default function CompressionPanel() {
}).map((id) => {
const meta = outputStyleMeta(id);
const sel = config.outputStyles?.find((s) => s.id === id);
+ const styleLabel = t(`compressionOutputStyle.${id}.label`);
+ const styleDescription = t(`compressionOutputStyle.${id}.description`);
return (
-
{meta.label}
- {meta.description &&
{meta.description}
}
+
{styleLabel}
+ {meta.description &&
{styleDescription}
}
{CAVEMAN_OUTPUT_LEVELS.map((lvl) => (
- {lvl}
+ {t(`compressionLevel.${lvl}`)}
))}
@@ -425,7 +442,7 @@ export default function CompressionPanel() {
checked={Boolean(sel)}
onChange={(enabled) => setOutputStyle(id, { enabled })}
disabled={saving}
- ariaLabel={meta.label}
+ ariaLabel={styleLabel}
/>
@@ -474,9 +491,7 @@ export default function CompressionPanel() {
{t("mcpAccessibilityTitle")}
-
- Scopes MCP tool outputs (separate store).
-
+
{t("mcpAccessibilityDescription")}
= {
+ Sun: 0,
+ Mon: 1,
+ Tue: 2,
+ Wed: 3,
+ Thu: 4,
+ Fri: 5,
+ Sat: 6,
+};
+
+function formatWeekdayLabel(day: string, locale: string): string {
+ const index = SHORT_WEEKDAY_INDEX[day.slice(0, 3)];
+ if (index === undefined) return day;
+ return new Intl.DateTimeFormat(locale, { weekday: "short" }).format(
+ new Date(Date.UTC(2024, 0, 7 + index))
+ );
+}
+
export function createCurrencyFormatter(locale: string) {
return new Intl.NumberFormat(locale, {
style: "currency",
@@ -875,14 +893,19 @@ export default function CostOverviewTab() {
({
+ ...row,
+ day: formatWeekdayLabel(row.day, locale),
+ }))}
locale={locale}
+ tokensLabel={t("tokens")}
/>
@@ -1102,12 +1125,14 @@ function ActivityHeatmap({
activityMap,
lessLabel,
moreLabel,
+ tokensLabel,
locale,
}: {
title: string;
activityMap: Record;
lessLabel: string;
moreLabel: string;
+ tokensLabel: string;
locale: string;
}) {
const days: Array<{ date: string; value: number }> = [];
@@ -1151,7 +1176,7 @@ function ActivityHeatmap({
className={`w-2.75 h-2.75 rounded-xs ${getIntensity(day.value)}`}
title={`${day.date}: ${
day.value > 0
- ? `${new Intl.NumberFormat(locale).format(day.value)} tokens`
+ ? `${new Intl.NumberFormat(locale).format(day.value)} ${tokensLabel}`
: "No activity"
}`}
/>
diff --git a/src/app/(dashboard)/dashboard/costs/components/ApiKeyUsageLimitCard.tsx b/src/app/(dashboard)/dashboard/costs/components/ApiKeyUsageLimitCard.tsx
index 7b4166ad83..8ee25fa2b5 100644
--- a/src/app/(dashboard)/dashboard/costs/components/ApiKeyUsageLimitCard.tsx
+++ b/src/app/(dashboard)/dashboard/costs/components/ApiKeyUsageLimitCard.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
+import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
export interface ApiKeyUsageLimitPayload {
@@ -46,16 +47,18 @@ function parseUsdInput(value: string): number | null {
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
-function formatResetHint(resetAtIso: string | null): string {
- if (!resetAtIso) return "fallback: rolling 7 days";
+function formatResetHint(resetAtIso: string | null, t: ReturnType): string {
+ if (!resetAtIso) return t("fallbackRollingDays", { days: 7 });
const resetMs = Date.parse(resetAtIso);
- if (!Number.isFinite(resetMs)) return "fallback: rolling 7 days";
+ if (!Number.isFinite(resetMs)) return t("fallbackRollingDays", { days: 7 });
const deltaMs = resetMs - Date.now();
- if (deltaMs <= 0) return "reset due now";
+ if (deltaMs <= 0) return t("resetDueNow");
const hourMs = 60 * 60 * 1000;
const dayMs = 24 * hourMs;
- if (deltaMs < dayMs) return `resets in ${Math.max(1, Math.ceil(deltaMs / hourMs))}h`;
- return `resets in ${Math.max(1, Math.ceil(deltaMs / dayMs))}d`;
+ if (deltaMs < dayMs) {
+ return t("resetsInHours", { count: Math.max(1, Math.ceil(deltaMs / hourMs)) });
+ }
+ return t("resetsInDays", { count: Math.max(1, Math.ceil(deltaMs / dayMs)) });
}
function UsageQuotaMetric({ label, value }: { label: string; value: string }) {
@@ -78,6 +81,7 @@ export function ApiKeyUsageLimitCard({
locale: string;
onSave: (next: ApiKeyUsageLimitSavePayload) => Promise;
}) {
+ const t = useTranslations("usageLimits");
const [enabled, setEnabled] = useState(false);
const [dailyLimit, setDailyLimit] = useState("");
const [weeklyLimit, setWeeklyLimit] = useState("");
@@ -113,7 +117,7 @@ export function ApiKeyUsageLimitCard({
weeklyUsageLimitUsd: parseUsdInput(weeklyLimit),
});
} catch (saveError) {
- setError(saveError instanceof Error ? saveError.message : "Failed to save usage limits");
+ setError(saveError instanceof Error ? saveError.message : t("saveFailed"));
} finally {
setSaving(false);
}
@@ -125,17 +129,14 @@ export function ApiKeyUsageLimitCard({
paid
-
API key USD quota
+ {t("apiKeyUsdQuota")}
{payload?.key.name && (
{payload.key.name}
)}
-
- When enabled, @@om-usage returns daily quota, weekly quota, daily spend, and weekly
- spend in USD. Weekly follows the cached Claude reset when available.
-
+
{t("apiKeyUsdQuotaDescription")}
paid
- {enabled ? "Enabled" : "Disabled"}
+ {enabled ? t("enabled") : t("disabled")}
@@ -207,7 +208,7 @@ export function ApiKeyUsageLimitCard({
{saving ? "hourglass_empty" : "save"}
- {saving ? "Saving..." : "Save quota"}
+ {saving ? t("saving") : t("saveQuota")}
diff --git a/src/app/(dashboard)/dashboard/costs/components/CostCharts.tsx b/src/app/(dashboard)/dashboard/costs/components/CostCharts.tsx
index eb2decd36c..dec6c2ded4 100644
--- a/src/app/(dashboard)/dashboard/costs/components/CostCharts.tsx
+++ b/src/app/(dashboard)/dashboard/costs/components/CostCharts.tsx
@@ -182,10 +182,12 @@ export function WeeklyPatternCard({
title,
rows,
locale,
+ tokensLabel,
}: {
title: string;
rows: Array<{ day: string; avgTokens: number; totalTokens: number }>;
locale: string;
+ tokensLabel: string;
}) {
const chartData = rows.map((row) => ({
day: row.day,
@@ -219,7 +221,7 @@ export function WeeklyPatternCard({
/>
- `${new Intl.NumberFormat(locale).format(value || 0)} tokens`
+ `${new Intl.NumberFormat(locale).format(value || 0)} ${tokensLabel}`
}
contentStyle={{
background: "var(--surface)",
diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/AllocationTable.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/AllocationTable.tsx
index 14ad070b1c..72d3badca0 100644
--- a/src/app/(dashboard)/dashboard/costs/quota-share/components/AllocationTable.tsx
+++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/AllocationTable.tsx
@@ -41,11 +41,11 @@ export default function AllocationTable({ allocations, usage, keyLabels }: Alloc
- API Key
- Weight
+ {t("apiKeyColumn")}
+ {t("weightColumn")}
{t("realConsumedColumn")}
{t("deficitColumn")}
- Policy
+ {t("policy")}
@@ -88,17 +88,22 @@ export default function AllocationTable({ allocations, usage, keyLabels }: Alloc
{deficit !== null ? (
0 ? "text-red-400" : deficit < 0 ? "text-emerald-400" : "text-text-muted"
+ deficit > 0
+ ? "text-red-400"
+ : deficit < 0
+ ? "text-emerald-400"
+ : "text-text-muted"
}
>
- {deficit > 0 ? "+" : ""}{deficit.toLocaleString()}
+ {deficit > 0 ? "+" : ""}
+ {deficit.toLocaleString()}
) : (
—
)}
{fairShare !== null && (
- (fair: {fairShare.toLocaleString()})
+ ({t("fairShareShort")}: {fairShare.toLocaleString()})
)}
@@ -112,7 +117,7 @@ export default function AllocationTable({ allocations, usage, keyLabels }: Alloc
: "bg-emerald-500/10 text-emerald-400"
}`}
>
- {alloc.policy}
+ {t(`policy${alloc.policy[0].toUpperCase()}${alloc.policy.slice(1)}`)}
diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx
index 912dbef726..66dfa6d2f8 100644
--- a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx
+++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx
@@ -24,7 +24,13 @@ import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { maskEmailLikeValue } from "@/shared/utils/maskEmail";
import { getKnownPlan } from "@/lib/quota/planRegistry";
import { quotaModelName } from "@/lib/quota/quotaModelNaming";
-import type { Policy, PoolAllocation, QuotaDimension, QuotaUnit, QuotaWindow } from "@/lib/quota/dimensions";
+import type {
+ Policy,
+ PoolAllocation,
+ QuotaDimension,
+ QuotaUnit,
+ QuotaWindow,
+} from "@/lib/quota/dimensions";
import type { QuotaPool } from "@/lib/db/quotaPools";
// ────────────────────────────────────────────────────────────────────────────
@@ -346,8 +352,7 @@ export default function PoolWizard({
const availableKeys = apiKeys.filter((k) => !allocations.some((a) => a.apiKeyId === k.id));
- const keyLabel = (id: string) =>
- apiKeys.find((k) => k.id === id)?.name || id.slice(0, 12) + "…";
+ const keyLabel = (id: string) => apiKeys.find((k) => k.id === id)?.name || id.slice(0, 12) + "…";
const addKey = (id: string) => {
setAllocations((prev) => {
@@ -362,9 +367,7 @@ export default function PoolWizard({
const updateWeight = (id: string, value: number) => {
setAllocations((prev) =>
- prev.map((a) =>
- a.apiKeyId === id ? { ...a, weight: Math.max(0, Math.min(100, value)) } : a
- )
+ prev.map((a) => (a.apiKeyId === id ? { ...a, weight: Math.max(0, Math.min(100, value)) } : a))
);
};
@@ -397,15 +400,17 @@ export default function PoolWizard({
if (connectionIds.length === 0 || !name) return [];
const MAX_PER_PROVIDER = 3;
- return connectionIds.map((cid) => {
- const conn = connections.find((c) => c.id === cid);
- if (!conn) return null;
- const allModels = getPreviewModels(conn.provider);
- const names = allModels.slice(0, MAX_PER_PROVIDER).map((m) =>
- quotaModelName(name, conn.provider, m)
- );
- return { provider: conn.provider, names, totalModels: allModels.length };
- }).filter(Boolean) as Array<{ provider: string; names: string[]; totalModels: number }>;
+ return connectionIds
+ .map((cid) => {
+ const conn = connections.find((c) => c.id === cid);
+ if (!conn) return null;
+ const allModels = getPreviewModels(conn.provider);
+ const names = allModels
+ .slice(0, MAX_PER_PROVIDER)
+ .map((m) => quotaModelName(name, conn.provider, m));
+ return { provider: conn.provider, names, totalModels: allModels.length };
+ })
+ .filter(Boolean) as Array<{ provider: string; names: string[]; totalModels: number }>;
}, [connectionIds, connections, poolName]);
// Flat list (for legacy single-provider path, kept for step-3 rendering simplicity)
@@ -502,8 +507,7 @@ export default function PoolWizard({
if (!editPatchRes.ok) {
const errBody = await editPatchRes.json().catch(() => null);
throw new Error(
- errBody?.error?.message ||
- `PATCH /api/quota/pools failed: HTTP ${editPatchRes.status}`
+ errBody?.error?.message || `PATCH /api/quota/pools failed: HTTP ${editPatchRes.status}`
);
}
@@ -537,7 +541,12 @@ export default function PoolWizard({
if (!open) return null;
return (
-
+
@@ -631,7 +640,9 @@ export default function PoolWizard({
type="text"
value={poolName}
onChange={(e) => setPoolName(e.target.value)}
- placeholder={selectedConn ? selectedConn.provider : t("wizardPoolNamePlaceholder")}
+ placeholder={
+ selectedConn ? selectedConn.provider : t("wizardPoolNamePlaceholder")
+ }
className="w-full px-3 py-2 rounded border border-border bg-bg-base text-sm"
/>
@@ -675,7 +686,11 @@ export default function PoolWizard({
: "border-border text-text-muted hover:text-text-main"
}`}
>
- {p === "hard" ? t("policyHard") : p === "soft" ? t("policySoft") : t("policyBurst")}
+ {p === "hard"
+ ? t("policyHard")
+ : p === "soft"
+ ? t("policySoft")
+ : t("policyBurst")}
))}
@@ -845,7 +860,7 @@ export default function PoolWizard({
value={a.weight}
onChange={(e) => updateWeight(a.apiKeyId, Number(e.target.value))}
className="px-2 py-1 rounded border border-border bg-bg-base text-sm text-right tabular-nums"
- title="Weight %"
+ title={t("weightPercent")}
/>
- LOCAL
+ {t("badgeLocal")}
)}
{ep.alwaysProtected && (
@@ -70,7 +70,7 @@ export default function ApiEndpointsTab() {
className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-red-500/15 text-red-500 border border-red-500/30"
title={t("badgeAlwaysProtectedTooltip")}
>
- PROTECTED
+ {t("badgeProtected")}
)}
{ep.internal && (
@@ -78,7 +78,7 @@ export default function ApiEndpointsTab() {
className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-gray-500/15 text-gray-400 border border-gray-500/30"
title={t("badgeInternalTooltip")}
>
- INTERNAL
+ {t("badgeInternal")}
)}
@@ -109,7 +109,7 @@ export default function ApiEndpointsTab() {
const [useManualKey, setUseManualKey] = useState(false);
const selectedApiKey = availableApiKeys.find((apiKey) => apiKey.id === selectedApiKeyId) || null;
- const loadCatalog = async () => {
+ const loadCatalog = useCallback(async () => {
try {
const res = await fetch("/api/openapi/spec");
if (res.ok) {
@@ -120,13 +120,13 @@ export default function ApiEndpointsTab() {
const message =
body && typeof body.error === "string"
? body.error
- : `API catalog request failed with HTTP ${res.status}`;
+ : t("catalogLoadFailed", { status: res.status });
return { data: null, error: message };
} catch (error) {
- const message = error instanceof Error ? error.message : "Failed to load API catalog";
+ const message = error instanceof Error ? error.message : t("catalogLoadFailedGeneric");
return { data: null, error: message };
}
- };
+ }, [t]);
useEffect(() => {
let cancelled = false;
@@ -140,7 +140,7 @@ export default function ApiEndpointsTab() {
return () => {
cancelled = true;
};
- }, []);
+ }, [loadCatalog]);
// Load API keys for Try It functionality. The list endpoint returns masked
// keys; the selected key is revealed only when sending a Try It request.
@@ -148,7 +148,7 @@ export default function ApiEndpointsTab() {
let cancelled = false;
fetch("/api/keys?limit=100", { credentials: "same-origin" })
.then(async (res) => {
- if (!res.ok) throw new Error(`Failed to load API keys (${res.status})`);
+ if (!res.ok) throw new Error(t("apiKeysLoadFailed", { status: res.status }));
return res.json();
})
.then((data) => {
@@ -166,13 +166,15 @@ export default function ApiEndpointsTab() {
.catch((error) => {
if (!cancelled) {
setAvailableApiKeys([]);
- setApiKeyLoadError(error instanceof Error ? error.message : "Failed to load API keys");
+ setApiKeyLoadError(
+ error instanceof Error ? error.message : t("apiKeysLoadFailedGeneric")
+ );
}
});
return () => {
cancelled = true;
};
- }, []);
+ }, [t]);
// Filter endpoints
const filteredEndpoints = useMemo(() => {
@@ -245,13 +247,13 @@ export default function ApiEndpointsTab() {
if (!res.ok) {
throw new Error(
res.status === 403
- ? "API key reveal is disabled (ALLOW_API_KEY_REVEAL). Change it in the feature flag page or paste an API key manually."
- : `Failed to reveal API key (${res.status})`
+ ? t("apiKeyRevealDisabled")
+ : t("apiKeyRevealFailed", { status: res.status })
);
}
const data = await res.json();
if (!data?.key || typeof data.key !== "string") {
- throw new Error("API key reveal returned an invalid response");
+ throw new Error(t("apiKeyRevealInvalid"));
}
setRevealedApiKeys((current) => ({ ...current, [selectedApiKey.id]: data.key }));
return data.key;
@@ -274,7 +276,7 @@ export default function ApiEndpointsTab() {
if (apiKeyForRequest) {
headers["Authorization"] = `Bearer ${apiKeyForRequest}`;
} else {
- throw new Error("API key is required for this endpoint.");
+ throw new Error(t("apiKeyRequired"));
}
}
@@ -291,12 +293,12 @@ export default function ApiEndpointsTab() {
if (res.ok) setTryResult(await res.json());
else {
const err = await res.json().catch(() => ({}));
- throw new Error(err.error || `Request failed (${res.status})`);
+ throw new Error(err.error || t("requestFailed", { status: res.status }));
}
} catch (err: any) {
setTryResult({
status: 0,
- statusText: "Error",
+ statusText: t("errorStatus"),
headers: {},
body: { error: err.message },
latencyMs: 0,
@@ -333,7 +335,10 @@ export default function ApiEndpointsTab() {
- {catalog.endpoints.length} endpoints across {allTags.length} categories
+ {t("catalogStats", {
+ endpoints: catalog.endpoints.length,
+ categories: allTags.length,
+ })}
@@ -375,7 +380,7 @@ export default function ApiEndpointsTab() {
{t("apiEndpointsCatalogUnavailable")}
- {catalogError || "The OpenAPI specification could not be loaded."}
+ {catalogError || t("catalogUnavailableDescription")}
open_in_new
- Open JSON response
+ {t("openJsonResponse")}
@@ -421,7 +426,7 @@ export default function ApiEndpointsTab() {
: "bg-black/5 dark:bg-white/5 text-text-muted hover:text-text-main"
}`}
>
- All
+ {t("all")}
{allTags.slice(0, 8).map((tag) => (
8 && (
- +{allTags.length - 8} more
+ {t("more", { count: allTags.length - 8 })}
)}
@@ -475,7 +480,7 @@ export default function ApiEndpointsTab() {
? "bg-amber-500/10 text-amber-500"
: "bg-black/5 dark:bg-white/5 text-text-muted hover:text-text-main"
}`}
- title="Show/hide internal routes (hidden by default)"
+ title={t("showInternalTooltip")}
>
{showInternal ? t("hideInternal") : t("showInternal")}
@@ -553,7 +558,7 @@ export default function ApiEndpointsTab() {
lock
- Bearer Auth
+ {t("bearerAuth")}
)}
{ep.requestBody && (
@@ -561,11 +566,11 @@ export default function ApiEndpointsTab() {
description
- Request Body
+ {t("requestBody")}
)}
- Responses: {ep.responses.join(", ")}
+ {t("responses")}: {ep.responses.join(", ")}
@@ -585,14 +590,14 @@ export default function ApiEndpointsTab() {
{isTrying ? "close" : "play_arrow"}
- {isTrying ? "Close" : "Try It"}
+ {isTrying ? t("close") : t("tryIt")}
{/* curl example */}
- Example
+ {t("example")}
curl -X {ep.method} {baseUrl}
@@ -611,14 +616,14 @@ export default function ApiEndpointsTab() {
- API Key
+ {t("apiKey")}
setUseManualKey(!useManualKey)}
className="text-[9px] text-primary hover:underline"
>
- {useManualKey ? "Switch to Selection" : "Enter Manually"}
+ {useManualKey ? t("switchToSelection") : t("enterManually")}
@@ -627,7 +632,7 @@ export default function ApiEndpointsTab() {
type="password"
value={manualApiKey}
onChange={(e) => setManualApiKey(e.target.value)}
- placeholder="Paste your API key here"
+ placeholder={t("pasteApiKey")}
className="w-full px-3 py-2 text-xs font-mono rounded-lg border border-black/10
dark:border-white/10 bg-white dark:bg-black/30 focus:outline-none
focus:ring-1 focus:ring-primary"
@@ -648,8 +653,7 @@ export default function ApiEndpointsTab() {
) : (
- {apiKeyLoadError ||
- "No active API keys found. Toggle manual entry to paste one."}
+ {apiKeyLoadError || t("noActiveApiKeys")}
)}
@@ -657,7 +661,7 @@ export default function ApiEndpointsTab() {
{ep.method !== "GET" && (
- Request Body (JSON)
+ {t("requestBodyJson")}
{trying ? "hourglass_empty" : "send"}
- {trying ? "Sending..." : "Send Request"}
+ {trying ? t("sending") : t("sendRequest")}
{tryResult && (
@@ -733,7 +737,7 @@ export default function ApiEndpointsTab() {
data_object
- Data Schemas
+ {t("dataSchemas")}
{catalog.schemas.length}
diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx
index e14c0948fa..d9f4d8cc4f 100644
--- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx
+++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx
@@ -110,11 +110,11 @@ type EndpointTunnelVisibility = {
type EndpointTab = "apis" | "mcp" | "a2a" | "context-sources";
-const ENDPOINT_TABS: Array<{ value: EndpointTab; label: string; icon: string }> = [
- { value: "apis", label: "APIs", icon: "api" },
- { value: "mcp", label: "MCP", icon: "extension" },
- { value: "a2a", label: "A2A", icon: "hub" },
- { value: "context-sources", label: "Context Sources", icon: "database" },
+const ENDPOINT_TABS: Array<{ value: EndpointTab; labelKey: string; icon: string }> = [
+ { value: "apis", labelKey: "tabApis", icon: "api" },
+ { value: "mcp", labelKey: "tabMcp", icon: "extension" },
+ { value: "a2a", labelKey: "tabA2a", icon: "hub" },
+ { value: "context-sources", labelKey: "tabContextSources", icon: "database" },
];
const DEFAULT_TUNNEL_VISIBILITY: EndpointTunnelVisibility = {
@@ -1113,9 +1113,9 @@ export default function APIPageClient({ machineId }: Readonly
candidates.findIndex((other) => other.url === candidate.url) === index
@@ -1248,10 +1248,10 @@ export default function APIPageClient({ machineId }: Readonly
({ ...tab, label: t(tab.labelKey) }))}
value={activeEndpointTab}
onChange={(value) => setActiveEndpointTab(value as EndpointTab)}
- aria-label="Endpoint sections"
+ aria-label={t("endpointSections")}
className="w-fit"
/>
@@ -1300,7 +1300,7 @@ export default function APIPageClient({ machineId }: Readonly 0 && (
- Active Endpoints
+ {t("activeEndpoints")}
{activeUrls.map(({ label, url, key }) => (
@@ -1341,7 +1341,7 @@ export default function APIPageClient({ machineId }: Readonly
void copy(url, `lan_${url}`)}
- title={`Copy ${url}`}
+ title={t("copyUrlTitle", { url })}
className="inline-flex items-center gap-0.5 text-[10px] text-text-muted hover:text-text transition-colors"
>
{url.replace(/^https?:\/\//, "")}
@@ -1354,7 +1354,7 @@ export default function APIPageClient({ machineId }: Readonly
- Running
+ {t("statusRunning")}
void copy(localApiUrl, "endpoint_url")}
@@ -1373,11 +1373,14 @@ export default function APIPageClient({ machineId }: Readonly
- Tunnels
+ {t("tunnels")}
- {activeTunnelCount} / {visibleTunnelCount} active
+ {t("activeTunnelCount", {
+ active: activeTunnelCount,
+ total: visibleTunnelCount,
+ })}
@@ -1399,7 +1402,7 @@ export default function APIPageClient({ machineId }: Readonly
- {cloudEnabled ? "Active" : "Disabled"}
+ {cloudEnabled ? tc("active") : tc("disabled")}
{cloudEnabled ? (
) : (
- Not configured
+ {tc("notConfigured")}
)}
diff --git a/src/app/(dashboard)/dashboard/endpoint/components/ObsidianSourceCard.tsx b/src/app/(dashboard)/dashboard/endpoint/components/ObsidianSourceCard.tsx
index 18370cec98..411642ed70 100644
--- a/src/app/(dashboard)/dashboard/endpoint/components/ObsidianSourceCard.tsx
+++ b/src/app/(dashboard)/dashboard/endpoint/components/ObsidianSourceCard.tsx
@@ -63,7 +63,7 @@ export default function ObsidianSourceCard() {
const handleSaveToken = async () => {
if (!token.trim()) {
- setMessage({ type: "error", text: "Please enter an Obsidian API token" });
+ setMessage({ type: "error", text: t("obsidianEnterToken") });
return;
}
setBusy(true);
@@ -83,11 +83,14 @@ export default function ObsidianSourceCard() {
setConnected(true);
setMessage({ type: "success", text: data.message });
} else {
- setMessage({ type: "error", text: data.error ?? "Failed to connect" });
+ setMessage({ type: "error", text: data.error ?? t("obsidianConnectFailed") });
setConnected(false);
}
} catch (err) {
- setMessage({ type: "error", text: err instanceof Error ? err.message : "Connection failed" });
+ setMessage({
+ type: "error",
+ text: err instanceof Error ? err.message : t("obsidianConnectionFailed"),
+ });
} finally {
setBusy(false);
}
@@ -105,10 +108,13 @@ export default function ObsidianSourceCard() {
setBaseUrl(DEFAULT_URL);
setMessage({ type: "success", text: data.message });
} else {
- setMessage({ type: "error", text: data.error ?? "Failed to disconnect" });
+ setMessage({ type: "error", text: data.error ?? t("obsidianDisconnectFailed") });
}
} catch (err) {
- setMessage({ type: "error", text: err instanceof Error ? err.message : "Disconnect failed" });
+ setMessage({
+ type: "error",
+ text: err instanceof Error ? err.message : t("obsidianDisconnectFailed"),
+ });
} finally {
setBusy(false);
}
@@ -116,7 +122,7 @@ export default function ObsidianSourceCard() {
const handleEnableWebdav = async () => {
if (!vaultPath.trim()) {
- setMessage({ type: "error", text: "Please enter the vault directory path" });
+ setMessage({ type: "error", text: t("obsidianEnterVaultPath") });
return;
}
setWebdavBusy(true);
@@ -132,12 +138,15 @@ export default function ObsidianSourceCard() {
setWebdavEnabled(true);
setWebdavUsername(data.username);
setWebdavPassword(data.password);
- setMessage({ type: "success", text: "WebDAV sync enabled. Configure your mobile device below." });
+ setMessage({ type: "success", text: t("obsidianWebdavEnabledMessage") });
} else {
- setMessage({ type: "error", text: data.error ?? "Failed to enable WebDAV" });
+ setMessage({ type: "error", text: data.error ?? t("obsidianEnableWebdavFailed") });
}
} catch (err) {
- setMessage({ type: "error", text: err instanceof Error ? err.message : "Failed to enable WebDAV" });
+ setMessage({
+ type: "error",
+ text: err instanceof Error ? err.message : t("obsidianEnableWebdavFailed"),
+ });
} finally {
setWebdavBusy(false);
}
@@ -153,12 +162,15 @@ export default function ObsidianSourceCard() {
setWebdavEnabled(false);
setWebdavUsername(null);
setWebdavPassword(null);
- setMessage({ type: "success", text: "WebDAV sync disabled" });
+ setMessage({ type: "success", text: t("obsidianWebdavDisabledMessage") });
} else {
- setMessage({ type: "error", text: data.error ?? "Failed to disable WebDAV" });
+ setMessage({ type: "error", text: data.error ?? t("obsidianDisableWebdavFailed") });
}
} catch (err) {
- setMessage({ type: "error", text: err instanceof Error ? err.message : "Failed to disable WebDAV" });
+ setMessage({
+ type: "error",
+ text: err instanceof Error ? err.message : t("obsidianDisableWebdavFailed"),
+ });
} finally {
setWebdavBusy(false);
}
@@ -180,25 +192,32 @@ export default function ObsidianSourceCard() {
className="w-full flex items-center gap-3 text-left"
>
Obsidian
- {connected ? "Connected" : "Not connected"}
+ {connected ? t("obsidianConnected") : t("obsidianNotConnected")}
{webdavEnabled && (
-
- WebDAV Sync
+
+ {t("obsidianWebdavSync")}
)}
-
- Search, read, write, and manage notes in Obsidian through routed AI models
-
+
{t("obsidianDescription")}
- Obsidian Local REST API Token
+ {t("obsidianRestToken")}
setToken(e.target.value)}
- placeholder="Obsidian API key"
+ placeholder={t("obsidianApiKeyPlaceholder")}
disabled={busy}
className="font-mono text-sm flex-1"
/>
- Connect
+ {t("obsidianConnect")}
- Base URL (optional)
+ {t("obsidianBaseUrlOptional")}
warning
-
- Port 27124 is the MCP endpoint (HTTPS, self-signed cert).
- {" "}The REST API uses HTTP on port 27123.
-
+ {t("obsidianPortWarning")}
)}
- Default: {DEFAULT_URL}. For remote vaults, enter the Tailscale IP +
- {" "}port (e.g., http://100.x.x.x:27123). Enable the Local REST API
- {" "}plugin on the machine running Obsidian.
+ {t("obsidianRemoteVaultHint", { defaultUrl: DEFAULT_URL })}
) : (
- Token configured. Obsidian tools are available via MCP.
+ {t("obsidianTokenConfigured")}
- Disconnect
+ {t("obsidianDisconnect")}
- Vault Sync (WebDAV)
+
+ {t("obsidianVaultSync")}
+
-
- Sync your vault to Obsidian mobile using WebDAV over Tailscape.
- Obsidian mobile has built-in WebDAV support — no plugins needed.
-
+
{t("obsidianVaultSyncDescription")}
{!webdavEnabled ? (
) : (
-
cloud_sync
+
+ cloud_sync
+
-
WebDAV sync enabled
-
{getWebdavUrl()}
+
+ {t("obsidianWebdavEnabled")}
+
+
+ {getWebdavUrl()}
+
- Disable
+ {t("obsidianDisable")}
-
Configure Obsidian Mobile
+
+ {t("obsidianConfigureMobile")}
+
- In Obsidian mobile: Settings → Sync → WebDAV → enter the following:
+ {t("obsidianMobileInstructions")}
-
WebDAV URL
+
+ {t("obsidianWebdavUrl")}
+
{getWebdavUrl()}
@@ -353,7 +376,9 @@ export default function ObsidianSourceCard() {
-
Username
+
+ {t("obsidianUsername")}
+
{webdavUsername ?? "—"}
@@ -362,7 +387,9 @@ export default function ObsidianSourceCard() {
-
Password
+
+ {t("obsidianPassword")}
+
@@ -382,17 +409,13 @@ export default function ObsidianSourceCard() {
-
- Use your Tailscale IP instead of localhost if connecting from mobile.
- {" "}Both devices must be on the same Tailscale network.
-
+
{t("obsidianTailscaleHint")}
)}
)}
-
)}
diff --git a/src/app/(dashboard)/dashboard/health/ProviderHealthAutopilotCard.tsx b/src/app/(dashboard)/dashboard/health/ProviderHealthAutopilotCard.tsx
index 15d264b015..8eefe67201 100644
--- a/src/app/(dashboard)/dashboard/health/ProviderHealthAutopilotCard.tsx
+++ b/src/app/(dashboard)/dashboard/health/ProviderHealthAutopilotCard.tsx
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
+import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
import { getProviderDisplayName } from "@/lib/display/names";
import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap";
@@ -20,9 +21,11 @@ type AutopilotAction = {
type AutopilotIssue = {
id: string;
+ kind: string;
severity: "info" | "warning" | "critical";
title: string;
recommendation: string;
+ target: AutopilotAction["target"];
evidence?: Record
;
actions: AutopilotAction[];
};
@@ -85,21 +88,144 @@ function getErrorMessage(payload: unknown, fallback: string): string {
return fallback;
}
-function formatConnectionEvidence(issue: AutopilotIssue): string | null {
+function formatConnectionEvidence(
+ issue: AutopilotIssue,
+ t: ReturnType
+): string | null {
const evidence = issue.evidence || {};
const parts: string[] = [];
if (typeof evidence.label === "string") parts.push(evidence.label);
if (typeof evidence.remainingMs === "number" && evidence.remainingMs > 0) {
- parts.push(`remaining ${Math.ceil(evidence.remainingMs / 1000)}s`);
+ parts.push(t("remainingSeconds", { seconds: Math.ceil(evidence.remainingMs / 1000) }));
}
if (typeof evidence.errorCode === "string" || typeof evidence.errorCode === "number") {
- parts.push(`code ${evidence.errorCode}`);
+ parts.push(t("errorCode", { code: String(evidence.errorCode) }));
}
if (typeof evidence.lastErrorType === "string") parts.push(evidence.lastErrorType);
return parts.length > 0 ? parts.join(" · ") : null;
}
+function issueText(
+ issue: AutopilotIssue,
+ field: "title" | "recommendation",
+ t: ReturnType
+) {
+ const label = typeof issue.evidence?.label === "string" ? issue.evidence.label : "";
+ const model = issue.target.model ?? "";
+ const status = typeof issue.evidence?.status === "string" ? issue.evidence.status : "";
+ const keys: Record = {
+ provider_circuit_open: {
+ title: "issue.circuitOpenTitle",
+ recommendation: "issue.circuitOpenRecommendation",
+ },
+ provider_circuit_half_open: {
+ title: "issue.circuitRecoveryTitle",
+ recommendation: "issue.circuitRecoveryRecommendation",
+ },
+ terminal_connection_error: {
+ title: "issue.terminalTitle",
+ recommendation: "issue.terminalRecommendation",
+ },
+ connection_cooldown: {
+ title: "issue.cooldownTitle",
+ recommendation: "issue.cooldownRecommendation",
+ },
+ stale_connection_error: {
+ title: "issue.staleErrorTitle",
+ recommendation: "issue.staleErrorRecommendation",
+ },
+ inactive_connection: {
+ title: "issue.inactiveTitle",
+ recommendation: "issue.inactiveRecommendation",
+ },
+ model_lockout: {
+ title: "issue.modelLockoutTitle",
+ recommendation: "issue.modelLockoutRecommendation",
+ },
+ quota_monitor_warning: {
+ title: "issue.quotaTitle",
+ recommendation: "issue.quotaRecommendation",
+ },
+ };
+ const key = keys[issue.kind]?.[field];
+ return key ? t(key, { label, model, status }) : issue[field];
+}
+
+function actionLabel(action: AutopilotAction, t: ReturnType) {
+ const keys: Record = {
+ clear_provider_breaker: "action.resetProviderBreaker",
+ clear_connection_cooldown: "action.clearConnectionCooldown",
+ deactivate_connection: "action.disableConnection",
+ clear_stale_connection_error: "action.clearStaleError",
+ reactivate_connection: "action.reactivateConnection",
+ clear_model_lockout: "action.clearModelLockout",
+ };
+ return keys[action.type] ? t(keys[action.type]) : action.label;
+}
+
+function ProviderIssues({
+ issues,
+ busyAction,
+ onApply,
+}: {
+ issues: AutopilotIssue[];
+ busyAction: string | null;
+ onApply: (issue: AutopilotIssue, action: AutopilotAction) => Promise;
+}) {
+ const t = useTranslations("providerHealthAutopilot");
+ const orderedIssues = [...issues]
+ .sort((left, right) => SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity])
+ .slice(0, 4);
+ return (
+
+ {orderedIssues.map((issue) => {
+ const evidence = formatConnectionEvidence(issue, t);
+ return (
+
+
+
+
+
+ {t(`severity.${issue.severity}`)}
+
+
+ {issueText(issue, "title", t)}
+
+
+
+ {issueText(issue, "recommendation", t)}
+
+ {evidence &&
{evidence}
}
+
+ {issue.actions.length > 0 && (
+
+ {issue.actions.map((action) => {
+ const busy = busyAction === `${issue.id}:${action.type}`;
+ return (
+ void onApply(issue, action)}
+ disabled={busy || Boolean(busyAction)}
+ className="rounded-lg border border-primary/30 bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20 disabled:opacity-50"
+ >
+ {busy ? t("applying") : actionLabel(action, t)}
+
+ );
+ })}
+
+ )}
+
+
+ );
+ })}
+
+ );
+}
+
export default function ProviderHealthAutopilotCard() {
+ const t = useTranslations("providerHealthAutopilot");
const nodeMap = useProviderNodeMap();
const [report, setReport] = useState(null);
const [loading, setLoading] = useState(true);
@@ -117,11 +243,11 @@ export default function ProviderHealthAutopilotCard() {
setReport(json);
setError(null);
} catch (err) {
- setError(err instanceof Error ? err.message : "Failed to load autopilot report");
+ setError(err instanceof Error ? err.message : t("loadFailed"));
} finally {
setLoading(false);
}
- }, []);
+ }, [t]);
useEffect(() => {
void load();
@@ -137,7 +263,12 @@ export default function ProviderHealthAutopilotCard() {
const applyAction = useCallback(
async (issue: AutopilotIssue, action: AutopilotAction) => {
- if (action.requiresConfirmation && !confirm(`${action.label}?\n\n${issue.recommendation}`)) {
+ const localizedAction = actionLabel(action, t);
+ const localizedRecommendation = issueText(issue, "recommendation", t);
+ if (
+ action.requiresConfirmation &&
+ !confirm(`${localizedAction}?\n\n${localizedRecommendation}`)
+ ) {
return;
}
@@ -156,15 +287,15 @@ export default function ProviderHealthAutopilotCard() {
});
const json = await res.json();
if (!res.ok) throw new Error(getErrorMessage(json, `HTTP ${res.status}`));
- setMessage(`${action.label} applied.`);
+ setMessage(t("actionApplied", { action: localizedAction }));
await load();
} catch (err) {
- setMessage(err instanceof Error ? err.message : "Autopilot action failed");
+ setMessage(err instanceof Error ? err.message : t("actionFailed"));
} finally {
setBusyAction(null);
}
},
- [load]
+ [load, t]
);
return (
@@ -176,10 +307,8 @@ export default function ProviderHealthAutopilotCard() {
health_and_safety
-
Provider Health Autopilot
-
- Finds unstable providers, account cooldowns, stale errors, and safe manual fixes.
-
+
{t("title")}
+
{t("description")}
@@ -188,7 +317,7 @@ export default function ProviderHealthAutopilotCard() {
disabled={loading}
className="rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main transition-colors hover:bg-surface/80 disabled:opacity-50"
>
- Refresh
+ {t("refresh")}
@@ -196,21 +325,23 @@ export default function ProviderHealthAutopilotCard() {
-
Status
-
{report?.status || "loading"}
+
{t("status")}
+
+ {t(`state.${report?.status || "loading"}`)}
+
-
Issues
+
{t("issues")}
{report?.summary.issueCount ?? 0}
-
Actions
+
{t("actions")}
{report?.summary.actionableCount ?? 0}
-
Connections
+
{t("connections")}
{report?.summary.connectionCount ?? 0}
@@ -228,11 +359,9 @@ export default function ProviderHealthAutopilotCard() {
{error}
) : loading && !report ? (
- Loading provider recommendations...
+ {t("loadingRecommendations")}
) : topProviders.length === 0 ? (
-
- No provider health recommendations right now.
-
+ {t("noRecommendations")}
) : (
{topProviders.map((provider) => (
@@ -246,10 +375,13 @@ export default function ProviderHealthAutopilotCard() {
{resolveProviderName(provider.provider, nodeMap)}
- score {(provider.score * 100).toFixed(0)}% · active{" "}
- {provider.signals.connections.active}/{provider.signals.connections.total} ·{" "}
- cooldown {provider.signals.connections.cooldown} · model lockouts{" "}
- {provider.signals.modelLockouts}
+ {t("providerMetrics", {
+ score: (provider.score * 100).toFixed(0),
+ active: provider.signals.connections.active,
+ total: provider.signals.connections.total,
+ cooldown: provider.signals.connections.cooldown,
+ lockouts: provider.signals.modelLockouts,
+ })}
- {provider.state}
+ {t(`providerState.${provider.state}`)}
-
- {[...provider.issues]
- .sort(
- (left, right) => SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity]
- )
- .slice(0, 4)
- .map((issue) => (
-
-
-
-
-
- {issue.severity}
-
-
{issue.title}
-
-
{issue.recommendation}
- {formatConnectionEvidence(issue) && (
-
- {formatConnectionEvidence(issue)}
-
- )}
-
- {issue.actions.length > 0 && (
-
- {issue.actions.map((action) => {
- const busy = busyAction === `${issue.id}:${action.type}`;
- return (
- void applyAction(issue, action)}
- disabled={busy || Boolean(busyAction)}
- className="rounded-lg border border-primary/30 bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20 disabled:opacity-50"
- >
- {busy ? "Applying..." : action.label}
-
- );
- })}
-
- )}
-
-
- ))}
-
+
))}
diff --git a/src/app/(dashboard)/dashboard/health/page.tsx b/src/app/(dashboard)/dashboard/health/page.tsx
index 30e2eeb5e0..6cedd23bb8 100644
--- a/src/app/(dashboard)/dashboard/health/page.tsx
+++ b/src/app/(dashboard)/dashboard/health/page.tsx
@@ -18,7 +18,7 @@ import { AI_PROVIDERS } from "@/shared/constants/providers";
import { getProviderDisplayName } from "@/lib/display/names";
import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap";
import { compareTr } from "@/shared/utils/turkishText";
-import { useTranslations } from "next-intl";
+import { useLocale, useTranslations } from "next-intl";
import TelemetryCard from "./TelemetryCard";
import ProviderHealthAutopilotCard from "./ProviderHealthAutopilotCard";
import ProviderHealthMatrixCard from "./ProviderHealthMatrixCard";
@@ -57,6 +57,7 @@ const CB_STYLES = {
};
export default function HealthPage() {
+ const locale = useLocale();
const t = useTranslations("health");
const tc = useTranslations("common");
const tp = useTranslations("providers");
@@ -112,15 +113,20 @@ export default function HealthPage() {
}, []);
useEffect(() => {
- fetchHealth();
- fetchExtras();
- fetchDbHealth();
+ const initialFetch = setTimeout(() => {
+ void fetchHealth();
+ void fetchExtras();
+ void fetchDbHealth();
+ }, 0);
const interval = setInterval(() => {
- fetchHealth();
- fetchExtras();
- fetchDbHealth();
+ void fetchHealth();
+ void fetchExtras();
+ void fetchDbHealth();
}, 15000);
- return () => clearInterval(interval);
+ return () => {
+ clearTimeout(initialFetch);
+ clearInterval(interval);
+ };
}, [fetchHealth, fetchExtras, fetchDbHealth]);
const handleResetHealth = async () => {
@@ -206,7 +212,7 @@ export default function HealthPage() {
{lastRefresh && (
- {t("updatedAt", { time: lastRefresh.toLocaleTimeString() })}
+ {t("updatedAt", { time: lastRefresh.toLocaleTimeString(locale) })}
)}
{t("databaseHealth")}
-
- Diagnose and repair stale quota/domain rows and broken combo references.
-
+
{t("databaseHealthDescription")}
-
Status
+
{t("status")}
- {dbHealth?.isHealthy ? "Healthy" : "Attention needed"}
+ {dbHealth?.isHealthy ? t("healthy") : t("attentionNeeded")}
-
Issues
+
{t("issues")}
{dbHealth?.issues?.length ?? 0}
-
Repairs
+
{t("repairs")}
{dbHealth?.repairedCount ?? 0}
@@ -301,12 +305,10 @@ export default function HealthPage() {
disabled={repairingDb}
className="px-4 py-2 rounded-lg bg-primary/10 text-primary text-sm hover:bg-primary/20 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
- {repairingDb ? "Repairing..." : "Run Auto-Repair"}
+ {repairingDb ? t("repairing") : t("runAutoRepair")}
{dbHealth?.backupCreated && (
-
- A repair backup was created before mutating.
-
+
{t("repairBackupCreated")}
)}
{dbHealthError &&
{dbHealthError}
}
@@ -420,9 +422,11 @@ export default function HealthPage() {
groups
- Session Activity
+ {t("sessionActivity")}
- {sessions?.activeCount ?? 0} active
+
+ {t("activeCount", { count: sessions?.activeCount ?? 0 })}
+
@@ -450,13 +454,15 @@ export default function HealthPage() {
{session.sessionId}
- {session.requestCount} requests
+ {t("requestCount", { count: session.requestCount })}
{session.connectionId ? ` • ${session.connectionId.slice(0, 8)}…` : ""}
-
{Math.round((session.idleMs || 0) / 1000)}s idle
-
{Math.round((session.ageMs || 0) / 1000)}s age
+
+ {t("idleSeconds", { count: Math.round((session.idleMs || 0) / 1000) })}
+
+
{t("ageSeconds", { count: Math.round((session.ageMs || 0) / 1000) })}
))}
@@ -470,31 +476,33 @@ export default function HealthPage() {
radar
- Quota Monitors
+ {t("quotaMonitors")}
- {quotaMonitor?.active ?? 0} active
+
+ {t("activeCount", { count: quotaMonitor?.active ?? 0 })}
+
-
Alerting
+
{t("alerting")}
{quotaMonitor?.alerting ?? 0}
-
Exhausted
+
{t("limitExhausted")}
{quotaMonitor?.exhausted ?? 0}
-
Errors
+
{t("errors")}
{quotaMonitor?.errors ?? 0}
-
Providers
+
{t("providers")}
{Object.keys(quotaMonitor?.byProvider || {}).length}
@@ -552,20 +560,20 @@ export default function HealthPage() {
healing
- Graceful Degradation Status
+ {t("gracefulDegradationStatus")}
- Full: {degradation.summary.full}
+ {t("degradationFull")}: {degradation.summary.full}
- Reduced: {degradation.summary.reduced}
+ {t("degradationReduced")}: {degradation.summary.reduced}
- Minimal: {degradation.summary.minimal}
+ {t("degradationMinimal")}: {degradation.summary.minimal}
- Default: {degradation.summary.default}
+ {t("degradationDefault")}: {degradation.summary.default}
@@ -590,11 +598,11 @@ export default function HealthPage() {
return (
-
+
{feat.feature}
@@ -611,7 +619,9 @@ export default function HealthPage() {
)}
- Since {new Date(feat.since).toLocaleTimeString()}
+ {t("sinceTime", {
+ time: new Date(feat.since).toLocaleTimeString(locale),
+ })}
);
@@ -799,12 +809,14 @@ export default function HealthPage() {
? t("failures", { count: cb.failures })
: t("failuresPlural", { count: cb.failures })}
{Number(cb.retryAfterMs) > 0 && (
-
· retry in {fmtMs(cb.retryAfterMs)}
+
+ · {t("retryIn", { duration: fmtMs(cb.retryAfterMs) })}
+
)}
{cb.lastFailure && (
· {t("lastFailure")}:{" "}
- {new Date(cb.lastFailure).toLocaleTimeString()}
+ {new Date(cb.lastFailure).toLocaleTimeString(locale)}
)}
diff --git a/src/app/(dashboard)/dashboard/leaderboard/page.tsx b/src/app/(dashboard)/dashboard/leaderboard/page.tsx
index 7024cf1d4d..7a8571e01b 100644
--- a/src/app/(dashboard)/dashboard/leaderboard/page.tsx
+++ b/src/app/(dashboard)/dashboard/leaderboard/page.tsx
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
-import { useTranslations } from "next-intl";
+import { useLocale, useTranslations } from "next-intl";
import { Card } from "@/shared/components";
type LeaderboardScope = "global" | "weekly" | "monthly" | "tokens_shared";
@@ -11,11 +11,11 @@ interface LeaderboardEntry {
score: number;
}
-const SCOPE_LABELS: Record
= {
- global: "All Time",
- weekly: "Weekly",
- monthly: "Monthly",
- tokens_shared: "Tokens Shared",
+const SCOPE_LABEL_KEYS: Record = {
+ global: "leaderboardScopes.allTime",
+ weekly: "leaderboardScopes.weekly",
+ monthly: "leaderboardScopes.monthly",
+ tokens_shared: "leaderboardScopes.tokensShared",
};
const MEDAL_COLORS = [
@@ -28,6 +28,8 @@ const MEDAL_EMOJI = ["🥇", "🥈", "🥉"];
export default function LeaderboardPage() {
const t = useTranslations("common");
+ const tg = useTranslations("gamification");
+ const locale = useLocale();
const [scope, setScope] = useState("global");
const [entries, setEntries] = useState([]);
const [myRank, setMyRank] = useState(null);
@@ -35,24 +37,27 @@ export default function LeaderboardPage() {
const [error, setError] = useState("");
const eventSourceRef = useRef(null);
- const fetchLeaderboard = useCallback(async (s: LeaderboardScope) => {
- setLoading(true);
- setError("");
- try {
- const res = await fetch(`/api/gamification/leaderboard?scope=${s}&limit=50`);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const data = await res.json();
- setEntries(data.entries || []);
- setMyRank(data.myRank ?? null);
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to load leaderboard");
- } finally {
- setLoading(false);
- }
- }, []);
+ const fetchLeaderboard = useCallback(
+ async (s: LeaderboardScope) => {
+ try {
+ const res = await fetch(`/api/gamification/leaderboard?scope=${s}&limit=50`);
+ if (!res.ok) throw new Error(tg("leaderboardLoadFailed", { status: res.status }));
+ const data = await res.json();
+ setEntries(data.entries || []);
+ setMyRank(data.myRank ?? null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : tg("leaderboardLoadFailed"));
+ } finally {
+ setLoading(false);
+ }
+ },
+ [tg]
+ );
useEffect(() => {
- fetchLeaderboard(scope);
+ const loadTimer = window.setTimeout(() => {
+ void fetchLeaderboard(scope);
+ }, 0);
// SSE real-time updates
const es = new EventSource(`/api/gamification/stream?scope=${scope}`);
@@ -80,6 +85,7 @@ export default function LeaderboardPage() {
};
return () => {
+ window.clearTimeout(loadTimer);
es.close();
eventSourceRef.current = null;
};
@@ -92,17 +98,21 @@ export default function LeaderboardPage() {
{/* Scope selector */}
- {(Object.keys(SCOPE_LABELS) as LeaderboardScope[]).map((s) => (
+ {(Object.keys(SCOPE_LABEL_KEYS) as LeaderboardScope[]).map((s) => (
setScope(s)}
+ onClick={() => {
+ setLoading(true);
+ setError("");
+ setScope(s);
+ }}
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
scope === s
? "bg-violet-500 border-violet-500 text-white"
: "border-border text-text-muted hover:text-text-main hover:border-violet-500/50"
}`}
>
- {SCOPE_LABELS[s]}
+ {tg(SCOPE_LABEL_KEYS[s])}
))}
@@ -116,8 +126,8 @@ export default function LeaderboardPage() {
#{myRank}
-
Scope
-
{SCOPE_LABELS[scope]}
+
{tg("scope")}
+
{tg(SCOPE_LABEL_KEYS[scope])}
@@ -145,9 +155,11 @@ export default function LeaderboardPage() {
{entry.apiKeyId.slice(0, 8)}...
- {entry.score.toLocaleString()}
+
+ {entry.score.toLocaleString(locale)}
+
- {scope === "tokens_shared" ? "tokens shared" : "points"}
+ {scope === "tokens_shared" ? tg("tokensShared") : tg("points")}
{idx + 1}
@@ -164,9 +176,9 @@ export default function LeaderboardPage() {
- Rank
- Name
- Score
+ {tg("rank")}
+ {tg("name")}
+ {tg("score")}
@@ -178,7 +190,7 @@ export default function LeaderboardPage() {
{idx + 4}
{entry.apiKeyId.slice(0, 12)}...
- {entry.score.toLocaleString()}
+ {entry.score.toLocaleString(locale)}
))}
@@ -190,9 +202,7 @@ export default function LeaderboardPage() {
{entries.length === 0 && !error && (
-
- No entries yet for this scope. Start using OmniRoute to earn points!
-
+ {tg("leaderboardEmpty")}
)}
>
diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/MediaProviderPageClient.tsx b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/MediaProviderPageClient.tsx
index 125c631f91..f2430c68d2 100644
--- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/MediaProviderPageClient.tsx
+++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/MediaProviderPageClient.tsx
@@ -36,7 +36,11 @@ interface MediaProviderPageClientProps {
freeNote?: string;
}
-function renderPlayground(kind: MediaKind, providerId: string) {
+function renderPlayground(
+ kind: MediaKind,
+ providerId: string,
+ imageToTextCopy: { title: string; description: React.ReactNode }
+) {
switch (kind) {
case "embedding":
return ;
@@ -62,15 +66,9 @@ function renderPlayground(kind: MediaKind, providerId: string) {
image_search
-
Image to Text
+ {imageToTextCopy.title}
-
- Inline playground for Image-to-Text will be available when{" "}
-
- /api/v1/images/understanding
- {" "}
- is implemented.
-
+
{imageToTextCopy.description}
);
default:
@@ -188,7 +186,7 @@ export default function MediaProviderPageClient({
{conn.name ?? conn.id}
{conn.isActive === false && (
- disabled
+ {t("disabled")}
)}
@@ -198,7 +196,12 @@ export default function MediaProviderPageClient({
{/* Playground */}
- {renderPlayground(activeKind, providerId)}
+ {renderPlayground(activeKind, providerId, {
+ title: t("imageToText"),
+ description: t.rich("imageToTextComingSoon", {
+ code: (chunks) => {chunks},
+ }),
+ })}
);
}
diff --git a/src/app/(dashboard)/dashboard/media-providers/components/EmbeddingExampleCard.tsx b/src/app/(dashboard)/dashboard/media-providers/components/EmbeddingExampleCard.tsx
index 1024ea77a1..2f8983ba8a 100644
--- a/src/app/(dashboard)/dashboard/media-providers/components/EmbeddingExampleCard.tsx
+++ b/src/app/(dashboard)/dashboard/media-providers/components/EmbeddingExampleCard.tsx
@@ -29,7 +29,7 @@ export function EmbeddingExampleCard({ providerId }: Props) {
const firstModel = models[0]?.id ?? "";
const [model, setModel] = useState("");
- const [inputText, setInputText] = useState("Hello, world!");
+ const [inputText, setInputText] = useState(() => t("embeddingSample"));
const [running, setRunning] = useState(false);
const [result, setResult] = useState<{ data: unknown; latencyMs: number } | undefined>();
const [error, setError] = useState(null);
@@ -73,7 +73,7 @@ export function EmbeddingExampleCard({ providerId }: Props) {
setResult({ data, latencyMs });
}
} catch (err) {
- setError(err instanceof Error ? err.message : "Request failed");
+ setError(err instanceof Error ? err.message : t("requestFailed"));
} finally {
setRunning(false);
}
@@ -83,7 +83,7 @@ export function EmbeddingExampleCard({ providerId }: Props) {
return (
setInputText(e.target.value)}
- placeholder="Hello, world!"
+ placeholder={t("embeddingSample")}
className="w-full rounded-md border border-border bg-bg-subtle text-sm px-2 py-1.5 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
/>
diff --git a/src/app/(dashboard)/dashboard/media-providers/components/ImageExampleCard.tsx b/src/app/(dashboard)/dashboard/media-providers/components/ImageExampleCard.tsx
index fea18d1207..4ebc8ab815 100644
--- a/src/app/(dashboard)/dashboard/media-providers/components/ImageExampleCard.tsx
+++ b/src/app/(dashboard)/dashboard/media-providers/components/ImageExampleCard.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
+import Image from "next/image";
import { useTranslations } from "next-intl";
import { useApiKey } from "../../providers/hooks/useApiKey";
import { useProviderModels } from "../../providers/hooks/useProviderModels";
@@ -66,7 +67,7 @@ function extractError(data: unknown): string | null {
return null;
}
-function ImageResultRenderer(data: unknown) {
+function ImageResultRenderer(data: unknown, altText: string) {
if (!data || typeof data !== "object") return null;
const d = data as Record;
const items = Array.isArray(d.data) ? (d.data as Array>) : [];
@@ -81,12 +82,15 @@ function ImageResultRenderer(data: unknown) {
const src = url ?? (b64 ? `data:image/png;base64,${b64}` : null);
if (!src) return null;
return (
-
);
})}
@@ -103,7 +107,7 @@ export function ImageExampleCard({ providerId }: Props) {
const firstModel = models[0]?.id ?? "dall-e-3";
const [model, setModel] = useState("");
- const [prompt, setPrompt] = useState("A serene landscape with mountains at sunset");
+ const [prompt, setPrompt] = useState(() => t("imageSample"));
const [size, setSize] = useState("1024x1024");
const [running, setRunning] = useState(false);
const [result, setResult] = useState<{ data: unknown; latencyMs: number } | undefined>();
@@ -147,7 +151,7 @@ export function ImageExampleCard({ providerId }: Props) {
setResult({ data, latencyMs });
}
} catch (err) {
- setError(err instanceof Error ? err.message : "Request failed");
+ setError(err instanceof Error ? err.message : t("requestFailed"));
} finally {
setRunning(false);
}
@@ -160,14 +164,14 @@ export function ImageExampleCard({ providerId }: Props) {
return (
ImageResultRenderer(data, tMedia("generatedImageAlt"))}
>
{/* Model */}
@@ -187,9 +191,7 @@ export function ImageExampleCard({ providerId }: Props) {
{/* Suggested models from HuggingFace Hub (image kind only) */}
{suggestedOnly.length > 0 && (
-
- {tMedia("suggestedModels")}
-
+
{tMedia("suggestedModels")}
{suggestedOnly.map((m) => (
setPrompt(e.target.value)}
rows={2}
- placeholder="A serene landscape..."
+ placeholder={t("imageSample")}
className="w-full rounded-md border border-border bg-bg-subtle text-sm px-2 py-1.5 text-text-main focus:outline-none focus:ring-1 focus:ring-primary resize-none"
/>
diff --git a/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx b/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx
index 4c36f83b2e..27a900c541 100644
--- a/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx
+++ b/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx
@@ -248,7 +248,11 @@ export function LlmChatCard({
setMessages((prev) => {
const next = [...prev];
const last = next[next.length - 1];
- next[next.length - 1] = { ...last, role: "assistant", content: `[Error: ${errMsg}]` };
+ next[next.length - 1] = {
+ ...last,
+ role: "assistant",
+ content: `[${t("errorLabel")}: ${errMsg}]`,
+ };
return next;
});
return;
@@ -315,11 +319,15 @@ export function LlmChatCard({
// Cancelled by user — leave partial message
return;
}
- const msg = err instanceof Error ? err.message : "Request failed";
+ const msg = err instanceof Error ? err.message : t("requestFailed");
setMessages((prev) => {
const next = [...prev];
const last = next[next.length - 1];
- next[next.length - 1] = { ...last, role: "assistant", content: `[Error: ${msg}]` };
+ next[next.length - 1] = {
+ ...last,
+ role: "assistant",
+ content: `[${t("errorLabel")}: ${msg}]`,
+ };
return next;
});
} finally {
@@ -328,7 +336,7 @@ export function LlmChatCard({
// Refocus textarea so user can keep typing
requestAnimationFrame(() => textareaRef.current?.focus());
}
- }, [input, streaming, selectedKey, keys, providerId, qualifiedModel, messages]);
+ }, [input, streaming, selectedKey, keys, providerId, qualifiedModel, messages, t]);
const handleKeyDown = (e: React.KeyboardEvent
) => {
if (e.key === "Enter" && !e.shiftKey) {
@@ -403,7 +411,7 @@ export function LlmChatCard({
onChange={(e) => setSelectedKey(e.target.value)}
className="rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
>
- (default)
+ {t("defaultKey")}
{keys.map((k) => (
{k.name ?? k.id}
@@ -419,7 +427,7 @@ export function LlmChatCard({
onClick={handleClear}
className="text-xs text-text-muted hover:text-text-primary transition-colors"
>
- Clear
+ {t("clear")}
)}
@@ -442,17 +450,17 @@ export function LlmChatCard({
forum
-
Send a message to start the conversation
-
- Shift+Enter for newline · Enter to send
-
+
{t("emptyConversation")}
+
{t("sendHint")}
) : (
{messages.map((msg, i) => {
const isUser = msg.role === "user";
const isError =
- !isUser && typeof msg.content === "string" && msg.content.startsWith("[Error");
+ !isUser &&
+ typeof msg.content === "string" &&
+ msg.content.startsWith(`[${t("errorLabel")}`);
return (
- {isUser ? "You" : isError ? "Error" : "Assistant"}
+ {isUser ? t("you") : isError ? t("errorLabel") : t("assistant")}
{!isUser && !isError && msg.model && (
stop
diff --git a/src/app/(dashboard)/dashboard/media-providers/components/MusicExampleCard.tsx b/src/app/(dashboard)/dashboard/media-providers/components/MusicExampleCard.tsx
index 9244363c48..ceef366d2e 100644
--- a/src/app/(dashboard)/dashboard/media-providers/components/MusicExampleCard.tsx
+++ b/src/app/(dashboard)/dashboard/media-providers/components/MusicExampleCard.tsx
@@ -29,7 +29,7 @@ export function MusicExampleCard({ providerId }: Props) {
const firstModel = models[0]?.id ?? "";
const [model, setModel] = useState("");
- const [prompt, setPrompt] = useState("Upbeat jazz piano with light percussion");
+ const [prompt, setPrompt] = useState(() => t("musicSample"));
const [duration, setDuration] = useState(10);
const [running, setRunning] = useState(false);
const [audioUrl, setAudioUrl] = useState(null);
@@ -90,7 +90,7 @@ export function MusicExampleCard({ providerId }: Props) {
setAudioUrl(url);
setLatencyMs(elapsed);
} else {
- setError("No audio URL in response: " + JSON.stringify(data));
+ setError(t("noAudioUrl", { response: JSON.stringify(data) }));
}
} else {
const blob = await res.blob();
@@ -99,7 +99,7 @@ export function MusicExampleCard({ providerId }: Props) {
setLatencyMs(elapsed);
}
} catch (err) {
- setError(err instanceof Error ? err.message : "Request failed");
+ setError(err instanceof Error ? err.message : t("requestFailed"));
} finally {
setRunning(false);
}
@@ -110,7 +110,7 @@ export function MusicExampleCard({ providerId }: Props) {
const renderAudio = () => (
- Your browser does not support audio.
+ {t("browserAudioUnsupported")}
);
@@ -119,7 +119,7 @@ export function MusicExampleCard({ providerId }: Props) {
return (
setPrompt(e.target.value)}
rows={2}
- placeholder="Upbeat jazz piano with light percussion"
+ placeholder={t("musicSample")}
className="w-full rounded-md border border-border bg-bg-subtle text-sm px-2 py-1.5 text-text-main focus:outline-none focus:ring-1 focus:ring-primary resize-none"
/>
diff --git a/src/app/(dashboard)/dashboard/media-providers/components/OcrExampleCard.tsx b/src/app/(dashboard)/dashboard/media-providers/components/OcrExampleCard.tsx
index f5a675b034..4c2e0dab95 100644
--- a/src/app/(dashboard)/dashboard/media-providers/components/OcrExampleCard.tsx
+++ b/src/app/(dashboard)/dashboard/media-providers/components/OcrExampleCard.tsx
@@ -77,7 +77,7 @@ export function OcrExampleCard({ providerId }: Props) {
setResult({ data, latencyMs });
}
} catch (err) {
- setError(err instanceof Error ? err.message : "Request failed");
+ setError(err instanceof Error ? err.message : t("requestFailed"));
} finally {
setRunning(false);
}
@@ -112,7 +112,7 @@ export function OcrExampleCard({ providerId }: Props) {
{/* Document URL */}
-
Document URL
+
{t("documentUrl")}
MAX_FILE_SIZE_BYTES) {
- setFileError("File too large — max 25 MB");
+ setFileError(t("fileTooLarge25Mb"));
setFile(null);
return;
}
@@ -93,7 +93,7 @@ export function SttExampleCard({ providerId }: Props) {
const handleRun = async () => {
if (!file) {
- setError("Please select an audio file first.");
+ setError(t("selectAudioFirst"));
return;
}
setRunning(true);
@@ -122,7 +122,7 @@ export function SttExampleCard({ providerId }: Props) {
setResult({ data, latencyMs });
}
} catch (err) {
- setError(err instanceof Error ? err.message : "Request failed");
+ setError(err instanceof Error ? err.message : t("requestFailed"));
} finally {
setRunning(false);
}
@@ -132,7 +132,7 @@ export function SttExampleCard({ providerId }: Props) {
return (
upload_file
- {file ? file.name : "Choose file…"}
+ {file ? file.name : t("chooseFile")}
{file && (
{Math.round(file.size / 1024)}KB
@@ -180,7 +180,7 @@ export function SttExampleCard({ providerId }: Props) {
onChange={handleFileChange}
/>
{fileError && {fileError}
}
- mp3, wav, m4a, ogg, flac — max 25 MB
+ {t("audioFormats25Mb")}
);
diff --git a/src/app/(dashboard)/dashboard/media-providers/components/TtsExampleCard.tsx b/src/app/(dashboard)/dashboard/media-providers/components/TtsExampleCard.tsx
index 21c693d4e4..17875795df 100644
--- a/src/app/(dashboard)/dashboard/media-providers/components/TtsExampleCard.tsx
+++ b/src/app/(dashboard)/dashboard/media-providers/components/TtsExampleCard.tsx
@@ -33,7 +33,7 @@ export function TtsExampleCard({ providerId }: Props) {
const [model, setModel] = useState
("");
const [voice, setVoice] = useState("alloy");
const [speed, setSpeed] = useState("1.0");
- const [inputText, setInputText] = useState("Hello, this is a text-to-speech test.");
+ const [inputText, setInputText] = useState(() => t("ttsSample"));
const [running, setRunning] = useState(false);
const [audioUrl, setAudioUrl] = useState(null);
const [audioSize, setAudioSize] = useState(null);
@@ -92,7 +92,7 @@ export function TtsExampleCard({ providerId }: Props) {
setAudioSize(blob.size);
setLatencyMs(elapsed);
} catch (err) {
- setError(err instanceof Error ? err.message : "Request failed");
+ setError(err instanceof Error ? err.message : t("requestFailed"));
} finally {
setRunning(false);
}
@@ -113,7 +113,7 @@ export function TtsExampleCard({ providerId }: Props) {
const renderAudio = () => (
- Your browser does not support audio.
+ {t("browserAudioUnsupported")}
{audioSize !== null && (
@@ -135,7 +135,7 @@ export function TtsExampleCard({ providerId }: Props) {
return (
setInputText(e.target.value)}
rows={2}
- placeholder="Hello, this is a text-to-speech test."
+ placeholder={t("ttsSample")}
className="w-full rounded-md border border-border bg-bg-subtle text-sm px-2 py-1.5 text-text-main focus:outline-none focus:ring-1 focus:ring-primary resize-none"
/>
diff --git a/src/app/(dashboard)/dashboard/media-providers/components/VideoExampleCard.tsx b/src/app/(dashboard)/dashboard/media-providers/components/VideoExampleCard.tsx
index efcf80d137..1a4d872d3d 100644
--- a/src/app/(dashboard)/dashboard/media-providers/components/VideoExampleCard.tsx
+++ b/src/app/(dashboard)/dashboard/media-providers/components/VideoExampleCard.tsx
@@ -22,7 +22,7 @@ function extractError(data: unknown): string | null {
return null;
}
-function VideoResultRenderer(data: unknown) {
+function VideoResultRenderer(data: unknown, unsupportedText: string) {
if (!data || typeof data !== "object") {
return
{JSON.stringify(data, null, 2)} ;
}
@@ -38,7 +38,7 @@ function VideoResultRenderer(data: unknown) {
return (
- Your browser does not support video.
+ {unsupportedText}
);
@@ -53,7 +53,7 @@ export function VideoExampleCard({ providerId }: Props) {
const firstModel = models[0]?.id ?? "";
const [model, setModel] = useState
("");
- const [prompt, setPrompt] = useState("A time-lapse of clouds over a mountain range");
+ const [prompt, setPrompt] = useState(() => t("videoSample"));
const [running, setRunning] = useState(false);
const [result, setResult] = useState<{ data: unknown; latencyMs: number } | undefined>();
const [error, setError] = useState(null);
@@ -96,7 +96,7 @@ export function VideoExampleCard({ providerId }: Props) {
setResult({ data, latencyMs });
}
} catch (err) {
- setError(err instanceof Error ? err.message : "Request failed");
+ setError(err instanceof Error ? err.message : t("requestFailed"));
} finally {
setRunning(false);
}
@@ -106,14 +106,14 @@ export function VideoExampleCard({ providerId }: Props) {
return (
VideoResultRenderer(data, t("browserVideoUnsupported"))}
>
{/* Model */}
@@ -137,7 +137,7 @@ export function VideoExampleCard({ providerId }: Props) {
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={2}
- placeholder="A time-lapse of clouds over a mountain range"
+ placeholder={t("videoSample")}
className="w-full rounded-md border border-border bg-bg-subtle text-sm px-2 py-1.5 text-text-main focus:outline-none focus:ring-1 focus:ring-primary resize-none"
/>
diff --git a/src/app/(dashboard)/dashboard/media-providers/components/WebFetchExampleCard.tsx b/src/app/(dashboard)/dashboard/media-providers/components/WebFetchExampleCard.tsx
index d54bd242ae..4c746e4216 100644
--- a/src/app/(dashboard)/dashboard/media-providers/components/WebFetchExampleCard.tsx
+++ b/src/app/(dashboard)/dashboard/media-providers/components/WebFetchExampleCard.tsx
@@ -80,7 +80,7 @@ export function WebFetchExampleCard({ providerId }: Props) {
setResult({ data, latencyMs });
}
} catch (err) {
- setError(err instanceof Error ? err.message : "Request failed");
+ setError(err instanceof Error ? err.message : t("requestFailed"));
} finally {
setRunning(false);
}
@@ -88,7 +88,7 @@ export function WebFetchExampleCard({ providerId }: Props) {
return (
string) {
if (!data || typeof data !== "object") {
return {JSON.stringify(data, null, 2)} ;
}
@@ -33,7 +33,7 @@ function SearchResultRenderer(data: unknown) {
return (
{(results as Array
>).map((item, i) => {
- const title = typeof item.title === "string" ? item.title : `Result ${i + 1}`;
+ const title = typeof item.title === "string" ? item.title : fallbackTitle(i + 1);
const url = typeof item.url === "string" ? item.url : null;
const snippet =
typeof item.snippet === "string"
@@ -66,7 +66,7 @@ export function WebSearchExampleCard({ providerId }: Props) {
const t = useTranslations("miniPlayground");
const { apiKey } = useApiKey();
- const [query, setQuery] = useState("What is OmniRoute AI gateway?");
+ const [query, setQuery] = useState(() => t("webSearchSample"));
const [numResults, setNumResults] = useState(5);
const [running, setRunning] = useState(false);
const [result, setResult] = useState<{ data: unknown; latencyMs: number } | undefined>();
@@ -109,7 +109,7 @@ export function WebSearchExampleCard({ providerId }: Props) {
setResult({ data, latencyMs });
}
} catch (err) {
- setError(err instanceof Error ? err.message : "Request failed");
+ setError(err instanceof Error ? err.message : t("requestFailed"));
} finally {
setRunning(false);
}
@@ -117,14 +117,16 @@ export function WebSearchExampleCard({ providerId }: Props) {
return (
+ SearchResultRenderer(data, (number) => t("searchResultFallback", { number }))
+ }
>
{/* Query */}
@@ -133,7 +135,7 @@ export function WebSearchExampleCard({ providerId }: Props) {
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
- placeholder="What is OmniRoute AI gateway?"
+ placeholder={t("webSearchSample")}
className="w-full rounded-md border border-border bg-bg-subtle text-sm px-2 py-1.5 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
/>
diff --git a/src/app/(dashboard)/dashboard/omni-skills/components/SkillInspectorPane.tsx b/src/app/(dashboard)/dashboard/omni-skills/components/SkillInspectorPane.tsx
index 4ec4839cb8..595bab76ea 100644
--- a/src/app/(dashboard)/dashboard/omni-skills/components/SkillInspectorPane.tsx
+++ b/src/app/(dashboard)/dashboard/omni-skills/components/SkillInspectorPane.tsx
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
-import { useTranslations } from "next-intl";
+import { useLocale, useTranslations } from "next-intl";
import type { OmniSkill } from "./OmniSkillCard";
interface SkillDetail extends OmniSkill {
@@ -36,6 +36,7 @@ export function SkillInspectorPane({
onSetMode,
onUninstall,
}: SkillInspectorPaneProps): JSX.Element {
+ const locale = useLocale();
const t = useTranslations("skills");
const [activeTab, setActiveTab] = useState("schema");
const [detail, setDetail] = useState(null);
@@ -93,10 +94,8 @@ export function SkillInspectorPane({
if (!selectedSkillId || !skill) {
return (
-
- manage_search
-
- Selecione uma skill à esquerda para inspecionar.
+ manage_search
+ {t("selectSkillToInspect")}
);
}
@@ -104,8 +103,8 @@ export function SkillInspectorPane({
const effectiveMode = skill.mode || (skill.enabled ? "on" : "off");
const tabs: { id: InspectorTab; label: string }[] = [
- { id: "schema", label: "Schema" },
- { id: "handler", label: "Handler" },
+ { id: "schema", label: t("schemaTab") },
+ { id: "handler", label: t("handlerTab") },
{ id: "executions", label: t("executionsTab") },
{ id: "sandbox", label: t("sandboxTab") },
];
@@ -162,7 +161,7 @@ export function SkillInspectorPane({
- Input Schema
+ {t("inputSchema")}
{JSON.stringify(detail?.schema?.input ?? {}, null, 2) || "{}"}
@@ -170,7 +169,7 @@ export function SkillInspectorPane({
- Output Schema
+ {t("outputSchema")}
{JSON.stringify(detail?.schema?.output ?? {}, null, 2) || "{}"}
@@ -182,10 +181,10 @@ export function SkillInspectorPane({
{activeTab === "handler" && (
- Handler Code
+ {t("handlerCode")}
- {detail?.handler ?? "// Handler not available"}
+ {detail?.handler ?? `// ${t("handlerUnavailable")}`}
)}
@@ -223,7 +222,7 @@ export function SkillInspectorPane({
{exec.duration}ms
- {new Date(exec.createdAt).toLocaleString()}
+ {new Date(exec.createdAt).toLocaleString(locale)}
))}
@@ -256,7 +255,7 @@ export function SkillInspectorPane({
))}
- Run test (placeholder)
+ {t("runTestPlaceholder")}
)}
@@ -268,7 +267,7 @@ export function SkillInspectorPane({
onSetMode(skill.id, mode)}
- aria-label={`Set mode ${mode}`}
+ aria-label={t("setModeAria", { mode })}
className={`flex-1 text-xs px-2 py-1.5 rounded border transition-colors ${
effectiveMode === mode
? mode === "on"
@@ -284,7 +283,7 @@ export function SkillInspectorPane({
))}
onUninstall(skill.id)}
- aria-label="Uninstall skill"
+ aria-label={t("uninstallSkill")}
className="flex-1 text-xs px-2 py-1.5 rounded border border-border text-red-400 hover:bg-red-500/10 transition-colors"
>
{t("delete")}
diff --git a/src/app/(dashboard)/dashboard/playground/components/CompareColumn.tsx b/src/app/(dashboard)/dashboard/playground/components/CompareColumn.tsx
index 5c9ff6c064..4b4923356a 100644
--- a/src/app/(dashboard)/dashboard/playground/components/CompareColumn.tsx
+++ b/src/app/(dashboard)/dashboard/playground/components/CompareColumn.tsx
@@ -3,6 +3,7 @@
// src/app/(dashboard)/dashboard/playground/components/CompareColumn.tsx
import type { StreamMetrics } from "@/shared/schemas/playground";
+import { useTranslations } from "next-intl";
import MarkdownMessage from "./MarkdownMessage";
import ProviderMetrics from "./ProviderMetrics";
@@ -28,6 +29,7 @@ interface CompareColumnProps {
* Shows the model name, streaming response (via MarkdownMessage), and ProviderMetrics.
*/
export default function CompareColumn({ column, onCancel, onRemove }: CompareColumnProps) {
+ const t = useTranslations("playground");
const { id, model, status, metrics, response, errorMessage } = column;
return (
@@ -46,13 +48,10 @@ export default function CompareColumn({ column, onCancel, onRemove }: CompareCol
? "bg-destructive"
: "bg-text-muted/30"
}`}
- aria-label={`Status: ${status}`}
+ aria-label={t("statusLabel", { status: t(`status.${status}`) })}
/>
-
- {model || No model }
+
+ {model || {t("noModel")} }
@@ -61,16 +60,16 @@ export default function CompareColumn({ column, onCancel, onRemove }: CompareCol
onCancel(id)}
className="text-[10px] px-1.5 py-0.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
- aria-label="Cancel stream"
+ aria-label={t("cancelStream")}
>
- Cancel
+ {t("cancel")}
)}
onRemove(id)}
className="p-0.5 rounded text-text-muted hover:text-destructive transition-colors"
- title="Remove column"
- aria-label={`Remove column for ${model}`}
+ title={t("removeColumn")}
+ aria-label={t("removeModelColumn", { model: model || t("noModel") })}
>
close
@@ -86,23 +85,19 @@ export default function CompareColumn({ column, onCancel, onRemove }: CompareCol
{/* Response content */}
- {status === "idle" && (
-
- Ready to run.
-
- )}
+ {status === "idle" &&
{t("readyToRun")}
}
{status === "error" && (
- Error:
- {errorMessage ?? "Unknown error occurred."}
+ {t("errorLabel")}:
+ {errorMessage ?? t("unknownError")}
)}
{status === "streaming" && response === "" && (
- Waiting for response…
+ {t("waitingForResponse")}
)}
diff --git a/src/app/(dashboard)/dashboard/playground/components/ImprovePromptButton.tsx b/src/app/(dashboard)/dashboard/playground/components/ImprovePromptButton.tsx
index 0d11f690f7..5aaa6638d1 100644
--- a/src/app/(dashboard)/dashboard/playground/components/ImprovePromptButton.tsx
+++ b/src/app/(dashboard)/dashboard/playground/components/ImprovePromptButton.tsx
@@ -3,6 +3,7 @@
// src/app/(dashboard)/dashboard/playground/components/ImprovePromptButton.tsx
import { useState } from "react";
+import { useTranslations } from "next-intl";
import { useImprovePrompt } from "../hooks/useImprovePrompt";
import type { ConfigState } from "./StudioConfigPane";
@@ -19,7 +20,11 @@ interface ImprovePromptButtonProps {
*
* D8: uses the model configured in the Config pane (never overrides with cheap model).
*/
-export default function ImprovePromptButton({ configState, setConfigState }: ImprovePromptButtonProps) {
+export default function ImprovePromptButton({
+ configState,
+ setConfigState,
+}: ImprovePromptButtonProps) {
+ const t = useTranslations("playground");
const { loading, error, improve } = useImprovePrompt();
const [confirmOpen, setConfirmOpen] = useState(false);
const [improveError, setImproveError] = useState
(null);
@@ -30,7 +35,7 @@ export default function ImprovePromptButton({ configState, setConfigState }: Imp
const model = configState.model.trim();
if (!model) {
- setImproveError("Please set a model in the Config pane first.");
+ setImproveError(t("setModelInConfigFirst"));
return;
}
@@ -40,7 +45,7 @@ export default function ImprovePromptButton({ configState, setConfigState }: Imp
});
if (result == null) {
- setImproveError(error ?? "Improve prompt failed.");
+ setImproveError(error ?? t("improvePromptFailed"));
return;
}
@@ -66,16 +71,14 @@ export default function ImprovePromptButton({ configState, setConfigState }: Imp
}}
disabled={isDisabled}
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-40 disabled:cursor-not-allowed self-start"
- aria-label="Improve prompt using AI"
- title={!configState.model.trim() ? "Set a model first" : "Improve your prompt with AI"}
+ aria-label={t("improvePromptAria")}
+ title={!configState.model.trim() ? t("setModelFirst") : t("improvePromptTitle")}
>
✨
- {loading ? "Improving…" : "Improve prompt"}
+ {loading ? t("improvingPrompt") : t("improvePrompt")}
- {improveError && (
- {improveError}
- )}
+ {improveError && {improveError}
}
{/* Quota confirmation modal */}
@@ -85,7 +88,7 @@ export default function ImprovePromptButton({ configState, setConfigState }: Imp
onClick={() => setConfirmOpen(false)}
role="dialog"
aria-modal="true"
- aria-label="Confirm improve prompt"
+ aria-label={t("confirmImprovePrompt")}
>
✨
-
- Improve prompt
-
+
{t("improvePrompt")}
- This will send your current system prompt to{" "}
- {configState.model}
- {" "}to generate an improved version.
+ {t.rich("improvePromptDescription", {
+ model: () => (
+ {configState.model}
+ ),
+ })}
- This action will consume model quota.
+ {t("improveQuotaWarning")}
@@ -113,14 +116,14 @@ export default function ImprovePromptButton({ configState, setConfigState }: Imp
onClick={() => setConfirmOpen(false)}
className="text-xs px-3 py-1.5 rounded border border-border text-text-muted hover:text-text-main transition-colors"
>
- Cancel
+ {t("cancel")}
void handleConfirm()}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors"
>
✨
- Improve
+ {t("improveConfirm")}
diff --git a/src/app/(dashboard)/dashboard/playground/components/ParamSliders.tsx b/src/app/(dashboard)/dashboard/playground/components/ParamSliders.tsx
index c13b60b269..c1e738cdd4 100644
--- a/src/app/(dashboard)/dashboard/playground/components/ParamSliders.tsx
+++ b/src/app/(dashboard)/dashboard/playground/components/ParamSliders.tsx
@@ -2,6 +2,8 @@
// src/app/(dashboard)/dashboard/playground/components/ParamSliders.tsx
+import { useTranslations } from "next-intl";
+
export interface PlaygroundParams {
temperature: number;
max_tokens: number;
@@ -80,6 +82,7 @@ function SliderRow({ label, value, min, max, step, onChange }: SliderRowProps) {
* Props: { params, setParams }
*/
export default function ParamSliders({ params, setParams }: ParamSlidersProps) {
+ const t = useTranslations("playground");
function update(key: K, value: PlaygroundParams[K]) {
setParams({ ...params, [key]: value });
}
@@ -87,7 +90,7 @@ export default function ParamSliders({ params, setParams }: ParamSlidersProps) {
return (
- Seed
+ {t("seed")}
{
const raw = e.target.value;
update("seed", raw === "" ? null : parseInt(raw, 10));
@@ -148,11 +151,11 @@ export default function ParamSliders({ params, setParams }: ParamSlidersProps) {
{/* Stop sequences */}
-
Stop sequences
+
{t("stopSequences")}
update("stop", e.target.value)}
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary"
/>
@@ -160,7 +163,7 @@ export default function ParamSliders({ params, setParams }: ParamSlidersProps) {
{/* JSON mode toggle */}
- JSON mode
+ {t("jsonMode")}
{/* TTFT */}
-
- TTFT {formatMs(ttftMs)}
-
+ TTFT {formatMs(ttftMs)}
{/* TPS */}
-
- · {formatTps(tps)}
-
+ · {formatTps(tps)}
{/* Token counts */}
-
+
· {tokensIn}↑ {tokensOut}↓
{/* Cost */}
-
- · {formatCost(costUsd)} (estimated)
+
+ · {formatCost(costUsd)} {t("costEstimated")}
);
diff --git a/src/app/(dashboard)/dashboard/playground/components/ReasoningControls.tsx b/src/app/(dashboard)/dashboard/playground/components/ReasoningControls.tsx
index d77beeb16c..2a6bb4462e 100644
--- a/src/app/(dashboard)/dashboard/playground/components/ReasoningControls.tsx
+++ b/src/app/(dashboard)/dashboard/playground/components/ReasoningControls.tsx
@@ -8,6 +8,7 @@
import type { PlaygroundParams } from "./ParamSliders";
import type { ReasoningControlSpec } from "./reasoningControlUtils";
+import { useTranslations } from "next-intl";
interface ReasoningControlsProps {
spec: ReasoningControlSpec;
@@ -21,6 +22,7 @@ function effortLabel(value: string): string {
}
export default function ReasoningControls({ spec, params, setParams }: ReasoningControlsProps) {
+ const t = useTranslations("playground");
if (!spec.show) return null;
function update
(key: K, value: PlaygroundParams[K]) {
@@ -30,17 +32,17 @@ export default function ReasoningControls({ spec, params, setParams }: Reasoning
return (
- Reasoning
+ {t("reasoningLabel")}
{/* Thinking toggle */}
-
Thinking
+
{t("thinking")}
update("thinking", !params.thinking)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 ${
params.thinking ? "bg-primary" : "bg-neutral-300 dark:bg-neutral-600"
@@ -56,13 +58,13 @@ export default function ReasoningControls({ spec, params, setParams }: Reasoning
{/* Effort selector */}
-
Effort
+
{t("effort")}
update("effort", e.target.value)}
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
>
- Default
+ {t("effortDefault")}
{spec.effortOptions.map((opt) => (
{effortLabel(opt)}
diff --git a/src/app/(dashboard)/dashboard/playground/components/StructuredOutputEditor.tsx b/src/app/(dashboard)/dashboard/playground/components/StructuredOutputEditor.tsx
index f51ff4b16d..8b35a5e86b 100644
--- a/src/app/(dashboard)/dashboard/playground/components/StructuredOutputEditor.tsx
+++ b/src/app/(dashboard)/dashboard/playground/components/StructuredOutputEditor.tsx
@@ -3,6 +3,7 @@
// src/app/(dashboard)/dashboard/playground/components/StructuredOutputEditor.tsx
import { useState } from "react";
+import { useTranslations } from "next-intl";
import { useStructuredOutput } from "../hooks/useStructuredOutput";
import type { StructuredOutputSchemaInput } from "../hooks/useStructuredOutput";
@@ -29,11 +30,10 @@ const DEFAULT_SCHEMA: StructuredOutputSchemaInput = {
* Schema validation is client-side via Zod StructuredOutputSchema (D9).
*/
export default function StructuredOutputEditor({ structuredOutput }: StructuredOutputEditorProps) {
+ const t = useTranslations("playground");
const { enabled, schema, error, setEnabled, setSchema } = structuredOutput;
- const [schemaRaw, setSchemaRaw] = useState(
- JSON.stringify(schema ?? DEFAULT_SCHEMA, null, 2),
- );
+ const [schemaRaw, setSchemaRaw] = useState(JSON.stringify(schema ?? DEFAULT_SCHEMA, null, 2));
const [nameField, setNameField] = useState(schema?.name ?? "my_schema");
const [parseError, setParseError] = useState(null);
@@ -42,7 +42,7 @@ export default function StructuredOutputEditor({ structuredOutput }: StructuredO
try {
parsed = JSON.parse(schemaRaw);
} catch {
- setParseError("Invalid JSON");
+ setParseError(t("invalidJson"));
return;
}
setParseError(null);
@@ -62,10 +62,8 @@ export default function StructuredOutputEditor({ structuredOutput }: StructuredO
{/* Toggle */}
{/* Errors */}
{(parseError ?? error) && (
-
- {parseError ?? error}
-
+ {parseError ?? error}
)}
{/* Status — show when schema is set and no errors */}
{schema != null && !parseError && !error && (
-
- ✓ Schema validated
-
+ ✓ {t("schemaValidated")}
)}
{/* Validate button */}
@@ -134,7 +128,7 @@ export default function StructuredOutputEditor({ structuredOutput }: StructuredO
onClick={handleValidate}
className="text-xs px-3 py-1.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors self-start"
>
- Validate schema
+ {t("validateSchema")}
)}
diff --git a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx
index 73e43b8165..ff1f23788e 100644
--- a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx
+++ b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx
@@ -18,10 +18,7 @@ import {
} from "@/shared/constants/providers";
import { filterModelsByQuery, pickDefaultModel, resolveModelFilterKey } from "./modelSelection";
import ReasoningControls from "./ReasoningControls";
-import {
- resolveReasoningControls,
- type ReasoningControlSpec,
-} from "./reasoningControlUtils";
+import { resolveReasoningControls, type ReasoningControlSpec } from "./reasoningControlUtils";
export interface ConfigState {
endpoint: PlaygroundEndpoint;
@@ -41,20 +38,20 @@ interface StudioConfigPaneProps {
setConfigState: (s: ConfigState) => void;
}
-const ENDPOINT_OPTIONS: Array<{ value: PlaygroundEndpoint; label: string }> = [
- { value: "chat.completions", label: "Chat completions" },
- { value: "responses", label: "Responses" },
- { value: "completions", label: "Completions" },
- { value: "embeddings", label: "Embeddings" },
- { value: "images", label: "Images" },
- { value: "audio.transcriptions", label: "Audio transcriptions" },
- { value: "audio.speech", label: "Audio speech" },
- { value: "video", label: "Video" },
- { value: "music", label: "Music" },
- { value: "moderations", label: "Moderations" },
- { value: "rerank", label: "Rerank" },
- { value: "search", label: "Search" },
- { value: "web.fetch", label: "Web fetch" },
+const ENDPOINT_OPTIONS: Array<{ value: PlaygroundEndpoint; labelKey: string }> = [
+ { value: "chat.completions", labelKey: "chat" },
+ { value: "responses", labelKey: "responses" },
+ { value: "completions", labelKey: "completions" },
+ { value: "embeddings", labelKey: "embeddings" },
+ { value: "images", labelKey: "images" },
+ { value: "audio.transcriptions", labelKey: "transcription" },
+ { value: "audio.speech", labelKey: "speech" },
+ { value: "video", labelKey: "video" },
+ { value: "music", labelKey: "music" },
+ { value: "moderations", labelKey: "moderations" },
+ { value: "rerank", labelKey: "rerank" },
+ { value: "search", labelKey: "search" },
+ { value: "web.fetch", labelKey: "webFetch" },
];
/**
@@ -65,6 +62,7 @@ const ENDPOINT_OPTIONS: Array<{ value: PlaygroundEndpoint; label: string }> = [
*/
export default function StudioConfigPane({ configState, setConfigState }: StudioConfigPaneProps) {
const t = useTranslations("common");
+ const tp = useTranslations("playground");
const [collapsed, setCollapsed] = useState(false);
// #4086: search/filter query for the Model dropdown — flat provider catalogs (e.g.
// 50+ OpenRouter models) made the plain unusable without scrolling.
@@ -93,8 +91,11 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
selectedProviderOption?.modelPrefix,
isCompatibleConnectionId
);
- const { availableModels, modelCapabilities, loading: loadingModels } =
- useAvailableModels(modelFilterKey);
+ const {
+ availableModels,
+ modelCapabilities,
+ loading: loadingModels,
+ } = useAvailableModels(modelFilterKey);
// #4086: filter the dropdown by the search query, but always keep the currently selected
// model in the list even when it doesn't match — otherwise typing a query would silently
@@ -142,8 +143,8 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
setCollapsed(false)}
className="mt-2 p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
- title="Expand config pane"
- aria-label="Expand config pane"
+ title={tp("expandConfig")}
+ aria-label={tp("expandConfig")}
>
settings
@@ -154,18 +155,18 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
return (
{/* Header */}
- Config
+ {tp("configPane")}
setCollapsed(true)}
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
- title="Collapse config pane"
- aria-label="Collapse config pane"
+ title={tp("collapseConfig")}
+ aria-label={tp("collapseConfig")}
>
chevron_right
@@ -178,7 +179,7 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
{/* Endpoint */}
- Endpoint
+ {tp("endpointLabel")}
{ENDPOINT_OPTIONS.map((opt) => (
- {opt.label} — {endpointToPath(opt.value)}
+ {tp(`endpointOptions.${opt.labelKey}`)} — {endpointToPath(opt.value)}
))}
@@ -196,7 +197,7 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
{/* Provider */}
- Provider
+ {tp("provider")}
- Auto
+ {tp("autoProvider")}
{providerOptions.map((opt) => (
{opt.label}
@@ -220,7 +221,7 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
{/* Model */}
- Model
+ {tp("model")}
{availableModels.length > 0 ? (
<>
@@ -252,7 +253,7 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
type="text"
value={configState.model}
onChange={(e) => update("model", e.target.value)}
- placeholder="e.g. openai/gpt-4o"
+ placeholder={tp("modelPlaceholder")}
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
)}
@@ -261,12 +262,12 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
{/* System prompt */}
- System prompt
+ {tp("systemPrompt")}
update("systemPrompt", e.target.value)}
- placeholder="You are a helpful assistant."
+ placeholder={tp("systemPromptPlaceholder")}
rows={4}
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
/>
@@ -277,7 +278,7 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
{/* Param sliders */}
- Parameters
+ {tp("parametersLabel")}
update("params", p)} />
diff --git a/src/app/(dashboard)/dashboard/playground/components/ToolsBuilder.tsx b/src/app/(dashboard)/dashboard/playground/components/ToolsBuilder.tsx
index c129a23b8c..65b00d71b5 100644
--- a/src/app/(dashboard)/dashboard/playground/components/ToolsBuilder.tsx
+++ b/src/app/(dashboard)/dashboard/playground/components/ToolsBuilder.tsx
@@ -3,6 +3,7 @@
// src/app/(dashboard)/dashboard/playground/components/ToolsBuilder.tsx
import { useState } from "react";
+import { useTranslations } from "next-intl";
import { useToolsBuilder } from "../hooks/useToolsBuilder";
import type { ToolDefinition } from "@/lib/playground/codeExport";
@@ -23,6 +24,7 @@ const EMPTY_TOOL: { name: string; description: string; parametersRaw: string } =
* Validation uses Zod ToolDefinitionSchema (via useToolsBuilder).
*/
export default function ToolsBuilder({ toolsBuilder }: ToolsBuilderProps) {
+ const t = useTranslations("playground");
const { tools, errors, add, remove, update } = toolsBuilder;
// Form state for the "Add tool" form
@@ -31,14 +33,18 @@ export default function ToolsBuilder({ toolsBuilder }: ToolsBuilderProps) {
// Per-tool editing state: index → draft values
const [editingIndex, setEditingIndex] = useState(null);
- const [editDraft, setEditDraft] = useState<{ name: string; description: string; parametersRaw: string } | null>(null);
+ const [editDraft, setEditDraft] = useState<{
+ name: string;
+ description: string;
+ parametersRaw: string;
+ } | null>(null);
function handleAdd() {
let parsed: unknown;
try {
parsed = JSON.parse(form.parametersRaw);
} catch {
- setFormError("Parameters must be valid JSON");
+ setFormError(t("toolParamsInvalid"));
return;
}
@@ -108,26 +114,21 @@ export default function ToolsBuilder({ toolsBuilder }: ToolsBuilderProps) {
{tools.length > 0 && (
- Tools ({tools.length})
+ {t("toolsCount", { count: tools.length })}
{tools.map((tool, idx) => {
const isEditing = editingIndex === idx;
const toolError = errors.get(idx);
return (
-
+
{/* Tool header */}
function
-
- {tool.function.name}
-
+
{tool.function.name}
{tool.function.description && (
— {tool.function.description}
@@ -139,7 +140,7 @@ export default function ToolsBuilder({ toolsBuilder }: ToolsBuilderProps) {
startEdit(idx)}
className="p-1 rounded text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
- aria-label={`Edit tool ${tool.function.name}`}
+ aria-label={t("editTool", { name: tool.function.name })}
>
edit
@@ -147,7 +148,7 @@ export default function ToolsBuilder({ toolsBuilder }: ToolsBuilderProps) {
remove(idx)}
className="p-1 rounded text-text-muted hover:text-destructive transition-colors"
- aria-label={`Remove tool ${tool.function.name}`}
+ aria-label={t("removeTool", { name: tool.function.name })}
>
delete
@@ -161,38 +162,38 @@ export default function ToolsBuilder({ toolsBuilder }: ToolsBuilderProps) {
type="text"
value={editDraft.name}
onChange={(e) => setEditDraft({ ...editDraft, name: e.target.value })}
- placeholder="Function name"
+ placeholder={t("toolNamePlaceholder")}
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
setEditDraft({ ...editDraft, description: e.target.value })}
- placeholder="Description (optional)"
+ placeholder={t("toolDescPlaceholder")}
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
setEditDraft({ ...editDraft, parametersRaw: e.target.value })}
+ onChange={(e) =>
+ setEditDraft({ ...editDraft, parametersRaw: e.target.value })
+ }
rows={6}
className="text-xs font-mono bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
- aria-label="JSON schema for parameters"
+ aria-label={t("toolParamsJsonSchema")}
/>
- {toolError && (
- {toolError}
- )}
+ {toolError && {toolError}
}
handleUpdate(idx)}
className="text-xs px-2.5 py-1 rounded bg-primary text-white hover:bg-primary/90 transition-colors"
>
- Save
+ {t("save")}
- Cancel
+ {t("cancel")}
@@ -206,14 +207,14 @@ export default function ToolsBuilder({ toolsBuilder }: ToolsBuilderProps) {
{/* Add tool form */}
- Add tool
+ {t("addTool")}
setForm({ ...form, name: e.target.value })}
- placeholder="Function name *"
+ placeholder={t("toolNameRequiredPlaceholder")}
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
@@ -221,32 +222,30 @@ export default function ToolsBuilder({ toolsBuilder }: ToolsBuilderProps) {
type="text"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
- placeholder="Description (optional)"
+ placeholder={t("toolDescPlaceholder")}
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
- Parameters (JSON schema)
+ {t("toolParamsLabel")}
setForm({ ...form, parametersRaw: e.target.value })}
rows={6}
className="text-xs font-mono bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
- aria-label="JSON schema for parameters"
+ aria-label={t("toolParamsJsonSchema")}
/>
- {formError && (
-
{formError}
- )}
+ {formError &&
{formError}
}
- + Add tool
+ + {t("addTool")}
diff --git a/src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx b/src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx
index 7e31060ee4..8f048ee02d 100644
--- a/src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx
+++ b/src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx
@@ -3,6 +3,7 @@
// src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx
import { useState, useRef, useEffect } from "react";
+import { useTranslations } from "next-intl";
import MarkdownMessage from "../MarkdownMessage";
import TokenCostCounter from "../TokenCostCounter";
import { useStreamMetrics } from "../../hooks/useStreamMetrics";
@@ -30,6 +31,7 @@ interface ChatTabProps {
* - Regenerate button
*/
export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps) {
+ const t = useTranslations("playground");
const pricing = getModelPricing(configState.model);
const streamMetrics = useStreamMetrics(pricing ?? undefined);
@@ -53,10 +55,14 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
if (configState.systemPrompt.trim()) {
out.push({ role: "system", content: configState.systemPrompt });
}
- out.push(...chatMessages.filter((m) => m.role !== "system").map((m) => ({
- role: m.role,
- content: m.content,
- })));
+ out.push(
+ ...chatMessages
+ .filter((m) => m.role !== "system")
+ .map((m) => ({
+ role: m.role,
+ content: m.content,
+ }))
+ );
return out;
}
@@ -92,130 +98,132 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
}
const doSend = async (chatMessages: Message[], appendIndex?: number) => {
- if (!configState.model) {
- setError("Set a model in the config pane.");
+ if (!configState.model) {
+ setError(t("setModelInConfigFirst"));
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ setResponseStatus(null);
+
+ const controller = new AbortController();
+ abortRef.current = controller;
+ const startTime = Date.now();
+
+ streamMetrics.start();
+
+ // If regenerating, replace the last assistant message; otherwise append
+ const targetIndex = appendIndex ?? chatMessages.length;
+ setMessages((prev) => {
+ const next = [...prev];
+ if (appendIndex !== undefined && next[appendIndex]?.role === "assistant") {
+ next[appendIndex] = { role: "assistant", content: "" };
+ } else {
+ next.push({ role: "assistant", content: "" });
+ }
+ return next;
+ });
+
+ try {
+ const fetchHeaders: Record
= { "Content-Type": "application/json" };
+
+ const res = await fetch("/api/v1/chat/completions", {
+ method: "POST",
+ headers: fetchHeaders,
+ body: JSON.stringify(buildRequestBody(chatMessages)),
+ signal: controller.signal,
+ });
+
+ setResponseStatus(res.status);
+
+ if (!res.ok) {
+ const errData = await res.json().catch(() => ({}));
+ const errMsg: string =
+ (errData as { error?: { message?: string } }).error?.message ||
+ t("httpError", { status: res.status });
+ setError(errMsg);
+ setMessages((prev) => prev.slice(0, targetIndex));
+ setLoading(false);
+ setResponseDuration(Date.now() - startTime);
+ streamMetrics.reset();
return;
}
- setLoading(true);
- setError(null);
- setResponseStatus(null);
+ let firstChunk = true;
+ const reader = res.body?.getReader();
+ const decoder = new TextDecoder();
+ let assistantResponse = "";
+ let usageData: { prompt_tokens?: number; completion_tokens?: number } | undefined;
- const controller = new AbortController();
- abortRef.current = controller;
- const startTime = Date.now();
+ if (reader) {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
- streamMetrics.start();
+ if (firstChunk) {
+ streamMetrics.onFirstChunk();
+ firstChunk = false;
+ }
- // If regenerating, replace the last assistant message; otherwise append
- const targetIndex = appendIndex ?? chatMessages.length;
- setMessages((prev) => {
- const next = [...prev];
- if (appendIndex !== undefined && next[appendIndex]?.role === "assistant") {
- next[appendIndex] = { role: "assistant", content: "" };
- } else {
- next.push({ role: "assistant", content: "" });
- }
- return next;
- });
+ const chunk = decoder.decode(value, { stream: true });
+ const lines = chunk.split("\n");
- try {
- const fetchHeaders: Record = { "Content-Type": "application/json" };
-
- const res = await fetch("/api/v1/chat/completions", {
- method: "POST",
- headers: fetchHeaders,
- body: JSON.stringify(buildRequestBody(chatMessages)),
- signal: controller.signal,
- });
-
- setResponseStatus(res.status);
-
- if (!res.ok) {
- const errData = await res.json().catch(() => ({}));
- const errMsg: string = (errData as { error?: { message?: string } }).error?.message || `Error ${res.status}`;
- setError(errMsg);
- setMessages((prev) => prev.slice(0, targetIndex));
- setLoading(false);
- setResponseDuration(Date.now() - startTime);
- streamMetrics.reset();
- return;
- }
-
- let firstChunk = true;
- const reader = res.body?.getReader();
- const decoder = new TextDecoder();
- let assistantResponse = "";
- let usageData: { prompt_tokens?: number; completion_tokens?: number } | undefined;
-
- if (reader) {
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- if (firstChunk) {
- streamMetrics.onFirstChunk();
- firstChunk = false;
- }
-
- const chunk = decoder.decode(value, { stream: true });
- const lines = chunk.split("\n");
-
- for (const line of lines) {
- if (line === "data: [DONE]") continue;
- if (line.startsWith("data: ")) {
- try {
- const parsed = JSON.parse(line.slice(6)) as {
- choices?: Array<{ delta?: { content?: string } }>;
- usage?: { prompt_tokens?: number; completion_tokens?: number };
- };
- const delta = parsed.choices?.[0]?.delta?.content ?? "";
- if (delta) {
- assistantResponse += delta;
- streamMetrics.onChunk(1);
- setMessages((prev) => {
- const next = [...prev];
- const idx = appendIndex !== undefined ? appendIndex : next.length - 1;
- next[idx] = { ...next[idx], content: assistantResponse };
- return next;
- });
- }
- if (parsed.usage) {
- usageData = parsed.usage;
- }
- } catch {
- // ignore parse errors for partial chunks
+ for (const line of lines) {
+ if (line === "data: [DONE]") continue;
+ if (line.startsWith("data: ")) {
+ try {
+ const parsed = JSON.parse(line.slice(6)) as {
+ choices?: Array<{ delta?: { content?: string } }>;
+ usage?: { prompt_tokens?: number; completion_tokens?: number };
+ };
+ const delta = parsed.choices?.[0]?.delta?.content ?? "";
+ if (delta) {
+ assistantResponse += delta;
+ streamMetrics.onChunk(1);
+ setMessages((prev) => {
+ const next = [...prev];
+ const idx = appendIndex !== undefined ? appendIndex : next.length - 1;
+ next[idx] = { ...next[idx], content: assistantResponse };
+ return next;
+ });
}
+ if (parsed.usage) {
+ usageData = parsed.usage;
+ }
+ } catch {
+ // ignore parse errors for partial chunks
}
}
}
}
-
- streamMetrics.finish(usageData);
- // metrics state will update asynchronously; snapshot from refs via computeMetrics directly
- const metricsSnapshot = streamMetrics.metrics;
-
- // Attach metrics to the assistant message
- setMessages((prev) => {
- const next = [...prev];
- const idx = appendIndex !== undefined ? appendIndex : next.length - 1;
- next[idx] = { ...next[idx], metrics: metricsSnapshot };
- return next;
- });
-
- onMetricsUpdate?.(metricsSnapshot);
- } catch (err: unknown) {
- const e = err as { name?: string; message?: string };
- if (e.name === "AbortError") {
- setError("Request cancelled");
- } else {
- setError(e.message ?? "Network error");
- }
- streamMetrics.reset();
}
- setResponseDuration(Date.now() - startTime);
- setLoading(false);
+ streamMetrics.finish(usageData);
+ // metrics state will update asynchronously; snapshot from refs via computeMetrics directly
+ const metricsSnapshot = streamMetrics.metrics;
+
+ // Attach metrics to the assistant message
+ setMessages((prev) => {
+ const next = [...prev];
+ const idx = appendIndex !== undefined ? appendIndex : next.length - 1;
+ next[idx] = { ...next[idx], metrics: metricsSnapshot };
+ return next;
+ });
+
+ onMetricsUpdate?.(metricsSnapshot);
+ } catch (err: unknown) {
+ const e = err as { name?: string; message?: string };
+ if (e.name === "AbortError") {
+ setError(t("requestCancelled"));
+ } else {
+ setError(e.message ?? t("networkError"));
+ }
+ streamMetrics.reset();
+ }
+
+ setResponseDuration(Date.now() - startTime);
+ setLoading(false);
};
const handleSend = async () => {
@@ -271,11 +279,13 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
chat
-
Chat
+
{t("tabChat")}
{responseStatus !== null && (
{responseStatus}
@@ -288,16 +298,16 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
void handleRegenerate()}
className="flex items-center gap-1 text-xs text-text-muted hover:text-text-main transition-colors"
- title="Regenerate last response"
+ title={t("regenerateLastResponse")}
>
refresh
- Regenerate
+ {t("regenerate")}
)}
delete
@@ -310,9 +320,9 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
chat
-
Start a conversation — type a message below
+
{t("startConversation")}
{!configState.model && (
-
Set a model in the config pane first
+
{t("setModelInConfigFirst")}
)}
@@ -328,7 +338,7 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
}`}
>
- {msg.role}
+ {t(`role.${msg.role}`)}
-
assistant
+
+ {t("role.assistant")}
+
progress_activity
- Generating...
+ {t("generating")}
)}
@@ -385,7 +397,7 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
- placeholder="Type a message... (Enter to send, Shift+Enter for newline)"
+ placeholder={t("typeMessageWithShortcut")}
className="flex-1 min-h-[44px] max-h-[120px] bg-surface border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary resize-y"
rows={1}
disabled={loading}
@@ -396,7 +408,7 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border text-sm text-text-muted hover:text-text-main hover:bg-black/5 transition-colors shrink-0"
>
stop
- Stop
+ {t("stop")}
) : (
send
- Send
+ {t("send")}
)}
diff --git a/src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx b/src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx
index a06b1a7cff..55ade7edc3 100644
--- a/src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx
+++ b/src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx
@@ -3,6 +3,7 @@
// src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx
import { useEffect, useRef, useState } from "react";
+import { useTranslations } from "next-intl";
import CompareColumn, { type CompareColumnData, type ColumnStatus } from "../CompareColumn";
import type { ConfigState } from "../StudioConfigPane";
import type { StreamMetrics } from "@/shared/schemas/playground";
@@ -96,6 +97,7 @@ class ColumnMetricsTracker {
* Cmd+K (or Ctrl+K) focuses the "add column" model input.
*/
export default function CompareTab({ configState }: CompareTabProps) {
+ const t = useTranslations("playground");
const [columns, setColumns] = useState
(() => [
{
id: createColumnId(),
@@ -242,11 +244,11 @@ export default function CompareTab({ configState }: CompareTabProps) {
});
if (!res.ok) {
- const text = await res.text().catch(() => "Unknown error");
+ const text = await res.text().catch(() => t("unknownError"));
throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
}
- if (!res.body) throw new Error("No response body");
+ if (!res.body) throw new Error(t("noResponseBody"));
const reader = res.body.getReader();
const decoder = new TextDecoder();
@@ -290,8 +292,7 @@ export default function CompareTab({ configState }: CompareTabProps) {
}
const usage = parsed["usage"] as
- | { prompt_tokens?: number; completion_tokens?: number }
- | undefined;
+ { prompt_tokens?: number; completion_tokens?: number } | undefined;
if (usage != null) {
tracker.finish(usage);
}
@@ -350,10 +351,10 @@ export default function CompareTab({ configState }: CompareTabProps) {
setPrompt(e.target.value)}
- placeholder="Enter your prompt here…"
+ placeholder={t("comparePromptPlaceholder")}
rows={3}
className="w-full text-sm bg-surface border border-border rounded px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
- aria-label="User prompt"
+ aria-label={t("userPrompt")}
/>
@@ -364,20 +365,20 @@ export default function CompareTab({ configState }: CompareTabProps) {
stop
- Cancel all
+ {t("cancelAll")}
) : (
void runAll()}
disabled={columns.length === 0 || !prompt.trim()}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
- aria-label="Run all columns"
+ aria-label={t("runAllColumns")}
>
play_arrow
- Run all
+ {t("runAll")}
)}
@@ -391,26 +392,26 @@ export default function CompareTab({ configState }: CompareTabProps) {
onKeyDown={(e) => {
if (e.key === "Enter") addColumn();
}}
- placeholder="Model (Cmd+K)…"
+ placeholder={t("modelPlaceholderCompare")}
disabled={atColumnLimit}
className="text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main w-40 disabled:opacity-40"
- aria-label="Model name for new column"
+ aria-label={t("newColumnModelName")}
/>
add
- Add model
+ {t("addModel")}
{/* Column count indicator */}
- {columns.length}/{MAX_COLUMNS} columns
+ {t("columnCount", { count: columns.length, max: MAX_COLUMNS })}
@@ -436,9 +437,9 @@ export default function CompareTab({ configState }: CompareTabProps) {
compare
-
Add a model column to compare
+
{t("addModelToCompare")}
- Up to {MAX_COLUMNS} models simultaneously
+ {t("modelsSimultaneously", { max: MAX_COLUMNS })}
diff --git a/src/app/(dashboard)/dashboard/profile/page.tsx b/src/app/(dashboard)/dashboard/profile/page.tsx
index 328dfdabe5..dd456bbe2f 100644
--- a/src/app/(dashboard)/dashboard/profile/page.tsx
+++ b/src/app/(dashboard)/dashboard/profile/page.tsx
@@ -1,15 +1,9 @@
"use client";
import { useState, useEffect, useCallback } from "react";
-import { useTranslations } from "next-intl";
+import { useLocale, useTranslations } from "next-intl";
import { Card, Badge } from "@/shared/components";
-import {
- xpForLevel,
- calculateLevel,
- cumulativeXpForLevel,
- getLevelTier,
- getLevelTitle,
-} from "@/lib/gamification/xp";
+import { cumulativeXpForLevel, getLevelTier, getLevelTitle } from "@/lib/gamification/xp";
interface UserLevel {
apiKeyId: string;
@@ -41,14 +35,50 @@ interface UserBadge {
badgeRarity?: string;
}
-const TIER_CONFIG: Record
= {
- bronze: { label: "Bronze", color: "text-amber-600", bg: "bg-amber-600/10" },
- silver: { label: "Silver", color: "text-gray-300", bg: "bg-gray-300/10" },
- gold: { label: "Gold", color: "text-yellow-400", bg: "bg-yellow-400/10" },
- platinum: { label: "Platinum", color: "text-cyan-300", bg: "bg-cyan-300/10" },
- diamond: { label: "Diamond", color: "text-violet-400", bg: "bg-violet-400/10" },
+const TIER_CONFIG: Record = {
+ bronze: { labelKey: "tiers.bronze", color: "text-amber-600", bg: "bg-amber-600/10" },
+ silver: { labelKey: "tiers.silver", color: "text-gray-300", bg: "bg-gray-300/10" },
+ gold: { labelKey: "tiers.gold", color: "text-yellow-400", bg: "bg-yellow-400/10" },
+ platinum: { labelKey: "tiers.platinum", color: "text-cyan-300", bg: "bg-cyan-300/10" },
+ diamond: { labelKey: "tiers.diamond", color: "text-violet-400", bg: "bg-violet-400/10" },
};
+const BADGE_ICONS: Record = {
+ sparkles: "auto_awesome",
+ zap: "bolt",
+ cpu: "memory",
+ whale: "water",
+ gift: "redeem",
+ heart: "favorite",
+ santa: "celebration",
+ trophy: "emoji_events",
+ compass: "explore",
+ languages: "translate",
+ blocks: "widgets",
+ gauge: "speed",
+ shield: "shield",
+ flame: "local_fire_department",
+ sword: "swords",
+ crown: "workspace_premium",
+ infinity: "all_inclusive",
+ rocket: "rocket_launch",
+ bug: "bug_report",
+ "git-merge": "merge",
+ medal: "military_tech",
+ question: "help",
+};
+
+function BadgeIcon({ icon, earned }: { icon: string | null; earned: boolean }) {
+ const materialIcon = icon ? BADGE_ICONS[icon] : null;
+ return materialIcon ? (
+
+ {materialIcon}
+
+ ) : (
+ {earned ? "🏅" : "🔒"}
+ );
+}
+
const RARITY_COLORS: Record = {
common: "text-gray-400 border-gray-500/30",
uncommon: "text-green-400 border-green-500/30",
@@ -59,15 +89,17 @@ const RARITY_COLORS: Record = {
export default function ProfilePage() {
const t = useTranslations("common");
+ const tg = useTranslations("gamification");
+ const locale = useLocale();
const [userLevel, setUserLevel] = useState(null);
const [allBadges, setAllBadges] = useState([]);
const [earnedBadges, setEarnedBadges] = useState([]);
const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
const [selectedBadge, setSelectedBadge] = useState(null);
const [streak] = useState(0); // streak data comes from future API
const fetchData = useCallback(async () => {
- setLoading(true);
try {
const [levelRes, badgesRes, earnedRes] = await Promise.all([
fetch("/api/gamification/level"),
@@ -75,6 +107,10 @@ export default function ProfilePage() {
fetch("/api/gamification/badges/earned"),
]);
+ if (!levelRes.ok && !badgesRes.ok && !earnedRes.ok) {
+ throw new Error(tg("profileLoadFailed"));
+ }
+
if (levelRes.ok) {
const data = await levelRes.json();
setUserLevel(data.level ?? data);
@@ -87,15 +123,19 @@ export default function ProfilePage() {
const data = await earnedRes.json();
setEarnedBadges(data.badges ?? data ?? []);
}
- } catch {
- // silent fail
+ } catch (loadError) {
+ setError(loadError instanceof Error ? loadError.message : tg("profileLoadFailed"));
} finally {
setLoading(false);
}
- }, []);
+ }, [tg]);
useEffect(() => {
- fetchData();
+ const loadTimer = window.setTimeout(() => {
+ void fetchData();
+ }, 0);
+
+ return () => window.clearTimeout(loadTimer);
}, [fetchData]);
if (loading) {
@@ -116,9 +156,24 @@ export default function ProfilePage() {
const tier = getLevelTier(level);
const tierConfig = TIER_CONFIG[tier] || TIER_CONFIG.bronze;
const earnedIds = new Set(earnedBadges.map((b) => b.badgeId));
+ const translateBadge = (badge: BadgeDef, field: "name" | "description" | "criteria") => {
+ const key = `badges.${badge.id}.${field}`;
+ const fallback = field === "name" ? badge.name : badge.description || "";
+ return tg.has(key) ? tg(key) : fallback;
+ };
+ const translateRarity = (rarity: string) => {
+ const key = `rarities.${rarity}`;
+ return tg.has(key) ? tg(key) : rarity;
+ };
+ const translateCategory = (category: string) => {
+ const key = `categories.${category}`;
+ return tg.has(key) ? tg(key) : category;
+ };
return (
+ {error &&
{error}
}
+
{/* Level & XP Card */}
@@ -129,14 +184,16 @@ export default function ProfilePage() {
{level}
-
{getLevelTitle(level)}
+
+ {tg(`levelTitles.${getLevelTitle(level).toLowerCase()}`)}
+
- {tierConfig.label} Tier
+ {tg("tierLabel", { tier: tg(tierConfig.labelKey) })}
{streak > 0 && (
- 🔥 {streak} day streak
+ 🔥 {tg("dayStreak", { count: streak })}
)}
@@ -146,7 +203,7 @@ export default function ProfilePage() {
- Level {level} → {level + 1}
+ {tg("levelProgress", { current: level, next: level + 1 })}
{xpInCurrentLevel.toLocaleString()} / {xpForNext.toLocaleString()} XP
@@ -159,7 +216,7 @@ export default function ProfilePage() {
/>
- {totalXp.toLocaleString()} total XP earned
+ {tg("totalXpEarned", { count: totalXp.toLocaleString() })}
@@ -171,10 +228,8 @@ export default function ProfilePage() {
🔥
-
{streak} Day Streak
-
- Keep using OmniRoute daily to maintain your streak!
-
+
{tg("dayStreak", { count: streak })}
+
{tg("maintainStreak")}
@@ -183,14 +238,12 @@ export default function ProfilePage() {
{/* Badges Grid */}
- Badges ({earnedBadges.length} / {allBadges.length})
+ {tg("badgesTitle", { earned: earnedBadges.length, total: allBadges.length })}
{allBadges.length === 0 ? (
-
- No badges available yet. Keep using OmniRoute to unlock achievements!
-
+ {tg("noBadges")}
) : (
@@ -209,16 +262,22 @@ export default function ProfilePage() {
: "border-border/50 bg-surface/50 opacity-50 grayscale hover:opacity-70"
}`}
>
-
{badge.icon || (isEarned ? "🏅" : "🔒")}
+
+
+
- {badge.hidden && !isEarned ? "???" : badge.name}
+ {badge.hidden && !isEarned ? "???" : translateBadge(badge, "name")}
- {badge.hidden && !isEarned ? "Hidden badge" : badge.description || ""}
+ {badge.hidden && !isEarned
+ ? tg("hiddenBadge")
+ : translateBadge(badge, "description")}
{isEarned && earnedInfo?.unlockedAt && (
- Earned {new Date(earnedInfo.unlockedAt).toLocaleDateString()}
+ {tg("earnedDate", {
+ date: new Date(earnedInfo.unlockedAt).toLocaleDateString(locale),
+ })}
)}
{!isEarned && badge.criteria && (
@@ -241,56 +300,66 @@ export default function ProfilePage() {
-
{selectedBadge.icon || "🏅"}
+
+
+
-
{selectedBadge.name}
+ {translateBadge(selectedBadge, "name")}
- {selectedBadge.rarity}
+ {translateRarity(selectedBadge.rarity)}
setSelectedBadge(null)}
className="text-text-muted hover:text-text-main text-xl"
+ aria-label={t("close")}
>
×
{selectedBadge.description && (
-
{selectedBadge.description}
+
+ {translateBadge(selectedBadge, "description")}
+
)}
{selectedBadge.category && (
- Category: {selectedBadge.category}
+ {tg("category")}:{" "}
+ {translateCategory(selectedBadge.category)}
)}
{selectedBadge.criteria && (
{t("profileHowToEarn")}
-
{selectedBadge.criteria}
+
{translateBadge(selectedBadge, "criteria")}
)}
{earnedIds.has(selectedBadge.id) && (
- ✓ Earned on{" "}
- {new Date(
- earnedBadges.find((b) => b.badgeId === selectedBadge.id)?.unlockedAt || ""
- ).toLocaleDateString()}
+ ✓{" "}
+ {tg("earnedOn", {
+ date: new Date(
+ earnedBadges.find((b) => b.badgeId === selectedBadge.id)?.unlockedAt || ""
+ ).toLocaleDateString(locale),
+ })}
)}
setSelectedBadge(null)}
className="px-4 py-2 text-sm rounded-lg border border-border text-text-muted hover:text-text-main transition-colors"
>
- Close
+ {t("close")}
diff --git a/src/app/(dashboard)/dashboard/provider-stats/page.tsx b/src/app/(dashboard)/dashboard/provider-stats/page.tsx
index da4e218a19..bdc1e54cee 100644
--- a/src/app/(dashboard)/dashboard/provider-stats/page.tsx
+++ b/src/app/(dashboard)/dashboard/provider-stats/page.tsx
@@ -8,6 +8,7 @@
*/
import { useState, useEffect, useCallback, Fragment } from "react";
+import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap";
@@ -63,6 +64,7 @@ function successRate(successful: number, total: number): string {
}
export default function ProviderStatsPage() {
+ const t = useTranslations("providerStats");
const nodeMap = useProviderNodeMap();
const [data, setData] = useState<{
providers: ProviderStat[];
@@ -86,9 +88,9 @@ export default function ProviderStatsPage() {
setError(null);
setLastRefresh(new Date());
} catch (err) {
- setError(err instanceof Error ? err.message : "Unknown error");
+ setError(err instanceof Error ? err.message : t("unknownError"));
}
- }, []);
+ }, [t]);
useEffect(() => {
fetchData();
@@ -151,7 +153,7 @@ export default function ProviderStatsPage() {
-
Loading provider stats...
+
{t("loading")}
);
@@ -162,12 +164,12 @@ export default function ProviderStatsPage() {
error
-
Failed to load provider stats: {error}
+
{t("loadFailed", { error })}
- Retry
+ {t("retry")}
@@ -180,13 +182,13 @@ export default function ProviderStatsPage() {
{lastRefresh && (
- Updated {lastRefresh.toLocaleTimeString()}
+ {t("updated", { time: lastRefresh.toLocaleTimeString() })}
)}
refresh
@@ -199,7 +201,7 @@ export default function ProviderStatsPage() {
analytics
-
Total Requests
+
{t("totalRequests")}
{formatNumber(totalRequests)}
@@ -209,7 +211,7 @@ export default function ProviderStatsPage() {
timer
-
Avg Latency
+
{t("avgLatency")}
{formatLatency(avgLatency)}
@@ -219,7 +221,7 @@ export default function ProviderStatsPage() {
check_circle
-
Success Rate
+
{t("successRate")}
{successRate(totalSuccessful, totalRequests)}
@@ -231,7 +233,7 @@ export default function ProviderStatsPage() {
dns
-
Active Providers
+
{t("activeProviders")}
{activeProviders}
@@ -242,58 +244,60 @@ export default function ProviderStatsPage() {
table_chart
- Provider Breakdown
+ {t("providerBreakdown")}
- {activeProviders} providers
+
+ {t("providerCount", { count: activeProviders })}
+
- Provider
+ {t("provider")}
handleSort("totalRequests")}
>
- Requests
+ {t("requests")}
handleSort("successfulRequests")}
>
- Success
+ {t("success")}
- Rate
+ {t("rate")}
handleSort("avgLatencyMs")}
>
- Avg Latency
+ {t("avgLatency")}
handleSort("totalTokensIn")}
>
- Tokens In
+ {t("tokensIn")}
handleSort("totalTokensOut")}
>
- Tokens Out
+ {t("tokensOut")}
handleSort("avgTtftAfterToolMs")}
>
- TTFT After Tool
+ {t("ttftAfterTool")}
handleSort("avgGapAfterToolMs")}
>
- Gap After Tool
+ {t("gapAfterTool")}
@@ -370,12 +374,20 @@ export default function ProviderStatsPage() {
- Model
- Requests
- Success
- Rate
+
+ {t("model")}
+
- Avg Latency
+ {t("requests")}
+
+
+ {t("success")}
+
+
+ {t("rate")}
+
+
+ {t("avgLatency")}
@@ -427,7 +439,7 @@ export default function ProviderStatsPage() {
{sortedProviders.length === 0 && (
- No provider data recorded yet.
+ {t("noProviderData")}
)}
@@ -439,8 +451,8 @@ export default function ProviderStatsPage() {
{/* In-Memory Metrics */}
{data?.comboMetrics && Object.keys(data.comboMetrics).length > 0 && (
@@ -449,10 +461,18 @@ export default function ProviderStatsPage() {
Combo
- Requests
- Avg TTFT
- Avg Total
- Success
+
+ {t("requests")}
+
+
+ {t("avgTtft")}
+
+
+ {t("avgTotal")}
+
+
+ {t("success")}
+
@@ -487,8 +507,8 @@ export default function ProviderStatsPage() {
{/* Telemetry Phase Breakdown */}
{data?.telemetry && Object.keys(data.telemetry).length > 0 && (
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx
index 0ccd343137..61d1dfbe1d 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx
@@ -173,7 +173,7 @@ export default function ConnectionsHeaderToolbar({
handleChangeCodexGlobalServiceMode(event.target.value as CodexGlobalServiceMode)
}
disabled={savingCodexGlobalServiceMode || !codexSettingsLoaded}
- aria-label="Global Codex service mode"
+ aria-label={providerText(t, "globalCodexServiceMode", "Global Codex service mode")}
className="rounded-md border border-border bg-bg px-2 py-1 text-xs text-text-main outline-none transition-colors focus:border-primary disabled:opacity-60"
>
{codexGlobalServiceModeOptions.map((option) => (
@@ -285,7 +285,7 @@ export default function ConnectionsHeaderToolbar({
)
}
>
- Connect
+ {providerText(t, "connect", "Connect")}
gateConnectionFlow(openApiKeyAddFlow)}
>
- Manual API key
+ {providerText(t, "manualApiKey", "Manual API key")}
>
) : (
<>
gateConnectionFlow(openPrimaryAddFlow)}>
- {providerSupportsPat ? "Add PAT" : t("add")}
+ {providerSupportsPat ? providerText(t, "addPat", "Add PAT") : t("add")}
{providerId === "qoder" && (
gateConnectionFlow(onOpenOAuthModal)}
>
- Experimental OAuth
+ {providerText(t, "experimentalOauth", "Experimental OAuth")}
)}
{providerId === "codex" && (
@@ -337,9 +337,7 @@ export default function ConnectionsHeaderToolbar({
icon="upload_file"
onClick={() => gateConnectionFlow(onOpenImportCodex)}
>
- {typeof (t as any).has === "function" && (t as any).has("importCodexAuth")
- ? t("importCodexAuth")
- : "Import auth"}
+ {providerText(t, "importCodexAuth", "Import auth")}
)}
{providerId === "claude" && (
@@ -349,9 +347,7 @@ export default function ConnectionsHeaderToolbar({
icon="upload_file"
onClick={() => gateConnectionFlow(onOpenImportClaude)}
>
- {typeof (t as any).has === "function" && (t as any).has("importClaudeAuth")
- ? t("importClaudeAuth")
- : "Import auth"}
+ {providerText(t, "importClaudeAuth", "Import auth")}
)}
{providerId === "grok-cli" && (
@@ -361,7 +357,7 @@ export default function ConnectionsHeaderToolbar({
icon="upload_file"
onClick={() => gateConnectionFlow(onOpenImportGrokCli)}
>
- Import auth
+ {providerText(t, "importGrokAuth", "Import auth")}
)}
>
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx
index 0a008286ca..44c0708325 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx
@@ -364,13 +364,13 @@ export default function ModelCompatPopover({
setParamDirty(true);
}}
onBlur={() => saveModelParamFilters()}
- placeholder="thinking, … (comma-separated)"
+ placeholder={t("compatBlockedParamsPlaceholder")}
disabled={disabled}
className="mb-1 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] font-mono text-text-main placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
/>
{t("compatBlockedParamsHint") ?? "Blocked params (stripped from requests)"}
- {paramSaving && " ● saving…"}
+ {paramSaving && ` ● ${t("compatSaving")}`}
@@ -382,7 +382,7 @@ export default function ModelCompatPopover({
setParamDirty(true);
}}
onBlur={() => saveModelParamFilters()}
- placeholder="reasoning, … (comma-separated)"
+ placeholder={t("compatAllowedParamsPlaceholder")}
disabled={disabled}
className="mb-1 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] font-mono text-text-main placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
/>
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/NoAuthProviderControls.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/NoAuthProviderControls.tsx
index 2e18a08542..bccea2c617 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/components/NoAuthProviderControls.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/components/NoAuthProviderControls.tsx
@@ -1,10 +1,10 @@
"use client";
import { useCallback, useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
import { NoAuthAccountCard, NoAuthProviderCard } from "@/shared/components";
import { getProviderAlias, supportsNoAuthProviderProxy } from "@/shared/constants/providers";
import { useNotificationStore } from "@/store/notificationStore";
-import { useTranslations } from "next-intl";
const ACCOUNT_PROVIDER_NAMES: Record
= {
mimocode: "MiMoCode",
@@ -25,6 +25,7 @@ export default function NoAuthProviderControls({
providerProxy,
onConfigureProviderProxy,
}: NoAuthProviderControlsProps) {
+ const noAuthT = useTranslations("noAuthProvider");
const notify = useNotificationStore();
const t = useTranslations("providers");
const [blockedProviders, setBlockedProviders] = useState([]);
@@ -74,18 +75,22 @@ export default function NoAuthProviderControls({
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
- throw new Error(data?.error?.message || data?.error || "Failed to update provider");
+ throw new Error(data?.error?.message || data?.error || noAuthT("updateProviderFailed"));
}
setBlockedProviders(Array.isArray(data.blockedProviders) ? data.blockedProviders : next);
- notify.success(`${providerName} ${nextEnabled ? "enabled" : "disabled"}`);
+ notify.success(
+ nextEnabled
+ ? noAuthT("providerEnabled", { provider: providerName })
+ : noAuthT("providerDisabled", { provider: providerName })
+ );
} catch (error) {
setBlockedProviders(previous);
- notify.error(error instanceof Error ? error.message : "Failed to update provider");
+ notify.error(error instanceof Error ? error.message : noAuthT("updateProviderFailed"));
} finally {
setSavingEnabled(false);
}
},
- [blockedProviders, notify, providerAlias, providerId, providerName]
+ [blockedProviders, noAuthT, notify, providerAlias, providerId, providerName]
);
const accountProviderName = ACCOUNT_PROVIDER_NAMES[providerId];
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderParamFilterSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderParamFilterSection.tsx
index 323df12322..07b064e2e8 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderParamFilterSection.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderParamFilterSection.tsx
@@ -325,14 +325,14 @@ export default function ProviderParamFilterSection({
label={t("paramFiltersBlockedLabel")}
hint={t("paramFiltersBlockedHint")}
value={blockText}
- placeholder="thinking, reasoning_budget, … (comma-separated)"
+ placeholder={t("paramFiltersBlockedPlaceholder")}
onChange={setBlockText}
/>
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ZedImportCard.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ZedImportCard.tsx
index d120dcff1f..630a07f2b4 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/components/ZedImportCard.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ZedImportCard.tsx
@@ -1,14 +1,20 @@
"use client";
import { useState, useCallback } from "react";
+import { useTranslations } from "next-intl";
import { Button, Card } from "@/shared/components";
type ZedImportCardProps = {
fetchConnections: () => Promise;
- notify: { success: (msg: string) => void; error: (msg: string) => void; info: (msg: string) => void };
+ notify: {
+ success: (msg: string) => void;
+ error: (msg: string) => void;
+ info: (msg: string) => void;
+ };
};
export default function ZedImportCard({ fetchConnections, notify }: ZedImportCardProps) {
+ const t = useTranslations("providers");
const [importingZed, setImportingZed] = useState(false);
const [showZedManual, setShowZedManual] = useState(false);
const [zedManualProvider, setZedManualProvider] = useState("openai");
@@ -25,28 +31,29 @@ export default function ZedImportCard({ fetchConnections, notify }: ZedImportCar
if (data.zedDockerEnvironment) {
setShowZedManual(true);
}
- notify.error(data.error || "Zed import failed");
+ notify.error(data.error || t("zedImportFailed"));
} else if (!data.count) {
const found = data.credentials?.length ?? 0;
if (found === 0) {
- notify.info("No Zed credentials found in keychain");
+ notify.info(t("zedNoCredentials"));
} else {
- notify.info(
- `Found ${found} keychain credential(s), but none matched supported providers`
- );
+ notify.info(t("zedUnsupportedCredentials", { count: found }));
}
} else {
notify.success(
- `Imported ${data.count} credential(s) from Zed for ${data.providers?.length ?? 0} provider(s)`
+ t("zedImportSuccess", {
+ credentials: data.count,
+ providers: data.providers?.length ?? 0,
+ })
);
await fetchConnections();
}
} catch (e: any) {
- notify.error(e?.message || "Zed import failed");
+ notify.error(e?.message || t("zedImportFailed"));
} finally {
setImportingZed(false);
}
- }, [importingZed, notify, fetchConnections]);
+ }, [fetchConnections, importingZed, notify, t]);
const handleZedManualImport = useCallback(async () => {
if (importingZedManual || !zedManualToken.trim()) return;
@@ -59,18 +66,18 @@ export default function ZedImportCard({ fetchConnections, notify }: ZedImportCar
});
const data = await res.json();
if (!res.ok || !data.success) {
- notify.error(data.error?.message ?? data.error ?? "Manual import failed");
+ notify.error(data.error?.message ?? data.error ?? t("zedManualImportFailed"));
} else {
- notify.success(`Imported ${zedManualProvider} token from Zed`);
+ notify.success(t("zedManualImportSuccess", { provider: zedManualProvider }));
setZedManualToken("");
await fetchConnections();
}
} catch (e: any) {
- notify.error(e?.message || "Manual import failed");
+ notify.error(e?.message || t("zedManualImportFailed"));
} finally {
setImportingZedManual(false);
}
- }, [importingZedManual, zedManualProvider, zedManualToken, notify, fetchConnections]);
+ }, [fetchConnections, importingZedManual, notify, t, zedManualProvider, zedManualToken]);
return (
<>
@@ -79,13 +86,9 @@ export default function ZedImportCard({ fetchConnections, notify }: ZedImportCar
download
- Import from Zed Keychain
+ {t("zedImportTitle")}
-
- Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) that
- Zed IDE stored in the OS keychain and import them as connections. Requires Zed IDE
- installed on this machine.
-
+
{t("zedImportDescription")}
- {importingZed ? "Importing…" : "Import from Zed"}
+ {importingZed ? t("zedImporting") : t("zedImportButton")}
@@ -106,7 +109,7 @@ export default function ZedImportCard({ fetchConnections, notify }: ZedImportCar
>
edit
- Manual Token Import
+ {t("zedManualTitle")}
{showZedManual ? "expand_less" : "expand_more"}
@@ -115,10 +118,9 @@ export default function ZedImportCard({ fetchConnections, notify }: ZedImportCar
{showZedManual && (
- Use this when OmniRoute runs in Docker or the keychain is unavailable. Paste the
- API key that Zed stored under{" "}
- ~/.config/zed/settings.json or copy
- it from the Zed AI settings panel.
+ {t.rich("zedManualDescription", {
+ path: (chunks) => {chunks},
+ })}
setZedManualToken(e.target.value)}
/>
@@ -148,7 +150,7 @@ export default function ZedImportCard({ fetchConnections, notify }: ZedImportCar
onClick={handleZedManualImport}
disabled={importingZedManual || !zedManualToken.trim()}
>
- {importingZedManual ? "Saving…" : "Import"}
+ {importingZedManual ? t("zedSaving") : t("zedImportAction")}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx
index 758c7abef4..c45d90c0f9 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx
@@ -950,7 +950,7 @@ export default function EditConnectionModal({
min={0}
value={formData.rpm}
onChange={(e) => setFormData({ ...formData, rpm: e.target.value })}
- placeholder="Inherit"
+ placeholder={t("inherit")}
hint={t("rateLimitOverridesRpmHint")}
/>
setFormData({ ...formData, tpm: e.target.value })}
- placeholder="Inherit"
+ placeholder={t("inherit")}
hint={t("rateLimitOverridesTpmHint")}
/>
setFormData({ ...formData, tpd: e.target.value })}
- placeholder="Inherit"
+ placeholder={t("inherit")}
hint={t("rateLimitOverridesTpdHint")}
/>
setFormData({ ...formData, minTime: e.target.value })}
- placeholder="Inherit"
+ placeholder={t("inherit")}
hint={t("rateLimitOverridesMinTimeHint")}
/>
setFormData({ ...formData, rateLimitMaxConcurrent: e.target.value })
}
- placeholder="Inherit"
+ placeholder={t("inherit")}
hint={t("rateLimitOverridesMaxConcurrentHint")}
/>
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/ImportClaudeAuthModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/ImportClaudeAuthModal.tsx
index e10ad6e98f..cbd698c846 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/ImportClaudeAuthModal.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/ImportClaudeAuthModal.tsx
@@ -202,7 +202,7 @@ export function ImportClaudeAuthModal({ onClose, onSuccess }: ImportClaudeAuthMo
newEntries.push({
name: file.name,
json: null,
- parseError: "Not valid JSON",
+ parseError: t("claudeImportInvalidJson"),
email: null,
});
}
@@ -225,18 +225,30 @@ export function ImportClaudeAuthModal({ onClose, onSuccess }: ImportClaudeAuthMo
if (Array.isArray(arr)) {
setBulkEntries(
arr.map((item, i) => ({
- name: `entry ${i + 1}`,
+ name: t("claudeImportEntryName", { number: i + 1 }),
json: item,
parseError: null,
email: null,
}))
);
} else {
- setBulkEntries([{ name: "entry 1", json: arr, parseError: null, email: null }]);
+ setBulkEntries([
+ {
+ name: t("claudeImportEntryName", { number: 1 }),
+ json: arr,
+ parseError: null,
+ email: null,
+ },
+ ]);
}
} catch {
setBulkEntries([
- { name: "parse error", json: null, parseError: "Invalid JSON", email: null },
+ {
+ name: t("claudeImportParseError"),
+ json: null,
+ parseError: t("claudeImportInvalidJson"),
+ email: null,
+ },
]);
}
};
@@ -289,7 +301,7 @@ export function ImportClaudeAuthModal({ onClose, onSuccess }: ImportClaudeAuthMo
try {
const validEntries = bulkEntries.filter((e) => e.json !== null);
if (validEntries.length === 0) {
- notify.error("No valid entries to import");
+ notify.error(t("claudeImportNoValidEntries"));
return;
}
const res = await fetch("/api/providers/claude-auth/import-bulk", {
@@ -462,7 +474,7 @@ export function ImportClaudeAuthModal({ onClose, onSuccess }: ImportClaudeAuthMo
type="text"
value={singleName}
onChange={(e) => setSingleName(e.target.value)}
- placeholder="My Claude account"
+ placeholder={t("providerDetailMyClaudeAccountPlaceholder")}
className="w-full rounded border border-border bg-bg-subtle px-2 py-1.5 text-xs text-text-main"
/>
@@ -692,7 +704,7 @@ export function ApplyClaudeAuthModal({
~/.claude/.credentials.json
- Path is auto-detected per OS (Linux/Mac).
+ {t("providerDetailPathAutoDetected")}
{backupLabel}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/ImportGrokCliAuthModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/ImportGrokCliAuthModal.tsx
index 4316a8d740..ee7f874c66 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/ImportGrokCliAuthModal.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/ImportGrokCliAuthModal.tsx
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
+import { useTranslations } from "next-intl";
import { useNotificationStore } from "@/store/notificationStore";
import { Button, Modal } from "@/shared/components";
@@ -41,6 +42,7 @@ export default function ImportGrokCliAuthModal({
onSuccess,
}: ImportGrokCliAuthModalProps) {
const notify = useNotificationStore();
+ const t = useTranslations("providers");
const [tab, setTab] = useState<"upload" | "paste">("upload");
const [parsedJson, setParsedJson] = useState
(null);
const [detectedEmail, setDetectedEmail] = useState(null);
@@ -58,9 +60,7 @@ export default function ImportGrokCliAuthModal({
setHasRefreshToken(false);
const result = parseGrokJson(json);
if (!result.valid) {
- setParseError(
- "Not a valid Grok Build auth.json. Expected an object with a key containing a JWT."
- );
+ setParseError(t("grokInvalidAuth"));
return;
}
setDetectedEmail(result.email);
@@ -76,7 +76,7 @@ export default function ImportGrokCliAuthModal({
try {
handlePreview(JSON.parse(ev.target?.result as string));
} catch {
- setParseError("Could not parse JSON");
+ setParseError(t("grokParseFailed"));
}
};
reader.readAsText(file);
@@ -94,7 +94,7 @@ export default function ImportGrokCliAuthModal({
try {
handlePreview(JSON.parse(text));
} catch {
- setParseError("Could not parse JSON");
+ setParseError(t("grokParseFailed"));
setParsedJson(null);
}
}
@@ -111,24 +111,26 @@ export default function ImportGrokCliAuthModal({
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
- setError(data.error || "Failed to import");
+ setError(data.error || t("grokImportFailed"));
return;
}
- notify.success("Grok Build connection imported successfully");
+ notify.success(t("grokImportSuccess"));
onSuccess();
} catch {
- setError("Failed to import Grok Build auth");
+ setError(t("grokImportFailed"));
} finally {
setLoading(false);
}
}
return (
-
+
- Import your Grok Build ~/.grok/auth.json file. You can get it by running{" "}
- grok login in your terminal.
+ {t.rich("grokImportDescription", {
+ authPath: (chunks) => {chunks},
+ command: (chunks) => {chunks},
+ })}
{/* Tab toggle */}
@@ -137,13 +139,13 @@ export default function ImportGrokCliAuthModal({
className={`text-sm px-3 py-1 rounded-t ${tab === "upload" ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
onClick={() => setTab("upload")}
>
- Upload file
+ {t("grokUploadFile")}
setTab("paste")}
>
- Paste JSON
+ {t("grokPasteJson")}
@@ -179,22 +181,19 @@ export default function ImportGrokCliAuthModal({
- Valid Grok Build token detected{detectedEmail ? ` (${detectedEmail})` : ""}
+ {t("grokValidToken")}
+ {detectedEmail ? ` (${detectedEmail})` : ""}
{hasRefreshToken && (
-
- Refresh token included — automatic token renewal enabled
-
+
{t("grokRefreshIncluded")}
)}
{!hasRefreshToken && (
-
- No refresh token found — you will need to re-import when the token expires
-
+
{t("grokRefreshMissing")}
)}
setName(e.target.value)}
className="w-full p-2 text-sm bg-input border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
@@ -208,10 +207,10 @@ export default function ImportGrokCliAuthModal({
{/* Buttons */}
- {loading ? "Saving…" : "Save Connection"}
+ {loading ? t("grokSaving") : t("grokSaveConnection")}
- Cancel
+ {t("cancel")}
diff --git a/src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx b/src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx
index 4a44da1a89..2d29778037 100644
--- a/src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx
+++ b/src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx
@@ -14,6 +14,7 @@ import {
KiroOAuthWrapper,
OAuthModal,
} from "@/shared/components";
+import ProviderIcon from "@/shared/components/ProviderIcon";
import {
buildProviderSpecificData,
@@ -98,6 +99,21 @@ function providerText(
return fallback;
}
+function localizeProviderOptions(
+ options: WizardProviderOption[],
+ t: ProviderMessageTranslator
+): WizardProviderOption[] {
+ return options.map((option) => ({
+ ...option,
+ description: providerText(
+ t,
+ `onboardingProviderDescriptions.${option.id}`,
+ option.description,
+ { provider: option.name }
+ ),
+ }));
+}
+
function StepPill({ active, done, label }: { active: boolean; done: boolean; label: string }) {
return (
{option.name}
@@ -299,8 +321,14 @@ export default function ProviderOnboardingWizard() {
providerText(t, key, fallback, values);
const defaultConnectionName = (provider: string) =>
text("onboardingDefaultConnectionName", "{provider} Primary", { provider });
- const apiKeyOptions = useMemo(() => getWizardApiKeyProviderOptions(), []);
- const oauthOptions = useMemo(() => getWizardOAuthProviderOptions(), []);
+ const apiKeyOptions = useMemo(
+ () => localizeProviderOptions(getWizardApiKeyProviderOptions(), t),
+ [t]
+ );
+ const oauthOptions = useMemo(
+ () => localizeProviderOptions(getWizardOAuthProviderOptions(), t),
+ [t]
+ );
const [kind, setKind] = useState
("apikey");
const [step, setStep] = useState("type");
const [query, setQuery] = useState("");
@@ -326,22 +354,30 @@ export default function ProviderOnboardingWizard() {
.then((data) => {
if (!cancelled) {
setCcCompatibleProviderEnabled(data.ccCompatibleProviderEnabled);
+ if (!data.ccCompatibleProviderEnabled) {
+ setCustomForm((current) =>
+ current.mode === "cc"
+ ? { ...current, mode: "openai", baseUrl: "https://api.openai.com/v1" }
+ : current
+ );
+ }
}
})
.catch(() => {
- if (!cancelled) setCcCompatibleProviderEnabled(false);
+ if (!cancelled) {
+ setCcCompatibleProviderEnabled(false);
+ setCustomForm((current) =>
+ current.mode === "cc"
+ ? { ...current, mode: "openai", baseUrl: "https://api.openai.com/v1" }
+ : current
+ );
+ }
});
return () => {
cancelled = true;
};
}, []);
- useEffect(() => {
- if (!ccCompatibleProviderEnabled && customForm.mode === "cc") {
- setCustomForm((prev) => ({ ...prev, mode: "openai", baseUrl: "https://api.openai.com/v1" }));
- }
- }, [ccCompatibleProviderEnabled, customForm.mode]);
-
const resetProviderSelection = (nextKind: WizardKind) => {
setKind(nextKind);
setSelectedProvider(null);
@@ -787,7 +823,7 @@ export default function ProviderOnboardingWizard() {
label={text("displayName", "Display name")}
value={customForm.name}
onChange={(event) => setCustomForm({ ...customForm, name: event.target.value })}
- placeholder="My Gateway"
+ placeholder={text("customGatewayNamePlaceholder", "My Gateway")}
/>
(null);
@@ -43,10 +45,10 @@ export function ApiKeyField({ name, serviceLabel, showReveal = false }: ApiKeyFi
try {
const res = await fetch(`/api/services/${name}/rotate-key`, { method: "POST" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
- setMsg({ ok: true, text: `Key rotated — ${label} restarted to apply the new key` });
+ setMsg({ ok: true, text: t("keyRotated", { name: label }) });
mutate();
} catch {
- setMsg({ ok: false, text: "Failed to rotate key" });
+ setMsg({ ok: false, text: t("keyRotateFailed") });
} finally {
setPending(false);
}
@@ -64,10 +66,10 @@ export function ApiKeyField({ name, serviceLabel, showReveal = false }: ApiKeyFi
setPlainKey(body.apiKeyPlain);
setMsg(null);
} else {
- setMsg({ ok: false, text: "Reveal failed: key not returned" });
+ setMsg({ ok: false, text: t("keyRevealEmpty") });
}
} catch {
- setMsg({ ok: false, text: "Failed to reveal key" });
+ setMsg({ ok: false, text: t("keyRevealFailed") });
} finally {
setRevealPending(false);
setRevealModalOpen(false);
@@ -82,10 +84,8 @@ export function ApiKeyField({ name, serviceLabel, showReveal = false }: ApiKeyFi
key
-
API Key
-
- Key used by OmniRoute to authenticate with {label}
-
+
{t("apiKey")}
+
{t("apiKeyDescription", { name: label })}
@@ -116,7 +116,7 @@ export function ApiKeyField({ name, serviceLabel, showReveal = false }: ApiKeyFi
disabled={revealPending || !data?.installedVersion}
className="shrink-0"
>
- Reveal
+ {t("reveal")}
)}
{plainKey && (
@@ -126,7 +126,7 @@ export function ApiKeyField({ name, serviceLabel, showReveal = false }: ApiKeyFi
onClick={() => setPlainKey(null)}
className="shrink-0"
>
- Hide
+ {t("hide")}
)}
- {pending ? "Rotating…" : "Rotate key"}
+ {pending ? t("rotating") : t("rotateKey")}
- {plainKey && (
-
- Key will be hidden automatically in 30 seconds.
-
- )}
+ {plainKey && {t("keyAutoHide")}
}
{showReveal && (
@@ -152,10 +148,10 @@ export function ApiKeyField({ name, serviceLabel, showReveal = false }: ApiKeyFi
isOpen={revealModalOpen}
onClose={() => setRevealModalOpen(false)}
onConfirm={confirmReveal}
- title="Reveal API Key"
- message="Revealing the API key will be logged in the audit trail. Continue?"
- confirmText={revealPending ? "Revealing…" : "Reveal"}
- cancelText="Cancel"
+ title={t("revealTitle")}
+ message={t("revealConfirm")}
+ confirmText={revealPending ? t("revealing") : t("reveal")}
+ cancelText={t("cancel")}
variant="secondary"
loading={revealPending}
/>
diff --git a/src/app/(dashboard)/dashboard/providers/services/components/AutoStartToggle.tsx b/src/app/(dashboard)/dashboard/providers/services/components/AutoStartToggle.tsx
index 14f1a3aca6..68450bbcec 100644
--- a/src/app/(dashboard)/dashboard/providers/services/components/AutoStartToggle.tsx
+++ b/src/app/(dashboard)/dashboard/providers/services/components/AutoStartToggle.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import { useTranslations } from "next-intl";
import { Card, Toggle } from "@/shared/components";
import { useServiceStatus } from "../hooks/useServiceStatus";
@@ -11,11 +12,12 @@ interface AutoStartToggleProps {
}
export function AutoStartToggle({ name, label, description }: AutoStartToggleProps) {
+ const t = useTranslations("embeddedServices");
const { data, mutate } = useServiceStatus(name);
const [pending, setPending] = useState(false);
- const displayLabel = label ?? "Auto-start";
- const displayDescription = description ?? `Launch ${name} automatically when OmniRoute starts`;
+ const displayLabel = label ?? t("autoStart");
+ const displayDescription = description ?? t("autoStartDescription", { name });
async function handleToggle(enabled: boolean) {
setPending(true);
diff --git a/src/app/(dashboard)/dashboard/providers/services/components/CliproxyConnectionPanel.tsx b/src/app/(dashboard)/dashboard/providers/services/components/CliproxyConnectionPanel.tsx
index 2a6b130f88..f6ac2f26a9 100644
--- a/src/app/(dashboard)/dashboard/providers/services/components/CliproxyConnectionPanel.tsx
+++ b/src/app/(dashboard)/dashboard/providers/services/components/CliproxyConnectionPanel.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
+import { useTranslations } from "next-intl";
import { Card, Toggle, Input } from "@/shared/components";
function isValidUrl(value: string): boolean {
@@ -19,6 +20,7 @@ interface FallbackSettings {
}
export function CliproxyConnectionPanel() {
+ const t = useTranslations("embeddedServices");
const [settings, setSettings] = useState({
cliproxyapi_fallback_enabled: false,
cliproxyapi_url: "http://127.0.0.1:8317",
@@ -48,30 +50,33 @@ export function CliproxyConnectionPanel() {
.catch(() => setLoaded(true));
}, []);
- const saveSetting = useCallback(async (key: string, value: boolean | string) => {
- if (key === "cliproxyapi_url" && typeof value === "string" && value.trim() !== "") {
- if (!isValidUrl(value)) {
- setMsg({ ok: false, text: "Invalid URL — must start with http:// or https://" });
- return;
+ const saveSetting = useCallback(
+ async (key: string, value: boolean | string) => {
+ if (key === "cliproxyapi_url" && typeof value === "string" && value.trim() !== "") {
+ if (!isValidUrl(value)) {
+ setMsg({ ok: false, text: t("invalidUrl") });
+ return;
+ }
}
- }
- setSaving(true);
- setMsg(null);
- try {
- const res = await fetch("/api/settings", {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ [key]: value }),
- });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- setSettings((prev) => ({ ...prev, [key]: value }));
- setMsg({ ok: true, text: "Saved" });
- } catch {
- setMsg({ ok: false, text: "Failed to save setting" });
- } finally {
- setSaving(false);
- }
- }, []);
+ setSaving(true);
+ setMsg(null);
+ try {
+ const res = await fetch("/api/settings", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ [key]: value }),
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ setSettings((prev) => ({ ...prev, [key]: value }));
+ setMsg({ ok: true, text: t("saved") });
+ } catch {
+ setMsg({ ok: false, text: t("saveFailed") });
+ } finally {
+ setSaving(false);
+ }
+ },
+ [t]
+ );
if (!loaded) return null;
@@ -82,10 +87,8 @@ export function CliproxyConnectionPanel() {
swap_horiz
-
Fallback Routing
-
- Retry failed provider requests through CLIProxyAPI
-
+
{t("fallbackRouting")}
+
{t("fallbackRoutingDescription")}
@@ -106,7 +109,7 @@ export function CliproxyConnectionPanel() {
-
Enable fallback
+
{t("enableFallback")}
saveSetting("cliproxyapi_fallback_enabled", v)}
@@ -117,7 +120,7 @@ export function CliproxyConnectionPanel() {
{settings.cliproxyapi_fallback_enabled && (
<>
- CLIProxyAPI URL
+ {t("cliproxyUrl")}
saveSetting("cliproxyapi_url", e.target.value)}
@@ -125,9 +128,7 @@ export function CliproxyConnectionPanel() {
/>
-
- Fallback status codes (comma-separated)
-
+ {t("fallbackCodes")}
saveSetting("cliproxyapi_fallback_codes", e.target.value)}
diff --git a/src/app/(dashboard)/dashboard/providers/services/components/CliproxyModelMappingEditor.tsx b/src/app/(dashboard)/dashboard/providers/services/components/CliproxyModelMappingEditor.tsx
index 8e13697304..47558bcece 100644
--- a/src/app/(dashboard)/dashboard/providers/services/components/CliproxyModelMappingEditor.tsx
+++ b/src/app/(dashboard)/dashboard/providers/services/components/CliproxyModelMappingEditor.tsx
@@ -6,6 +6,7 @@
"use client";
import { useState, useEffect, useRef } from "react";
+import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
// ── Pure validator (exported for unit tests) ──────────────────────────────────
@@ -13,7 +14,12 @@ import { Card } from "@/shared/components";
/** Result of parsing the textarea value. */
export type MappingParseResult =
| { ok: true; value: Record }
- | { ok: false; error: string };
+ | {
+ ok: false;
+ error: string;
+ messageKey: "mappingInvalidJson" | "mappingMustBeObject" | "mappingValueMustBeString";
+ messageValues?: { key: string };
+ };
/**
* Parse and validate the raw textarea string.
@@ -25,11 +31,15 @@ export function parseMappingJson(raw: string): MappingParseResult {
parsed = JSON.parse(raw);
} catch (e) {
const msg = e instanceof SyntaxError ? e.message : "Invalid JSON";
- return { ok: false, error: msg };
+ return { ok: false, error: msg, messageKey: "mappingInvalidJson" };
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
- return { ok: false, error: "Must be a JSON object (not an array or primitive)" };
+ return {
+ ok: false,
+ error: "Must be a JSON object (not an array or primitive)",
+ messageKey: "mappingMustBeObject",
+ };
}
const obj = parsed as Record;
@@ -38,6 +48,8 @@ export function parseMappingJson(raw: string): MappingParseResult {
return {
ok: false,
error: `Value for key "${key}" must be a string, got ${Array.isArray(val) ? "array" : typeof val}`,
+ messageKey: "mappingValueMustBeString",
+ messageValues: { key },
};
}
}
@@ -54,7 +66,16 @@ function formatMapping(value: Record | null): string {
return JSON.stringify(value, null, 2);
}
+function getMappingValidationMessage(
+ result: MappingParseResult,
+ t: ReturnType
+): string | null {
+ if ("messageKey" in result) return t(result.messageKey, result.messageValues);
+ return null;
+}
+
export function CliproxyModelMappingEditor() {
+ const t = useTranslations("embeddedServices");
const [rawText, setRawText] = useState(EMPTY_MAPPING);
const [savedText, setSavedText] = useState(EMPTY_MAPPING);
const [loaded, setLoaded] = useState(false);
@@ -93,6 +114,7 @@ export function CliproxyModelMappingEditor() {
const parseResult = parseMappingJson(rawText);
const isValid = parseResult.ok;
+ const validationMessage = getMappingValidationMessage(parseResult, t);
const isDirty = rawText !== savedText;
const canSave = isValid && isDirty && !saving;
@@ -111,9 +133,9 @@ export function CliproxyModelMappingEditor() {
const formatted = formatMapping(parseResult.value);
setSavedText(formatted);
setRawText(formatted);
- showMsg(true, "Mapping saved");
+ showMsg(true, t("mappingSaved"));
} catch {
- showMsg(false, "Failed to save mapping");
+ showMsg(false, t("mappingSaveFailed"));
} finally {
setSaving(false);
}
@@ -128,13 +150,15 @@ export function CliproxyModelMappingEditor() {
account_tree
-
Model Mapping
+
{t("modelMapping")}
- Map OmniRoute model IDs to CLIProxyAPI model IDs (e.g.{" "}
-
- {'"gpt-4o": "openai-gpt-4o"'}
-
- )
+ {t.rich("modelMappingDescription", {
+ example: () => (
+
+ {'"gpt-4o": "openai-gpt-4o"'}
+
+ ),
+ })}
@@ -166,13 +190,13 @@ export function CliproxyModelMappingEditor() {
setMsg(null);
}}
spellCheck={false}
- aria-label="Model mapping JSON editor"
+ aria-label={t("modelMappingEditor")}
/>
- {!isValid && rawText !== EMPTY_MAPPING && (
+ {validationMessage && rawText !== EMPTY_MAPPING && (
error
- {parseResult.error}
+ {validationMessage}
)}
@@ -191,7 +215,7 @@ export function CliproxyModelMappingEditor() {
progress_activity
)}
- Save
+ {t("save")}
diff --git a/src/app/(dashboard)/dashboard/providers/services/components/NinerouterEmbedFrame.tsx b/src/app/(dashboard)/dashboard/providers/services/components/NinerouterEmbedFrame.tsx
index 57360992f0..263ab8b695 100644
--- a/src/app/(dashboard)/dashboard/providers/services/components/NinerouterEmbedFrame.tsx
+++ b/src/app/(dashboard)/dashboard/providers/services/components/NinerouterEmbedFrame.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
import { useServiceStatus } from "../hooks/useServiceStatus";
@@ -11,6 +12,7 @@ const NAME = "9router";
* CSP exception (`frame-ancestors 'self'`) is applied to this proxy path in next.config.mjs.
*/
export function NinerouterEmbedFrame() {
+ const t = useTranslations("embeddedServices");
const { data } = useServiceStatus(NAME);
const [expanded, setExpanded] = useState(false);
@@ -27,7 +29,7 @@ export function NinerouterEmbedFrame() {
>
@@ -46,7 +48,7 @@ export function NinerouterEmbedFrame() {
{expanded && (
diff --git a/src/app/(dashboard)/dashboard/providers/services/components/NinerouterInstallWizard.tsx b/src/app/(dashboard)/dashboard/providers/services/components/NinerouterInstallWizard.tsx
index f132b55cb0..4f1a14945b 100644
--- a/src/app/(dashboard)/dashboard/providers/services/components/NinerouterInstallWizard.tsx
+++ b/src/app/(dashboard)/dashboard/providers/services/components/NinerouterInstallWizard.tsx
@@ -6,6 +6,7 @@
"use client";
import { useState } from "react";
+import { useTranslations } from "next-intl";
import { Card, Button } from "@/shared/components";
import { useServiceStatus } from "../hooks/useServiceStatus";
@@ -13,6 +14,7 @@ const NAME = "9router";
const DEFAULT_PORT = 20130;
export function NinerouterInstallWizard() {
+ const t = useTranslations("embeddedServices");
const { mutate } = useServiceStatus(NAME);
const [version, setVersion] = useState("latest");
const [port, setPort] = useState(String(DEFAULT_PORT));
@@ -31,14 +33,14 @@ export function NinerouterInstallWizard() {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const errorMsg =
- body?.error?.message ?? body?.message ?? `Installation failed (HTTP ${res.status})`;
+ body?.error?.message ?? body?.message ?? t("installationFailed", { status: res.status });
setMsg({ ok: false, text: errorMsg });
return;
}
- setMsg({ ok: true, text: "9Router installed successfully. Starting up…" });
+ setMsg({ ok: true, text: t("installationSucceeded") });
mutate();
} catch {
- setMsg({ ok: false, text: "Network error — could not reach the install endpoint" });
+ setMsg({ ok: false, text: t("networkInstallFailed") });
} finally {
setInstalling(false);
}
@@ -51,10 +53,8 @@ export function NinerouterInstallWizard() {
download
-
Install 9Router
-
- Downloads and installs 9Router via npm. Requires ~500 MB disk space.
-
+
{t("install9Router")}
+
{t("install9RouterDescription")}
@@ -62,7 +62,7 @@ export function NinerouterInstallWizard() {
{/* Version field */}
- Version
+ {t("version")}
-
- Enter "latest" or a specific version (e.g. 1.2.3).
-
+
{t("versionHint")}
{/* Port field */}
- Port
+ {t("servicePort")}
-
- OmniRoute uses port 20128. Keep 9Router on a different port (default 20130).
-
+
{t("servicePortHint")}
{/* Message */}
@@ -121,10 +117,10 @@ export function NinerouterInstallWizard() {
progress_activity
- Installing…
+ {t("installing")}
) : (
- "Install 9Router"
+ t("install9Router")
)}
diff --git a/src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx b/src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx
index 2ec793df27..27c618145c 100644
--- a/src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx
+++ b/src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx
@@ -6,6 +6,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
+import { useTranslations } from "next-intl";
import { Card, Button } from "@/shared/components";
const NAME = "9router";
@@ -34,34 +35,38 @@ export function paginateModels(
// ── Component ─────────────────────────────────────────────────────────────────
export function NinerouterModelList() {
+ const t = useTranslations("embeddedServices");
const [models, setModels] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(null);
- const fetchModels = useCallback(async (refresh = false) => {
- if (refresh) {
- setRefreshing(true);
- } else {
- setLoading(true);
- }
- setError(null);
- try {
- const url = `/api/services/${NAME}/models${refresh ? "?refresh=true" : ""}`;
- const res = await fetch(url);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const body = await res.json();
- const data: ServiceModel[] = Array.isArray(body?.data) ? body.data : [];
- setModels(data);
- setPage(1);
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to load models");
- } finally {
- setLoading(false);
- setRefreshing(false);
- }
- }, []);
+ const fetchModels = useCallback(
+ async (refresh = false) => {
+ if (refresh) {
+ setRefreshing(true);
+ } else {
+ setLoading(true);
+ }
+ setError(null);
+ try {
+ const url = `/api/services/${NAME}/models${refresh ? "?refresh=true" : ""}`;
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const body = await res.json();
+ const data: ServiceModel[] = Array.isArray(body?.data) ? body.data : [];
+ setModels(data);
+ setPage(1);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : t("modelsLoadFailed"));
+ } finally {
+ setLoading(false);
+ setRefreshing(false);
+ }
+ },
+ [t]
+ );
useEffect(() => {
void fetchModels(false);
@@ -78,11 +83,9 @@ export function NinerouterModelList() {
list
-
Available Models
+
{t("availableModels")}
- {loading
- ? "Loading…"
- : `${models.length} model${models.length !== 1 ? "s" : ""} discovered`}
+ {loading ? t("modelsLoading") : t("modelsDiscovered", { count: models.length })}
@@ -98,10 +101,10 @@ export function NinerouterModelList() {
progress_activity
- Refreshing…
+ {t("refreshing")}
) : (
- "Refresh now"
+ t("refreshNow")
)}
@@ -114,9 +117,7 @@ export function NinerouterModelList() {
)}
{!loading && models.length === 0 && !error && (
-
- No models found. Click "Refresh now" to sync from the running service.
-
+ {t("noModels")}
)}
{visibleModels.length > 0 && (
@@ -129,7 +130,7 @@ export function NinerouterModelList() {
{model.id}
{model.available === false && (
- unavailable
+ {t("unavailable")}
)}
@@ -141,7 +142,7 @@ export function NinerouterModelList() {
{totalPages > 1 && (
- Page {page} of {totalPages}
+ {t("pageOf", { page, total: totalPages })}
- Prev
+ {t("previous")}
setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-2 py-1 text-xs rounded border border-border disabled:opacity-40 hover:bg-bg-subtle transition-colors"
>
- Next
+ {t("next")}
diff --git a/src/app/(dashboard)/dashboard/providers/services/components/NinerouterProviderExposureCard.tsx b/src/app/(dashboard)/dashboard/providers/services/components/NinerouterProviderExposureCard.tsx
index c40764d33e..806293f9fd 100644
--- a/src/app/(dashboard)/dashboard/providers/services/components/NinerouterProviderExposureCard.tsx
+++ b/src/app/(dashboard)/dashboard/providers/services/components/NinerouterProviderExposureCard.tsx
@@ -5,13 +5,24 @@
*/
"use client";
+import type { ReactNode } from "react";
import { useState } from "react";
+import { useTranslations } from "next-intl";
import { Card, Toggle } from "@/shared/components";
import { useServiceStatus } from "../hooks/useServiceStatus";
const NAME = "9router";
+function renderPrefix(chunks: ReactNode) {
+ return {chunks};
+}
+
+function renderSmallPrefix(chunks: ReactNode) {
+ return {chunks};
+}
+
export function NinerouterProviderExposureCard() {
+ const t = useTranslations("embeddedServices");
const { data, mutate } = useServiceStatus(NAME);
const [pending, setPending] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
@@ -28,14 +39,16 @@ export function NinerouterProviderExposureCard() {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const errorMsg =
- body?.error?.message ?? body?.message ?? `Failed to update (HTTP ${res.status})`;
+ body?.error?.message ??
+ body?.message ??
+ t("providerExposureUpdateFailed", { status: res.status });
setMsg({ ok: false, text: errorMsg });
return;
}
setMsg(null);
mutate();
} catch {
- setMsg({ ok: false, text: "Network error — could not update provider exposure setting" });
+ setMsg({ ok: false, text: t("providerExposureNetworkFailed") });
} finally {
setPending(false);
}
@@ -48,10 +61,11 @@ export function NinerouterProviderExposureCard() {
hub
-
Provider Exposure
+
{t("providerExposure")}
- Expose 9Router models as a routing target under the{" "}
- 9router/ prefix.
+ {t.rich("providerExposureDescription", {
+ prefix: renderPrefix,
+ })}
@@ -74,12 +88,11 @@ export function NinerouterProviderExposureCard() {
- Expose as{" "}
- 9router/...
-
-
- When enabled, discovered models appear in provider selects across OmniRoute.
+ {t.rich("providerExposureLabel", {
+ prefix: renderSmallPrefix,
+ })}
+
{t("providerExposureHint")}
(null);
const [error, setError] = useState(null);
@@ -51,7 +53,7 @@ export function ServiceLifecycleButtons({ name }: ServiceLifecycleButtonsProps)
action("start")}>
- {pending === "start" ? "Starting…" : "Start"}
+ {pending === "start" ? t("starting") : t("start")}
action("stop")}
>
- {pending === "stop" ? "Stopping…" : "Stop"}
+ {pending === "stop" ? t("stopping") : t("stop")}
action("restart")}
>
- {pending === "restart" ? "Restarting…" : "Restart"}
+ {pending === "restart" ? t("restarting") : t("restart")}
action("update")}>
- {pending === "update" ? "Updating…" : "Update"}
+ {pending === "update" ? t("updating") : t("update")}
{error &&
{error}
}
@@ -81,7 +83,7 @@ export function ServiceLifecycleButtons({ name }: ServiceLifecycleButtonsProps)
return (
action("install")}>
- {pending === "install" ? "Installing…" : "Install"}
+ {pending === "install" ? t("installing") : t("install")}
{error &&
{error}
}
diff --git a/src/app/(dashboard)/dashboard/providers/services/components/ServiceLogsPanel.tsx b/src/app/(dashboard)/dashboard/providers/services/components/ServiceLogsPanel.tsx
index f0f5825a6e..c4495d2b1a 100644
--- a/src/app/(dashboard)/dashboard/providers/services/components/ServiceLogsPanel.tsx
+++ b/src/app/(dashboard)/dashboard/providers/services/components/ServiceLogsPanel.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
+import { useLocale, useTranslations } from "next-intl";
import { cn } from "@/shared/utils/cn";
import { useServiceLogs } from "../hooks/useServiceLogs";
import type { LogLine } from "../hooks/useServiceLogs";
@@ -9,8 +10,8 @@ interface ServiceLogsPanelProps {
name: string;
}
-function LogLineRow({ line }: { line: LogLine }) {
- const ts = new Date(line.ts).toLocaleTimeString("en", { hour12: false });
+function LogLineRow({ line, locale }: { line: LogLine; locale: string }) {
+ const ts = new Date(line.ts).toLocaleTimeString(locale, { hour12: false });
return (
{ts}
@@ -28,6 +29,8 @@ function LogLineRow({ line }: { line: LogLine }) {
}
export function ServiceLogsPanel({ name }: ServiceLogsPanelProps) {
+ const locale = useLocale();
+ const t = useTranslations("embeddedServices");
const [filterInput, setFilterInput] = useState("");
const { lines, isPaused, error, togglePause, clear, setFilter } = useServiceLogs(name, {
tail: 200,
@@ -63,7 +66,7 @@ export function ServiceLogsPanel({ name }: ServiceLogsPanelProps) {
applyFilter(e.target.value)}
className="flex-1 bg-transparent text-xs outline-none placeholder:text-text-muted min-w-0"
@@ -73,30 +76,30 @@ export function ServiceLogsPanel({ name }: ServiceLogsPanelProps) {
onClick={togglePause}
className="text-xs text-text-muted hover:text-text-primary shrink-0"
>
- {isPaused ? "Resume" : "Pause"}
+ {isPaused ? t("resume") : t("pause")}
- Clear
+ {t("clear")}
- Download
+ {t("download")}
{error ? (
{error}
) : lines.length === 0 ? (
-
No log output yet.
+
{t("noLogs")}
) : (
- lines.map((l, i) =>
)
+ lines.map((l, i) =>
)
)}
diff --git a/src/app/(dashboard)/dashboard/providers/services/components/ServiceStatusCard.tsx b/src/app/(dashboard)/dashboard/providers/services/components/ServiceStatusCard.tsx
index 74bf5120fd..fbe85ccd81 100644
--- a/src/app/(dashboard)/dashboard/providers/services/components/ServiceStatusCard.tsx
+++ b/src/app/(dashboard)/dashboard/providers/services/components/ServiceStatusCard.tsx
@@ -1,6 +1,7 @@
"use client";
import { Card } from "@/shared/components";
+import { useTranslations } from "next-intl";
import { cn } from "@/shared/utils/cn";
import { useServiceStatus } from "../hooks/useServiceStatus";
@@ -24,6 +25,7 @@ interface ServiceStatusCardProps {
}
export function ServiceStatusCard({ name }: ServiceStatusCardProps) {
+ const t = useTranslations("embeddedServices");
const { data, isLoading, error } = useServiceStatus(name);
if (isLoading && !data) {
@@ -44,15 +46,26 @@ export function ServiceStatusCard({ name }: ServiceStatusCardProps) {
if (!data) return null;
+ const stateKey =
+ {
+ running: "stateRunning",
+ stopped: "stateStopped",
+ starting: "stateStarting",
+ stopping: "stateStopping",
+ error: "stateError",
+ not_installed: "stateNotInstalled",
+ unknown: "stateUnknown",
+ }[data.state] ?? "stateUnknown";
+
return (
-
{data.state}
+
{t(stateKey)}
- port {data.port}
+ {t("port", { port: data.port })}
{data.pid ? ` · PID ${data.pid}` : ""}
diff --git a/src/app/(dashboard)/dashboard/providers/services/hooks/useServiceLogs.ts b/src/app/(dashboard)/dashboard/providers/services/hooks/useServiceLogs.ts
index 9ad436c828..5c7d2d951a 100644
--- a/src/app/(dashboard)/dashboard/providers/services/hooks/useServiceLogs.ts
+++ b/src/app/(dashboard)/dashboard/providers/services/hooks/useServiceLogs.ts
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
+import { useTranslations } from "next-intl";
export interface LogLine {
ts: number;
@@ -28,6 +29,7 @@ export function useServiceLogs(
name: string,
options: UseServiceLogsOptions = {}
): UseServiceLogsResult {
+ const t = useTranslations("embeddedServices");
const [lines, setLines] = useState
([]);
const [isPaused, setIsPaused] = useState(false);
const [error, setError] = useState(null);
@@ -79,7 +81,7 @@ export function useServiceLogs(
});
es.onerror = () => {
- setError(`Unable to stream ${name} logs. Check local access and service status.`);
+ setError(t("logStreamFailed", { name }));
es.close();
};
@@ -87,7 +89,7 @@ export function useServiceLogs(
es.close();
esRef.current = null;
};
- }, [name, filter, options.tail]);
+ }, [name, filter, options.tail, t]);
return { lines, isPaused, error, togglePause, clear, setFilter };
}
diff --git a/src/app/(dashboard)/dashboard/providers/services/page.tsx b/src/app/(dashboard)/dashboard/providers/services/page.tsx
index 8cc5a09443..3d58603477 100644
--- a/src/app/(dashboard)/dashboard/providers/services/page.tsx
+++ b/src/app/(dashboard)/dashboard/providers/services/page.tsx
@@ -1,6 +1,7 @@
"use client";
import { useSearchParams, useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
import { cn } from "@/shared/utils/cn";
import { CliproxyServiceTab } from "./tabs/CliproxyServiceTab";
import { NinerouterServiceTab } from "./tabs/NinerouterServiceTab";
@@ -17,6 +18,7 @@ const TABS: { id: Tab; label: string; icon: string }[] = [
];
export default function ServicesPage() {
+ const t = useTranslations("embeddedServices");
const sp = useSearchParams();
const router = useRouter();
const active = (sp.get("tab") ?? "cliproxy") as Tab;
@@ -28,11 +30,8 @@ export default function ServicesPage() {
return (
- Embedded Services
-
- External engines managed on demand — CLIProxyAPI, 9Router, Mux, and Bifrost. Accessible on
- loopback only.
-
+ {t("title")}
+ {t("description")}
{/* Tab strip */}
diff --git a/src/app/(dashboard)/dashboard/providers/services/tabs/BifrostServiceTab.tsx b/src/app/(dashboard)/dashboard/providers/services/tabs/BifrostServiceTab.tsx
index fd4f474c79..0d832a9f26 100644
--- a/src/app/(dashboard)/dashboard/providers/services/tabs/BifrostServiceTab.tsx
+++ b/src/app/(dashboard)/dashboard/providers/services/tabs/BifrostServiceTab.tsx
@@ -12,10 +12,7 @@ export function BifrostServiceTab() {
);
diff --git a/src/app/(dashboard)/dashboard/providers/services/tabs/CliproxyServiceTab.tsx b/src/app/(dashboard)/dashboard/providers/services/tabs/CliproxyServiceTab.tsx
index a25686f292..4df6720418 100644
--- a/src/app/(dashboard)/dashboard/providers/services/tabs/CliproxyServiceTab.tsx
+++ b/src/app/(dashboard)/dashboard/providers/services/tabs/CliproxyServiceTab.tsx
@@ -15,10 +15,7 @@ export function CliproxyServiceTab() {
-
+
diff --git a/src/app/(dashboard)/dashboard/providers/services/tabs/MuxServiceTab.tsx b/src/app/(dashboard)/dashboard/providers/services/tabs/MuxServiceTab.tsx
index a51dc07e81..087bc9b3ff 100644
--- a/src/app/(dashboard)/dashboard/providers/services/tabs/MuxServiceTab.tsx
+++ b/src/app/(dashboard)/dashboard/providers/services/tabs/MuxServiceTab.tsx
@@ -12,7 +12,7 @@ export function MuxServiceTab() {
);
diff --git a/src/app/(dashboard)/dashboard/providers/services/tabs/NinerouterServiceTab.tsx b/src/app/(dashboard)/dashboard/providers/services/tabs/NinerouterServiceTab.tsx
index b1e687c9b1..3d5395fc3f 100644
--- a/src/app/(dashboard)/dashboard/providers/services/tabs/NinerouterServiceTab.tsx
+++ b/src/app/(dashboard)/dashboard/providers/services/tabs/NinerouterServiceTab.tsx
@@ -29,10 +29,7 @@ export function NinerouterServiceTab() {
-
+
diff --git a/src/app/(dashboard)/dashboard/relay/RelayProxyClient.tsx b/src/app/(dashboard)/dashboard/relay/RelayProxyClient.tsx
index e84011adb1..7d673b281e 100644
--- a/src/app/(dashboard)/dashboard/relay/RelayProxyClient.tsx
+++ b/src/app/(dashboard)/dashboard/relay/RelayProxyClient.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
+import { useTranslations } from "next-intl";
import Card from "@/shared/components/Card";
import Badge from "@/shared/components/Badge";
import Button from "@/shared/components/Button";
@@ -21,6 +22,7 @@ interface RelayToken {
}
export default function RelayProxyClient() {
+ const t = useTranslations("relay");
const [tokens, setTokens] = useState
([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
@@ -41,7 +43,9 @@ export default function RelayProxyClient() {
}
}, []);
- useEffect(() => { fetchTokens(); }, [fetchTokens]);
+ useEffect(() => {
+ void fetchTokens();
+ }, [fetchTokens]);
const createToken = async () => {
if (!form.name.trim()) return;
@@ -61,13 +65,13 @@ export default function RelayProxyClient() {
setNewTokenData({ rawToken: data.rawToken, name: data.name });
setForm({ name: "", description: "", maxRpm: "60", maxRpd: "10000" });
setShowCreate(false);
- addNotification({ type: "success", message: "Relay token created" });
- fetchTokens();
+ addNotification({ type: "success", message: t("created") });
+ void fetchTokens();
} else {
- addNotification({ type: "error", message: data.error || "Failed to create token" });
+ addNotification({ type: "error", message: data.error || t("createFailed") });
}
} catch {
- addNotification({ type: "error", message: "Failed to create token" });
+ addNotification({ type: "error", message: t("createFailed") });
}
};
@@ -78,20 +82,20 @@ export default function RelayProxyClient() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
- fetchTokens();
+ void fetchTokens();
} catch {
- addNotification({ type: "error", message: "Failed to toggle token" });
+ addNotification({ type: "error", message: t("toggleFailed") });
}
};
const deleteToken = async (id: string) => {
- if (!confirm("Delete this relay token? This cannot be undone.")) return;
+ if (!confirm(t("deleteConfirm"))) return;
try {
await fetch(`/api/relay/tokens/${id}`, { method: "DELETE" });
- addNotification({ type: "success", message: "Token deleted" });
- fetchTokens();
+ addNotification({ type: "success", message: t("deleted") });
+ void fetchTokens();
} catch {
- addNotification({ type: "error", message: "Failed to delete token" });
+ addNotification({ type: "error", message: t("deleteFailed") });
}
};
@@ -99,13 +103,11 @@ export default function RelayProxyClient() {
-
Serverless Relay Proxies
-
- Create public API endpoints that proxy to OmniRoute with rate limiting and access control
-
+
{t("title")}
+
{t("description")}
setShowCreate(!showCreate)}>
- {showCreate ? "Cancel" : "New Relay Token"}
+ {showCreate ? t("cancel") : t("newToken")}
@@ -113,10 +115,10 @@ export default function RelayProxyClient() {
{showCreate && (
-
Create Relay Token
+
{t("createTitle")}
-
Create Token
+
+ {t("createButton")}
+
)}
@@ -162,18 +166,21 @@ export default function RelayProxyClient() {
- Token Created — Copy it now!
+ {t("createdTitle")}
-
Token for {newTokenData.name} :
+
+ {t.rich("tokenFor", {
+ name: newTokenData.name,
+ strong: (chunks) => {chunks} ,
+ })}
+
{newTokenData.rawToken}
-
- This token will not be shown again. Store it securely.
-
-
{ setNewTokenData(null); }}>Dismiss
+
{t("shownOnce")}
+
setNewTokenData(null)}>{t("dismiss")}
)}
@@ -181,12 +188,10 @@ export default function RelayProxyClient() {
{/* Usage Guide */}
-
Usage
-
- Send requests to your relay endpoint:
-
+
{t("usage")}
+
{t("usageDescription")}
-{`curl http://localhost:20128/v1/relay/chat/completions \\
+ {`curl http://localhost:20128/v1/relay/chat/completions \\
-H "Authorization: Bearer relay_..." \\
-H "Content-Type: application/json" \\
-d '{"model":"claude-sonnet-4","messages":[{"role":"user","content":"Hello"}]}'`}
@@ -198,40 +203,51 @@ export default function RelayProxyClient() {
- Relay Tokens ({tokens.length})
+ {t("tokenCount", { count: tokens.length })}
{loading ? (
-
Loading...
+
{t("loading")}
) : tokens.length === 0 ? (
-
No relay tokens configured. Create one to get started.
+
{t("empty")}
) : (
- {tokens.map((t) => (
-
+ {tokens.map((token) => (
+
-
+
-
{t.name}
-
{t.tokenPrefix}...
- {t.description && (
-
{t.description}
+
{token.name}
+
+ {token.tokenPrefix}...
+
+ {token.description && (
+
{token.description}
)}
- {t.maxRequestsPerMinute}/min
- {t.maxRequestsPerDay}/day
+
+ {token.maxRequestsPerMinute}/min
+
+
+ {token.maxRequestsPerDay}/day
+
toggleToken(t.id, !t.enabled)}
+ onClick={() => toggleToken(token.id, !token.enabled)}
className="text-xs text-primary hover:underline"
>
- {t.enabled ? "Disable" : "Enable"}
+ {token.enabled ? t("disable") : t("enable")}
deleteToken(t.id)}
+ onClick={() => deleteToken(token.id)}
className="text-xs text-red-500 hover:underline"
>
- Delete
+ {t("delete")}
diff --git a/src/app/(dashboard)/dashboard/runtime/components/ModelCooldownsCard.tsx b/src/app/(dashboard)/dashboard/runtime/components/ModelCooldownsCard.tsx
index fba5a80a73..32303c4e88 100644
--- a/src/app/(dashboard)/dashboard/runtime/components/ModelCooldownsCard.tsx
+++ b/src/app/(dashboard)/dashboard/runtime/components/ModelCooldownsCard.tsx
@@ -34,11 +34,11 @@ export default function ModelCooldownsCard() {
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
setItems(Array.isArray(json.items) ? json.items : []);
} catch (error) {
- notify.error(error instanceof Error ? error.message : "Failed to load cooldowns");
+ notify.error(error instanceof Error ? error.message : t("modelCooldownsLoadFailed"));
} finally {
setLoading(false);
}
- }, [notify]);
+ }, [notify, t]);
useEffect(() => {
void load();
@@ -60,15 +60,15 @@ export default function ModelCooldownsCard() {
});
const json = await res.json();
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
- notify.success(`Model reactivated: ${provider}/${model}`);
+ notify.success(t("modelCooldownReactivated", { model: `${provider}/${model}` }));
await load();
} catch (error) {
- notify.error(error instanceof Error ? error.message : "Failed to clear cooldown");
+ notify.error(error instanceof Error ? error.message : t("modelCooldownClearFailed"));
} finally {
setBusyKey(null);
}
},
- [load, notify]
+ [load, notify, t]
);
const clearAll = useCallback(async () => {
@@ -81,14 +81,14 @@ export default function ModelCooldownsCard() {
});
const json = await res.json();
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
- notify.success("All models in cooldown have been reactivated.");
+ notify.success(t("modelCooldownsAllReactivated"));
await load();
} catch (error) {
- notify.error(error instanceof Error ? error.message : "Failed to clear cooldowns");
+ notify.error(error instanceof Error ? error.message : t("modelCooldownsClearFailed"));
} finally {
setBusyKey(null);
}
- }, [load, notify]);
+ }, [load, notify, t]);
const hasItems = items.length > 0;
const sorted = useMemo(() => [...items].sort((a, b) => b.remainingMs - a.remainingMs), [items]);
@@ -98,14 +98,11 @@ export default function ModelCooldownsCard() {
{t("modelCooldownsTitle")}
-
- Models temporarily isolated after a failure. When the cooldown expires they come back
- automatically.
-
+
{t("modelCooldownsDescription")}
void load()} disabled={loading}>
- Refresh
+ {t("refresh")}
void clearAll()}
disabled={!hasItems || busyKey === "ALL"}
>
- Reactivate all
+ {t("modelCooldownsReactivateAll")}
{loading ? (
-
Loading...
+
{t("loading")}
) : !hasItems ? (
{t("modelCooldownsEmpty")}
) : (
@@ -136,7 +133,10 @@ export default function ModelCooldownsCard() {
{item.provider}/{item.model}
- reason: {item.reason} • remaining: {formatRemaining(item.remainingMs)}
+ {t("modelCooldownsReasonRemaining", {
+ reason: item.reason,
+ remaining: formatRemaining(item.remainingMs),
+ })}
void clearOne(item.provider, item.model)}
disabled={busyKey === rowKey}
>
- Reactivate
+ {t("modelCooldownsReactivate")}
);
diff --git a/src/app/(dashboard)/dashboard/search-tools/components/ResultsPanel.tsx b/src/app/(dashboard)/dashboard/search-tools/components/ResultsPanel.tsx
index 62293805e2..c8eac0b588 100644
--- a/src/app/(dashboard)/dashboard/search-tools/components/ResultsPanel.tsx
+++ b/src/app/(dashboard)/dashboard/search-tools/components/ResultsPanel.tsx
@@ -223,13 +223,13 @@ export default function ResultsPanel({
🔌
-
No active search provider
+
{t("noActiveProvider")}
- Configure more providers →
+ {t("configureMoreProviders")} →
)}
diff --git a/src/app/(dashboard)/dashboard/search-tools/components/ScrapeResult.tsx b/src/app/(dashboard)/dashboard/search-tools/components/ScrapeResult.tsx
index 638d5cb164..f2fff1bed0 100644
--- a/src/app/(dashboard)/dashboard/search-tools/components/ScrapeResult.tsx
+++ b/src/app/(dashboard)/dashboard/search-tools/components/ScrapeResult.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState, lazy, Suspense } from "react";
+import { useTranslations } from "next-intl";
import type { ScrapeResult as ScrapeResultType } from "@/shared/schemas/searchTools";
/** D21 — cap at 256 KB to avoid freezing the renderer */
@@ -8,10 +9,7 @@ const CONTENT_CAP_BYTES = 256 * 1024;
// Lazy-load MarkdownMessage to avoid increasing initial bundle size
const MarkdownMessage = lazy(
- () =>
- import(
- "@/app/(dashboard)/dashboard/playground/components/MarkdownMessage"
- ),
+ () => import("@/app/(dashboard)/dashboard/playground/components/MarkdownMessage")
);
interface ScrapeResultProps {
@@ -26,14 +24,13 @@ function formatBytes(bytes: number): string {
}
export default function ScrapeResult({ result, latencyMs }: ScrapeResultProps) {
+ const t = useTranslations("search");
const [mode, setMode] = useState<"markdown" | "raw">("markdown");
const [rawModalOpen, setRawModalOpen] = useState(false);
const contentSize = new TextEncoder().encode(result.content).length;
const isTruncated = contentSize > CONTENT_CAP_BYTES;
- const displayContent = isTruncated
- ? result.content.slice(0, CONTENT_CAP_BYTES)
- : result.content;
+ const displayContent = isTruncated ? result.content.slice(0, CONTENT_CAP_BYTES) : result.content;
return (
@@ -42,23 +39,23 @@ export default function ScrapeResult({ result, latencyMs }: ScrapeResultProps) {
{result.provider && (
- Provider:{" "}
+ {`${t("provider")}: `}
{result.provider}
)}
{latencyMs != null && (
- Latência:{" "}
+ {`${t("latency")}: `}
{latencyMs}ms
)}
- Tamanho:{" "}
+ {`${t("size")}: `}
{formatBytes(contentSize)}
{result.links.length > 0 && (
- Links:{" "}
+ {`${t("links")}: `}
{result.links.length}
)}
@@ -76,7 +73,7 @@ export default function ScrapeResult({ result, latencyMs }: ScrapeResultProps) {
onClick={() => setMode("markdown")}
data-testid="toggle-markdown"
>
- Preview
+ {t("scrapePreview")}
setMode("raw")}
data-testid="toggle-raw"
>
- Raw
+ {t("scrapeRaw")}
@@ -119,15 +116,13 @@ export default function ScrapeResult({ result, latencyMs }: ScrapeResultProps) {
className="flex items-center justify-between p-3 bg-warning/10 border border-warning/30 rounded-lg text-xs text-warning"
data-testid="truncation-warning"
>
-
- Conteúdo truncado a 256 KB (tamanho original: {formatBytes(contentSize)})
-
+
{t("contentTruncated", { size: formatBytes(contentSize) })}
setRawModalOpen(true)}
data-testid="view-raw-button"
>
- Ver raw completo
+ {t("viewFullRaw")}
)}
@@ -152,7 +147,7 @@ export default function ScrapeResult({ result, latencyMs }: ScrapeResultProps) {
value={displayContent}
className="w-full h-64 bg-surface border border-border rounded-lg p-3 text-xs text-text-main font-mono resize-none focus:outline-none"
data-testid="raw-content"
- aria-label="Raw scraped content"
+ aria-label={t("rawScrapedContent")}
/>
)}
@@ -165,12 +160,12 @@ export default function ScrapeResult({ result, latencyMs }: ScrapeResultProps) {
- Raw content — {formatBytes(contentSize)}
+ {t("rawContent", { size: formatBytes(contentSize) })}
setRawModalOpen(false)}
- aria-label="Close raw content modal"
+ aria-label={t("closeRawModal")}
>
✕
diff --git a/src/app/(dashboard)/dashboard/search-tools/components/SearchToolsConfigPane.tsx b/src/app/(dashboard)/dashboard/search-tools/components/SearchToolsConfigPane.tsx
index f804f2940e..58237ff7b9 100644
--- a/src/app/(dashboard)/dashboard/search-tools/components/SearchToolsConfigPane.tsx
+++ b/src/app/(dashboard)/dashboard/search-tools/components/SearchToolsConfigPane.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import { useTranslations } from "next-intl";
import { Select } from "@/shared/components";
import type { SearchProviderCatalogItem } from "@/shared/schemas/searchTools";
import type { ActiveTab } from "./SearchToolsTopBar";
@@ -28,6 +29,7 @@ export default function SearchToolsConfigPane({
activeTab,
rerankModels = [],
}: SearchToolsConfigPaneProps) {
+ const t = useTranslations("search");
const [historyExpanded, setHistoryExpanded] = useState(false);
const searchProviders = providers.filter((p) => p.kind === "search" && p.status !== "missing");
@@ -40,18 +42,18 @@ export default function SearchToolsConfigPane({
- Configuration
+ {t("configuration")}
{/* Provider selector */}
- Provider
+ {t("provider")}
({ value: p.id, label: p.name })),
]}
className="w-full"
@@ -69,14 +71,14 @@ export default function SearchToolsConfigPane({
{selectedProvider && (
- Cost:{" "}
+ {`${t("cost")}: `}
${selectedProvider.costPerQuery.toFixed(4)}/query
{selectedProvider.freeMonthlyQuota > 0 && (
- Free quota:{" "}
+ {`${t("freeQuota")}: `}
{selectedProvider.freeMonthlyQuota >= 1000
? `${(selectedProvider.freeMonthlyQuota / 1000).toFixed(0)}k`
@@ -86,7 +88,7 @@ export default function SearchToolsConfigPane({
)}
- Status:{" "}
+ {`${t("status")}: `}
{selectedProvider.status === "configured"
- ? "Configured"
+ ? t("configuredStatus")
: selectedProvider.status === "rate_limited"
- ? "Rate limited"
- : "Missing credential"}
+ ? t("rateLimitedStatus")
+ : t("noCredential")}
@@ -111,7 +113,7 @@ export default function SearchToolsConfigPane({
{activeTab === "search" && (
- Search type
+ {t("searchType")}
@@ -131,7 +133,7 @@ export default function SearchToolsConfigPane({
{activeTab === "scrape" && (
- Format
+ {t("scrapeFormat")}
@@ -152,7 +154,7 @@ export default function SearchToolsConfigPane({
onChange={(e) => onConfigChange({ fullPage: e.target.checked })}
className="rounded"
/>
- Full page
+ {t("scrapeFullPage")}
)}
@@ -160,9 +162,7 @@ export default function SearchToolsConfigPane({
{/* Compare tab options */}
{activeTab === "compare" && (
-
- Select up to 4 providers on the Compare tab to compare them side by side.
-
+
{t("compareProviderHint")}
)}
@@ -170,14 +170,14 @@ export default function SearchToolsConfigPane({
{activeTab === "search" && rerankModels.length > 0 && (
- Rerank model
+ {t("rerankModelLabel")}
) =>
onConfigChange({ rerankModel: e.target.value })
}
- options={[{ value: "", label: "None" }, ...rerankModels]}
+ options={[{ value: "", label: t("noneOption") }, ...rerankModels]}
className="w-full"
/>
@@ -191,16 +191,14 @@ export default function SearchToolsConfigPane({
aria-expanded={historyExpanded}
>
- History
+ {t("history")}
{historyExpanded ? "▼" : "▶"}
{historyExpanded && (
-
- History is available on the Search tab.
-
+
{t("historyHint")}
)}
diff --git a/src/app/(dashboard)/dashboard/search-tools/components/tabs/CompareTab.tsx b/src/app/(dashboard)/dashboard/search-tools/components/tabs/CompareTab.tsx
index 8b702028cd..59f172cf2c 100644
--- a/src/app/(dashboard)/dashboard/search-tools/components/tabs/CompareTab.tsx
+++ b/src/app/(dashboard)/dashboard/search-tools/components/tabs/CompareTab.tsx
@@ -77,9 +77,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
}, []);
const selectAll = useCallback(() => {
- setSelectedProviderIds(
- activeSearchProviders.slice(0, MAX_COMPARE_PROVIDERS).map((p) => p.id)
- );
+ setSelectedProviderIds(activeSearchProviders.slice(0, MAX_COMPARE_PROVIDERS).map((p) => p.id));
}, [activeSearchProviders]);
const clearAll = useCallback(() => {
@@ -113,7 +111,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
responseSize: 0,
urls: [],
results: [],
- error: data?.error?.message ?? `Error ${res.status}`,
+ error: data?.error?.message ?? t("httpError", { status: res.status }),
} as CompareResult;
}
@@ -141,7 +139,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
responseSize: 0,
urls: [],
results: [],
- error: err instanceof Error ? err.message : "Failed",
+ error: err instanceof Error ? err.message : t("failed"),
} as CompareResult;
}
})
@@ -158,7 +156,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
responseSize: 0,
urls: [],
results: [],
- error: "Request failed",
+ error: t("requestFailed"),
} as CompareResult)
);
setResults(resolved);
@@ -170,7 +168,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
const totalCost = valid.reduce((sum, r) => sum + r.cost, 0);
onMetrics?.(minLatency, totalCost);
}
- }, [query, selectedProviderIds, onMetrics]);
+ }, [query, selectedProviderIds, onMetrics, t]);
// Compute best/worst indices for column header coloring
const validResults = results.filter((r) => !r.error);
@@ -192,11 +190,9 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
⚖
-
- No active search provider. Configure one in Providers.
-
+ {t("noActiveProviderDescription")}
- Configure providers
+ {t("configureProviders")}
);
@@ -210,7 +206,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
htmlFor="compare-query"
className="block text-[10px] font-semibold text-text-muted uppercase tracking-wider"
>
- Query para comparar
+ {t("compareQuery")}
setQuery(e.target.value)}
- placeholder="artificial intelligence trends 2026"
+ placeholder={t("compareQueryPlaceholder")}
className="flex-1 bg-bg-alt border border-border rounded-lg px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/30"
onKeyDown={(e) => {
if (e.key === "Enter") void handleRun();
@@ -231,7 +227,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
disabled={loading || selectedProviderIds.length === 0 || !query.trim()}
data-testid="run-compare-button"
>
- {loading ? "Comparando..." : "Comparar"}
+ {loading ? t("compareRunning") : t("compareRun")}
@@ -239,7 +235,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
- Providers ({selectedProviderIds.length} selected):
+ {t("selectedProviders", { count: selectedProviderIds.length })}
- Select all
+ {t("selectAll")}
- Clear
+ {t("clear")}
{selectedProviderIds.length >= MAX_COMPARE_PROVIDERS && (
- Maximum of {MAX_COMPARE_PROVIDERS} providers can be compared at once.
+ {t("maxCompareProviders", { count: MAX_COMPARE_PROVIDERS })}
)}
@@ -310,7 +306,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
>
- Results — “{query}”
+ {t("compareResults", { query })}
@@ -340,7 +336,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
${cr.cost.toFixed(4)}
- {cr.resultCount} results
+ {t("resultCount", { count: cr.resultCount })}
{formatBytes(cr.responseSize)}
)}
@@ -354,7 +350,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
) : cr.results.length === 0 ? (
-
No results
+
{t("noResults")}
) : (
cr.results.map((r, idx) => {
@@ -365,8 +361,8 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
{isShared && (
⭐
@@ -405,15 +401,11 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
const baseUrls = results[0]?.urls ?? [];
return (
-
- {results[0]?.provider.replace("-search", "")}
-
- {" vs "}
-
- {cr.provider.replace("-search", "")}
-
- {": "}
- {cr.error ? "—" : computeOverlap(baseUrls, cr.urls)} in common
+ {t("overlapSummary", {
+ first: results[0]?.provider.replace("-search", "") ?? "",
+ second: cr.provider.replace("-search", ""),
+ overlap: cr.error ? "—" : computeOverlap(baseUrls, cr.urls),
+ })}
);
})}
@@ -432,12 +424,8 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
⚖
-
- Select providers and enter a query to compare
-
-
- Results will be shown side by side with latency, cost, and URL overlap
-
+
{t("compareEmptyTitle")}
+
{t("compareEmptyDescription")}
)}
diff --git a/src/app/(dashboard)/dashboard/search-tools/components/tabs/SearchTab.tsx b/src/app/(dashboard)/dashboard/search-tools/components/tabs/SearchTab.tsx
index efc5d5b6ad..cee02a3644 100644
--- a/src/app/(dashboard)/dashboard/search-tools/components/tabs/SearchTab.tsx
+++ b/src/app/(dashboard)/dashboard/search-tools/components/tabs/SearchTab.tsx
@@ -25,7 +25,11 @@ interface SearchResponse {
results: SearchResult[];
cached: boolean;
usage: { queries_used: number; search_cost_usd: number };
- metrics: { response_time_ms: number; upstream_latency_ms: number; total_results_available: number | null };
+ metrics: {
+ response_time_ms: number;
+ upstream_latency_ms: number;
+ total_results_available: number | null;
+ };
}
interface SearchProvider {
@@ -45,8 +49,15 @@ interface SearchTabProps {
}
import { useState, useRef } from "react";
+import { useTranslations } from "next-intl";
-export default function SearchTab({ configState, providers, catalogProviders, onMetrics }: SearchTabProps) {
+export default function SearchTab({
+ configState,
+ providers,
+ catalogProviders,
+ onMetrics,
+}: SearchTabProps) {
+ const t = useTranslations("search");
const [response, setResponse] = useState
(null);
const [rawJson, setRawJson] = useState("");
const [loading, setLoading] = useState(false);
@@ -92,7 +103,10 @@ export default function SearchTab({ configState, providers, catalogProviders, on
if (res.ok) {
setResponse(data);
- onMetrics?.(data.metrics?.response_time_ms ?? duration, data.usage?.search_cost_usd ?? null);
+ onMetrics?.(
+ data.metrics?.response_time_ms ?? duration,
+ data.usage?.search_cost_usd ?? null
+ );
} else {
setError(data.error?.message || data.error || `Error ${res.status}`);
onMetrics?.(duration, null);
@@ -100,9 +114,9 @@ export default function SearchTab({ configState, providers, catalogProviders, on
} catch (err: unknown) {
setDuration(Date.now() - start);
if (err instanceof Error && err.name === "AbortError") {
- setError("Request timed out after 15s");
+ setError(t("requestTimedOut", { seconds: 15 }));
} else {
- setError(err instanceof Error ? err.message : "Network error");
+ setError(err instanceof Error ? err.message : t("networkError"));
}
} finally {
setLoading(false);
@@ -114,7 +128,11 @@ export default function SearchTab({ configState, providers, catalogProviders, on
abortRef.current?.abort();
};
- const handleHistoryReplay = (entry: { query: string; provider: string; filters: Record }) => {
+ const handleHistoryReplay = (entry: {
+ query: string;
+ provider: string;
+ filters: Record;
+ }) => {
handleSearch({
query: entry.query,
provider: entry.provider || "",
@@ -158,7 +176,7 @@ export default function SearchTab({ configState, providers, catalogProviders, on
⇅
- Rerank
+ {t("rerank")}
)}
diff --git a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx
index db6249fa01..4b462861ec 100644
--- a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx
+++ b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx
@@ -30,7 +30,10 @@ export default function AppearanceTab() {
}, [isElectron]);
const [settings, setSettings] = useState>({});
const [loading, setLoading] = useState(true);
- const [uploadError, setUploadError] = useState(null);
+ const [uploadError, setUploadError] = useState<{
+ target: "logo" | "favicon";
+ message: string;
+ } | null>(null);
const [customThemeColor, setCustomThemeColor] = useState(customColor || "#3b82f6");
const isValidHex = /^#([0-9a-fA-F]{6})$/.test(
customThemeColor.startsWith("#") ? customThemeColor : `#${customThemeColor}`
@@ -189,9 +192,7 @@ export default function AppearanceTab() {
{getSettingsLabel("homePinProviderQuotaToHome", "Pin Information to Home Page")}
-
- Choose which sections to pin to the top of the Home page.
-
+ {t("homePinnedSectionsDesc")}
@@ -450,7 +451,7 @@ export default function AppearanceTab() {
disabled={loading || !autoRefreshProviderQuota}
className="h-10 w-28 px-3 rounded-lg bg-surface border border-border text-sm text-text-main focus:outline-none focus:border-primary disabled:opacity-50"
/>
- seconds
+ {t("seconds")}
@@ -591,7 +592,7 @@ export default function AppearanceTab() {
const file = e.target.files?.[0];
if (file) {
if (file.size > 500 * 1024) {
- setUploadError("Logo file must be less than 500KB");
+ setUploadError({ target: "logo", message: t("logoFileTooLarge") });
return;
}
const validTypes = [
@@ -602,15 +603,13 @@ export default function AppearanceTab() {
"image/webp",
];
if (!validTypes.includes(file.type)) {
- setUploadError(
- "Invalid file type. Please upload PNG, JPG, SVG, GIF, or WebP."
- );
+ setUploadError({ target: "logo", message: t("invalidLogoFileType") });
return;
}
setUploadError(null);
const reader = new FileReader();
reader.onerror = () => {
- setUploadError("Failed to read file");
+ setUploadError({ target: "logo", message: t("failedToReadFile") });
};
reader.onload = (event) => {
const base64 = event.target?.result as string;
@@ -634,7 +633,9 @@ export default function AppearanceTab() {
{t("resetLogo")}
- {uploadError &&
{uploadError}
}
+ {uploadError?.target === "logo" && (
+
{uploadError.message}
+ )}
{(settings.customLogoBase64 || settings.customLogoUrl) && (
{t("logoPreview")}
@@ -685,7 +686,10 @@ export default function AppearanceTab() {
const file = e.target.files?.[0];
if (file) {
if (file.size > 50 * 1024) {
- setUploadError("Favicon file must be less than 50KB");
+ setUploadError({
+ target: "favicon",
+ message: t("faviconFileTooLarge"),
+ });
return;
}
const validTypes = [
@@ -696,15 +700,16 @@ export default function AppearanceTab() {
"image/webp",
];
if (!validTypes.includes(file.type)) {
- setUploadError(
- "Invalid file type. Please upload PNG, ICO, SVG, GIF, or WebP."
- );
+ setUploadError({
+ target: "favicon",
+ message: t("invalidFaviconFileType"),
+ });
return;
}
setUploadError(null);
const reader = new FileReader();
reader.onerror = () => {
- setUploadError("Failed to read file");
+ setUploadError({ target: "favicon", message: t("failedToReadFile") });
};
reader.onload = (event) => {
const base64 = event.target?.result as string;
@@ -728,8 +733,8 @@ export default function AppearanceTab() {
{t("resetFavicon")}
- {uploadError && !uploadError.includes("Logo") && (
-
{uploadError}
+ {uploadError?.target === "favicon" && (
+
{uploadError.message}
)}
{(settings.customFaviconBase64 || settings.customFaviconUrl) && (
@@ -746,11 +751,8 @@ export default function AppearanceTab() {
{isElectron && (
-
Start on Login
-
- Automatically launch OmniRoute on system startup and run silently in the
- background tray.
-
+
{t("startOnLogin")}
+
{t("startOnLoginDesc")}
moveProviderOverride(provider, -1)}
disabled={index === 0}
className={`p-0.5 rounded ${index === 0 ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`}
- title="Move up"
+ title={t("moveUp")}
>
arrow_upward
@@ -770,7 +770,7 @@ export default function ComboDefaultsTab() {
onClick={() => moveProviderOverride(provider, 1)}
disabled={index === Object.keys(providerOverrides).length - 1}
className={`p-0.5 rounded ${index === Object.keys(providerOverrides).length - 1 ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`}
- title="Move down"
+ title={t("moveDown")}
>
arrow_downward
@@ -842,8 +842,8 @@ export default function ComboDefaultsTab() {
{filteredProviders.length === 0 ? (
{availableProviders.filter((p) => !providerOverrides[p.provider]).length === 0
- ? "All providers added"
- : "No providers found"}
+ ? t("allProvidersAdded")
+ : t("noProvidersFound")}
) : (
filteredProviders.map((p, idx) => (
diff --git a/src/app/(dashboard)/dashboard/settings/components/DatabaseBackupRetentionCard.tsx b/src/app/(dashboard)/dashboard/settings/components/DatabaseBackupRetentionCard.tsx
index 5ef11f490b..8cb8e3f6ae 100644
--- a/src/app/(dashboard)/dashboard/settings/components/DatabaseBackupRetentionCard.tsx
+++ b/src/app/(dashboard)/dashboard/settings/components/DatabaseBackupRetentionCard.tsx
@@ -1,6 +1,7 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
+import { useTranslations } from "next-intl";
import { Badge, Button } from "@/shared/components";
export type BackupCleanupOptions = {
@@ -73,33 +74,35 @@ export default function DatabaseBackupRetentionCard({
onSaveRetention,
onCleanupBackups,
}: DatabaseBackupRetentionCardProps) {
+ const t = useTranslations("settings");
+
return (
{title}
- Automatic SQLite backups are stored in db_backups. Configure how many
- snapshots to keep and optionally delete backups older than N days.
+ {t("storageBackupRetentionDescription")} db_backups.{" "}
+ {t("storageBackupRetentionHelp")}
- {storageHealth.backupCount || 0} backups
+ {t("storageBackupCount", { count: storageHealth.backupCount || 0 })}
- Max {storageHealth.backupRetention.maxFiles}
+ {t("storageBackupMaximum", { count: storageHealth.backupRetention.maxFiles })}
{storageHealth.backupRetention.days > 0
- ? `${storageHealth.backupRetention.days}d retention`
- : "Age retention off"}
+ ? t("storageBackupAgeRetention", { count: storageHealth.backupRetention.days })
+ : t("storageBackupAgeRetentionOff")}
- Keep latest backups
+ {t("storageBackupKeepLatest")}
- Delete older than days
+ {t("storageBackupDeleteOlderThan")}
save
- Save retention
+ {t("storageBackupSaveRetention")}
auto_delete
- Clean old backups
+ {t("storageBackupCleanOld")}
diff --git a/src/app/(dashboard)/dashboard/settings/components/FeatureFlagCard.tsx b/src/app/(dashboard)/dashboard/settings/components/FeatureFlagCard.tsx
index 3443b6063a..27ca396b8b 100644
--- a/src/app/(dashboard)/dashboard/settings/components/FeatureFlagCard.tsx
+++ b/src/app/(dashboard)/dashboard/settings/components/FeatureFlagCard.tsx
@@ -1,5 +1,7 @@
"use client";
+import { useTranslations } from "next-intl";
+
interface FeatureFlagCardProps {
flag: {
key: string;
@@ -20,43 +22,37 @@ interface FeatureFlagCardProps {
const CATEGORY_STYLES: Record<
FeatureFlagCardProps["flag"]["category"],
- { bg: string; border: string; text: string; label: string }
+ { bg: string; border: string; text: string }
> = {
security: {
bg: "bg-red-50 dark:bg-red-500/15",
border: "border-red-200 dark:border-red-500/20",
text: "text-red-700 dark:text-red-300",
- label: "Security",
},
network: {
bg: "bg-sky-50 dark:bg-blue-500/15",
border: "border-sky-200 dark:border-blue-500/20",
text: "text-sky-700 dark:text-blue-300",
- label: "Network",
},
policies: {
bg: "bg-amber-50 dark:bg-amber-500/15",
border: "border-amber-200 dark:border-amber-500/20",
text: "text-amber-700 dark:text-amber-300",
- label: "Policies",
},
runtime: {
bg: "bg-violet-50 dark:bg-purple-500/15",
border: "border-violet-200 dark:border-purple-500/20",
text: "text-violet-700 dark:text-purple-300",
- label: "Runtime",
},
cli: {
bg: "bg-emerald-50 dark:bg-green-500/15",
border: "border-emerald-200 dark:border-green-500/20",
text: "text-emerald-700 dark:text-green-300",
- label: "CLI",
},
health: {
bg: "bg-cyan-50 dark:bg-cyan-500/15",
border: "border-cyan-200 dark:border-cyan-500/20",
text: "text-cyan-700 dark:text-cyan-300",
- label: "Health",
},
};
@@ -103,6 +99,7 @@ export default function FeatureFlagCard({
onReset,
saving = false,
}: FeatureFlagCardProps) {
+ const t = useTranslations("featureFlags");
const enabled = flag.type === "boolean" ? isEnabled(flag.effectiveValue) : false;
const category = CATEGORY_STYLES[flag.category];
const source = SOURCE_STYLES[flag.source];
@@ -121,10 +118,10 @@ export default function FeatureFlagCard({
{/* Top row: category badge + toggle/select */}
- {category.label}
+ {t(`categories.${flag.category}`)}
@@ -158,7 +155,7 @@ export default function FeatureFlagCard({
>
{(flag.enumValues ?? []).map((val) => (
- {val}
+ {t.has(`enumValues.${val}`) ? t(`enumValues.${val}`) : val}
))}
@@ -173,20 +170,20 @@ export default function FeatureFlagCard({
{flag.warningLevel === "caution" && (
-
+
⚠️
)}
{flag.warningLevel === "danger" && (
-
+
🔴
)}
{flag.requiresRestart && (
restart
@@ -199,7 +196,7 @@ export default function FeatureFlagCard({
{/* Bottom row: source badge + reset button */}
- Source:
+ {t("source")}:
@@ -209,7 +206,7 @@ export default function FeatureFlagCard({
{flag.source === "db" && (
onReset(flag.key)}
className="inline-flex items-center gap-1 rounded text-xs text-text-muted transition-colors hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
@@ -217,7 +214,7 @@ export default function FeatureFlagCard({
refresh
- Reset
+ {t("reset")}
)}
diff --git a/src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx b/src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx
index d4989165be..4ea5684aa9 100644
--- a/src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx
+++ b/src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
+import { useTranslations } from "next-intl";
import { matchesSearch } from "@/shared/utils/turkishText";
import FeatureFlagCard from "./FeatureFlagCard";
@@ -42,19 +43,20 @@ function isActiveFlagValue(value: string): boolean {
}
const CATEGORIES = [
- { value: "all", label: "All" },
- { value: "security", label: "Security" },
- { value: "network", label: "Network" },
- { value: "policies", label: "Policies" },
- { value: "runtime", label: "Runtime" },
- { value: "cli", label: "CLI" },
- { value: "health", label: "Health" },
+ { value: "all", labelKey: "all" },
+ { value: "security", labelKey: "security" },
+ { value: "network", labelKey: "network" },
+ { value: "policies", labelKey: "policies" },
+ { value: "runtime", labelKey: "runtime" },
+ { value: "cli", labelKey: "cli" },
+ { value: "health", labelKey: "health" },
// Synthetic "category" that filters by requiresRestart=true regardless of
// real category — surfaces flags that need a process restart to take effect.
- { value: "__restart", label: "Requires Restart" },
+ { value: "__restart", labelKey: "requiresRestart" },
];
export default function FeatureFlagsGrid() {
+ const t = useTranslations("featureFlags");
const [flags, setFlags] = useState
([]);
const [summary, setSummary] = useState(null);
const [loading, setLoading] = useState(true);
@@ -81,14 +83,15 @@ export default function FeatureFlagsGrid() {
setFlags(data.flags);
setSummary(data.summary);
} catch (err) {
- setError(err instanceof Error ? err.message : "Failed to load feature flags");
+ setError(err instanceof Error ? err.message : t("loadFailed"));
} finally {
setLoading(false);
}
- }, []);
+ }, [t]);
useEffect(() => {
- loadFlags();
+ const timer = window.setTimeout(() => void loadFlags(), 0);
+ return () => window.clearTimeout(timer);
}, [loadFlags]);
useEffect(() => {
@@ -133,9 +136,11 @@ export default function FeatureFlagsGrid() {
(f) =>
debouncedSearch === "" ||
matchesSearch(f.key, debouncedSearch) ||
- matchesSearch(f.description, debouncedSearch)
+ matchesSearch(f.description, debouncedSearch) ||
+ (t.has(`definitions.${f.key}.description`) &&
+ matchesSearch(t(`definitions.${f.key}.description`), debouncedSearch))
);
- }, [flags, debouncedSearch, category]);
+ }, [flags, debouncedSearch, category, t]);
const handleToggle = useCallback(
async (key: string, newValue: string) => {
@@ -147,7 +152,7 @@ export default function FeatureFlagsGrid() {
body: JSON.stringify({ key, value: newValue }),
});
if (!res.ok) {
- setError(`Failed to update flag: HTTP ${res.status}`);
+ setError(t("updateFailedHttp", { status: res.status }));
return;
}
const result = (await res.json()) as FlagUpdateResult;
@@ -156,7 +161,7 @@ export default function FeatureFlagsGrid() {
setPendingRestartKeys((prev) => new Set(prev).add(key));
}
} catch (err) {
- setError(err instanceof Error ? err.message : "Failed to update flag");
+ setError(err instanceof Error ? err.message : t("updateFailed"));
} finally {
setSavingKeys((prev) => {
const next = new Set(prev);
@@ -165,7 +170,7 @@ export default function FeatureFlagsGrid() {
});
}
},
- [applyFlagResult]
+ [applyFlagResult, t]
);
const handleReset = useCallback(
@@ -178,7 +183,7 @@ export default function FeatureFlagsGrid() {
body: JSON.stringify({ key }), // no value = remove override
});
if (!res.ok) {
- setError(`Failed to update flag: HTTP ${res.status}`);
+ setError(t("updateFailedHttp", { status: res.status }));
return;
}
const result = (await res.json()) as FlagUpdateResult;
@@ -187,7 +192,7 @@ export default function FeatureFlagsGrid() {
setPendingRestartKeys((prev) => new Set(prev).add(key));
}
} catch (err) {
- setError(err instanceof Error ? err.message : "Failed to update flag");
+ setError(err instanceof Error ? err.message : t("updateFailed"));
} finally {
setSavingKeys((prev) => {
const next = new Set(prev);
@@ -196,7 +201,7 @@ export default function FeatureFlagsGrid() {
});
}
},
- [applyFlagResult]
+ [applyFlagResult, t]
);
const handleRestart = useCallback(async () => {
@@ -204,7 +209,7 @@ export default function FeatureFlagsGrid() {
try {
const res = await fetch("/api/restart", { method: "POST" });
if (!res.ok) {
- setError(`Restart failed: HTTP ${res.status}`);
+ setError(t("restartFailedHttp", { status: res.status }));
setShowRestartConfirm(false);
setRestarting(false);
return;
@@ -231,47 +236,49 @@ export default function FeatureFlagsGrid() {
}
}, 500);
} catch (err) {
- setError(err instanceof Error ? err.message : "Restart failed");
+ setError(err instanceof Error ? err.message : t("restartFailed"));
setShowRestartConfirm(false);
setRestarting(false);
}
- }, []);
+ }, [t]);
const handleResetAll = useCallback(async () => {
setResettingAll(true);
try {
const res = await fetch("/api/settings/feature-flags", { method: "DELETE" });
if (!res.ok) {
- setError(`Failed to reset overrides: HTTP ${res.status}`);
+ setError(t("resetOverridesFailedHttp", { status: res.status }));
setShowResetConfirm(false);
return;
}
await loadFlags();
setShowResetConfirm(false);
} catch (err) {
- setError(err instanceof Error ? err.message : "Failed to reset overrides");
+ setError(err instanceof Error ? err.message : t("resetOverridesFailed"));
setShowResetConfirm(false);
} finally {
setResettingAll(false);
}
- }, [loadFlags]);
+ }, [loadFlags, t]);
return (
{/* Header */}
-
Feature Flags
+
{t("title")}
{summary && (
- {summary.active} active
+ {t("activeCount", { count: summary.active })}
·
- {summary.inactive} inactive
+
+ {t("inactiveCount", { count: summary.inactive })}
+
·
- {summary.overriddenByDb} DB overrides
+ {t("dbOverrideCount", { count: summary.overriddenByDb })}
)}
@@ -286,7 +293,7 @@ export default function FeatureFlagsGrid() {
setSearch(e.target.value)}
className="w-full rounded-lg border border-border bg-bg-subtle py-1.5 pl-8 pr-4 text-sm text-text-primary placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/15 sm:w-64"
@@ -301,7 +308,7 @@ export default function FeatureFlagsGrid() {
>
{CATEGORIES.map((cat) => (
- {cat.label}
+ {t(`categories.${cat.labelKey}`)}
))}
@@ -319,12 +326,10 @@ export default function FeatureFlagsGrid() {
- {pendingRestartKeys.size} change{pendingRestartKeys.size === 1 ? "" : "s"} require
- a server restart to take effect.
+ {t("restartRequiredCount", { count: pendingRestartKeys.size })}
- These flags only apply after the process reloads. Restart now or continue editing
- — pending flags stay queued until you confirm.
+ {t("restartRequiredDescription")}
@@ -333,7 +338,7 @@ export default function FeatureFlagsGrid() {
onClick={() => setShowRestartConfirm(true)}
className="shrink-0 rounded-lg border border-amber-300 bg-white/70 px-4 py-2 text-sm font-medium text-amber-800 transition-colors hover:bg-amber-100 dark:border-amber-400/40 dark:bg-transparent dark:text-amber-300 dark:hover:bg-amber-500/20"
>
- Restart Server
+ {t("restartServer")}
) : (
@@ -342,14 +347,14 @@ export default function FeatureFlagsGrid() {
className="text-sm text-amber-800/80 hover:text-amber-950 dark:text-amber-300/80 dark:hover:text-amber-200"
disabled={restarting}
>
- Cancel
+ {t("cancel")}
- {restarting ? "Restarting…" : "Confirm Restart"}
+ {restarting ? t("restarting") : t("confirmRestart")}
)}
@@ -363,9 +368,7 @@ export default function FeatureFlagsGrid() {
info
- These flags only take effect after the server restarts. Toggle them like any other
- flag — the change is persisted immediately, but the new value is only read at process
- startup. Use the Restart Server banner above to apply.
+ {t.rich("restartViewDescription", { strong: (chunks) => {chunks} })}
@@ -394,7 +397,7 @@ export default function FeatureFlagsGrid() {
onClick={loadFlags}
className="text-sm font-medium text-red-700 underline hover:no-underline dark:text-red-300"
>
- Retry
+ {t("retry")}
)}
@@ -405,7 +408,7 @@ export default function FeatureFlagsGrid() {
{filteredFlags.length === 0 ? (
search_off
-
No flags match your search
+
{t("noSearchResults")}
) : (
(
setShowResetConfirm(true)}
className="rounded-lg border border-red-200 bg-red-50/60 px-4 py-2 text-sm font-medium text-red-700 transition-colors hover:bg-red-100 dark:border-red-500/40 dark:bg-transparent dark:text-red-300 dark:hover:bg-red-500/10"
>
- Reset All Overrides
+ {t("resetAllOverrides")}
) : (
- Reset all {summary.overriddenByDb} DB override(s)?
+ {t("confirmResetOverrides", { count: summary.overriddenByDb })}
setShowResetConfirm(false)}
className="text-sm text-text-muted hover:text-text-primary"
>
- Cancel
+ {t("cancel")}
- {resettingAll ? "Resetting..." : "Confirm Reset"}
+ {resettingAll ? t("resetting") : t("confirmReset")}
)}
diff --git a/src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx b/src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx
index f686776fc3..3850268653 100644
--- a/src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx
+++ b/src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx
@@ -23,8 +23,6 @@ const DEFAULTS: ModelLockoutSettings = {
useExponentialBackoff: true,
};
-
-
function NumberField({
label,
value,
@@ -91,23 +89,17 @@ export default function ModelLockoutCard() {
if (!mounted) return;
const raw = (json as Record).modelLockout as
- | Record
- | undefined;
+ Record | undefined;
const parsed: ModelLockoutSettings = {
- enabled:
- typeof raw?.enabled === "boolean" ? raw.enabled : DEFAULTS.enabled,
+ enabled: typeof raw?.enabled === "boolean" ? raw.enabled : DEFAULTS.enabled,
errorCodes: Array.isArray(raw?.errorCodes)
? [...(raw.errorCodes as number[])].sort((a, b) => a - b)
: [...DEFAULTS.errorCodes].sort((a, b) => a - b),
baseCooldownMs:
- typeof raw?.baseCooldownMs === "number"
- ? raw.baseCooldownMs
- : DEFAULTS.baseCooldownMs,
+ typeof raw?.baseCooldownMs === "number" ? raw.baseCooldownMs : DEFAULTS.baseCooldownMs,
maxCooldownMs:
- typeof raw?.maxCooldownMs === "number"
- ? raw.maxCooldownMs
- : DEFAULTS.maxCooldownMs,
+ typeof raw?.maxCooldownMs === "number" ? raw.maxCooldownMs : DEFAULTS.maxCooldownMs,
maxBackoffSteps:
typeof raw?.maxBackoffSteps === "number"
? raw.maxBackoffSteps
@@ -122,11 +114,7 @@ export default function ModelLockoutCard() {
setDraft(parsed);
setErrorCodesInput("");
} catch (error) {
- notify.error(
- error instanceof Error
- ? error.message
- : "Failed to load model lockout settings"
- );
+ notify.error(error instanceof Error ? error.message : t("modelLockoutLoadFailed"));
} finally {
if (mounted) setLoading(false);
}
@@ -136,7 +124,7 @@ export default function ModelLockoutCard() {
return () => {
mounted = false;
};
- }, []);
+ }, [notify, t]);
const hasChanges =
draft.enabled !== data.enabled ||
@@ -149,13 +137,10 @@ export default function ModelLockoutCard() {
function validateDraft(d: ModelLockoutSettings): string | null {
if (d.baseCooldownMs < 5000 || d.baseCooldownMs > 600000)
- return `Base Cooldown must be between 5,000ms and 600,000ms`;
- if (d.maxCooldownMs < 5000 || d.maxCooldownMs > 3600000)
- return `Max Cooldown must be between 5,000ms and 3,600,000ms`;
- if (d.maxCooldownMs < d.baseCooldownMs)
- return `Max Cooldown must be ≥ Base Cooldown`;
- if (d.maxBackoffSteps < 0 || d.maxBackoffSteps > 20)
- return `Max Backoff Steps must be between 0 and 20`;
+ return t("modelLockoutBaseRangeError");
+ if (d.maxCooldownMs < 5000 || d.maxCooldownMs > 3600000) return t("modelLockoutMaxRangeError");
+ if (d.maxCooldownMs < d.baseCooldownMs) return t("modelLockoutOrderError");
+ if (d.maxBackoffSteps < 0 || d.maxBackoffSteps > 20) return t("modelLockoutStepsRangeError");
return null;
}
@@ -178,10 +163,10 @@ export default function ModelLockoutCard() {
const issues = err?.error?.issues ?? err?.error?.details;
if (Array.isArray(issues) && issues.length > 0) {
const fieldLabels: Record = {
- "modelLockout.baseCooldownMs": "Base Cooldown",
- "modelLockout.maxCooldownMs": "Max Cooldown",
- "modelLockout.maxBackoffSteps": "Max Backoff Steps",
- "modelLockout.errorCodes": "Error Codes",
+ "modelLockout.baseCooldownMs": t("modelLockoutBaseCooldown"),
+ "modelLockout.maxCooldownMs": t("modelLockoutMaxCooldown"),
+ "modelLockout.maxBackoffSteps": t("modelLockoutMaxBackoffSteps"),
+ "modelLockout.errorCodes": t("modelLockoutErrorCodes"),
};
const msg = issues
.map(
@@ -192,29 +177,21 @@ export default function ModelLockoutCard() {
.join("\n");
if (msg) throw new Error(msg);
}
- throw new Error(
- err?.error?.message || `HTTP ${res.status}`
- );
+ throw new Error(err?.error?.message || `HTTP ${res.status}`);
}
const json = await res.json();
const raw = (json as Record).modelLockout as
- | Record
- | undefined;
+ Record | undefined;
if (raw) {
setData({
- enabled:
- typeof raw.enabled === "boolean" ? raw.enabled : saveDraft.enabled,
+ enabled: typeof raw.enabled === "boolean" ? raw.enabled : saveDraft.enabled,
errorCodes: Array.isArray(raw.errorCodes)
? [...(raw.errorCodes as number[])].sort((a, b) => a - b)
: [...saveDraft.errorCodes].sort((a, b) => a - b),
baseCooldownMs:
- typeof raw.baseCooldownMs === "number"
- ? raw.baseCooldownMs
- : saveDraft.baseCooldownMs,
+ typeof raw.baseCooldownMs === "number" ? raw.baseCooldownMs : saveDraft.baseCooldownMs,
maxCooldownMs:
- typeof raw.maxCooldownMs === "number"
- ? raw.maxCooldownMs
- : saveDraft.maxCooldownMs,
+ typeof raw.maxCooldownMs === "number" ? raw.maxCooldownMs : saveDraft.maxCooldownMs,
maxBackoffSteps:
typeof raw.maxBackoffSteps === "number"
? raw.maxBackoffSteps
@@ -228,13 +205,9 @@ export default function ModelLockoutCard() {
setData(saveDraft);
}
setErrorCodesInput("");
- notify.success(t("savedSuccessfully") || "Settings saved successfully");
+ notify.success(t("savedSuccessfully"));
} catch (error) {
- notify.error(
- error instanceof Error
- ? error.message
- : "Failed to save model lockout settings"
- );
+ notify.error(error instanceof Error ? error.message : t("modelLockoutSaveFailed"));
} finally {
setSaving(false);
}
@@ -253,12 +226,18 @@ export default function ModelLockoutCard() {
setErrorCodesInput("");
return;
}
- setDraft((prev) => ({ ...prev, errorCodes: [...prev.errorCodes, code].sort((a, b) => a - b) }));
+ setDraft((prev) => ({
+ ...prev,
+ errorCodes: [...prev.errorCodes, code].sort((a, b) => a - b),
+ }));
setErrorCodesInput("");
};
const removeErrorCode = (code: number) => {
- setDraft((prev) => ({ ...prev, errorCodes: prev.errorCodes.filter((c) => c !== code) }));
+ setDraft((prev) => ({
+ ...prev,
+ errorCodes: prev.errorCodes.filter((c) => c !== code),
+ }));
};
const handleResetDefaults = () => {
@@ -286,17 +265,17 @@ export default function ModelLockoutCard() {
notifyRef.current.volume = 0.3;
}
void notifyRef.current.play();
- } catch { /* audio not available */ }
+ } catch {
+ // Audio is optional.
+ }
}, []);
if (loading) {
return (
-
- progress_activity
-
- Loading model lockout settings...
+ progress_activity
+ {t("modelLockoutLoading")}
);
@@ -307,39 +286,23 @@ export default function ModelLockoutCard() {
-
- gpp_maybe
-
-
- {t("modelLockout") || "Model Lockout"}
-
+ gpp_maybe
+ {t("modelLockout")}
-
- {t("modelLockoutPageDescription")}
-
+
{t("modelLockoutPageDescription")}
{hasChanges ? (
{tc("cancel")}
-
- {saving ? tc("saving") || "Saving..." : tc("save")}
+
+ {saving ? tc("saving") : tc("save")}
) : (
-
- Reset defaults
+
+ {t("resetDefaults")}
)}
@@ -382,7 +345,7 @@ export default function ModelLockoutCard() {
type="button"
onClick={() => removeErrorCode(code)}
className="inline-flex size-4 items-center justify-center rounded-sm hover:bg-primary/20 transition-colors"
- aria-label={`Remove ${code}`}
+ aria-label={t("removeErrorCode", { code })}
>
close
@@ -407,7 +370,7 @@ export default function ModelLockoutCard() {
commitErrorCodes();
}
}}
- placeholder="Add error code..."
+ placeholder={t("addErrorCode")}
className="w-32 rounded-lg border border-border bg-bg px-3 py-2 text-sm outline-none focus:border-primary transition-colors placeholder:text-text-muted/50"
/>
- Add
+ {tc("add")}
{/* Suggested common codes — chips as clickable suggestions */}
{draft.errorCodes.length === 0 && errorCodesInput === "" && (
-
Suggestions:
+
{t("suggestions")}
{[403, 404, 429, 502, 503, 504].map((code) => (
- setDraft((prev) => ({ ...prev, baseCooldownMs }))
- }
+ onChange={(baseCooldownMs) => setDraft((prev) => ({ ...prev, baseCooldownMs }))}
/>
{t("modelLockoutBaseCooldownDescription")}
@@ -464,10 +425,8 @@ export default function ModelLockoutCard() {
min={draft.baseCooldownMs}
max={3600000}
suffix="ms"
- hint="≥ Base Cooldown — 3,600,000ms"
- onChange={(maxCooldownMs) =>
- setDraft((prev) => ({ ...prev, maxCooldownMs }))
- }
+ hint={t("modelLockoutMaxCooldownHint")}
+ onChange={(maxCooldownMs) => setDraft((prev) => ({ ...prev, maxCooldownMs }))}
/>
{t("modelLockoutMaxCooldownDescription")}
@@ -497,9 +456,7 @@ export default function ModelLockoutCard() {
label={t("modelLockoutMaxBackoffSteps")}
value={draft.maxBackoffSteps}
min={0}
- onChange={(maxBackoffSteps) =>
- setDraft((prev) => ({ ...prev, maxBackoffSteps }))
- }
+ onChange={(maxBackoffSteps) => setDraft((prev) => ({ ...prev, maxBackoffSteps }))}
/>
{t("modelLockoutMaxBackoffStepsDescription")}
diff --git a/src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx
index eb0a62d654..967c8d13ec 100644
--- a/src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx
+++ b/src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx
@@ -588,11 +588,11 @@ export default function PricingTab() {
setCoverageFilter(v as CoverageFilter)}
options={[
- { value: "all", label: `All (${allProviders.length})` },
+ { value: "all", label: `${t("pricingAll")} (${allProviders.length})` },
{ value: "lt50", label: "<50%" },
{ value: "gte50lt100", label: "50–99%" },
{ value: "full", label: "100%" },
@@ -600,26 +600,29 @@ export default function PricingTab() {
/>
setAuthFilter(v as AuthFilter)}
options={[
- { value: "all", label: "All" },
+ { value: "all", label: t("pricingAll") },
{ value: "oauth", label: `OAuth (${authCounts.oauth})` },
{ value: "apikey", label: `API Key (${authCounts.apikey})` },
- { value: "unknown", label: `Unknown (${authCounts.unknown})` },
+ {
+ value: "unknown",
+ label: `${t("pricingAuthUnknown")} (${authCounts.unknown})`,
+ },
]}
/>
setSortKey(v as SortKey)}
options={[
- { value: "modelsDesc", label: "Most models" },
- { value: "coverageDesc", label: "Highest coverage" },
- { value: "coverageAsc", label: "Lowest coverage" },
- { value: "nameAsc", label: "Name (A–Z)" },
+ { value: "modelsDesc", label: t("pricingMostModels") },
+ { value: "coverageDesc", label: t("pricingHighestCoverage") },
+ { value: "coverageAsc", label: t("pricingLowestCoverage") },
+ { value: "nameAsc", label: t("pricingNameAscending") },
]}
/>
@@ -636,7 +639,7 @@ export default function PricingTab() {
}`}
>
warning
- Coverage gaps ({coverageGapCount})
+ {t("pricingCoverageGaps")} ({coverageGapCount})
{(searchQuery ||
coverageFilter !== "all" ||
@@ -655,12 +658,16 @@ export default function PricingTab() {
className="inline-flex items-center gap-1 px-2 py-1 rounded-md border border-border bg-bg-subtle text-xs text-text-muted hover:text-text-main cursor-pointer"
>
close
- Clear filters
+ {t("pricingClearFilters")}
)}
- Showing {displayProviders.length} of {totalFiltered}
- {totalFiltered !== allProviders.length && ` (filtered from ${allProviders.length})`}
+ {t("pricingShowingProviders", {
+ visible: displayProviders.length,
+ total: totalFiltered,
+ })}
+ {totalFiltered !== allProviders.length &&
+ ` ${t("pricingFilteredFrom", { count: allProviders.length })}`}
@@ -697,8 +704,10 @@ export default function PricingTab() {
className="mt-2 mx-auto px-4 py-2 rounded-md border border-border bg-bg-subtle hover:bg-black/[0.04] dark:hover:bg-white/[0.04] text-sm text-text-main cursor-pointer flex items-center gap-1.5"
>
expand_more
- Show {Math.min(VISIBLE_INCREMENT, totalFiltered - visibleCount)} more (
- {totalFiltered - visibleCount} remaining)
+ {t("pricingShowMoreProviders", {
+ count: Math.min(VISIBLE_INCREMENT, totalFiltered - visibleCount),
+ remaining: totalFiltered - visibleCount,
+ })}
)}
diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx
index 6f7cfc6189..e5ce799907 100644
--- a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx
+++ b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx
@@ -9,7 +9,11 @@ import { ProxyStatusBadge } from "./ProxyStatusBadge";
import { ProxyHealthCell } from "./ProxyHealthCell";
import { ProxyBatchActions } from "./ProxyBatchActions";
import { ProxyCheckboxCell } from "./ProxyCheckboxCell";
-import { parseBulkImportText, type ParsedProxyEntry, type ParseError } from "./parseBulkProxyImport";
+import {
+ parseBulkImportText,
+ type ParsedProxyEntry,
+ type ParseError,
+} from "./parseBulkProxyImport";
import { POOL_STRATEGY_OPTIONS, isPoolStrategy, type PoolStrategy } from "./proxyStrategyOptions";
import type { ProxyItem } from "./proxyRegistryTypes";
@@ -90,7 +94,6 @@ const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import
# 200.234.177.62:50101:otheruser:otherpass
#`;
-
export default function ProxyRegistryManager({
onRedeployRelay,
}: {
@@ -827,7 +830,7 @@ export default function ProxyRegistryManager({
!allSelected && items.some((item) => selectedIds.has(item.id));
}}
onChange={() => hookToggleSelectAll(allSelected, items)}
- aria-label="Select all proxies"
+ aria-label={t("selectAllProxies")}
/>
{t("tableName")}
@@ -846,7 +849,7 @@ export default function ProxyRegistryManager({
toggleSelect(item.id)}
- label={`Select ${item.name}`}
+ label={t("selectProxy", { name: item.name })}
/>
{item.name}
diff --git a/src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx b/src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx
index 562e0d2881..8a756b76f2 100644
--- a/src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx
+++ b/src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx
@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { Card, Button, Input, Toggle, Modal } from "@/shared/components";
import { AI_PROVIDERS } from "@/shared/constants/providers";
+import ProviderIcon from "@/shared/components/ProviderIcon";
import IPFilterSection from "./IPFilterSection";
import SessionInfoCard from "./SessionInfoCard";
import AuthzSection from "./AuthzSection";
@@ -358,12 +359,16 @@ export default function SecurityTab() {
: t("blockProviderTitle", { provider: provider.name })
}
>
-
- {isBlocked ? "block" : provider.icon}
-
+ {isBlocked ? (
+ block
+ ) : (
+
+ )}
{provider.name}
{isBlocked && (
diff --git a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx
index e7a382026d..e67ca9b827 100644
--- a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx
+++ b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx
@@ -178,13 +178,13 @@ export default function SystemStorageTab() {
if (res.ok) {
setBackupRetentionStatus({
type: "success",
- message: "Backup retention saved.",
+ message: t("backupRetentionSaved"),
});
await loadStorageHealth();
} else {
setBackupRetentionStatus({
type: "error",
- message: data.error || "Failed to save backup retention",
+ message: data.error || t("backupRetentionSaveFailed"),
});
}
} catch {
@@ -207,14 +207,17 @@ export default function SystemStorageTab() {
if (res.ok) {
setCleanupBackupsStatus({
type: "success",
- message: `Deleted ${data.deletedBackupFamilies} backup set(s) and ${data.deletedFiles} file(s).`,
+ message: t("backupCleanupSuccess", {
+ backups: data.deletedBackupFamilies,
+ files: data.deletedFiles,
+ }),
});
await loadStorageHealth();
if (backupsExpanded) await loadBackups();
} else {
setCleanupBackupsStatus({
type: "error",
- message: data.error || "Failed to clean database backups",
+ message: data.error || t("backupCleanupFailed"),
});
}
} catch {
@@ -258,7 +261,7 @@ export default function SystemStorageTab() {
const deleted = data?.deleted ?? 0;
setPurgeLogsStatus({
type: "success",
- message: t("logsDeleted", { count: deleted }) || `Purged ${deleted} expired log(s)`,
+ message: t("logsDeleted", { count: deleted }),
});
} else {
setPurgeLogsStatus({
@@ -282,12 +285,12 @@ export default function SystemStorageTab() {
if (res.ok) {
setPurgeQuotaSnapshotsStatus({
type: "success",
- message: `Purged ${data.deleted} quota snapshots`,
+ message: t("purgeQuotaSnapshotsSuccess", { count: data.deleted }),
});
} else {
setPurgeQuotaSnapshotsStatus({
type: "error",
- message: data.error || "Failed to purge quota snapshots",
+ message: data.error || t("purgeQuotaSnapshotsFailed"),
});
}
} catch {
@@ -306,12 +309,12 @@ export default function SystemStorageTab() {
if (res.ok) {
setPurgeCallLogsStatus({
type: "success",
- message: `Purged ${data.deleted} call logs`,
+ message: t("purgeCallLogsSuccess", { count: data.deleted }),
});
} else {
setPurgeCallLogsStatus({
type: "error",
- message: data.error || "Failed to purge call logs",
+ message: data.error || t("purgeCallLogsFailed"),
});
}
} catch {
@@ -330,12 +333,12 @@ export default function SystemStorageTab() {
if (res.ok) {
setPurgeDetailedLogsStatus({
type: "success",
- message: `Purged ${data.deleted} detailed logs`,
+ message: t("purgeDetailedLogsSuccess", { count: data.deleted }),
});
} else {
setPurgeDetailedLogsStatus({
type: "error",
- message: data.error || "Failed to purge detailed logs",
+ message: data.error || t("purgeDetailedLogsFailed"),
});
}
} catch {
@@ -396,14 +399,14 @@ export default function SystemStorageTab() {
if (res.ok && data?.success !== false) {
setManualVacuumStatus({
type: "success",
- message: data?.message || "VACUUM completed",
+ message: data?.message || t("vacuumCompleted"),
});
await loadDatabaseSettings();
await loadStorageHealth();
} else {
setManualVacuumStatus({
type: "error",
- message: data?.error || "VACUUM failed",
+ message: data?.error || t("vacuumFailed"),
});
}
} catch {
@@ -516,7 +519,7 @@ export default function SystemStorageTab() {
await fetchAndDownload(
"/api/settings/export-json",
`omniroute-legacy-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.json`,
- "JSON Export failed"
+ t("jsonExportFailed")
);
} catch (err) {
console.error("Export JSON failed:", err);
@@ -539,7 +542,7 @@ export default function SystemStorageTab() {
if (!file.name.endsWith(".json")) {
setImportStatus({
type: "error",
- message: "Invalid file type. Only .json allowed.",
+ message: t("invalidJsonFileType"),
});
return;
}
@@ -558,15 +561,15 @@ export default function SystemStorageTab() {
if (res.ok) {
setImportStatus({
type: "success",
- message: data.message || "Legacy JSON imported successfully!",
+ message: data.message || t("legacyJsonImportSuccess"),
});
await loadStorageHealth();
if (backupsExpanded) await loadBackups();
} else {
- setImportStatus({ type: "error", message: data.error || "Failed to import JSON" });
+ setImportStatus({ type: "error", message: data.error || t("jsonImportFailed") });
}
} catch (err) {
- setImportStatus({ type: "error", message: "Error during JSON import" });
+ setImportStatus({ type: "error", message: t("jsonImportError") });
} finally {
setImportLoading(false);
if (jsonInputRef.current) jsonInputRef.current.value = "";
@@ -718,7 +721,7 @@ export default function SystemStorageTab() {
analytics
- Database Statistics
+ {t("storageDatabaseStatistics")}
refresh
- Refresh
+ {t("refresh")}
@@ -754,7 +757,7 @@ export default function SystemStorageTab() {
{dbSettings.stats.lastVacuumAt
? new Date(dbSettings.stats.lastVacuumAt).toLocaleString(locale)
- : "Never"}
+ : t("never")}
@@ -762,7 +765,7 @@ export default function SystemStorageTab() {
{dbSettings.stats.lastOptimizationAt
? new Date(dbSettings.stats.lastOptimizationAt).toLocaleString(locale)
- : "Never"}
+ : t("never")}
@@ -773,7 +776,7 @@ export default function SystemStorageTab() {
) : dbSettings.stats.integrityCheck === "error" ? (
{t("storageIntegrityError")}
) : (
- "Not checked"
+ t("storageIntegrityNotChecked")
)}
@@ -1001,7 +1004,7 @@ export default function SystemStorageTab() {
tune
- Optimization Settings
+ {t("storageOptimizationSettings")}
@@ -1022,9 +1025,9 @@ export default function SystemStorageTab() {
}
className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-bg focus:outline-none focus:ring-2 focus:ring-primary"
>
- None
- Full
- Incremental
+ {t("storageJournalModeNone")}
+ {t("storageJournalModeFull")}
+ {t("storageJournalModeIncremental")}
@@ -1044,10 +1047,10 @@ export default function SystemStorageTab() {
}
className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-bg focus:outline-none focus:ring-2 focus:ring-primary"
>
- Never
- Daily
- Weekly
- Monthly
+ {t("storageVacuumNever")}
+ {t("storageVacuumDaily")}
+ {t("storageVacuumWeekly")}
+ {t("storageVacuumMonthly")}
@@ -1090,7 +1093,9 @@ export default function SystemStorageTab() {
/>
- Cache Size (KB)
+
+ {t("storageCacheSizeKb")}
+
- Optimize on Startup
+ {t("storageOptimizeOnStartup")}
@@ -1137,7 +1142,7 @@ export default function SystemStorageTab() {
onClick={saveDatabaseSettings}
loading={dbSettingsSaving}
>
- Save Optimization Settings
+ {t("storageSaveOptimization")}
@@ -1153,7 +1158,7 @@ export default function SystemStorageTab() {
compress
- Compression & Aggregation Settings
+ {t("storageCompressionAggregation")}
@@ -1170,13 +1175,13 @@ export default function SystemStorageTab() {
className="w-4 h-4 rounded border-border text-primary focus:ring-2 focus:ring-primary"
/>
- Enable Data Aggregation
+ {t("storageEnableAggregation")}
@@ -1224,7 +1231,7 @@ export default function SystemStorageTab() {
onClick={saveDatabaseSettings}
loading={dbSettingsSaving}
>
- Save Aggregation Settings
+ {t("storageSaveAggregation")}
@@ -1322,7 +1329,7 @@ export default function SystemStorageTab() {
data_object
- Export JSON
+ {t("exportJson")}
data_object
- Import JSON
+ {t("importJson")}
cleaning_services
- Manual VACUUM
+ {t("manualVacuum")}
delete_forever
- Purge Quota Snapshots
+ {t("purgeQuotaSnapshots")}
delete_forever
- Purge Call Logs
+ {t("purgeCallLogs")}
delete_forever
- Purge Detailed Logs
+ {t("purgeDetailedLogs")}
}
- confirmText={
- resetUsageLoading ? t("resetting") || "Resetting..." : t("reset") || "Reset"
- }
+ confirmText={resetUsageLoading ? t("resetting") || "Resetting..." : t("reset") || "Reset"}
variant="danger"
loading={resetUsageLoading}
/>
diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx
index 1c51952c27..946020b049 100644
--- a/src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx
+++ b/src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx
@@ -27,6 +27,7 @@ export default function FreePoolTab() {
fallback: string,
values?: Record
) => (typeof t.has === "function" && !t.has(key) ? fallback : t(key, values));
+ const tc = useTranslations("common");
const [proxies, setProxies] = useState([]);
const [stats, setStats] = useState(null);
const [disabledSources, setDisabledSources] = useState>(new Set());
@@ -175,7 +176,7 @@ export default function FreePoolTab() {
const handleBulkAdd = async (ids: string[]) => {
if (!ids.length) return;
- setBulkProgress("Testing proxies...");
+ setBulkProgress(t("proxyFreePoolTesting"));
try {
const res = await fetch("/api/settings/free-proxies/bulk-add-to-pool", {
method: "POST",
@@ -183,7 +184,12 @@ export default function FreePoolTab() {
body: JSON.stringify({ ids }),
});
const data = await res.json();
- setBulkProgress(`${data.succeeded ?? 0} added, ${data.failed ?? 0} failed`);
+ setBulkProgress(
+ t("proxyFreePoolBulkResult", {
+ succeeded: data.succeeded ?? 0,
+ failed: data.failed ?? 0,
+ })
+ );
await loadData();
setSelected(new Set());
} catch {}
@@ -406,7 +412,7 @@ export default function FreePoolTab() {
type="button"
onClick={() => handlePageChange(page - 1)}
disabled={page <= 1}
- aria-label="Previous page"
+ aria-label={tc("previousPage")}
>
«
@@ -434,7 +440,7 @@ export default function FreePoolTab() {
type="button"
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages}
- aria-label="Next page"
+ aria-label={tc("nextPage")}
>
»
@@ -444,8 +450,8 @@ export default function FreePoolTab() {
{/* Per-page summary */}
{total > 0
- ? `Page ${page} of ${totalPages} (${total} total proxies)`
- : `${total} total proxies`}
+ ? t("proxyFreePoolPageSummary", { page, totalPages, total })
+ : t("proxyFreePoolTotalSummary", { total })}
);
diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/SourceToggleBar.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/SourceToggleBar.tsx
index 78152b18bf..d1e4f672ce 100644
--- a/src/app/(dashboard)/dashboard/settings/components/proxy/SourceToggleBar.tsx
+++ b/src/app/(dashboard)/dashboard/settings/components/proxy/SourceToggleBar.tsx
@@ -1,5 +1,7 @@
"use client";
+import { useTranslations } from "next-intl";
+
export type SourceId = "1proxy" | "proxifly" | "iplocate" | "webshare";
export const ALL_SOURCE_IDS: SourceId[] = ["1proxy", "proxifly", "iplocate", "webshare"];
@@ -36,8 +38,10 @@ const SOURCES: Array<{ id: SourceId; label: string }> = [
];
export default function SourceToggleBar({ disabledSources, onToggle }: SourceToggleBarProps) {
+ const t = useTranslations("settings");
+
return (
-
+
{SOURCES.map((s) => {
const enabled = !disabledSources.has(s.id);
return (
diff --git a/src/app/(dashboard)/dashboard/tokens/page.tsx b/src/app/(dashboard)/dashboard/tokens/page.tsx
index 9a4cdc78ae..17a31288aa 100644
--- a/src/app/(dashboard)/dashboard/tokens/page.tsx
+++ b/src/app/(dashboard)/dashboard/tokens/page.tsx
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
-import { useTranslations } from "next-intl";
+import { useLocale, useTranslations } from "next-intl";
import { Card } from "@/shared/components";
interface TokenLedgerEntry {
@@ -36,6 +36,7 @@ interface ServerConnection {
export default function TokensPage() {
const t = useTranslations("common");
+ const locale = useLocale();
// Balance & History
const [balance, setBalance] = useState(0);
const [history, setHistory] = useState
([]);
@@ -56,6 +57,7 @@ export default function TokensPage() {
const [inviteMaxUses, setInviteMaxUses] = useState("1");
const [inviteLoading, setInviteLoading] = useState(false);
const [newInviteCode, setNewInviteCode] = useState(null);
+ const [inviteError, setInviteError] = useState("");
// Redeem
const [redeemCode, setRedeemCode] = useState("");
@@ -70,9 +72,9 @@ export default function TokensPage() {
const [serverUrl, setServerUrl] = useState("");
const [serverApiKey, setServerApiKey] = useState("");
const [serverLoading, setServerLoading] = useState(false);
+ const [serverError, setServerError] = useState("");
const fetchData = useCallback(async () => {
- setHistoryLoading(true);
try {
const [transferRes, inviteRes, serverRes] = await Promise.all([
fetch("/api/gamification/transfer"),
@@ -101,7 +103,11 @@ export default function TokensPage() {
}, []);
useEffect(() => {
- fetchData();
+ const loadTimer = window.setTimeout(() => {
+ void fetchData();
+ }, 0);
+
+ return () => window.clearTimeout(loadTimer);
}, [fetchData]);
const handleTransfer = async (e: React.FormEvent) => {
@@ -122,18 +128,21 @@ export default function TokensPage() {
});
const data = await res.json();
if (res.ok) {
- setTransferMsg({ type: "success", text: `Transfer successful (${data.idempotencyKey})` });
+ setTransferMsg({
+ type: "success",
+ text: t("tokensTransferSuccess", { idempotencyKey: data.idempotencyKey }),
+ });
setToApiKeyId("");
setAmount("");
setReason("");
await fetchData();
} else {
- setTransferMsg({ type: "error", text: data.error || "Transfer failed" });
+ setTransferMsg({ type: "error", text: data.error || t("tokensTransferFailed") });
}
} catch (err) {
setTransferMsg({
type: "error",
- text: err instanceof Error ? err.message : "Transfer failed",
+ text: err instanceof Error ? err.message : t("tokensTransferFailed"),
});
} finally {
setTransferLoading(false);
@@ -143,6 +152,7 @@ export default function TokensPage() {
const handleCreateInvite = async () => {
setInviteLoading(true);
setNewInviteCode(null);
+ setInviteError("");
try {
const res = await fetch("/api/gamification/invite", {
@@ -157,20 +167,26 @@ export default function TokensPage() {
if (res.ok) {
setNewInviteCode(data.code);
await fetchData();
+ } else {
+ setInviteError(data.error || t("tokensCreateInviteFailed"));
}
- } catch {
- // silent fail
+ } catch (error) {
+ setInviteError(error instanceof Error ? error.message : t("tokensCreateInviteFailed"));
} finally {
setInviteLoading(false);
}
};
const handleRevokeInvite = async (inviteId: string) => {
+ setInviteError("");
try {
- await fetch(`/api/gamification/invite?id=${inviteId}`, { method: "DELETE" });
+ const response = await fetch(`/api/gamification/invite?id=${inviteId}`, {
+ method: "DELETE",
+ });
+ if (!response.ok) throw new Error(t("tokensRevokeInviteFailed"));
await fetchData();
- } catch {
- // silent fail
+ } catch (error) {
+ setInviteError(error instanceof Error ? error.message : t("tokensRevokeInviteFailed"));
}
};
@@ -189,15 +205,20 @@ export default function TokensPage() {
if (res.ok) {
setRedeemMsg({
type: "success",
- text: `Redeemed! Server: ${data.serverUrl || "connected"}`,
+ text: t("tokensRedeemSuccess", {
+ server: data.serverUrl || t("connected"),
+ }),
});
setRedeemCode("");
await fetchData();
} else {
- setRedeemMsg({ type: "error", text: data.error || "Redeem failed" });
+ setRedeemMsg({ type: "error", text: data.error || t("tokensRedeemFailed") });
}
} catch (err) {
- setRedeemMsg({ type: "error", text: err instanceof Error ? err.message : "Redeem failed" });
+ setRedeemMsg({
+ type: "error",
+ text: err instanceof Error ? err.message : t("tokensRedeemFailed"),
+ });
} finally {
setRedeemLoading(false);
}
@@ -206,6 +227,7 @@ export default function TokensPage() {
const handleConnectServer = async (e: React.FormEvent) => {
e.preventDefault();
setServerLoading(true);
+ setServerError("");
try {
const res = await fetch("/api/gamification/servers", {
@@ -218,20 +240,27 @@ export default function TokensPage() {
setServerUrl("");
setServerApiKey("");
await fetchData();
+ } else {
+ const data = await res.json().catch(() => null);
+ setServerError(data?.error || t("tokensConnectServerFailed"));
}
- } catch {
- // silent fail
+ } catch (error) {
+ setServerError(error instanceof Error ? error.message : t("tokensConnectServerFailed"));
} finally {
setServerLoading(false);
}
};
const handleDisconnectServer = async (serverId: string) => {
+ setServerError("");
try {
- await fetch(`/api/gamification/servers?id=${serverId}`, { method: "DELETE" });
+ const response = await fetch(`/api/gamification/servers?id=${serverId}`, {
+ method: "DELETE",
+ });
+ if (!response.ok) throw new Error(t("tokensDisconnectServerFailed"));
await fetchData();
- } catch {
- // silent fail
+ } catch (error) {
+ setServerError(error instanceof Error ? error.message : t("tokensDisconnectServerFailed"));
}
};
@@ -242,7 +271,7 @@ export default function TokensPage() {
{t("tokensTokenBalance")}
-
{balance.toLocaleString()}
+
{balance.toLocaleString(locale)}
🪙
@@ -266,7 +295,7 @@ export default function TokensPage() {
-
Amount
+
{t("tokensAmount")}
- {transferLoading ? "Sending..." : "Send Tokens"}
+ {transferLoading ? t("tokensSending") : t("tokensSendTokens")}
@@ -314,7 +343,7 @@ export default function TokensPage() {
{/* Transaction History */}
{historyLoading ? (
- Loading...
+ {t("loading")}
) : history.length === 0 ? (
{t("tokensNoTransactionsYet")}
) : (
@@ -322,12 +351,12 @@ export default function TokensPage() {
- Type
- From
- To
- Amount
- Reason
- Date
+ {t("type")}
+ {t("tokensFrom")}
+ {t("tokensTo")}
+ {t("tokensAmount")}
+ {t("tokensReason")}
+ {t("tokensDate")}
@@ -343,7 +372,7 @@ export default function TokensPage() {
: "bg-emerald-500/10 text-emerald-400"
}`}
>
- {isSent ? "Sent" : "Received"}
+ {isSent ? t("tokensSent") : t("tokensReceived")}
@@ -354,11 +383,11 @@ export default function TokensPage() {
className={`py-3 text-right font-mono ${isSent ? "text-red-400" : "text-emerald-400"}`}
>
{isSent ? "-" : "+"}
- {entry.amount.toLocaleString()}
+ {entry.amount.toLocaleString(locale)}
{entry.reason || "-"}
- {new Date(entry.createdAt).toLocaleDateString()}
+ {new Date(entry.createdAt).toLocaleDateString(locale)}
);
@@ -388,16 +417,21 @@ export default function TokensPage() {
disabled={inviteLoading}
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
>
- {inviteLoading ? "Creating..." : "Create Invite"}
+ {inviteLoading ? t("tokensCreatingInvite") : t("tokensCreateInvite")}
{newInviteCode && (
- Invite code created: {newInviteCode}
+ {t("tokensInviteCreated")}:{" "}
+ {newInviteCode}
)}
+ {inviteError && (
+ {inviteError}
+ )}
+
{/* Redeem */}
- {redeemLoading ? "Redeeming..." : "Redeem"}
+ {redeemLoading ? t("tokensRedeeming") : t("tokensRedeem")}
@@ -448,16 +482,18 @@ export default function TokensPage() {
{inv.code}
- {inv.useCount}/{inv.maxUses} uses
+ {t("tokensInviteUses", { used: inv.useCount, max: inv.maxUses })}
- {inv.revokedAt && REVOKED }
+ {inv.revokedAt && (
+ {t("tokensRevoked")}
+ )}
{!inv.revokedAt && (
handleRevokeInvite(inv.id)}
className="text-xs px-2 py-1 rounded text-red-400 hover:bg-red-500/10 transition-colors"
>
- Revoke
+ {t("tokensRevoke")}
)}
@@ -503,13 +539,17 @@ export default function TokensPage() {
disabled={serverLoading || !serverName || !serverUrl || !serverApiKey}
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors justify-self-start"
>
- {serverLoading ? "Connecting..." : "Connect Server"}
+ {serverLoading ? t("tokensConnecting") : t("tokensConnectServer")}
+ {serverError && (
+ {serverError}
+ )}
+
{servers.length === 0 ? (
- No servers connected. Connect to a community server to share leaderboards.
+ {t("tokensNoServersConnected")}
) : (
@@ -530,7 +570,9 @@ export default function TokensPage() {
: "bg-gray-500/10 text-gray-400"
}`}
>
- {server.status}
+ {t.has(`tokensServerStatus.${server.status}`)
+ ? t(`tokensServerStatus.${server.status}`)
+ : server.status}
{server.url}
@@ -539,7 +581,9 @@ export default function TokensPage() {
)}
{server.lastSyncAt && (
- Last sync: {new Date(server.lastSyncAt).toLocaleString()}
+ {t("tokensLastSync", {
+ date: new Date(server.lastSyncAt).toLocaleString(locale),
+ })}
)}
@@ -547,7 +591,7 @@ export default function TokensPage() {
onClick={() => handleDisconnectServer(server.id)}
className="text-xs px-3 py-1.5 rounded text-red-400 hover:bg-red-500/10 transition-colors ml-4"
>
- Disconnect
+ {t("tokensDisconnect")}
))}
diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx
index a9b25217db..aae3f01cab 100644
--- a/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx
+++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx
@@ -75,6 +75,7 @@ export default function AgentBridgePageClient({
hasProviders,
}: AgentBridgePageClientProps) {
const t = useTranslations("agentBridge");
+ const tc = useTranslations("common");
const { data, refresh } = useAgentBridgeState({ initialData });
const [actionError, setActionError] = useState(null);
const [certGuide, setCertGuide] = useState(null);
@@ -129,10 +130,10 @@ export default function AgentBridgePageClient({
try {
await postServerAction(action);
} catch (err) {
- setActionError(err instanceof Error ? err.message : "Unknown error");
+ setActionError(err instanceof Error ? err.message : t("unknownError"));
}
},
- [postServerAction, runPrivileged]
+ [postServerAction, runPrivileged, t]
);
// ── Upstream CA ───────────────────────────────────────────────────────────
@@ -148,9 +149,9 @@ export default function AgentBridgePageClient({
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await refresh();
} catch (err) {
- setActionError(err instanceof Error ? err.message : "Unknown error");
+ setActionError(err instanceof Error ? err.message : t("unknownError"));
}
- }, [refresh]);
+ }, [refresh, t]);
// ── Bypass list ───────────────────────────────────────────────────────────
@@ -165,9 +166,9 @@ export default function AgentBridgePageClient({
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await refresh();
} catch (err) {
- setActionError(err instanceof Error ? err.message : "Unknown error");
+ setActionError(err instanceof Error ? err.message : t("unknownError"));
}
- }, [refresh]);
+ }, [refresh, t]);
// ── DNS toggle ────────────────────────────────────────────────────────────
@@ -192,10 +193,10 @@ export default function AgentBridgePageClient({
await refresh();
});
} catch (err) {
- setActionError(err instanceof Error ? err.message : "Unknown error");
+ setActionError(err instanceof Error ? err.message : t("unknownError"));
}
},
- [refresh, runPrivileged]
+ [refresh, runPrivileged, t]
);
// ── Mappings save ─────────────────────────────────────────────────────────
@@ -212,10 +213,10 @@ export default function AgentBridgePageClient({
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await refresh();
} catch (err) {
- setActionError(err instanceof Error ? err.message : "Unknown error");
+ setActionError(err instanceof Error ? err.message : t("unknownError"));
}
},
- [refresh]
+ [refresh, t]
);
// ── Render ────────────────────────────────────────────────────────────────
@@ -237,7 +238,7 @@ export default function AgentBridgePageClient({
type="button"
onClick={() => setActionError(null)}
className="ml-auto text-red-500 hover:text-red-400"
- aria-label="Dismiss"
+ aria-label={tc("dismissNotification")}
>
close
@@ -252,13 +253,12 @@ export default function AgentBridgePageClient({
>
info
- {t("certManualTitle") ||
- "Certificate couldn't be installed automatically (e.g. inside a container). The bridge can still run — trust the CA manually:"}
+ {t("certManualTitle")}
setCertGuide(null)}
className="ml-auto text-amber-600 hover:text-amber-500"
- aria-label="Dismiss"
+ aria-label={tc("dismissNotification")}
>
close
@@ -276,7 +276,7 @@ export default function AgentBridgePageClient({
className="mt-2 inline-flex items-center gap-1.5 text-xs font-medium underline hover:no-underline"
>
download
- {t("downloadCert") || "Download Cert"}
+ {t("downloadCert")}
)}
@@ -319,7 +319,7 @@ export default function AgentBridgePageClient({
{/* Quick links */}
- {t("quickLinks") || "Quick links"}
+ {t("quickLinks")}
dns
- {t("quickLinkProviders") || "Configure providers"}
+ {t("quickLinkProviders")}
network_check
- {t("quickLinkInspector") || "View traffic in Traffic Inspector"}
+ {t("quickLinkInspector")}
diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/ModelSelectorModal.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/ModelSelectorModal.tsx
index 48a78c01c7..3ff11a58f6 100644
--- a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/ModelSelectorModal.tsx
+++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/ModelSelectorModal.tsx
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
+import { matchesSearch } from "@/shared/utils/turkishText";
interface ProviderModel {
id: string;
@@ -25,11 +26,13 @@ export function ModelSelectorModal({
onClose,
}: ModelSelectorModalProps) {
const t = useTranslations("agentBridge");
+ const tc = useTranslations("common");
const [models, setModels] = useState([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
const loadModels = useCallback(async () => {
+ setLoading(true);
try {
const r = await fetch("/api/v1/models");
const d = (await r.json()) as { data?: ProviderModel[] };
@@ -58,9 +61,7 @@ export function ModelSelectorModal({
if (!open) return null;
const filtered = models.filter(
- (m) =>
- m.id.toLowerCase().includes(search.toLowerCase()) ||
- m.name.toLowerCase().includes(search.toLowerCase())
+ (model) => matchesSearch(model.id, search) || matchesSearch(model.name, search)
);
return (
@@ -71,10 +72,8 @@ export function ModelSelectorModal({
>
-
- {t("modelSelectorTitle") || "Select target model"}
-
-
+ {t("modelSelectorTitle")}
+
close
@@ -86,22 +85,16 @@ export function ModelSelectorModal({
type="text"
autoFocus
className="w-full rounded-lg border border-border/50 bg-surface px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
- placeholder={t("modelSelectorSearch") || "Search models…"}
+ placeholder={t("modelSelectorSearch")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
- {loading && (
-
- {t("loading") || "Loading models…"}
-
- )}
+ {loading &&
{t("loading")}
}
{!loading && filtered.length === 0 && (
-
- {t("noModelsFound") || "No models found"}
-
+
{t("noModelsFound")}
)}
{filtered.map((m) => (
{m.id}
- {m.name !== m.id && (
- {m.name}
- )}
+ {m.name !== m.id && {m.name} }
))}
diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/SetupWizard.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/SetupWizard.tsx
index 59111ec325..5f6e1c9a2d 100644
--- a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/SetupWizard.tsx
+++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/SetupWizard.tsx
@@ -29,6 +29,7 @@ export function SetupWizard({
onDnsToggle,
}: SetupWizardProps) {
const t = useTranslations("agentBridge");
+ const tc = useTranslations("common");
const [step, setStep] = useState
("verify");
const [enablingDns, setEnablingDns] = useState(false);
@@ -54,9 +55,9 @@ export function SetupWizard({
};
const steps: { id: Step; label: string }[] = [
- { id: "verify", label: t("wizardStep1Label") || "Verify" },
- { id: "dns", label: t("wizardStep2Label") || "DNS" },
- { id: "mappings", label: t("wizardStep3Label") || "Mappings" },
+ { id: "verify", label: t("wizardStep1Label") },
+ { id: "dns", label: t("wizardStep2Label") },
+ { id: "mappings", label: t("wizardStep3Label") },
];
const stepIndex = steps.findIndex((s) => s.id === step);
@@ -71,20 +72,17 @@ export function SetupWizard({
{/* Header */}
-
+
{target.icon}
- {t("wizardTitle") || "Setup wizard"} — {target.name}
+ {t("wizardTitle")} — {target.name}
-
{t("wizardSubtitle") || "3-step setup"}
+
{t("wizardSubtitle")}
-
+
close
@@ -115,9 +113,7 @@ export function SetupWizard({
>
{s.label}
- {i < steps.length - 1 && (
-
- )}
+ {i < steps.length - 1 &&
}
))}
@@ -126,9 +122,7 @@ export function SetupWizard({
{step === "verify" && (
-
- {t("wizardStep1Desc") || "Confirm the server is running and the certificate is installed."}
-
+
{t("wizardStep1Desc")}
- {t("wizardServerCheck") || "AgentBridge server"}{" "}
- {serverRunning
- ? t("wizardRunning") || "running"
- : t("wizardNotRunning") || "not running"}
+ {t("wizardServerCheck")}{" "}
+ {serverRunning ? t("wizardRunning") : t("wizardNotRunning")}
@@ -150,10 +142,8 @@ export function SetupWizard({
{certTrusted ? "verified_user" : "warning"}
- {t("wizardCertCheck") || "Certificate"}{" "}
- {certTrusted
- ? t("wizardTrusted") || "trusted"
- : t("wizardNotTrusted") || "not yet trusted — use Trust Cert button"}
+ {t("wizardCertCheck")}{" "}
+ {certTrusted ? t("wizardTrusted") : t("wizardNotTrusted")}
@@ -162,7 +152,7 @@ export function SetupWizard({
{target.setupTutorial.steps.length > 0 && (
- {t("wizardTutorialTitle") || "Setup instructions:"}
+ {t("wizardTutorialTitle")}
{target.setupTutorial.steps.map((step, i) => (
@@ -179,9 +169,7 @@ export function SetupWizard({
{step === "dns" && (
-
- {t("wizardStep2Desc") || "The following entries will be added to /etc/hosts to redirect traffic through AgentBridge:"}
-
+
{t("wizardStep2Desc")}
{target.hosts.map((host) => (
@@ -192,7 +180,7 @@ export function SetupWizard({
{dnsEnabled && (
check_circle
- {t("wizardDnsAlreadyEnabled") || "DNS already enabled for this agent"}
+ {t("wizardDnsAlreadyEnabled")}
)}
@@ -202,13 +190,9 @@ export function SetupWizard({
check_circle
-
- {t("wizardStep3Success") || "Agent is configured!"}
-
+
{t("wizardStep3Success")}
-
- {t("wizardStep3Desc") || "You can now configure model mappings in the agent card. Restart the IDE to apply changes."}
-
+
{t("wizardStep3Desc")}
)}
@@ -224,7 +208,7 @@ export function SetupWizard({
}}
className="rounded-lg border border-border/50 bg-card px-4 py-2 text-sm text-text-muted hover:bg-surface transition-colors"
>
- {step === "verify" ? t("cancel") || "Cancel" : t("back") || "Back"}
+ {step === "verify" ? t("cancel") : t("back")}
@@ -234,7 +218,7 @@ export function SetupWizard({
onClick={() => setStep("dns")}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors"
>
- {t("next") || "Next"}{" "}
+ {t("next")}{" "}
arrow_forward
)}
@@ -247,7 +231,7 @@ export function SetupWizard({
onClick={() => setStep("mappings")}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors"
>
- {t("next") || "Next"}
+ {t("next")}
) : (
- {enablingDns
- ? t("enablingDns") || "Enabling…"
- : t("wizardEnableDns") || "Add /etc/hosts entries"}
+ {enablingDns ? t("enablingDns") : t("wizardEnableDns")}
)}
>
@@ -270,7 +252,7 @@ export function SetupWizard({
onClick={onClose}
className="rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-400 transition-colors"
>
- {t("done") || "Done"}
+ {t("done")}
)}
diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CustomHostsManager.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CustomHostsManager.tsx
index 2d51a7323a..b1b0d8d14b 100644
--- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CustomHostsManager.tsx
+++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CustomHostsManager.tsx
@@ -22,7 +22,11 @@ export function CustomHostsManager({ onClose }: CustomHostsManagerProps) {
const [error, setError] = useState
(null);
const [loading, setLoading] = useState(true);
- const HostInputSchema = z.string().min(1).max(253).regex(/^[a-z0-9.-]+$/i, "Invalid hostname");
+ const HostInputSchema = z
+ .string()
+ .min(1)
+ .max(253)
+ .regex(/^[a-z0-9.-]+$/i, t("invalidHostname"));
const fetchHosts = async () => {
setLoading(true);
@@ -45,7 +49,7 @@ export function CustomHostsManager({ onClose }: CustomHostsManagerProps) {
setError(null);
const parsed = HostInputSchema.safeParse(input.trim());
if (!parsed.success) {
- setError(parsed.error.errors[0]?.message ?? "Invalid host");
+ setError(parsed.error.errors[0]?.message ?? t("invalidHost"));
return;
}
const host = parsed.data;
@@ -57,13 +61,13 @@ export function CustomHostsManager({ onClose }: CustomHostsManagerProps) {
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
- setError(body?.error?.message ?? "Failed to add host");
+ setError(body?.error?.message ?? t("addHostFailed"));
return;
}
setInput("");
await fetchHosts();
} catch {
- setError("Network error");
+ setError(t("networkError"));
}
};
@@ -87,9 +91,11 @@ export function CustomHostsManager({ onClose }: CustomHostsManagerProps) {
type="button"
onClick={onClose}
className="text-text-muted hover:text-text-main focus-ring rounded"
- aria-label="Close"
+ aria-label={t("close")}
>
- close
+
+ close
+
@@ -127,7 +133,7 @@ export function CustomHostsManager({ onClose }: CustomHostsManagerProps) {
type="button"
onClick={() => deleteHost(h.host)}
className="text-text-muted hover:text-red-400 focus-ring rounded"
- aria-label={`Remove ${h.host}`}
+ aria-label={t("removeHost", { host: h.host })}
>
delete
diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/DetailsPanel.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/DetailsPanel.tsx
index c270111e63..e9f1f694fd 100644
--- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/DetailsPanel.tsx
+++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/DetailsPanel.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import { useTranslations } from "next-intl";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { cn } from "@/shared/utils/cn";
import { HeadersTab } from "./tabs/HeadersTab";
@@ -16,19 +17,19 @@ type TabId = "conversation" | "headers" | "request" | "response" | "timing" | "l
interface Tab {
id: TabId;
- label: string;
+ labelKey: string;
icon: string;
llmOnly?: boolean;
}
const TABS: Tab[] = [
- { id: "conversation", label: "Conversation", icon: "chat_bubble" },
- { id: "headers", label: "Headers", icon: "list" },
- { id: "request", label: "Request", icon: "upload" },
- { id: "response", label: "Response", icon: "download" },
- { id: "timing", label: "Timing", icon: "timer" },
- { id: "llm", label: "LLM", icon: "psychology", llmOnly: true },
- { id: "stats", label: "Stats", icon: "bar_chart" },
+ { id: "conversation", labelKey: "tabConversation", icon: "chat_bubble" },
+ { id: "headers", labelKey: "tabHeaders", icon: "list" },
+ { id: "request", labelKey: "tabRequest", icon: "upload" },
+ { id: "response", labelKey: "tabResponse", icon: "download" },
+ { id: "timing", labelKey: "tabTiming", icon: "timer" },
+ { id: "llm", labelKey: "tabLlm", icon: "psychology", llmOnly: true },
+ { id: "stats", labelKey: "tabStats", icon: "bar_chart" },
];
interface DetailsPanelProps {
@@ -37,19 +38,17 @@ interface DetailsPanelProps {
}
export function DetailsPanel({ request, allRequests }: DetailsPanelProps) {
+ const t = useTranslations("trafficInspector");
const [activeTab, setActiveTab] = useState("conversation");
if (!request) {
return (
-
+
info
- Select a request to inspect it.
+ {t("selectRequest")}
);
@@ -66,7 +65,7 @@ export function DetailsPanel({ request, allRequests }: DetailsPanelProps) {
{/* Tab bar */}
{visibleTabs.map((tab) => {
@@ -88,7 +87,7 @@ export function DetailsPanel({ request, allRequests }: DetailsPanelProps) {
{tab.icon}
- {tab.label}
+ {t(tab.labelKey)}
);
})}
diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/HttpProxySnippetCard.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/HttpProxySnippetCard.tsx
index 7afd513272..77baeb0a5d 100644
--- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/HttpProxySnippetCard.tsx
+++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/HttpProxySnippetCard.tsx
@@ -39,9 +39,11 @@ export function HttpProxySnippetCard({ port, onClose }: HttpProxySnippetCardProp
type="button"
onClick={onClose}
className="text-text-muted hover:text-text-main focus-ring rounded"
- aria-label="Close"
+ aria-label={t("close")}
>
- close
+
+ close
+
diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestRow.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestRow.tsx
index 63a05a9fc0..c8ba09ef09 100644
--- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestRow.tsx
+++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestRow.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useTranslations } from "next-intl";
import { cn } from "@/shared/utils/cn";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { ContextColorBar } from "./shared/ContextColorBar";
@@ -41,6 +42,7 @@ function formatTime(iso: string): string {
}
export function RequestRow({ request, selected, onClick, onSameContext, style }: RequestRowProps) {
+ const t = useTranslations("trafficInspector");
const pathShort = request.path.length > 32 ? `…${request.path.slice(-30)}` : request.path;
const sc = statusColor(request.status);
@@ -60,11 +62,11 @@ export function RequestRow({ request, selected, onClick, onSameContext, style }:
-
{formatTime(request.timestamp)}
-
{request.method}
-
- {String(request.status)}
+
+ {formatTime(request.timestamp)}
+ {request.method}
+ {String(request.status)}
{formatSize(request.responseSize)}
@@ -86,7 +88,7 @@ export function RequestRow({ request, selected, onClick, onSameContext, style }:
{
e.stopPropagation();
onSameContext?.(request.contextKey as string);
diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestStreamingList.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestStreamingList.tsx
index dd68276783..5f79752131 100644
--- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestStreamingList.tsx
+++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestStreamingList.tsx
@@ -1,6 +1,7 @@
"use client";
import { useRef } from "react";
+import { useTranslations } from "next-intl";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { useVirtualList } from "../hooks/useVirtualList";
import { RequestRow } from "./RequestRow";
@@ -24,6 +25,7 @@ export function RequestStreamingList({
sameContextKey,
onClearContextFilter,
}: RequestStreamingListProps) {
+ const t = useTranslations("trafficInspector");
const { virtualItems, totalHeight, containerRef, rowRef } = useVirtualList(
requests,
containerHeight
@@ -34,13 +36,13 @@ export function RequestStreamingList({
{sameContextKey && (
- Filtering: context {sameContextKey.slice(0, 6)}
+ {t("filteringContext", { context: sameContextKey.slice(0, 6) })}
- [clear]
+ [{t("clear")}]
)}
@@ -55,8 +57,8 @@ export function RequestStreamingList({
>
network_check
-
No requests captured yet.
-
Make sure AgentBridge is running or enable another capture mode.
+
{t("noRequests")}
+
{t("noRequestsDesc")}
@@ -67,13 +69,13 @@ export function RequestStreamingList({
{sameContextKey && (
- Filtering: context {sameContextKey.slice(0, 6)}
+ {t("filteringContext", { context: sameContextKey.slice(0, 6) })}
- [clear]
+ [{t("clear")}]
)}
diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/TopBarControls.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/TopBarControls.tsx
index 50ac648e58..9ebf92a8b9 100644
--- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/TopBarControls.tsx
+++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/TopBarControls.tsx
@@ -41,7 +41,6 @@ interface TopBarControlsProps {
onSessionDelete: (id: string) => void;
}
-
export function TopBarControls({
filters,
onProfileChange,
@@ -82,7 +81,7 @@ export function TopBarControls({
{/* Profile selector */}
{PROFILE_IDS.map((id) => (
@@ -94,9 +93,7 @@ export function TopBarControls({
onClick={() => onProfileChange(id)}
className={cn(
"px-2 py-0.5 text-xs rounded focus-ring",
- profile === id
- ? "bg-blue-600 text-white"
- : "text-text-muted hover:text-text-main"
+ profile === id ? "bg-blue-600 text-white" : "text-text-muted hover:text-text-main"
)}
>
{profileLabels[id]}
@@ -116,9 +113,7 @@ export function TopBarControls({
{/* Status filter */}
- onStatusChange((e.target.value as ListFilters["status"]) || undefined)
- }
+ onChange={(e) => onStatusChange((e.target.value as ListFilters["status"]) || undefined)}
className="rounded border border-border bg-bg-subtle px-2 py-1 text-xs text-text-main focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{t("anyStatus")}
diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx
index c9b9042005..496cadd38a 100644
--- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx
+++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import { useTranslations } from "next-intl";
import type { NormalizedTurn } from "@/mitm/inspector/types";
import { cn } from "@/shared/utils/cn";
import { MessageContent } from "./MessageContent";
@@ -16,36 +17,43 @@ const ROLE_STYLES: Record = {
tool: "bg-gray-800 border border-gray-600/30 text-gray-200",
};
-const ROLE_LABEL: Record = {
- system: "System",
- user: "User",
- assistant: "Assistant",
- tool: "Tool",
+const ROLE_LABEL_KEY: Record = {
+ system: "roleSystem",
+ user: "roleUser",
+ assistant: "roleAssistant",
+ tool: "roleTool",
};
export function ChatBubble({ turn }: ChatBubbleProps) {
+ const t = useTranslations("trafficInspector");
const [collapsed, setCollapsed] = useState(turn.role === "system");
const isSystem = turn.role === "system";
const isUser = turn.role === "user";
return (
-
+
- {ROLE_LABEL[turn.role]}
+ {t(ROLE_LABEL_KEY[turn.role])}
{isSystem && (
setCollapsed((c) => !c)}
className="text-xs opacity-70 hover:opacity-100 focus-ring rounded"
>
- {collapsed ? "Expand" : "Collapse"}
+ {collapsed ? t("expand") : t("collapse")}
)}
{!collapsed &&
}
{collapsed && isSystem && (
-
System prompt hidden — click to expand
+
{t("systemPromptHidden")}
)}
);
diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/session/SessionPicker.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/session/SessionPicker.tsx
index 2a52d4091b..536a42638b 100644
--- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/session/SessionPicker.tsx
+++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/session/SessionPicker.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import { useTranslations } from "next-intl";
import type { SessionInfo } from "../../hooks/useSessionRecorder";
interface SessionPickerProps {
@@ -11,6 +12,7 @@ interface SessionPickerProps {
}
export function SessionPicker({ sessions, selectedId, onSelect, onDelete }: SessionPickerProps) {
+ const t = useTranslations("trafficInspector");
const [open, setOpen] = useState(false);
const selected = sessions.find((s) => s.id === selectedId);
@@ -25,7 +27,9 @@ export function SessionPicker({ sessions, selectedId, onSelect, onDelete }: Sess
folder_open
- {selected ? selected.name ?? `Session ${selected.id.slice(0, 6)}` : "Sessions"}
+ {selected
+ ? (selected.name ?? t("sessionName", { id: selected.id.slice(0, 6) }))
+ : t("sessions")}
{open ? "expand_less" : "expand_more"}
@@ -35,31 +39,42 @@ export function SessionPicker({ sessions, selectedId, onSelect, onDelete }: Sess
{ onSelect(undefined); setOpen(false); }}
+ onClick={() => {
+ onSelect(undefined);
+ setOpen(false);
+ }}
className="w-full text-left px-3 py-1.5 text-xs text-text-muted hover:bg-bg-subtle focus-ring"
>
- All traffic (no session)
+ {t("allTraffic")}
{sessions.length === 0 && (
-
No sessions yet
+
{t("noSessionsYet")}
)}
{sessions.map((s) => (
{ onSelect(s.id); setOpen(false); }}
+ onClick={() => {
+ onSelect(s.id);
+ setOpen(false);
+ }}
className={`flex-1 text-left px-3 py-1.5 text-xs hover:bg-bg-subtle focus-ring ${
selectedId === s.id ? "text-blue-400 font-medium" : "text-text-main"
}`}
>
- {s.name ?? `Session ${s.id.slice(0, 6)}`}
- ({s.requestCount} reqs)
+ {s.name ?? t("sessionName", { id: s.id.slice(0, 6) })}
+
+ ({t("requestCountShort", { count: s.requestCount })})
+
{ onDelete(s.id); if (selectedId === s.id) onSelect(undefined); }}
+ onClick={() => {
+ onDelete(s.id);
+ if (selectedId === s.id) onSelect(undefined);
+ }}
className="px-2 text-text-muted hover:text-red-400 opacity-0 group-hover:opacity-100 focus-ring rounded"
- aria-label="Delete session"
+ aria-label={t("deleteSession")}
>
delete
diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/shared/AnnotationField.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/shared/AnnotationField.tsx
index 6d7ddd98b9..4b115226a3 100644
--- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/shared/AnnotationField.tsx
+++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/shared/AnnotationField.tsx
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useState } from "react";
+import { useTranslations } from "next-intl";
import { useAnnotations } from "../../hooks/useAnnotations";
interface AnnotationFieldProps {
@@ -9,6 +10,7 @@ interface AnnotationFieldProps {
}
export function AnnotationField({ requestId, initialValue = "" }: AnnotationFieldProps) {
+ const t = useTranslations("trafficInspector");
const [value, setValue] = useState(initialValue);
const { save, saving } = useAnnotations(requestId);
@@ -25,14 +27,14 @@ export function AnnotationField({ requestId, initialValue = "" }: AnnotationFiel
{saving && (
- Saving…
+ {t("saving")}
)}
diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/shared/HeaderTable.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/shared/HeaderTable.tsx
index b4939ad5bb..bc69989f8b 100644
--- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/shared/HeaderTable.tsx
+++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/shared/HeaderTable.tsx
@@ -1,32 +1,34 @@
"use client";
import { useState } from "react";
+import { useTranslations } from "next-intl";
interface HeaderTableProps {
headers: Record
;
}
export function HeaderTable({ headers }: HeaderTableProps) {
+ const t = useTranslations("trafficInspector");
const [masked, setMasked] = useState(true);
const SENSITIVE = /authorization|cookie|x-api-key|bearer/i;
return (
- Sensitive headers
+ {t("sensitiveHeaders")}
setMasked((m) => !m)}
className="text-xs text-blue-400 hover:text-blue-300 focus-ring rounded"
>
- {masked ? "Show" : "Hide"}
+ {masked ? t("show") : t("hide")}
- Name
- Value
+ {t("name")}
+ {t("value")}
diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/shared/SseEventList.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/shared/SseEventList.tsx
index 2f1ea4cec7..6a856fe6f0 100644
--- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/shared/SseEventList.tsx
+++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/shared/SseEventList.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useTranslations } from "next-intl";
import type { SseEvent } from "@/mitm/inspector/sseMerger";
interface SseEventListProps {
@@ -7,6 +8,7 @@ interface SseEventListProps {
}
export function SseEventList({ events }: SseEventListProps) {
+ const t = useTranslations("trafficInspector");
return (
{events.map((ev, i) => (
@@ -16,9 +18,7 @@ export function SseEventList({ events }: SseEventListProps) {
{ev.data}
))}
- {events.length === 0 && (
- No SSE events
- )}
+ {events.length === 0 && {t("noSseEvents")}
}
);
}
diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/RequestBodyTab.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/RequestBodyTab.tsx
index 0e14f4e5fc..ab4172f443 100644
--- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/RequestBodyTab.tsx
+++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/RequestBodyTab.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import { useTranslations } from "next-intl";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { JsonViewer } from "../shared/JsonViewer";
import { SecretMaskToggle } from "../shared/SecretMaskToggle";
@@ -20,12 +21,13 @@ function maskSecrets(text: string): string {
}
export function RequestBodyTab({ request }: RequestBodyTabProps) {
+ const t = useTranslations("trafficInspector");
const [masked, setMasked] = useState(true);
const [raw, setRaw] = useState(false);
const body = request.requestBody;
if (!body) {
- return No request body.
;
+ return {t("noRequestBody")}
;
}
const display = masked ? maskSecrets(body) : body;
@@ -45,13 +47,15 @@ export function RequestBodyTab({ request }: RequestBodyTabProps) {
onClick={() => setRaw((r) => !r)}
className="text-xs text-text-muted hover:text-text-main border border-border rounded px-2 py-0.5 focus-ring"
>
- {raw ? "Formatted" : "Raw"}
+ {raw ? t("formatted") : t("raw")}
{request.requestSize} B
{raw || !parsed ? (
-
{display}
+
+ {display}
+
) : (
)}
diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/ResponseBodyTab.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/ResponseBodyTab.tsx
index c5fe9349f4..e549fce719 100644
--- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/ResponseBodyTab.tsx
+++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/ResponseBodyTab.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import { useTranslations } from "next-intl";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { parseSseStream, mergeStream } from "@/mitm/inspector/sseMerger";
import { JsonViewer } from "../shared/JsonViewer";
@@ -11,11 +12,12 @@ interface ResponseBodyTabProps {
}
export function ResponseBodyTab({ request }: ResponseBodyTabProps) {
+ const t = useTranslations("trafficInspector");
const [showRaw, setShowRaw] = useState(false);
const body = request.responseBody;
if (!body) {
- return
No response body.
;
+ return
{t("noResponseBody")}
;
}
const isSSE = body.startsWith("data:") || body.includes("\ndata:");
@@ -40,12 +42,12 @@ export function ResponseBodyTab({ request }: ResponseBodyTabProps) {
onClick={() => setShowRaw((r) => !r)}
className="text-xs text-text-muted hover:text-text-main border border-border rounded px-2 py-0.5 focus-ring"
>
- {showRaw ? "Merged view" : "Raw events"}
+ {showRaw ? t("mergedView") : t("rawEvents")}
)}
{request.responseSize} B
{request.status === "in-flight" && (
-
streaming…
+
{t("streaming")}
)}
@@ -54,7 +56,9 @@ export function ResponseBodyTab({ request }: ResponseBodyTabProps) {
) : isSSE && merged ? (
{merged.text && (
-
{merged.text}
+
+ {merged.text}
+
)}
{merged.toolCalls && merged.toolCalls.length > 0 && (
@@ -63,7 +67,9 @@ export function ResponseBodyTab({ request }: ResponseBodyTabProps) {
) : parsed ? (
) : (
-
{body}
+
+ {body}
+
)}
diff --git a/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx b/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx
index 65e655d151..2ff4349dd1 100644
--- a/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx
+++ b/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx
@@ -19,12 +19,17 @@ import type { AdvancedSlug, TranslatorTab } from "./types";
export default function TranslatorPageClient() {
return (
- Loading…}>
+ }>
);
}
+function TranslatorLoading() {
+ const t = useTranslations("translator");
+ return {t("loading")}
;
+}
+
function TranslatorPageClientInner() {
const t = useTranslations("translator");
const [sharedInputContent, setSharedInputContent] = useState("");
@@ -51,7 +56,7 @@ function TranslatorPageClientInner() {
return fallback;
}
},
- [t],
+ [t]
);
// Build PipelineStep[] from session.result so PipelineView reflects real state
@@ -77,7 +82,9 @@ function TranslatorPageClientInner() {
name: tr("pipelineStepFormatDetected", "Format Detected"),
description: tr("pipelineStepFormatDetectedDesc", "Auto-detected source format"),
format: r.detected ?? null,
- content: r.detected ? JSON.stringify({ detectedFormat: r.detected, confidence: "high" }, null, 2) : "",
+ content: r.detected
+ ? JSON.stringify({ detectedFormat: r.detected, confidence: "high" }, null, 2)
+ : "",
status: r.detected ? "done" : r.status === "translating" ? "active" : "pending",
});
@@ -180,9 +187,7 @@ function TranslatorPageClientInner() {
{state.tab === "translate" && advancedSlot}
- {state.tab === "monitor" && (
- setTab("translate")} />
- )}
+ {state.tab === "monitor" && setTab("translate")} />}
);
}
@@ -201,9 +206,7 @@ function AutoFeaturesCard() {
className="flex w-full items-center justify-between p-4 text-left"
>
-
- auto_fix_high
-
+
auto_fix_high
{t("autoFeaturesTitle")}
{t("autoFeaturesCount")}
diff --git a/src/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion.tsx b/src/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion.tsx
index ee13753420..427800c8f1 100644
--- a/src/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion.tsx
+++ b/src/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion.tsx
@@ -45,13 +45,15 @@ function sanitizeError(e: unknown): string {
// Constants
// ---------------------------------------------------------------------------
-const COMPRESSION_MODES = [
- { value: "off", label: "Off" },
- { value: "lite", label: "Lite" },
- { value: "standard", label: "Standard" },
- { value: "aggressive", label: "Aggressive" },
- { value: "ultra", label: "Ultra" },
-] as const;
+const COMPRESSION_MODES = ["off", "lite", "standard", "aggressive", "ultra"] as const;
+
+const COMPRESSION_MODE_LABEL_KEYS = {
+ off: "compressionModeOff",
+ lite: "compressionModeLite",
+ standard: "compressionModeStandard",
+ aggressive: "compressionModeAggressive",
+ ultra: "compressionModeUltra",
+} as const;
// ---------------------------------------------------------------------------
// Inner content (always mounted when hasOpened is true)
@@ -59,6 +61,7 @@ const COMPRESSION_MODES = [
function CompressionPreviewContent({ inputContent = "" }: { inputContent?: string }) {
const t = useTranslations("translator");
+ const ts = useTranslations("settings");
const [compressionMode, setCompressionMode] = useState("standard");
const [compressionResult, setCompressionResult] = useState(null);
@@ -90,14 +93,14 @@ function CompressionPreviewContent({ inputContent = "" }: { inputContent?: strin
body: JSON.stringify({ messages, mode: compressionMode }),
});
const data: CompressionPreviewResult & { error?: string } = await res.json();
- if (!res.ok) throw new Error(data.error ?? "Preview failed");
+ if (!res.ok) throw new Error(data.error ?? t("compressionPreviewFailed"));
setCompressionResult(data);
} catch (e: unknown) {
setCompressionError(sanitizeError(e));
} finally {
setCompressionLoading(false);
}
- }, [hasInput, inputContent, compressionMode]);
+ }, [hasInput, inputContent, compressionMode, t]);
return (
@@ -107,10 +110,7 @@ function CompressionPreviewContent({ inputContent = "" }: { inputContent?: strin
info
-
- {t("compressionEmptyHint") ||
- "Fill in the input field on the Translate tab (Simple Controls or Raw JSON) to enable the preview."}
-
+ {t("compressionEmptyHint")}
)}
@@ -119,9 +119,12 @@ function CompressionPreviewContent({ inputContent = "" }: { inputContent?: strin
setCompressionMode(e.target.value)}
- options={COMPRESSION_MODES}
+ options={COMPRESSION_MODES.map((value) => ({
+ value,
+ label: ts(COMPRESSION_MODE_LABEL_KEYS[value]),
+ }))}
className="text-sm"
- aria-label={t("compressionModeLabel") || "Compression mode"}
+ aria-label={t("compressionModeLabel")}
/>
- {compressionLoading
- ? t("compressionPreviewing") || "Previewing…"
- : t("compressionPreviewButton") || "Preview Compression"}
+ {compressionLoading ? t("compressionPreviewing") : t("compressionPreviewButton")}
@@ -151,24 +152,24 @@ function CompressionPreviewContent({ inputContent = "" }: { inputContent?: strin
data-testid="compression-result-grid"
>
-
Original
+
{t("compressionOriginal")}
{compressionResult.originalTokens}
-
tokens
+
{t("tokens")}
-
Compressed
+
{t("compressionCompressed")}
{compressionResult.compressedTokens}
-
tokens
+
{t("tokens")}
-
Saved
+
{t("compressionSaved")}
{compressionResult.tokensSaved}
{compressionResult.savingsPct}%
-
Duration
+
{t("compressionDuration")}
{compressionResult.durationMs}
ms
@@ -176,7 +177,7 @@ function CompressionPreviewContent({ inputContent = "" }: { inputContent?: strin
{compressionResult.techniquesUsed.length > 0 && (
- {t("techniques") || "Techniques:"} {" "}
+ {t("techniques")} {" "}
{compressionResult.techniquesUsed.join(", ")}
)}
@@ -223,12 +224,13 @@ export default function CompressionPreviewAccordion({
useEffect(() => {
const prev = prevForceOpen.current;
prevForceOpen.current = Boolean(forceOpen);
- if (!prev && forceOpen) {
- // eslint-disable-next-line react-hooks/set-state-in-effect -- syncing deep-link prop into local state
+ if (prev || !forceOpen) return;
+ const openFromDeepLink = setTimeout(() => {
setOpen(true);
setHasOpened(true);
onOpenChange?.(true);
- }
+ }, 0);
+ return () => clearTimeout(openFromDeepLink);
}, [forceOpen, onOpenChange]);
const handleToggle = useCallback(() => {
@@ -240,10 +242,8 @@ export default function CompressionPreviewAccordion({
onOpenChange?.(next);
}, [open, hasOpened, onOpenChange]);
- // i18n with inline EN fallbacks (D19 pattern).
- const title = t("advancedCompressionTitle") || "Compression Preview";
- const subtitle =
- t("advancedCompressionSubtitle") || "Estime economia de tokens em diferentes modos.";
+ const title = t("advancedCompressionTitle");
+ const subtitle = t("advancedCompressionSubtitle");
return (
{
- if (forceOpen && !open) {
+ if (!forceOpen || open) return;
+ const openFromDeepLink = setTimeout(() => {
setOpen(true);
setHasOpened(true);
- }
+ }, 0);
+ return () => clearTimeout(openFromDeepLink);
}, [forceOpen, open]);
const handleOpenChange = useCallback(
@@ -142,7 +148,7 @@ export default function PipelineView({
if (next) setHasOpened(true);
onOpenChange?.(next);
},
- [onOpenChange],
+ [onOpenChange]
);
const steps = pipelineSteps ?? DEMO_STEPS;
@@ -181,23 +187,24 @@ export default function PipelineView({
{/* Demo badge when showing placeholder data */}
{!pipelineSteps && (
-
+
info
{tr(
"pipelineVisualizationHint",
- "Envie um request pelo Chat Tester para ver o pipeline em tempo real. Abaixo: exemplo estático.",
+ "Envie um request pelo Chat Tester para ver o pipeline em tempo real. Abaixo: exemplo estático."
)}
)}
{/* Step list */}
-
+
{steps.map((step, i) => {
const meta = (step.format && FORMAT_META[step.format]) ?? {
label: step.format ?? "unknown",
diff --git a/src/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel.tsx b/src/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel.tsx
index 200670dd16..839e77a229 100644
--- a/src/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel.tsx
+++ b/src/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel.tsx
@@ -46,11 +46,13 @@ export default function RawJsonPanel({
// Sync forceOpen changes from parent (deep-link after mount).
useEffect(() => {
- if (forceOpen && !open) {
+ if (!forceOpen || open) return;
+ const openFromDeepLink = setTimeout(() => {
setOpen(true);
setHasOpened(true);
onOpenChange?.(true);
- }
+ }, 0);
+ return () => clearTimeout(openFromDeepLink);
}, [forceOpen, open, onOpenChange]);
const handleOpenChange = useCallback(
@@ -59,7 +61,7 @@ export default function RawJsonPanel({
if (next) setHasOpened(true);
onOpenChange?.(next);
},
- [onOpenChange],
+ [onOpenChange]
);
// ── Translator state (copied from PlaygroundMode.tsx) ──────────────────────
@@ -238,7 +240,10 @@ export default function RawJsonPanel({
return (
{translationPath === "hub-and-spoke" ? (
- {tr("translationPathHubSpoke", "").replace("{source}", FORMAT_META[sourceFormat]?.label ?? sourceFormat).replace("{target}", FORMAT_META[targetFormat]?.label ?? targetFormat) ||
+ {tr("translationPathHubSpoke", "")
+ .replace("{source}", FORMAT_META[sourceFormat]?.label ?? sourceFormat)
+ .replace("{target}", FORMAT_META[targetFormat]?.label ?? targetFormat) ||
`${FORMAT_META[sourceFormat]?.label ?? sourceFormat} → OpenAI → ${FORMAT_META[targetFormat]?.label ?? targetFormat}`}
) : translationPath === "direct" ? (
- {tr("translationPathDirect", "").replace("{source}", FORMAT_META[sourceFormat]?.label ?? sourceFormat).replace("{target}", FORMAT_META[targetFormat]?.label ?? targetFormat) ||
+ {tr("translationPathDirect", "")
+ .replace("{source}", FORMAT_META[sourceFormat]?.label ?? sourceFormat)
+ .replace("{target}", FORMAT_META[targetFormat]?.label ?? targetFormat) ||
`${FORMAT_META[sourceFormat]?.label ?? sourceFormat} → ${FORMAT_META[targetFormat]?.label ?? targetFormat}`}
) : (
@@ -490,7 +499,7 @@ export default function RawJsonPanel({
onClick={() => handleCopy(intermediateContent)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title={tc("copy" as Parameters[0])}
- aria-label="Copy intermediate JSON"
+ aria-label={tr("copyIntermediateJson", "Copy intermediate JSON")}
>
content_copy
@@ -543,7 +552,7 @@ export default function RawJsonPanel({
onClick={() => handleCopy(outputContent)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title={tc("copy" as Parameters[0])}
- aria-label="Copy output JSON"
+ aria-label={tr("copyOutputJson", "Copy output JSON")}
>
content_copy
@@ -619,15 +628,12 @@ export default function RawJsonPanel({
{activeTemplate && (
-
+
info
{tr("templateLoadHint", "Template loaded for format: {format}").replace(
"{format}",
- FORMAT_META[sourceFormat]?.label ?? sourceFormat,
+ FORMAT_META[sourceFormat]?.label ?? sourceFormat
)}
)}
diff --git a/src/app/(dashboard)/dashboard/translator/components/advanced/StreamTransformerAccordion.tsx b/src/app/(dashboard)/dashboard/translator/components/advanced/StreamTransformerAccordion.tsx
index 0c1e966b68..7bf6bcedb3 100644
--- a/src/app/(dashboard)/dashboard/translator/components/advanced/StreamTransformerAccordion.tsx
+++ b/src/app/(dashboard)/dashboard/translator/components/advanced/StreamTransformerAccordion.tsx
@@ -206,7 +206,10 @@ export default function StreamTransformerAccordion({
setTransformedSse(data.transformed || "");
} catch (err) {
- const raw = err instanceof Error ? err.message : "Failed to transform stream";
+ const raw =
+ err instanceof Error
+ ? err.message
+ : translateOrFallback("streamTransformFailed", "Failed to transform stream");
// Defence-in-depth: strip any accidental stack-trace suffix.
setError(raw.replace(/\s+at\s+\/.*/g, ""));
} finally {
diff --git a/src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx b/src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx
index aef40fcaec..2a28e42c5b 100644
--- a/src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx
+++ b/src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx
@@ -121,34 +121,29 @@ function projectEndOfMonth(monthlyCost: number, now = new Date()): number {
return burnRate * daysInMonth;
}
-const STATUS_META: Record
= {
- all: { label: "All", tone: "text-text-main", bg: "bg-bg-subtle", dot: "var(--color-text-muted)" },
+const STATUS_META: Record = {
+ all: { tone: "text-text-main", bg: "bg-bg-subtle", dot: "var(--color-text-muted)" },
blocked: {
- label: "Blocked",
tone: "text-red-400",
bg: "bg-red-500/10 border-red-500/30",
dot: "#ef4444",
},
alerting: {
- label: "Alerting",
tone: "text-amber-400",
bg: "bg-amber-500/10 border-amber-500/30",
dot: "#f59e0b",
},
warning: {
- label: "Warning",
tone: "text-yellow-400",
bg: "bg-yellow-500/10 border-yellow-500/30",
dot: "#eab308",
},
safe: {
- label: "Safe",
tone: "text-emerald-400",
bg: "bg-emerald-500/10 border-emerald-500/30",
dot: "#22c55e",
},
"no-limit": {
- label: "No limit",
tone: "text-text-muted",
bg: "bg-bg-subtle border-border",
dot: "var(--color-text-muted)",
@@ -185,6 +180,13 @@ export default function BudgetTab() {
return DEFAULT_TEMPLATES;
});
const [breakdownCache, setBreakdownCache] = useState>({});
+ const templateName = useCallback(
+ (template: Template) => {
+ const key = `budgetTemplateNames.${template.id}`;
+ return t.has(key) ? t(key) : template.name;
+ },
+ [t]
+ );
const formatCurrency = useCallback(
(value: number | undefined | null) =>
@@ -238,33 +240,37 @@ export default function BudgetTab() {
}, []);
useEffect(() => {
- void loadAll();
+ const timer = window.setTimeout(() => void loadAll(), 0);
+ return () => window.clearTimeout(timer);
}, [loadAll]);
- const fetchBreakdown = useCallback(async (apiKeyId: string) => {
- try {
- const r = await fetch(`/api/usage/analytics?range=30d&apiKeyIds=${apiKeyId}`);
- if (!r.ok) return;
- const data = await r.json();
- const arr = Array.isArray(data?.byProvider) ? data.byProvider : [];
- const total =
- arr.reduce((s: number, p: any) => s + Number(p?.totalCost ?? p?.cost ?? 0), 0) || 0;
- const breakdown: ProviderBreakdown[] = arr
- .map((p: any) => {
- const cost = Number(p?.totalCost ?? p?.cost ?? 0);
- return {
- provider: String(p?.provider ?? "unknown"),
- cost,
- pct: total > 0 ? (cost / total) * 100 : 0,
- };
- })
- .filter((p: ProviderBreakdown) => p.cost > 0)
- .sort((a: ProviderBreakdown, b: ProviderBreakdown) => b.cost - a.cost);
- setBreakdownCache((prev) => ({ ...prev, [apiKeyId]: breakdown }));
- } catch {
- /* breakdown is best-effort */
- }
- }, []);
+ const fetchBreakdown = useCallback(
+ async (apiKeyId: string) => {
+ try {
+ const r = await fetch(`/api/usage/analytics?range=30d&apiKeyIds=${apiKeyId}`);
+ if (!r.ok) return;
+ const data = await r.json();
+ const arr = Array.isArray(data?.byProvider) ? data.byProvider : [];
+ const total =
+ arr.reduce((s: number, p: any) => s + Number(p?.totalCost ?? p?.cost ?? 0), 0) || 0;
+ const breakdown: ProviderBreakdown[] = arr
+ .map((p: any) => {
+ const cost = Number(p?.totalCost ?? p?.cost ?? 0);
+ return {
+ provider: String(p?.provider ?? t("unknownProvider")),
+ cost,
+ pct: total > 0 ? (cost / total) * 100 : 0,
+ };
+ })
+ .filter((p: ProviderBreakdown) => p.cost > 0)
+ .sort((a: ProviderBreakdown, b: ProviderBreakdown) => b.cost - a.cost);
+ setBreakdownCache((prev) => ({ ...prev, [apiKeyId]: breakdown }));
+ } catch {
+ /* breakdown is best-effort */
+ }
+ },
+ [t]
+ );
// ── Derived data ─────────────────────────────────────────────────────────
@@ -364,7 +370,7 @@ export default function BudgetTab() {
const applyTemplateToSelected = useCallback(
async (template: Template) => {
if (selectedIds.size === 0) {
- notify.error("No keys selected");
+ notify.error(t("budgetNoKeysSelected"));
return;
}
setSaving(true);
@@ -386,24 +392,29 @@ export default function BudgetTab() {
})
)
);
- notify.success(`Applied "${template.name}" to ${selectedIds.size} keys`);
+ notify.success(
+ t("budgetTemplateApplied", {
+ template: templateName(template),
+ count: selectedIds.size,
+ })
+ );
setSelectedIds(new Set());
await loadAll();
} catch {
- notify.error("Failed to apply template");
+ notify.error(t("budgetTemplateApplyFailed"));
} finally {
setSaving(false);
}
},
- [loadAll, notify, selectedIds]
+ [loadAll, notify, selectedIds, t, templateName]
);
// ── Effects ──────────────────────────────────────────────────────────────
useEffect(() => {
- if (expandedKeyId && !breakdownCache[expandedKeyId]) {
- fetchBreakdown(expandedKeyId);
- }
+ if (!expandedKeyId || breakdownCache[expandedKeyId]) return;
+ const timer = window.setTimeout(() => void fetchBreakdown(expandedKeyId), 0);
+ return () => window.clearTimeout(timer);
}, [expandedKeyId, breakdownCache, fetchBreakdown]);
// ── Render ───────────────────────────────────────────────────────────────
@@ -447,14 +458,12 @@ export default function BudgetTab() {
account_balance_wallet
- Budget
+ {t("budgetPageTitle")}
-
- Set daily/weekly/monthly spend limits per API key
-
+ {t("budgetPageDescription")}
- {templates.length} templates · edit via localStorage:{" "}
+ {t("budgetTemplateStorageHint", { count: templates.length })}{" "}
{LS_TEMPLATES}
@@ -466,7 +475,7 @@ export default function BudgetTab() {
label={t("budgetKpiProjEom")}
value={formatCurrency(stats.projectionEom)}
tone={projectionOverBudget ? "amber" : undefined}
- hint={projectionOverBudget ? "above limit ⚠" : "on track"}
+ hint={projectionOverBudget ? t("budgetAboveLimitShort") : t("budgetOnTrackShort")}
/>
0 ? "amber" : undefined}
- hint="≥ warning"
+ hint={t("budgetAtOrAboveWarning")}
/>
)}
- {meta.label}
+ {t(`budgetStatus.${key}`)}
{count}
);
@@ -545,7 +554,7 @@ export default function BudgetTab() {
{templates.length > 0 && (
- Templates:
+ {t("budgetTemplates")}:
{templates.map((tpl) => (
{tpl.emoji}
- {tpl.name}
+ {templateName(tpl)}
- {tpl.monthlyLimitUsd ? `$${tpl.monthlyLimitUsd}/mo` : `$${tpl.dailyLimitUsd}/d`}
+ {tpl.monthlyLimitUsd
+ ? t("budgetTemplateMonthlyAmount", { amount: tpl.monthlyLimitUsd })
+ : t("budgetTemplateDailyAmount", { amount: tpl.dailyLimitUsd })}
))}
{selectedIds.size > 0 && (
- {selectedIds.size} selected · click a template to apply
+ {t("budgetSelectedTemplateHint", { count: selectedIds.size })}
)}
@@ -600,13 +611,13 @@ export default function BudgetTab() {
/>
- Key
- Today
- Month
+ {t("budgetColumnKey")}
+ {t("budgetColumnToday")}
+ {t("budgetColumnMonth")}
{t("budgetColDailyLim")}
{t("budgetColMonthlyLim")}
{t("budgetColUsedPct")}
- Status
+ {t("budgetColumnStatus")}
{visibleRows.length === 0 ? (
@@ -703,6 +714,7 @@ function BudgetRow({
onSave: (payload: any) => void;
saving: boolean;
}) {
+ const t = useTranslations("usage");
const status = statusOf(row);
const meta = STATUS_META[status];
const today = row.budget?.totalCostToday || 0;
@@ -780,7 +792,7 @@ function BudgetRow({
- {meta.label.toUpperCase()}
+ {t(`budgetStatus.${status}`).toUpperCase()}
@@ -857,7 +869,7 @@ function BudgetRowExpanded({
- Projection
+ {t("budgetProjection")}
{t("budgetLinearExtrapolation")}
@@ -876,7 +888,9 @@ function BudgetRowExpanded({
>
{formatCurrency(projection)}
{projectionOver && (
-
⚠ above ${monthly}/mo
+
+ {t("budgetAboveMonthlyLimit", { limit: formatCurrency(monthly) })}
+
)}
@@ -886,7 +900,7 @@ function BudgetRowExpanded({
- Cost breakdown (30d)
+ {t("budgetCostBreakdown30d")}
{t("budgetByProvider")}
@@ -922,7 +936,7 @@ function BudgetRowExpanded({
@@ -1005,9 +1019,7 @@ function BudgetRowExpanded({
>
{t("saveLimits")}
-
- 💡 Hard-cap policy and email alerts coming soon
-
+
💡 {t("budgetHardCapComingSoon")}
diff --git a/src/app/(dashboard)/dashboard/usage/components/FreeBudgetCard.tsx b/src/app/(dashboard)/dashboard/usage/components/FreeBudgetCard.tsx
index e879a04902..63f0e979ab 100644
--- a/src/app/(dashboard)/dashboard/usage/components/FreeBudgetCard.tsx
+++ b/src/app/(dashboard)/dashboard/usage/components/FreeBudgetCard.tsx
@@ -2,6 +2,7 @@
import { useState, useEffect, useMemo } from "react";
import React from "react";
+import { useTranslations } from "next-intl";
import { matchesSearch } from "@/shared/utils/turkishText";
// ────────────────────────────────────────────────────────────────────────────
@@ -78,20 +79,72 @@ export function relativeTimeFromNow(iso: string, now: number = Date.now()): stri
return `${year}y ago`;
}
-const FREE_TYPE_LABEL: Record = {
- "recurring-daily": "daily",
- "recurring-monthly": "monthly",
- "recurring-credit": "credit/mo",
- "recurring-uncapped": "uncapped",
- "one-time-initial": "signup credit",
- keyless: "keyless",
- discontinued: "discontinued",
+interface FreeBudgetLabels {
+ title: string;
+ remaining: (remaining: string, percent: number, total: string) => string;
+ steadyMonth: string;
+ firstMonth: string;
+ usedThisMonth: string;
+ segmentHint: string;
+ boost: (tokens: string) => string;
+ uncapped: string;
+ tosRestricted: (count: number) => string;
+ provider: string;
+ model: string;
+ type: string;
+ tokensMonth: string;
+ credit: (tokens: string) => string;
+ freeTypes: Record;
+ tosTitles: Record;
+}
+
+const DEFAULT_LABELS: FreeBudgetLabels = {
+ title: "Free-token budget",
+ remaining: (remaining, percent, total) => `${remaining} remaining · ${percent}% of ${total}`,
+ steadyMonth: "Steady / month",
+ firstMonth: "First month (+ credits)",
+ usedThisMonth: "Used this month",
+ segmentHint:
+ "Each segment = one free pool · pool-deduped, honest counting (no inflated rate-limit ceilings).",
+ boost: (tokens) =>
+ `Unlock ~${tokens} more/mo with a one-time $10 OpenRouter top-up (50 → 1000 req/day)`,
+ uncapped:
+ "Permanently free, no published cap (rate-limited) — real access, not counted in the headline:",
+ tosRestricted: (count) =>
+ `${count} model${count === 1 ? "" : "s"} flagged as ToS-restricted — you decide`,
+ provider: "Provider",
+ model: "Model",
+ type: "Type",
+ tokensMonth: "Tokens/mo",
+ credit: (tokens) => `${tokens} credit`,
+ freeTypes: {
+ "recurring-daily": "daily",
+ "recurring-monthly": "monthly",
+ "recurring-credit": "credit/mo",
+ "recurring-uncapped": "uncapped",
+ "one-time-initial": "signup credit",
+ keyless: "keyless",
+ discontinued: "discontinued",
+ },
+ tosTitles: {
+ avoid: "ToS-restricted — review terms",
+ caution: "Caution — personal-use / proxy clauses",
+ ok: "Generally permissive",
+ },
};
// Distinct hues for stacked bar segments (cycling)
const BAR_HUES = [
- "#6366f1", "#10b981", "#f59e0b", "#3b82f6", "#ec4899",
- "#14b8a6", "#f97316", "#8b5cf6", "#06b6d4", "#84cc16",
+ "#6366f1",
+ "#10b981",
+ "#f59e0b",
+ "#3b82f6",
+ "#ec4899",
+ "#14b8a6",
+ "#f97316",
+ "#8b5cf6",
+ "#06b6d4",
+ "#84cc16",
];
const RECURRING_TYPES = new Set(["recurring-daily", "recurring-monthly", "keyless"]);
@@ -134,7 +187,11 @@ function buildBarSegments(perModel: FreeBudgetPerModel[]): BarSegment[] {
color: colorFor(m.provider),
});
} else if (m.monthlyTokens > existing.tokens) {
- seenPools.set(m.poolKey, { ...existing, tokens: m.monthlyTokens, label: `${m.displayName} (${m.provider})` });
+ seenPools.set(m.poolKey, {
+ ...existing,
+ tokens: m.monthlyTokens,
+ label: `${m.displayName} (${m.provider})`,
+ });
}
} else {
looseSegments.push({
@@ -161,7 +218,9 @@ function sortRows(rows: FreeBudgetPerModel[], sort: FreeBudgetSort): FreeBudgetP
const copy = rows.slice();
if (sort === "name") return copy.sort((a, b) => a.displayName.localeCompare(b.displayName));
if (sort === "provider")
- return copy.sort((a, b) => a.provider.localeCompare(b.provider) || b.monthlyTokens - a.monthlyTokens);
+ return copy.sort(
+ (a, b) => a.provider.localeCompare(b.provider) || b.monthlyTokens - a.monthlyTokens
+ );
return copy.sort((a, b) => b.monthlyTokens - a.monthlyTokens || b.creditTokens - a.creditTokens);
}
@@ -194,10 +253,19 @@ function filterRows(
return out;
}
-function tosBadge(tos: string): { icon: string; cls: string; title: string } | null {
- if (tos === "avoid") return { icon: "warning", cls: "text-amber-400", title: "ToS-restricted — review terms" };
- if (tos === "caution") return { icon: "bolt", cls: "text-text-muted", title: "Caution — personal-use / proxy clauses" };
- if (tos === "ok") return { icon: "check_circle", cls: "text-emerald-500", title: "Generally permissive" };
+function tosBadge(
+ tos: string,
+ labels: FreeBudgetLabels
+): { icon: string; cls: string; title: string } | null {
+ if (tos === "avoid") {
+ return { icon: "warning", cls: "text-amber-400", title: labels.tosTitles.avoid };
+ }
+ if (tos === "caution") {
+ return { icon: "bolt", cls: "text-text-muted", title: labels.tosTitles.caution };
+ }
+ if (tos === "ok") {
+ return { icon: "check_circle", cls: "text-emerald-500", title: labels.tosTitles.ok };
+ }
return null;
}
@@ -209,7 +277,9 @@ function Kpi({ label, value, valueClass }: { label: string; value: string; value
return (
{label}
- {value}
+
+ {value}
+
);
}
@@ -218,8 +288,7 @@ function Kpi({ label, value, valueClass }: { label: string; value: string; value
// Free-type badge (keyless gets an emerald highlight; the rest stay neutral)
// ────────────────────────────────────────────────────────────────────────────
-function FreeTypeBadge({ freeType }: { freeType: string }) {
- const label = FREE_TYPE_LABEL[freeType] ?? freeType;
+function FreeTypeBadge({ freeType, label }: { freeType: string; label: string }) {
const isKeyless = freeType === "keyless";
return (
savings
- Free-token budget
+ {labels.title}
{freshness && (
)}
- {fmt(remaining)} remaining · {pct}% of {fmt(steadyRecurringTokens)}
+ {labels.remaining(fmt(remaining), pct, fmt(steadyRecurringTokens))}
{/* KPI tiles */}
-
-
-
+
+
+
{/* Stacked bar — pool-deduped; segments sum to steadyRecurringTokens */}
@@ -323,7 +398,8 @@ export function FreeBudgetView({
{barSegments.map((seg) => {
- const width = totalBarTokens > 0 ? ((seg.tokens / totalBarTokens) * 100).toFixed(2) : "0";
+ const width =
+ totalBarTokens > 0 ? ((seg.tokens / totalBarTokens) * 100).toFixed(2) : "0";
return (
-
- Each segment = one free pool · pool-deduped, honest counting (no inflated rate-limit ceilings).
-
+
{labels.segmentHint}
)}
@@ -373,15 +447,13 @@ export function FreeBudgetView({
bolt
- Unlock ~{fmt(boostMonthlyTokens)} more/mo with a one-time $10 OpenRouter top-up (50 → 1000 req/day)
+ {labels.boost(fmt(boostMonthlyTokens))}
)}
{uncappedProviders.length > 0 && (
-
- Permanently free, no published cap (rate-limited) — real access, not counted in the headline:
-
+
{labels.uncapped}
{uncappedProviders.map((p) => (
warning
- {avoidModels.length} model{avoidModels.length !== 1 ? "s" : ""} flagged as ToS-restricted — you decide
+ {labels.tosRestricted(avoidModels.length)}
)}
@@ -412,10 +484,10 @@ export function FreeBudgetView({
- Provider
- Model
- Type
- Tokens/mo
+ {labels.provider}
+ {labels.model}
+ {labels.type}
+ {labels.tokensMonth}
ToS
@@ -428,12 +500,12 @@ export function FreeBudgetView({
)}
{rows.map((m) => {
- const badge = tosBadge(m.tos);
+ const badge = tosBadge(m.tos, labels);
const amount =
m.monthlyTokens > 0
? fmt(m.monthlyTokens)
: m.creditTokens > 0
- ? `${fmt(m.creditTokens)} credit`
+ ? labels.credit(fmt(m.creditTokens))
: "—";
return (
{m.provider}
-
+
{m.displayName}
-
+
{amount}
@@ -482,6 +560,7 @@ export function FreeBudgetView({
// ────────────────────────────────────────────────────────────────────────────
export default function FreeBudgetCard() {
+ const t = useTranslations("freeBudget");
const [data, setData] = useState(null);
const [sort, setSort] = useState("tokens");
const [hideAvoid, setHideAvoid] = useState(false);
@@ -551,18 +630,18 @@ export default function FreeBudgetCard() {
onChange={(e) => setHideAvoid(e.target.checked)}
className="accent-indigo-500"
/>
- Hide ToS-restricted
+ {t("hideTosRestricted")}
- Sort
+ {t("sort")}
setSort(e.target.value as FreeBudgetSort)}
className="rounded border border-border bg-surface px-1.5 py-0.5 text-[11px] text-text-main"
>
- Tokens/mo
- Provider
- Model name
+ {t("tokensMonth")}
+ {t("provider")}
+ {t("modelName")}
@@ -573,6 +652,36 @@ export default function FreeBudgetCard() {
search={search}
providerFilter={providerFilter}
keylessOnly={keylessOnly}
+ labels={{
+ title: t("title"),
+ remaining: (remaining, percent, total) => t("remaining", { remaining, percent, total }),
+ steadyMonth: t("steadyMonth"),
+ firstMonth: t("firstMonth"),
+ usedThisMonth: t("usedThisMonth"),
+ segmentHint: t("segmentHint"),
+ boost: (tokens) => t("boost", { tokens }),
+ uncapped: t("uncapped"),
+ tosRestricted: (count) => t("tosRestricted", { count }),
+ provider: t("provider"),
+ model: t("model"),
+ type: t("type"),
+ tokensMonth: t("tokensMonth"),
+ credit: (tokens) => t("credit", { tokens }),
+ freeTypes: {
+ "recurring-daily": t("freeType.daily"),
+ "recurring-monthly": t("freeType.monthly"),
+ "recurring-credit": t("freeType.creditMonthly"),
+ "recurring-uncapped": t("freeType.uncapped"),
+ "one-time-initial": t("freeType.signupCredit"),
+ keyless: t("freeType.keyless"),
+ discontinued: t("freeType.discontinued"),
+ },
+ tosTitles: {
+ avoid: t("tosTitle.avoid"),
+ caution: t("tosTitle.caution"),
+ ok: t("tosTitle.ok"),
+ },
+ }}
/>
);
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderUsdCostModal.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderUsdCostModal.tsx
index b5c911a0f0..36cc6b3d72 100644
--- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderUsdCostModal.tsx
+++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderUsdCostModal.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
+import { useTranslations } from "next-intl";
interface ProviderWindowCostRow {
apiKeyKey: string;
@@ -55,10 +56,10 @@ function formatUsd(value: number | null | undefined): string {
}).format(numeric);
}
-function formatDateTime(value: string | null | undefined): string {
- if (!value) return "unknown";
+function formatDateTime(value: string | null | undefined, fallback: string): string {
+ if (!value) return fallback;
const date = new Date(value);
- if (!Number.isFinite(date.getTime())) return "unknown";
+ if (!Number.isFinite(date.getTime())) return fallback;
return date.toLocaleString([], {
month: "short",
day: "2-digit",
@@ -79,6 +80,7 @@ export default function ProviderUsdCostModal({
providerLabel,
accountLabel,
}: Props) {
+ const t = useTranslations("usageLimits");
const [payload, setPayload] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
@@ -103,7 +105,7 @@ export default function ProviderUsdCostModal({
if (alive) setPayload(data);
} catch (loadError) {
if (alive) {
- setError(loadError instanceof Error ? loadError.message : "Failed to load USD costs");
+ setError(loadError instanceof Error ? loadError.message : t("loadUsdCostsFailed"));
setPayload(null);
}
} finally {
@@ -115,7 +117,7 @@ export default function ProviderUsdCostModal({
return () => {
alive = false;
};
- }, [isOpen, connection?.id, connection?.provider]);
+ }, [isOpen, connection?.id, connection?.provider, t]);
const maxCost = useMemo(
() => Math.max(...(payload?.rows || []).map((row) => row.costUsd), 0),
@@ -139,7 +141,7 @@ export default function ProviderUsdCostModal({
>
-
USD Cost
+
{t("usdCost")}
{providerLabel} · {accountLabel || connection?.id}
@@ -148,7 +150,7 @@ export default function ProviderUsdCostModal({
type="button"
onClick={onClose}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-bg-subtle text-text-main hover:bg-black/[0.04] dark:hover:bg-white/[0.04]"
- aria-label="Close"
+ aria-label={t("close")}
>
close
@@ -160,7 +162,7 @@ export default function ProviderUsdCostModal({
progress_activity
- Loading USD costs
+ {t("loadingUsdCosts")}
) : error ? (
@@ -171,14 +173,16 @@ export default function ProviderUsdCostModal({
-
Used
+
+ {t("used")}
+
{formatUsd(payload.totalCostUsd)}
- Quota used
+ {t("quotaUsed")}
{formatPercent(payload.quotaUsedPercent)}
@@ -186,7 +190,7 @@ export default function ProviderUsdCostModal({
- Est. 100%
+ {t("estimatedFullQuota")}
{payload.estimatedFullQuotaUsd === null
@@ -195,7 +199,9 @@ export default function ProviderUsdCostModal({
-
Rows
+
+ {t("rows")}
+
{payload.rows.length}
@@ -205,22 +211,22 @@ export default function ProviderUsdCostModal({
- Window: {formatDateTime(payload.windowStartAt)} →{" "}
- {formatDateTime(payload.windowResetAt)}
+ {t("window")}: {formatDateTime(payload.windowStartAt, t("unknown"))} →{" "}
+ {formatDateTime(payload.windowResetAt, t("unknown"))}
{payload.windowStartSource === "recorded_reset_event"
- ? `From recorded ${payload.quotaName || "weekly quota"} reset`
+ ? t("fromRecordedReset", { quota: payload.quotaName || t("weeklyQuota") })
: payload.windowStartSource === "observed_snapshot_reset"
- ? `From observed ${payload.quotaName || "weekly quota"} reset`
+ ? t("fromObservedReset", { quota: payload.quotaName || t("weeklyQuota") })
: payload.windowSource === "provider_weekly_reset"
- ? `From ${payload.quotaName || "weekly quota"} reset`
- : "Fallback rolling 7d"}
+ ? t("fromReset", { quota: payload.quotaName || t("weeklyQuota") })
+ : t("fallbackRollingDaysShort", { days: 7 })}
-
Quota estimator
+
{t("quotaEstimator")}
{simulatedPercent}% ={" "}
{simulatedUsd === null ? "n/a" : formatUsd(simulatedUsd)}
@@ -241,7 +247,7 @@ export default function ProviderUsdCostModal({
{payload.rows.length === 0 ? (
- No API key usage in this provider window.
+ {t("noApiKeyUsage")}
) : (
@@ -258,8 +264,10 @@ export default function ProviderUsdCostModal({
{row.apiKeyName}
- {row.requests.toLocaleString()} requests ·{" "}
- {row.totalTokens.toLocaleString()} tokens
+ {t("requestTokenCounts", {
+ requests: row.requests.toLocaleString(),
+ tokens: row.totalTokens.toLocaleString(),
+ })}
@@ -272,7 +280,9 @@ export default function ProviderUsdCostModal({
{row.limitPeriod ? ` ${row.limitPeriod}` : ""}
) : (
-
No USD limit
+
+ {t("noUsdLimit")}
+
)}
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx
index a51ea35c2a..77fadc7f26 100644
--- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx
+++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx
@@ -162,7 +162,9 @@ function QuotaDetailRow({
paid
-
{formatQuotaLabel(q.name) || "Credits"}
+
+ {formatQuotaLabel(q.name) || t("creditsLabel")}
+
{q.staleAfterReset ? (
- ⟳
+ ⟳
) : cd ? (
- ⏱ reset in {cd}
+
+ ⏱ {t("resetsIn")} {cd}
+
) : null}
@@ -407,7 +411,7 @@ export default function QuotaCardExpanded({
className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-bg-subtle hover:bg-black/[0.04] dark:hover:bg-white/[0.04] cursor-pointer"
>
bar_chart
- USD Cost
+ {t("usdCost")}
);
const isRtl = RTL_LOCALES.includes(locale as (typeof RTL_LOCALES)[number]);
@@ -138,7 +139,7 @@ export default async function RootLayout({ children }) {
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:bg-[#6366f1] focus:text-white focus:rounded-lg focus:text-sm focus:font-semibold focus:shadow-lg"
>
- Skip to content
+ {t("skipToContent")}
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index f92b07449d..df52ca0896 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -4,11 +4,19 @@
"cancel": "Cancel",
"delete": "Delete",
"loading": "Loading...",
+ "selectOption": "Select an option",
"error": "An error occurred",
"success": "Success",
"confirm": "Are you sure?",
"refresh": "Refresh",
"close": "Close",
+ "previousPage": "Previous page",
+ "nextPage": "Next page",
+ "capsLockOn": "Caps Lock is on",
+ "dismissNotification": "Dismiss notification",
+ "toggleColumns": "Toggle columns",
+ "confirmTitle": "Confirm",
+ "confirmAction": "Confirm",
"add": "Add",
"edit": "Edit",
"search": "Search",
@@ -145,6 +153,17 @@
"goToDashboard": "Go to Dashboard",
"checkSystemStatus": "Check System Status",
"selectModel": "Select Model",
+ "addModel": "Add model",
+ "effortNone": "None",
+ "effortLow": "Low",
+ "effortMedium": "Medium",
+ "effortHigh": "High",
+ "effortExtraHigh": "Extra high",
+ "effortMax": "Max",
+ "effortUltra": "Ultra",
+ "reasoningEffort": "Reasoning effort",
+ "wireApi": "Wire API",
+ "modelAliases": "Model aliases",
"combos": "Combos",
"noModelsFound": "No models found",
"clear": "Clear",
@@ -685,6 +704,42 @@
"tokensRedeemCode": "Redeem Code",
"tokensRedeemCodePlaceholder": "Enter invite code",
"tokensYourActiveInvites": "Your Active Invites",
+ "tokensTransferSuccess": "Transfer successful ({idempotencyKey})",
+ "tokensTransferFailed": "Transfer failed",
+ "tokensCreateInviteFailed": "Failed to create invite",
+ "tokensRevokeInviteFailed": "Failed to revoke invite",
+ "tokensRedeemSuccess": "Invite redeemed. Server: {server}",
+ "tokensRedeemFailed": "Failed to redeem invite",
+ "tokensConnectServerFailed": "Failed to connect server",
+ "tokensDisconnectServerFailed": "Failed to disconnect server",
+ "tokensAmount": "Amount",
+ "tokensSending": "Sending...",
+ "tokensFrom": "From",
+ "tokensTo": "To",
+ "tokensReason": "Reason",
+ "tokensDate": "Date",
+ "tokensSent": "Sent",
+ "tokensReceived": "Received",
+ "tokensCreatingInvite": "Creating...",
+ "tokensCreateInvite": "Create Invite",
+ "tokensInviteCreated": "Invite code created",
+ "tokensRedeeming": "Redeeming...",
+ "tokensRedeem": "Redeem",
+ "tokensInviteUses": "{used}/{max} uses",
+ "tokensRevoked": "REVOKED",
+ "tokensRevoke": "Revoke",
+ "tokensConnecting": "Connecting...",
+ "tokensConnectServer": "Connect Server",
+ "tokensNoServersConnected": "No servers connected. Connect to a community server to share leaderboards.",
+ "tokensServerStatus": {
+ "connected": "Connected",
+ "disconnected": "Disconnected",
+ "pending": "Pending",
+ "syncing": "Syncing",
+ "error": "Error"
+ },
+ "tokensLastSync": "Last sync: {date}",
+ "tokensDisconnect": "Disconnect",
"tierCoverageTitle": "Tier coverage",
"tierCoverageSubtitle": "Providers configured per fallback tier",
"batchDetailCopyId": "Copy ID",
@@ -708,8 +763,23 @@
"batchFileDetailCopyId": "Copy ID",
"batchFileDetailClose": "Close",
"batchFileDetailFailedToLoad": "Failed to load file contents",
+ "batchFileDetailLoadError": "Error loading file contents",
"batchFilesListSearchPlaceholder": "Search by ID or filename…",
"batchFilesListFilesTable": "Files",
+ "batchFilesCount": "{count, plural, one {# file} other {# files}}",
+ "batchFilesAllPurposes": "All purposes",
+ "batchFilesFilename": "Filename",
+ "batchFilesPurpose": "Purpose",
+ "batchFilesExpires": "Expires",
+ "batchFilesNoneFound": "No files found",
+ "batchFilesNeverExpires": "Never",
+ "batchFileInUseByActiveBatch": "File in use by an active batch",
+ "batchFilePurpose": {
+ "batch": "Batch input",
+ "batch-output": "Batch output",
+ "fine-tune": "Fine-tuning",
+ "assistants": "Assistants"
+ },
"batchPageLoadingMore": "Loading more…",
"recommended": "Recommended",
"understand": "I understand",
@@ -744,7 +814,22 @@
"wizardDropOrPick": "Drop a file or click to pick",
"wizardCsvMappingTitle": "Map CSV columns → request fields",
"wizardCsvMappingAddField": "Add field",
+ "wizardCsvNoColumns": "No columns detected in CSV header.",
+ "wizardCsvIgnoreColumn": "— ignore —",
+ "wizardCsvCustomIdMapped": "custom_id mapped",
+ "wizardCsvContentMapped": "Content field mapped (messages, input, or prompt)",
+ "wizardCsvApplyMapping": "Apply mapping",
+ "wizardCsvRowsParsed": "{count} rows parsed",
+ "wizardCsvRowsSkipped": "{count} rows skipped",
+ "wizardCsvRowError": "Row {row}: {reason}",
"wizardValidationOk": "All lines valid",
+ "wizardValidating": "Validating…",
+ "wizardValidationParseFailed": "Validation failed — could not parse content.",
+ "wizardValidationSummary": "{lines} lines · {ids} unique custom_ids",
+ "wizardValidationErrorCount": "{count, plural, one {# error} other {# errors}} found",
+ "wizardValidationDuplicateIds": "Duplicate custom_ids detected:",
+ "wizardValidationFirstErrors": "Errors (first {count}):",
+ "wizardValidationLine": "Line {line}",
"wizardValidationErrors": "Validation errors",
"wizardValidationPreview": "Preview (first 5 requests)",
"wizardValidationSamplingNote": "File is large — validated by sampling (first 1000 + last 100 lines). Full validation runs server-side.",
@@ -963,7 +1048,19 @@
"contextCcr": "CCR",
"contextLlmlingua": "LLMLingua",
"combosLive": "Combo Studio",
+ "combosLiveSubtitle": "Live routing cascade",
"compressionStudio": "Compression Studio",
+ "contextSettingsSubtitle": "Global defaults",
+ "contextHeadroomSubtitle": "Tabular compaction",
+ "contextSessionDedupSubtitle": "Cross-turn deduplication",
+ "contextCcrSubtitle": "Retrieve markers",
+ "contextLlmlinguaSubtitle": "Semantic pruning",
+ "contextLiteSubtitle": "Fast whitespace cleanup",
+ "contextAggressiveSubtitle": "Summary and aging",
+ "contextUltraSubtitle": "Heuristic pruning",
+ "contextOmniglyphSubtitle": "Context as images",
+ "compressionStudioSubtitle": "Live engine cascade",
+ "chaosConfigSubtitle": "Multi-model parallel execution",
"routingSection": "Routing",
"protocolsSection": "Protocols",
"agentsAiSection": "Agents & AI",
@@ -1112,6 +1209,7 @@
"acpAgents": "ACP Agents",
"acpAgentsSubtitle": "CLIs spawned by OmniRoute",
"skipToContent": "Skip to content",
+ "mainNavigation": "Main navigation",
"unpinSection": "Unpin section",
"pinSectionOpen": "Pin section open",
"reloadPage": "Reload Page",
@@ -1375,6 +1473,11 @@
},
"header": {
"logout": "Logout",
+ "quickNavigation": "Quick nav",
+ "quickNavigationTitle": "Quick navigation (⌘K / Ctrl+K)",
+ "openQuickNavigation": "Open quick navigation",
+ "switchToLightMode": "Switch to light mode",
+ "switchToDarkMode": "Switch to dark mode",
"language": "Language",
"providers": "Providers",
"providerDescription": "Manage your AI provider connections",
@@ -1474,6 +1577,108 @@
"oneProxyDescription": "Configure 1Proxy settings for advanced proxy chaining",
"omniSkillsDescription": "Install and manage sandbox skills for automated prompt and tool execution"
},
+ "cloudSyncStatus": {
+ "synced": "Synced",
+ "syncing": "Syncing...",
+ "off": "Sync off",
+ "error": "Sync error",
+ "disabled": "Disabled",
+ "connected": "connected",
+ "disconnected": "disconnected",
+ "lastSync": "Remote settings sync {status} — Last sync: {time}",
+ "statusLabel": "Remote settings sync status: {status}"
+ },
+ "breadcrumbs": {
+ "ariaLabel": "Breadcrumb",
+ "dashboard": "Dashboard",
+ "providers": "Providers",
+ "combos": "Combos",
+ "settings": "Settings",
+ "general": "General",
+ "appearance": "Appearance",
+ "ai": "AI Settings",
+ "routing": "Routing",
+ "resilience": "Resilience",
+ "advanced": "Advanced",
+ "accessTokens": "Access Tokens",
+ "featureFlags": "Feature Flags",
+ "logs": "Logs",
+ "auditLog": "Audit Log",
+ "console": "Console",
+ "logger": "Logger",
+ "translator": "Translator",
+ "playground": "Playground",
+ "add": "Add",
+ "edit": "Edit",
+ "apiKeys": "API Keys",
+ "models": "Models",
+ "cliCode": "CLI Code",
+ "cliAgents": "CLI Agents",
+ "acpAgents": "ACP Agents",
+ "endpoint": "Endpoint",
+ "apiManager": "API Manager",
+ "context": "Context",
+ "compression": "Compression",
+ "services": "Services",
+ "analytics": "Analytics",
+ "costs": "Costs",
+ "health": "Health",
+ "runtime": "Runtime",
+ "webhooks": "Webhooks",
+ "home": "Home",
+ "activity": "Activity",
+ "agentSkills": "AgentSkills",
+ "comboHealth": "Combo Health",
+ "evals": "Evals",
+ "search": "Search",
+ "utilization": "Utilization",
+ "apiEndpoints": "API Endpoints",
+ "audit": "Audit",
+ "a2a": "A2A",
+ "mcp": "MCP",
+ "batch": "Batch",
+ "files": "Files",
+ "media": "Media",
+ "cache": "Cache",
+ "changelog": "Changelog",
+ "chaos": "Chaos",
+ "cloudAgents": "Cloud Agents",
+ "live": "Live",
+ "studio": "Studio",
+ "aggressive": "Aggressive",
+ "caveman": "Caveman",
+ "ccr": "CCR",
+ "headroom": "Headroom",
+ "lite": "Lite",
+ "llmlingua": "LLMLingua",
+ "omniglyph": "OmniGlyph",
+ "rtk": "RTK",
+ "sessionDedup": "Session Dedup",
+ "ultra": "Ultra",
+ "budget": "Budget",
+ "pricing": "Pricing",
+ "quotaShare": "Quota Share",
+ "discovery": "Discovery",
+ "freeProviderRankings": "Free Provider Rankings",
+ "freeTiers": "Free Tiers",
+ "gamification": "Gamification",
+ "leaderboard": "Leaderboard",
+ "limits": "Limits",
+ "profile": "Profile",
+ "plugins": "Plugins",
+ "providerStats": "Provider Stats",
+ "new": "New",
+ "quota": "Quota",
+ "relay": "Relay",
+ "searchTools": "Search Tools",
+ "security": "Security",
+ "sidebar": "Sidebar",
+ "tokens": "Tokens",
+ "tools": "Tools",
+ "agentBridge": "Agent Bridge",
+ "trafficInspector": "Traffic Inspector",
+ "usage": "Usage"
+ },
"home": {
"quickStart": "Quick Start",
"quickStartDesc": "Get up and running in 4 steps. Connect providers, route models, monitor everything.",
@@ -1544,6 +1749,19 @@
"chartModelUsageOverTime": "Model Usage Over Time",
"chartNoData": "No data",
"chartWeekly": "Weekly",
+ "activitySummary": "{active} active days · {tokens} tokens · {days} days",
+ "activityCellTitle": "{date}: {tokens} tokens",
+ "activityLess": "Less",
+ "activityMore": "More",
+ "chartApiKeyBreakdown": "API Key Breakdown",
+ "chartApiKey": "API Key",
+ "mostActiveDay": "Most Active Day",
+ "datedTokenCount": "{date} · {tokens} tokens",
+ "noDataLast7Days": "No data in the last 7 days",
+ "requestTokenSummary": "{requests} requests · {tokens} tokens",
+ "chartByAccount": "By Account",
+ "chartByApiKey": "By API Key",
+ "unknownApiKey": "Unknown API key",
"chartModelBreakdown": "Model Breakdown",
"chartModel": "Model",
"chartProvider": "Provider",
@@ -1598,7 +1816,10 @@
"evalsDescription": "Run evaluation suites to test and validate your LLM endpoints. Compare model quality, detect regressions, and benchmark latency.",
"overview": "Overview",
"evals": "Evals",
+ "search": "Search",
"utilization": "Utilization",
+ "routeTrace": "Route Trace",
+ "sectionsAria": "Analytics sections",
"utilizationDescription": "Provider quota usage trends and rate limit tracking",
"modelStatus": "Model Status",
"modelStatusCooldown": "Cooldown",
@@ -1629,6 +1850,83 @@
"comboHealthTitle": "Combo health",
"comboHealthUnableToLoad": "Unable to load combo health",
"comboHealthGettingStarted": "Getting started",
+ "comboHealthForecastTitle": "Cost & quota forecast",
+ "comboHealthForecastDescription": "Linear projection from historical combo traffic and quota snapshots.",
+ "comboHealthQuotaRisk": "{level} quota risk",
+ "comboHealthConfidence": "{level} confidence",
+ "comboHealthProjectedCost": "Projected cost",
+ "comboHealthCostHistory": "history {total} · {daily}/day",
+ "comboHealthProjectedRequests": "Projected requests",
+ "comboHealthRequestsInRange": "{count} in selected range",
+ "comboHealthWorstProjectedQuota": "Worst projected quota",
+ "comboHealthNoDepletionEstimate": "No depletion estimate",
+ "comboHealthDaysToExhaust": "{days}d to exhaust",
+ "comboHealthTraffic": "traffic",
+ "comboHealthProjectedQuota": "Projected quota",
+ "comboHealthPricingCoverage": "Pricing coverage",
+ "comboHealthAutopilotTitle": "Combo Health Autopilot",
+ "comboHealthAutopilotDescription": "Prioritized recommendations from combo health, forecasts, quotas, and provider health.",
+ "comboHealthIssues": "Issues",
+ "comboHealthActionable": "{count} actionable",
+ "comboHealthDown": "Down",
+ "comboHealthDegraded": "Degraded",
+ "comboHealthHealthy": "Healthy",
+ "comboHealthNoActiveIssues": "No active combo health issues detected for the selected range.",
+ "comboHealthScoringInspector": "Intelligent scoring inspector",
+ "comboHealthReadOnlyRecompute": "Read-only recompute",
+ "comboHealthScoringDescription": "Factor-level explanation for target ranking using current health, forecast, and routing heuristics.",
+ "comboHealthTask": "Task: {task}",
+ "comboHealthSelectedRank": "Selected rank #1",
+ "comboHealthFactor": {
+ "quota": "Quota",
+ "health": "Health",
+ "costInv": "Cost",
+ "latencyInv": "Latency",
+ "taskFit": "Task fit",
+ "stability": "Stability",
+ "tierPriority": "Tier",
+ "tierAffinity": "Tier fit",
+ "specificityMatch": "Specificity",
+ "contextAffinity": "Context",
+ "resetWindowAffinity": "Reset window"
+ },
+ "comboHealthQuotaValue": "Quota {value}",
+ "comboHealthLatencyValue": "Latency {value}ms",
+ "comboHealthIssueCount": "Issues {count}",
+ "comboHealthNoInspectableTargets": "No inspectable targets for this combo.",
+ "comboHealthAutopilotState": {
+ "down": "Down",
+ "degraded": "Needs attention",
+ "healthy": "Healthy"
+ },
+ "comboHealthModelProviderCount": "{models} models across {providers} providers",
+ "comboHealthGiniCoefficient": "Gini coefficient",
+ "comboHealthRequestCount": "{count} requests",
+ "comboHealthQuotaHealthDescription": "Lowest remaining quota across providers with short trend signals.",
+ "comboHealthRemainingQuota": "Remaining quota {value}",
+ "comboHealthTrend": {
+ "improving": "Improving",
+ "declining": "Declining",
+ "stable": "Stable"
+ },
+ "comboHealthUsageSkewDescription": "Model request share and token share within this combo.",
+ "comboHealthShareSummary": "Request share {requests} · Token share {tokens}",
+ "comboHealthPerformance": "Performance",
+ "comboHealthPerformanceDescription": "Reliability and throughput for routed combo traffic.",
+ "comboHealthExecutionTargetsDescription": "Step-level runtime metrics and quota visibility for structured combo targets.",
+ "comboHealthRequestShort": "{count} req",
+ "comboHealthQuotaScope": "Quota scope: {scope}",
+ "comboHealthTrendValue": "Trend: {trend}",
+ "comboHealthFetchFailed": "Failed to fetch combo health data",
+ "unknownError": "Unknown error",
+ "comboHealthIntro": "Monitor quota pressure, skewed model usage, and delivery performance by combo.",
+ "comboHealthForecastHorizon": "{value} forecast",
+ "comboHealthNoData": "No combo health data available",
+ "comboHealthNoDataDescription": "Combo quota snapshots and routed requests will appear here after traffic starts flowing.",
+ "comboHealthStepCreate": "Create combos in Combos with multiple providers",
+ "comboHealthStepSend": "Send requests to combo endpoints to generate traffic data",
+ "comboHealthStepAutomatic": "Health metrics will appear automatically as requests are routed",
+ "comboHealthTracking": "Tracking {count} combos for {range}",
"compressionAnalyticsTotalRequests": "Total Requests",
"compressionAnalyticsTokensSaved": "Tokens Saved",
"compressionAnalyticsAvgSavings": "Avg Savings",
@@ -1640,6 +1938,26 @@
"compressionAnalyticsTotalTokens": "Total tokens",
"compressionAnalyticsCacheTokens": "Cache tokens",
"compressionAnalyticsNoDataYet": "No compression data yet",
+ "compressionAnalyticsLoading": "Loading compression analytics…",
+ "compressionAnalyticsNoDataDescription": "Compression requests will appear here after the first request through /v1/chat/completions with compression enabled.",
+ "rangeLast24h": "Last 24h",
+ "rangeLast7d": "Last 7d",
+ "rangeLast30d": "Last 30d",
+ "rangeAllTime": "All time",
+ "compressionAnalyticsModeStats": "{count} requests · {tokens} tokens saved",
+ "compressionAnalyticsSkipped": " · {count} skipped (no-op)",
+ "compressionAnalyticsRealTokens": "{count} real tokens",
+ "compressionAnalyticsValidationRestores": "validation restores",
+ "compressionAnalyticsRealUsageReceipts": "Real Usage Receipts",
+ "compressionAnalyticsSources": "Sources",
+ "compressionAnalyticsModeBreakdown": "Mode Breakdown",
+ "compressionAnalyticsProviderBreakdown": "Provider Breakdown",
+ "compressionAnalyticsLast24HoursActivity": "Last 24 Hours (Activity)",
+ "compressionAnalyticsChartPoint": "{hour}: {count} requests, {tokens} tokens saved",
+ "compressionAnalyticsMaxRequests": "Max requests/hour: {count}",
+ "compressionAnalyticsMaxTokens": "Max tokens/hour: {count}",
+ "compressionAnalyticsStartTracking": "Use POST /v1/chat/completions with compression configuration to start tracking compression analytics.",
+ "compressionAnalyticsInfo": "Compression analytics: Token savings tracked per mode (off, lite, standard, aggressive, ultra, RTK, stacked), engine, compression combo, and provider. Hover over charts for details. Use the time selector to view different time periods.",
"searchAnalyticsTotalSearches": "Total Searches",
"searchAnalyticsCacheHitRate": "Cache Hit Rate",
"searchAnalyticsTotalCost": "Total Cost",
@@ -1650,7 +1968,80 @@
"providerUtilizationNoData": "No utilization data available",
"providerUtilizationGettingStarted": "Getting started",
"providerUtilizationLatestSnapshot": "Latest quota snapshot",
- "providerUtilizationRemainingCapacity": "Remaining capacity"
+ "providerUtilizationRemainingCapacity": "Remaining capacity",
+ "utilizationRange": {
+ "1h": "Last hour",
+ "24h": "Last 24 hours",
+ "7d": "Last 7 days",
+ "30d": "Last 30 days"
+ },
+ "providerUtilizationGlobalView": "Global View",
+ "providerUtilizationAccountSplit": "Account Split",
+ "providerUtilizationLoading": "Loading utilization data…",
+ "retrying": "Retrying…",
+ "retry": "Retry",
+ "providerUtilizationNoDataDescription": "Provider quota snapshots will appear here after utilization data is collected.",
+ "providerUtilizationStepConnect": "Connect providers through OAuth or API keys in Providers ",
+ "providerUtilizationStepEnable": "Enable quota tracking by using the provider in a combo or direct request",
+ "providerUtilizationStepAutomatic": "Data will appear automatically as quota snapshots are collected",
+ "statusExhausted": "Exhausted",
+ "statusLow": "Low",
+ "statusHealthy": "Healthy",
+ "remainingQuota": "Remaining quota",
+ "routeTraceTitle": "Route Trace View",
+ "routeTraceDescription": "Inspect the persisted request trace: selected target, routing factors, fallback evidence, current scoring replay, latency, tokens and target health.",
+ "routeTraceRequestLog": "Request log",
+ "routeWeight": "Weight {weight}%",
+ "routeNoRelatedEvidence": "No related target evidence persisted yet.",
+ "unknown": "unknown",
+ "selected": "Selected",
+ "routeNoStepId": "no step id",
+ "routeNoStep": "no step",
+ "routeMatchesTopTarget": "Matches current top target",
+ "routeDiffersFromTop": "Differs from current top",
+ "routeTargetMissingNow": "Target missing now",
+ "routeNotComboRouted": "Not combo routed",
+ "routeWhyTarget": "Why this target?",
+ "routeWhyTargetSubtitle": "Exact runtime metadata plus read-only scoring replay",
+ "routeExactRuntimeLog": "Exact runtime log",
+ "routeCallLogsExact": "call_logs exact",
+ "routeReadOnlyRecompute": "Read-only recompute",
+ "routeRuntimeRankNow": "Runtime rank now",
+ "routeRuntimeScoreNow": "Runtime score now",
+ "routeWouldSelectNow": "Would select now",
+ "routeNoRecomputeCandidates": "No combo candidate ranking can be recomputed for this request.",
+ "runtime": "Runtime",
+ "routeTopNow": "Top now",
+ "routeFetchLogsFailed": "Failed to fetch request logs",
+ "routeExplainFailed": "Failed to explain route",
+ "direct": "direct",
+ "routeUnableToLoad": "Unable to load route explanation",
+ "routeNoRequestLogs": "No request logs available",
+ "routeNoRequestLogsDescription": "Send traffic through OmniRoute first. Route explanations are generated from persisted structured call logs.",
+ "routeDecisionSummary": "Decision summary",
+ "routeConfidence": "{confidence} confidence",
+ "routeScore": "Route score",
+ "latency": "Latency",
+ "routeRecentSuccess": "Recent success",
+ "routeAvgTargetLatency": "Avg target latency",
+ "routeSelectedTarget": "Selected target",
+ "provider": "Provider",
+ "model": "Model",
+ "account": "Account",
+ "connection": "Connection",
+ "combo": "Combo",
+ "routeStep": "Step",
+ "tokens": "Tokens",
+ "notAvailable": "n/a",
+ "routeTokenCounts": "{input} in · {output} out",
+ "routeEvidence": "Evidence",
+ "routeFactors": "Routing factors",
+ "routeFactorsSubtitle": "Weighted signals used for this explanation",
+ "routeFallbackTimeline": "Fallback and target timeline",
+ "routeFallbackTimelineSubtitle": "Inferred from persisted call logs around this request",
+ "routeRecommendations": "Recommendations",
+ "routeLimitations": "Limitations",
+ "routeNoKnownLimitations": "No known limitations for this explanation."
},
"apiManager": {
"title": "API Keys",
@@ -1786,6 +2177,15 @@
"restrictDesc": "This key can access {selectedCount} of {totalModels} models.",
"selectedCount": "{count} selected",
"maxActiveSessions": "Max Active Sessions",
+ "maxActiveSessionsDescription": "0 = unlimited. Return 429 when this key exceeds concurrent sticky sessions.",
+ "throttleDelay": "Throttle Delay",
+ "throttleDelayDescription": "Add a fixed delay before requests for this key are routed. 0 = no slowdown.",
+ "expandClaudeCodeFamilies": "Expand Claude Code families",
+ "removeClaudeCodeDefault": "Remove Claude Code default",
+ "allowedCombos": "Allowed Combos",
+ "restrict": "Restrict",
+ "allCombosAllowed": "This key can use any combo.",
+ "restrictedComboCount": "Restricted to {count, plural, one {# combo} other {# combos}}.",
"apiManagerCustomRateLimits": "Custom Rate Limits",
"apiManagerCustomRateLimitsDesc": "Override global default limits. Leave empty to use defaults.",
"apiManagerRateLimitRequestsPlaceholder": "Requests",
@@ -1818,6 +2218,8 @@
"disableNonPublicModelsDesc": "Reject requests for models that are not discovered or not marked as public in the provider catalog",
"normalKeysSection": "Normal keys",
"quotaKeysSection": "Quota keys",
+ "bypassProviderQuota": "Bypass provider quota cutoffs",
+ "bypassProviderQuotaDescription": "Allows this key to ignore upstream provider/account cutoff policy during routing. API key USD quotas still apply.",
"quotaPill": "QUOTA",
"quotaModeOnly": "qtSd-only"
},
@@ -1875,7 +2277,58 @@
"connections": "{count} Connections",
"noConnections": "No connections yet — add one from the provider page.",
"loading": "Loading...",
- "suggestedModels": "Suggested models from provider"
+ "suggestedModels": "Suggested models from provider",
+ "imageGeneration": "Image Generation",
+ "imageToText": "Image to Text",
+ "imageToTextComingSoon": "The inline Image-to-Text playground will be available when /api/v1/images/understanding is implemented.",
+ "disabled": "Disabled",
+ "videoGeneration": "Video Generation",
+ "musicGeneration": "Music Generation",
+ "textToSpeech": "Text to Speech",
+ "transcription": "Transcription",
+ "imagePromptPlaceholder": "A serene landscape with mountains at sunset...",
+ "videoPromptPlaceholder": "A timelapse of a flower blooming...",
+ "musicPromptPlaceholder": "Upbeat electronic music with synth pads...",
+ "speechTextPlaceholder": "Hello! Welcome to OmniRoute, your intelligent AI gateway...",
+ "transcriptionPlaceholder": "Upload an audio file to transcribe...",
+ "noImagesReturned": "No images returned. The provider might have accepted the request but returned empty data.",
+ "generatedImageAlt": "Generated image {index}",
+ "save": "Save",
+ "enterTextToSynthesize": "Please enter text to synthesize.",
+ "selectAudioToTranscribe": "Please select an audio file to transcribe.",
+ "noSpeechDetected": "No speech detected in the audio file. If you uploaded music or a silent file, try an audio file with spoken words. Provider: \"{provider}\".",
+ "emptyTranscription": "Transcription returned empty text. The audio may contain no recognizable speech, or the \"{provider}\" API key may be invalid. Check Dashboard → Logs → Proxy for details.",
+ "topazRequiresImage": "Topaz requires an input image.",
+ "enterPrompt": "Please enter a prompt.",
+ "enhanceThisImage": "Enhance this image",
+ "failedToReadFile": "Failed to read file",
+ "generationFailed": "Generation failed",
+ "requestFailed": "Request failed ({status})",
+ "provider": "Provider",
+ "credentialsRequired": "Requires API key in Providers ",
+ "voice": "Voice",
+ "format": "Format",
+ "audioVideoFile": "Audio / Video File",
+ "fileTooLarge": "File too large ({size}). Maximum allowed: {max}.",
+ "audioVideoFileHint": "Supports audio and video files up to 4 GB",
+ "sourceImage": "Source Image",
+ "sourceImageHint": "Optional for image-to-image, editing and upscale workflows.",
+ "maskImage": "Mask Image",
+ "maskImageHint": "Optional. Used by inpaint-style models that support masks.",
+ "text": "Text",
+ "promptOptional": "Prompt (optional)",
+ "enhancementInstructionsPlaceholder": "Optional enhancement instructions...",
+ "synthesizing": "Synthesizing...",
+ "transcribing": "Transcribing...",
+ "synthesizeSpeech": "Synthesize Speech",
+ "transcribeAudio": "Transcribe Audio",
+ "generateModality": "Generate {modality}",
+ "apiKeyRequired": "API Key Required",
+ "configureApiKeys": "Configure API keys in Providers",
+ "downloadFormat": "Download {format}",
+ "noTextReturned": "No text returned",
+ "wordTimestamps": "Word-level timestamps ({count} words)",
+ "providerCount": "{count} providers"
},
"search": {
"searchQuery": "Search Query",
@@ -1982,10 +2435,80 @@
"searchTypeLabel": "Type",
"rerankModelLabel": "Rerank model",
"noneOption": "None",
- "size": "Size"
+ "size": "Size",
+ "configurationPane": "Configuration pane",
+ "configuration": "Configuration",
+ "status": "Status",
+ "compareProviderHint": "Select up to 4 providers on the Compare tab to compare them side by side.",
+ "history": "History",
+ "historyHint": "History is available on the Search tab.",
+ "noActiveProvider": "No active search provider",
+ "configureMoreProviders": "Configure more providers",
+ "links": "Links",
+ "contentTruncated": "Content truncated to 256 KB (original size: {size})",
+ "viewFullRaw": "View full raw content",
+ "rawScrapedContent": "Raw scraped content",
+ "rawContent": "Raw content — {size}",
+ "closeRawModal": "Close raw content modal",
+ "httpError": "Error {status}",
+ "failed": "Failed",
+ "requestFailed": "Request failed",
+ "embedding": "Embedding",
+ "embeddingSample": "Hello, world!",
+ "image": "Image",
+ "imageSample": "A serene landscape with mountains at sunset",
+ "music": "Music",
+ "musicSample": "Upbeat jazz piano with light percussion",
+ "noAudioUrl": "No audio URL in response: {response}",
+ "documentUrl": "Document URL",
+ "fileTooLarge25Mb": "File too large — max 25 MB",
+ "selectAudioFirst": "Please select an audio file first.",
+ "speechToText": "Speech to Text",
+ "chooseFile": "Choose file…",
+ "audioFormats25Mb": "mp3, wav, m4a, ogg, flac — max 25 MB",
+ "textToSpeech": "Text to Speech",
+ "ttsSample": "Hello, this is a text-to-speech test.",
+ "video": "Video",
+ "videoSample": "A time-lapse of clouds over a mountain range",
+ "webFetch": "Web Fetch",
+ "webSearch": "Web Search",
+ "webSearchSample": "What is OmniRoute AI gateway?",
+ "noActiveProviderDescription": "No active search provider. Configure one in Providers.",
+ "configureProviders": "Configure providers",
+ "compareQuery": "Query to compare",
+ "compareQueryPlaceholder": "artificial intelligence trends 2026",
+ "selectedProviders": "Providers ({count} selected):",
+ "selectAll": "Select all",
+ "clear": "Clear",
+ "maxCompareProviders": "A maximum of {count} providers can be compared at once.",
+ "compareResults": "Results — “{query}”",
+ "resultCount": "{count} results",
+ "noResults": "No results",
+ "sharedResultTitle": "In common with another provider",
+ "sharedResult": "In common",
+ "overlapSummary": "{first} vs {second}: {overlap} in common",
+ "compareEmptyTitle": "Select providers and enter a query to compare",
+ "compareEmptyDescription": "Results will be shown side by side with latency, cost, and URL overlap"
},
"cliTools": {
"title": "CLI Tools",
+ "classifierCompatTitle": "Auto-permission classifier compatibility",
+ "classifierCompatDescription": "Short-circuit Claude Code’s --permission-mode auto security classifier with a synthetic allow response so fallback routes do not fail closed. Off by default.",
+ "classifierCompatCycle": "Cycle: off → auto → always",
+ "classifierCompatLoadFailed": "Failed to load setting",
+ "classifierCompatMode": {
+ "off": "Off",
+ "auto": "Auto",
+ "always": "Always"
+ },
+ "failedSave": "Failed to save",
+ "profileSyncTitle": "CLI profile auto-sync",
+ "profileSyncDescription": "After provider models are synchronized, automatically regenerate CLI tool profiles from the live catalog. Off by default — only profile files are written; the active/default configuration is never changed.",
+ "profileSyncLoadFailed": "Failed to load settings",
+ "codexProfiles": "Codex profiles",
+ "codexProfilesDescription": "Regenerate ~/.codex/*.config.toml after model discovery.",
+ "claudeProfiles": "Claude Code profiles",
+ "claudeProfilesDescription": "Regenerate each ~/.claude/profiles/…/settings.json after model discovery.",
"noActiveProviders": "No active providers",
"noActiveProvidersDesc": "Please add and connect providers first to configure CLI tools.",
"mapModels": "Map Models",
@@ -1994,6 +2517,18 @@
"configureEndpoint": "Configure Endpoint",
"instructions": "Instructions",
"modelMapping": "Model Mapping",
+ "reasoningEffort": "Reasoning effort",
+ "effortNone": "None",
+ "effortLow": "Low",
+ "effortMedium": "Medium",
+ "effortHigh": "High",
+ "effortExtraHigh": "Extra high",
+ "effortMax": "Max",
+ "effortUltra": "Ultra",
+ "wireApi": "Wire API",
+ "modelAliases": "Model aliases",
+ "addModel": "Add model",
+ "routeModelPlaceholder": "Route {model} to...",
"baseUrl": "Base URL",
"apiKey": "API Key",
"configured": "Configured",
@@ -2168,10 +2703,12 @@
"antigravity": "Google Antigravity IDE with MITM",
"claude": "Anthropic Claude Code CLI",
"codex": "OpenAI Codex CLI",
+ "grok-build": "xAI Grok Build TUI coding agent with custom provider support",
"droid": "Factory Droid AI Assistant",
"openclaw": "Open Claw AI Assistant",
"cline": "Cline AI Coding Assistant CLI",
"kilo": "Kilo Code AI Assistant CLI",
+ "qwen": "Alibaba Qwen Code CLI",
"cursor": "Cursor AI Code Editor",
"continue": "Continue AI Assistant",
"opencode": "OpenCode AI coding agent (Terminal)",
@@ -2181,7 +2718,23 @@
"amp": "Sourcegraph Amp coding assistant CLI",
"hermes": "Hermes AI Terminal Assistant",
"hermes-agent": "Hermes Agent (by Nousresearch) - Advanced terminal AI with multi-model support (delegation, vision, compression, etc.)",
- "custom": "Generic OpenAI-compatible CLI or SDK configuration generator"
+ "custom": "Generic OpenAI-compatible CLI or SDK configuration generator",
+ "aider": "Aider AI pair-programming CLI with an OpenAI-compatible base URL",
+ "forge": "ForgeCode coding agent CLI with a custom provider",
+ "cursor-cli": "Cursor Agent CLI in headless agent mode",
+ "roo": "Roo Code AI assistant for VS Code",
+ "jcode": "jcode terminal coding agent",
+ "deepseek-tui": "DeepSeek TUI coding agent written in Rust",
+ "codewhale": "CodeWhale coding agent, the successor to DeepSeek TUI",
+ "smelt": "Smelt coding agent CLI",
+ "pi": "Lightweight Pi terminal coding agent",
+ "crush": "Crush terminal coding agent by Charm",
+ "goose": "Goose autonomous agent CLI",
+ "interpreter": "Open Interpreter autonomous coding agent CLI",
+ "omp": "Oh My Pi terminal coding agent",
+ "letta": "Letta CLI agent with persistent memory and tool use",
+ "warp": "Warp AI terminal with custom provider support",
+ "agent-deck": "Agent Deck multi-agent orchestrator"
},
"guides": {
"cursor": {
@@ -2332,13 +2885,56 @@
"customCliEndpointHint": "Point any OpenAI-compatible client to the OmniRoute /v1 base URL. The raw chat completions endpoint is {endpoint}. Use the JSON block when the tool wants a provider object, or the env script when it reads OPENAI_* variables.",
"customCliEnvBlockTitle": "Env / shell snippet",
"customCliJsonBlockTitle": "Provider JSON block",
+ "networkError": "Network error",
+ "other": "Other",
+ "preview": "Preview",
+ "refreshAll": "Refresh all",
+ "hermesRoleDefault": "Default (main)",
+ "hermesRoleDefaultDesc": "Primary conversation model",
+ "hermesRoleDelegation": "Delegation (subagents)",
+ "hermesRoleDelegationDesc": "Orchestrator and sub-agent model",
+ "hermesRoleVision": "Vision",
+ "hermesRoleVisionDesc": "Image and screenshot understanding",
+ "hermesRoleCompression": "Compression",
+ "hermesRoleCompressionDesc": "Prompt compression and summarization",
+ "hermesRoleWebExtract": "Web Extract",
+ "hermesRoleWebExtractDesc": "Web page content extraction",
+ "hermesRoleSkillsHub": "Skills Hub",
+ "hermesRoleSkillsHubDesc": "Skills and tool-use reasoning",
+ "hermesRoleApproval": "Approval",
+ "hermesRoleApprovalDesc": "Safety and approval decisions",
+ "hermesSelectBeforePreview": "Select models for roles, or ensure the roles are loaded, before previewing.",
+ "hermesPreviewFailed": "Failed to generate preview",
+ "hermesSavedTo": "Saved to {path}",
+ "hermesFirstSetupTitle": "First set up via OmniRoute on {date}",
+ "hermesSinceSetup": "{time} since setup",
+ "hermesConfiguredRoles": "{configured}/{total} roles",
+ "hermesQuickApply": "Quickly apply the same model to all roles:",
+ "hermesApplyModelToAll": "Apply {model} to every role",
+ "hermesViaOmniRoute": "{provider} (via OmniRoute)",
+ "hermesNotOmniRoute": "{provider} (not OmniRoute)",
+ "hermesRemovePendingRole": "Remove this role from pending changes",
+ "hermesApply": "Apply to Hermes Agent",
+ "hermesRolesWillUpdate": "{count, plural, one {# role will be updated} other {# roles will be updated}}",
+ "hermesPreviewPath": "Preview — will write to ~/.hermes/config.yaml",
+ "hermesSaveDescription": "Saves the selected model for each role into",
"copilotConfigGenerator": "GitHub Copilot Config Generator",
+ "copilotGeneratorDescriptionPrefix": "Generates the",
+ "copilotGeneratorDescriptionSuffix": "block for VS Code GitHub Copilot using the Azure vendor pattern. Select the models you want, then copy the JSON into your config file.",
+ "copilotCompatibilityWarning": "This configuration uses the Azure vendor workaround for custom model lists. Tested with VS Code ≥ 1.109 and GitHub Copilot Chat ≥ v0.37 . Future extension updates may change this behavior.",
"copilotApiKey": "API Key",
+ "copilotSelectModels": "Select models ({selected}/{total})",
+ "selectAll": "Select all",
+ "loadingModels": "Loading models...",
+ "advancedOptions": "Advanced options",
+ "vision": "Vision",
+ "copilotCopyConfigForModels": "Copy config ({count, plural, one {# model} other {# models}})",
"copilotFilterModelsPlaceholder": "Filter models...",
"copilotMaxInputTokens": "Max Input Tokens",
"copilotMaxOutputTokens": "Max Output Tokens",
"copilotToolCalling": "Tool Calling",
"copilotPasteInto": "Paste into:",
+ "copilotReloadInstruction": "Then reload VS Code and set the API key in the input prompt.",
"wireApiChatCompletions": "Chat Completions (/chat/completions)",
"wireApiResponses": "Responses API (/responses)"
},
@@ -2440,6 +3036,45 @@
"moveDown": "Move down",
"removeModel": "Remove",
"saving": "Saving...",
+ "liveNoProviders": "No providers observed yet.",
+ "liveFleetDataHint": "Fleet data arrives through live combo events.",
+ "liveActiveCount": "Active ({count})",
+ "liveErrorCount": "Errors ({count})",
+ "liveInactiveCount": "Inactive ({count})",
+ "liveNoRun": "No combo run available.",
+ "liveDataHint": "Live data arrives through the WebSocket combo channel.",
+ "liveDisconnected": "Live disabled — WebSocket disconnected. Showing last known state.",
+ "liveSelectCombo": "Select combo",
+ "liveSelectComboPlaceholder": "— select combo —",
+ "liveTargetCount": "{count, plural, one {# target} other {# targets}}",
+ "liveSingle": "Single",
+ "liveFleet": "Fleet",
+ "playgroundStatusAvailable": "Available",
+ "playgroundStatusNoQuota": "No Quota",
+ "playgroundStatusDegraded": "Degraded",
+ "playgroundStatusError": "Error",
+ "playgroundStatusUnknown": "Unknown",
+ "playgroundNetworkError": "Network error during simulation",
+ "playgroundTitle": "Combo Playground",
+ "playgroundDescription": "Simulate how requests will be routed through your combos",
+ "playgroundConfiguration": "Configuration",
+ "playgroundNoCombosConfigured": "No combos configured",
+ "active": "active",
+ "inactive": "inactive",
+ "playgroundEstimatedPromptTokens": "Estimated Prompt Tokens",
+ "playgroundSimulating": "Simulating...",
+ "playgroundSimulateRoute": "Simulate Route",
+ "playgroundRoutingPath": "Routing Path",
+ "playgroundStrategy": "Strategy",
+ "playgroundEstimatedCost": "Est. Cost",
+ "playgroundEstimatedLatency": "Est. Latency",
+ "playgroundFallback": "fallback",
+ "playgroundWeight": "weight {value}",
+ "playgroundWarningCount": "Warnings ({count})",
+ "playgroundErrorCount": "Errors ({count})",
+ "playgroundEmptyHint": "Select a combo and click Simulate Route to see the routing path.",
+ "playgroundNoCombosYet": "No combos configured yet.",
+ "playgroundCreateFirst": "Create one first",
"weighted": "Weighted",
"leastUsed": "Least-Used",
"costOpt": "Cost-Opt",
@@ -2619,6 +3254,9 @@
"budgetCapLabel": "Budget Cap (USD / request)",
"budgetCapPlaceholder": "No limit",
"advancedWeightsTitle": "Advanced: Scoring Weights",
+ "nestedComboFlatten": "Flatten nested combos",
+ "nestedComboExecute": "Execute nested combos as targets",
+ "effectiveRoutingShare": "Effective routing share (weight ÷ total)",
"weightQuota": "Quota",
"weightHealth": "Health",
"weightCostInv": "Cost",
@@ -3134,6 +3772,51 @@
"apiEndpointsSearchPlaceholder": "Search endpoints...",
"apiEndpointsRequiresAuth": "Requires auth",
"apiEndpointsNoMatch": "No endpoints match your filter",
+ "endpointSections": "Endpoint sections",
+ "tabApis": "APIs",
+ "tabMcp": "MCP",
+ "tabA2a": "A2A",
+ "tabContextSources": "Context Sources",
+ "activeEndpoints": "Active Endpoints",
+ "activeLocal": "Local",
+ "activeCloud": "Cloud",
+ "copyUrlTitle": "Copy {url}",
+ "statusRunning": "Running",
+ "tunnels": "Tunnels",
+ "activeTunnelCount": "{active} / {total} active",
+ "badgeLocal": "LOCAL",
+ "badgeProtected": "PROTECTED",
+ "badgeInternal": "INTERNAL",
+ "catalogLoadFailed": "API catalog request failed with HTTP {status}",
+ "catalogLoadFailedGeneric": "Failed to load API catalog",
+ "apiKeysLoadFailed": "Failed to load API keys ({status})",
+ "apiKeysLoadFailedGeneric": "Failed to load API keys",
+ "apiKeyRevealDisabled": "API key reveal is disabled (ALLOW_API_KEY_REVEAL). Change it on the Feature Flags page or paste an API key manually.",
+ "apiKeyRevealFailed": "Failed to reveal API key ({status})",
+ "apiKeyRevealInvalid": "API key reveal returned an invalid response",
+ "apiKeyRequired": "An API key is required for this endpoint.",
+ "requestFailed": "Request failed ({status})",
+ "errorStatus": "Error",
+ "catalogStats": "{endpoints} endpoints across {categories} categories",
+ "catalogUnavailableDescription": "The OpenAPI specification could not be loaded.",
+ "openJsonResponse": "Open JSON response",
+ "all": "All",
+ "more": "+{count} more",
+ "showInternalTooltip": "Show or hide internal routes (hidden by default)",
+ "bearerAuth": "Bearer Auth",
+ "requestBody": "Request Body",
+ "close": "Close",
+ "tryIt": "Try It",
+ "example": "Example",
+ "apiKey": "API Key",
+ "switchToSelection": "Switch to Selection",
+ "enterManually": "Enter Manually",
+ "pasteApiKey": "Paste your API key here",
+ "noActiveApiKeys": "No active API keys found. Toggle manual entry to paste one.",
+ "requestBodyJson": "Request Body (JSON)",
+ "sending": "Sending...",
+ "sendRequest": "Send Request",
+ "dataSchemas": "Data Schemas",
"vscodeAliasTitle": "VS Code Token Alias",
"vscodeAliasDescriptionReady": "Ready-to-paste compatibility URLs using the /api/v1/vscode/TOKEN/... endpoint.",
"vscodeAliasDescriptionError": "Showing placeholder URLs because CLI keys could not be loaded in this session.",
@@ -3158,7 +3841,40 @@
"showInternal": "Show Internal",
"customSystemPromptTitle": "Custom System Prompt",
"customSystemPromptDescription": "Inject a custom system prompt into every model request",
- "customSystemPromptPlaceholder": "e.g. Always respond in pirate speak..."
+ "customSystemPromptPlaceholder": "e.g. Always respond in pirate speak...",
+ "obsidianEnterToken": "Please enter an Obsidian API token",
+ "obsidianConnectFailed": "Failed to connect",
+ "obsidianConnectionFailed": "Connection failed",
+ "obsidianDisconnectFailed": "Failed to disconnect",
+ "obsidianEnterVaultPath": "Please enter the vault directory path",
+ "obsidianWebdavEnabledMessage": "WebDAV sync enabled. Configure your mobile device below.",
+ "obsidianEnableWebdavFailed": "Failed to enable WebDAV",
+ "obsidianWebdavDisabledMessage": "WebDAV sync disabled",
+ "obsidianDisableWebdavFailed": "Failed to disable WebDAV",
+ "obsidianConnected": "Connected",
+ "obsidianNotConnected": "Not connected",
+ "obsidianWebdavSync": "WebDAV Sync",
+ "obsidianDescription": "Search, read, write, and manage notes in Obsidian through routed AI models",
+ "obsidianRestToken": "Obsidian Local REST API Token",
+ "obsidianApiKeyPlaceholder": "Obsidian API key",
+ "obsidianConnect": "Connect",
+ "obsidianBaseUrlOptional": "Base URL (optional)",
+ "obsidianPortWarning": "Port 27124 is the MCP endpoint (HTTPS, self-signed certificate). The REST API uses HTTP on port 27123.",
+ "obsidianRemoteVaultHint": "Default: {defaultUrl}. For remote vaults, enter the Tailscale IP + port (e.g., http://100.x.x.x:27123). Enable the Local REST API plugin on the machine running Obsidian.",
+ "obsidianTokenConfigured": "Token configured. Obsidian tools are available via MCP.",
+ "obsidianDisconnect": "Disconnect",
+ "obsidianVaultSync": "Vault Sync (WebDAV)",
+ "obsidianVaultSyncDescription": "Sync your vault to Obsidian mobile using WebDAV over Tailscale. Obsidian mobile has built-in WebDAV support — no plugins needed.",
+ "obsidianVaultDirectoryPath": "Vault Directory Path",
+ "obsidianEnable": "Enable",
+ "obsidianWebdavEnabled": "WebDAV sync enabled",
+ "obsidianDisable": "Disable",
+ "obsidianConfigureMobile": "Configure Obsidian Mobile",
+ "obsidianMobileInstructions": "In Obsidian mobile: Settings → Sync → WebDAV → enter the following:",
+ "obsidianWebdavUrl": "WebDAV URL",
+ "obsidianUsername": "Username",
+ "obsidianPassword": "Password",
+ "obsidianTailscaleHint": "Use your Tailscale IP instead of localhost if connecting from mobile. Both devices must be on the same Tailscale network."
},
"endpoints": {
"tabProxy": "Endpoint Proxy",
@@ -3525,6 +4241,16 @@
"description": "Manage and monitor AI skills",
"skillsTab": "Skills",
"executionsTab": "Executions",
+ "selectSkillToInspect": "Select a skill on the left to inspect.",
+ "schemaTab": "Schema",
+ "handlerTab": "Handler",
+ "inputSchema": "Input Schema",
+ "outputSchema": "Output Schema",
+ "handlerCode": "Handler Code",
+ "handlerUnavailable": "Handler not available",
+ "runTestPlaceholder": "Run test (placeholder)",
+ "setModeAria": "Set mode {mode}",
+ "uninstallSkill": "Uninstall skill",
"sandboxTab": "Sandbox",
"loading": "Loading skills...",
"noSkills": "No skills found",
@@ -3668,6 +4394,27 @@
"throttleStatus": "Throttle: {value}",
"lastHeaderUpdate": "Header update: {age}",
"databaseHealth": "Database Health",
+ "databaseHealthDescription": "Diagnose and repair stale quota/domain rows and broken combo references.",
+ "status": "Status",
+ "attentionNeeded": "Attention needed",
+ "repairs": "Repairs",
+ "repairing": "Repairing...",
+ "runAutoRepair": "Run Auto-Repair",
+ "repairBackupCreated": "A repair backup was created before mutating.",
+ "sessionActivity": "Session Activity",
+ "activeCount": "{count} active",
+ "requestCount": "{count, plural, one {# request} other {# requests}}",
+ "idleSeconds": "{count}s idle",
+ "ageSeconds": "{count}s age",
+ "quotaMonitors": "Quota Monitors",
+ "alerting": "Alerting",
+ "errors": "Errors",
+ "degradationFull": "Full",
+ "degradationReduced": "Reduced",
+ "degradationMinimal": "Minimal",
+ "degradationDefault": "Default",
+ "sinceTime": "Since {time}",
+ "retryIn": "retry in {duration}",
"stickyBoundSessions": "Sticky-bound sessions",
"sessionsByApiKey": "Sessions by API key",
"noActiveSessionsTracked": "No active sessions tracked yet.",
@@ -3864,7 +4611,20 @@
"consoleViewer": {
"fetchFailed": "Failed to fetch logs",
"copyFailed": "Failed to copy log entry",
- "copyLogEntry": "Copy log entry"
+ "copyLogEntry": "Copy log entry",
+ "filterByLevel": "Filter by log level",
+ "searchPlaceholder": "Search logs…",
+ "searchAria": "Search log entries",
+ "disableAutoScroll": "Disable auto-scroll",
+ "enableAutoScroll": "Enable auto-scroll",
+ "autoScroll": "Auto-scroll",
+ "entryCount": "{count, plural, one {# entry} other {# entries}}",
+ "lastHour": "Last 1h",
+ "updatedAt": "Updated {time}",
+ "fileLoggingRequired": "Make sure the application is writing logs to a file (APP_LOG_TO_FILE=true)",
+ "consoleAria": "Application console logs",
+ "applicationConsole": "Application Console",
+ "emptyFileLoggingHint": "Ensure APP_LOG_TO_FILE=true is set in your .env file"
},
"compressionLogTitle": "Compression Log",
"compressionLogEmpty": "No compressed requests yet. Compression stats will appear here when requests are processed with compression enabled.",
@@ -4798,13 +5558,45 @@
"webCookieProviders": "Web Cookie Providers",
"weeklyShort": "Weekly Short",
"xiaomiMimoBaseUrlHint": "Xiaomi Mimo Base Url Hint",
- "zedImportButton": "Zed Import Button",
- "zedImportFailed": "Zed Import Failed",
+ "globalCodexServiceMode": "Global Codex service mode",
+ "connect": "Connect",
+ "manualApiKey": "Manual API key",
+ "addPat": "Add PAT",
+ "experimentalOauth": "Experimental OAuth",
+ "importAuth": "Import auth",
+ "importGrokAuth": "Import Grok Build auth",
+ "zedImportTitle": "Import from Zed Keychain",
+ "zedImportDescription": "Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) stored by Zed IDE in the OS keychain and import them as connections. Zed IDE must be installed on this machine.",
+ "zedImportButton": "Import from Zed",
+ "zedImportFailed": "Zed import failed",
"zedImportHint": "Zed Import Hint",
"zedImportNetworkError": "Zed Import Network Error",
"zedImportNone": "Zed Import None",
- "zedImportSuccess": "Zed Import Success",
- "zedImporting": "Zed Importing",
+ "zedImportSuccess": "Imported {credentials} credential(s) from Zed for {providers} provider(s)",
+ "zedImporting": "Importing…",
+ "zedNoCredentials": "No Zed credentials found in the keychain",
+ "zedUnsupportedCredentials": "Found {count} keychain credential(s), but none matched supported providers",
+ "zedManualTitle": "Manual Token Import",
+ "zedManualDescription": "Use this when OmniRoute runs in Docker or the keychain is unavailable. Paste the API key that Zed stored under ~/.config/zed/settings.json , or copy it from the Zed AI settings panel.",
+ "zedPasteApiKey": "Paste API key…",
+ "zedSaving": "Saving…",
+ "zedImportAction": "Import",
+ "zedManualImportFailed": "Manual import failed",
+ "zedManualImportSuccess": "Imported {provider} token from Zed",
+ "grokImportTitle": "Import Grok Build Auth",
+ "grokImportDescription": "Import your Grok Build ~/.grok/auth.json file. You can get it by running grok login in your terminal.",
+ "grokUploadFile": "Upload file",
+ "grokPasteJson": "Paste JSON",
+ "grokInvalidAuth": "This is not a valid Grok Build auth.json file. Expected an object with a key containing a JWT.",
+ "grokParseFailed": "Could not parse JSON",
+ "grokImportFailed": "Failed to import Grok Build auth",
+ "grokImportSuccess": "Grok Build connection imported successfully",
+ "grokValidToken": "Valid Grok Build token detected",
+ "grokRefreshIncluded": "Refresh token included — automatic token renewal is enabled",
+ "grokRefreshMissing": "No refresh token found — re-import after the token expires",
+ "grokConnectionName": "Connection name (optional)",
+ "grokSaving": "Saving…",
+ "grokSaveConnection": "Save Connection",
"freeTierProviders": "Free Tier Providers",
"freeTierLabel": "Free tier available",
"freeTierProvidersDesc": "Providers with free tiers — some require an API key signup, others need no credentials at all.",
@@ -4821,6 +5613,16 @@
"providerDetailPathAutoDetectedAllOs": "Path is auto-detected per OS (Linux/Mac/Windows).",
"providerDetailMyClaudeAccountPlaceholder": "My Claude account",
"providerDetailPathAutoDetected": "Path is auto-detected per OS (Linux/Mac).",
+ "compatBlockedParamsPlaceholder": "thinking, … (comma-separated)",
+ "compatAllowedParamsPlaceholder": "reasoning, … (comma-separated)",
+ "compatSaving": "saving…",
+ "paramFiltersBlockedPlaceholder": "thinking, reasoning_budget, … (comma-separated)",
+ "paramFiltersAllowedPlaceholder": "reasoning, … (comma-separated)",
+ "customGatewayNamePlaceholder": "My Gateway",
+ "inherit": "Inherit",
+ "claudeImportEntryName": "entry {number}",
+ "claudeImportParseError": "parse error",
+ "claudeImportNoValidEntries": "No valid entries to import",
"webFetch": "Web Fetch",
"webFetchTooltip": "Providers that extract content from web URLs (HTML → Markdown, scrape, screenshot)",
"webFetchProvidersHeading": "Web Fetch Providers",
@@ -4906,6 +5708,199 @@
"onboardingConnectProvider": "Connect {provider}",
"onboardingOAuthFlowDescription": "OmniRoute will open the existing OAuth flow for this provider. After login, the wizard reloads the saved connection and runs the same connection test as the provider page.",
"onboardingStartOAuthFlow": "Start OAuth flow",
+ "onboardingProviderDescriptions": {
+ "360ai": "Get API key at ai.360.cn",
+ "agentrouter": "Get $200 free credits at https://agentrouter.org/register — no credit card required.",
+ "agnes": "Get API key at agnes-ai.com",
+ "aimlapi": "Free tier paused (2026) — AI/ML API is now pay-as-you-go only (min $20 top-up); no recurring free credits.",
+ "ai21": "$10 trial credits on signup (valid 3 months), no credit card required",
+ "alibaba": "Connect Alibaba with an API key.",
+ "alibaba-cn": "Connect Alibaba (China) with an API key.",
+ "bailian-coding-plan": "Connect Alibaba Coding Plan with an API key.",
+ "bedrock": "Native Bedrock integration: model discovery uses Bedrock foundation models and inference profiles, while chat uses the regional Bedrock Runtime Converse/ConverseStream APIs.",
+ "anthropic": "Connect Anthropic with an API key.",
+ "api-airforce": "Get your API key from https://panel.api.airforce — OpenAI-compatible endpoint at https://api.airforce/v1",
+ "arcee-ai": "Get API key at arcee.ai",
+ "azure-ai": "Foundry uses the OpenAI v1 surface with deployment names as models. OmniRoute normalizes root resource URLs to the v1 chat and /models endpoints.",
+ "azure-openai": "Use your Azure OpenAI API key. Base URL should be your resource endpoint, for example https://my-resource.openai.azure.com.",
+ "bai": "Bearer API key for the b.ai OpenAI-compatible LLM gateway (distinct from TheB.AI). Create a key at https://docs.b.ai, then use https://api.b.ai/v1 as the OpenAI-compatible base URL.",
+ "baichuan": "Get API key at platform.baichuan-ai.com",
+ "baidu": "Get API key at console.bce.baidu.com",
+ "qianfan": "Use a Qianfan API key from Baidu AI Cloud. The default endpoint is OpenAI-compatible v2.",
+ "baseten": "$30 free trial credits for GPU inference",
+ "bazaarlink": "Create a free API key at https://bazaarlink.ai — model 'auto:free' routes to zero-cost inference. All models use the provider/model-name format, e.g. xiaomi/mimo-v2.5-pro.",
+ "black-forest-labs": "Connect Black Forest Labs with an API key.",
+ "blackbox": "Free tier: unlimited basic chat plus Minimax-M2.5, no credit card required",
+ "bluesminds": "Get your API key at https://www.bluesminds.com — OpenAI-compatible endpoint at https://api.bluesminds.com/v1 with free daily credits. VIP models (Claude Opus 4.5, Gemini 2.5 Pro) consume pi credits.",
+ "byteplus": "Connect BytePlus ModelArk with an API key.",
+ "bytez": "$1 free credits, refreshes every 4 weeks",
+ "cerebras": "Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card.",
+ "charm-hyper": "Create an API key at https://hyper.charm.land, then paste it here as a Bearer token.",
+ "chutes": "Bearer API key for the Chutes OpenAI-compatible gateway.",
+ "clarifai": "Clarifai exposes OpenAI-compatible chat, responses and /models on /v2/ext/openai/v1. Public/community models typically require a PAT; app-scoped keys only work for resources inside that app.",
+ "cloudflare-ai": "Requires API Token AND Account ID (found at dash.cloudflare.com)",
+ "codestral": "Connect Codestral with an API key.",
+ "cohere": "Free Trial: 1,000 API calls/month for testing, no credit card required",
+ "command-code": "Create or copy an API key from Command Code, then paste it here as a Bearer token.",
+ "coze": "Get API key at coze.com/open/api",
+ "crof": "Connect CrofAI with an API key.",
+ "databricks": "Connect Databricks with an API key.",
+ "datarobot": "The default gateway catalogs active models from /genai/llmgw/catalog/. Deployment URLs are also supported for direct OpenAI-compatible chat requests.",
+ "deepinfra": "Free signup credits for API testing and model exploration",
+ "deepseek": "5M free tokens on signup - no credit card required",
+ "dgrid": "Create a DGrid API key at https://dgrid.ai, then use https://api.dgrid.ai/v1 as the OpenAI-compatible base URL.",
+ "dify": "Get API key from your Dify instance.",
+ "digitalocean": "Connect DigitalOcean with an API key.",
+ "dit": "dit.ai (Distributed Intelligence Trade) is an OpenAI-compatible router/gateway with dynamic per-request pricing, exposing /v1/chat/completions at https://api.dit.ai/v1. OmniRoute uses the OpenAI protocol; spend/savings analytics live in the dit.ai dashboard.",
+ "doubao": "Get API key at console.volcengine.com",
+ "empower": "Empower exposes OpenAI-compatible chat on https://app.empower.dev/api/v1 with tool-calling support on empower-functions.",
+ "factory": "Get your Factory API key at https://app.factory.ai/settings/api-keys, then paste it as a Bearer token. OpenAI-compatible endpoint at https://api.factory.ai/v1.",
+ "fal-ai": "Connect Fal.ai with an API key.",
+ "featherless-ai": "Free tier available — no credit card required",
+ "fenayai": "Bearer API key for the FenayAI OpenAI-compatible gateway.",
+ "firecrawl": "Connect Firecrawl with an API key.",
+ "fireworks": "$1 free starter credits on signup for API testing",
+ "freeaiapikey": "Discounted API proxy for 40+ models including GPT-5, Claude Opus 4.6, Claude Sonnet 4.6, Qwen 3.5. Get your API key at https://freeaiapikey.com/dashboard. Base URL: https://freeaiapikey.com/v1.",
+ "freemodel-dev": "Get $300 free API credits at https://freemodel.dev — no payment info required. OpenAI-compatible endpoint. GPT-5.4 and GPT-5.5 models available.",
+ "friendliai": "Free tier for serverless inference — no credit card required",
+ "gemini": "Free forever: 1,500 req/day for Gemini 2.5 Flash — no credit card, get key at aistudio.google.com",
+ "gigachat": "Connect GigaChat (Sber) with an API key.",
+ "github-models": "Create a GitHub PAT with 'models: read' scope at github.com/settings/tokens",
+ "gitlab": "GitLab personal access token for the public Code Suggestions API. Configure a self-hosted base URL when not using gitlab.com.",
+ "gitlawb-gmi": "Get your API key from Gitlawb Opengateway dashboard.",
+ "gitlawb": "Get your API key from Gitlawb Opengateway dashboard.",
+ "glm": "Connect GLM Coding with an API key.",
+ "glm-cn": "Connect GLM Coding (China) with an API key.",
+ "glmt": "Preset GLM profile with higher token budget, thinking enabled, and longer timeout.",
+ "getgoapi": "Connect GoAPI with an API key.",
+ "groq": "Free tier: 30 RPM / 14.4K RPD — no credit card",
+ "hackclub": "Sign in with your Hack Club account at ai.hackclub.com.",
+ "haiper": "Get API key at haiper.ai/haiper-api",
+ "heroku": "Connect Heroku AI with an API key.",
+ "hcnsec": "Get API key at api.hcnsec.cn",
+ "huggingface": "Free Inference API for thousands of models (Whisper, VITS, SDXL…)",
+ "hyperbolic": "$1-5 trial credits on signup for serverless inference",
+ "watsonx": "The watsonx model gateway exposes OpenAI-compatible /chat/completions and /models under /ml/gateway/v1.",
+ "ideogram": "Get API key at ideogram.ai/docs/api",
+ "iflytek": "Get API key at console.xfyun.cn",
+ "inference-net": "$25 free credits on signup plus research grants available",
+ "jina-ai": "Bearer API key for the Jina AI rerank API.",
+ "jina-reader": "Connect Jina Reader with an API key.",
+ "kenari": "Kenari exposes an OpenAI-compatible chat completions endpoint at https://kenari.id/v1/chat/completions, plus a live /v1/models catalog covering Claude, GPT, DeepSeek, GLM, Kimi and more. OmniRoute uses the OpenAI protocol and lists models via passthrough.",
+ "kie": "Connect KIE.AI with an API key.",
+ "kilo-gateway": "Connect Kilo Gateway with an API key.",
+ "kimi": "Connect Kimi with an API key.",
+ "kimi-coding-apikey": "Connect Kimi Coding (API Key) with an API key.",
+ "lambda-ai": "Connect Lambda AI with an API key.",
+ "laozhang": "Connect LaoZhang AI with an API key.",
+ "leonardo": "Get API key at leonardo.ai/developer",
+ "liquid": "Get API key at liquid.ai",
+ "llamagate": "Connect LlamaGate with an API key.",
+ "llm7": "Works without API key (use 'unused' as key). Get free token at token.llm7.io for higher limits.",
+ "longcat": "Free: one-time 10M-token grant after account signup + KYC verification (LongCat-2.0). One-time only — not a recurring daily/monthly allowance.",
+ "maritalk": "Connect Maritalk with an API key.",
+ "meta-llama": "Connect Meta Llama API with an API key.",
+ "minimax-cn": "Connect Minimax (China) with an API key.",
+ "minimax": "Connect Minimax Coding with an API key.",
+ "mistral": "Free Experiment tier: rate-limited access to all models, no credit card required",
+ "modal": "Modal commonly serves user-hosted OpenAI-compatible apps on /v1. OmniRoute will probe /v1/models and route chat traffic to /v1/chat/completions.",
+ "modelscope": "Free tier via ModelScope API-Inference — Alibaba account required.",
+ "monsterapi": "Get API key at monsterapi.ai",
+ "moonshot": "Connect Moonshot AI with an API key.",
+ "morph": "Free tier: 250K credits/month, $0",
+ "nanogpt": "Connect NanoGPT with an API key.",
+ "nebius": "~$1 trial credits on signup for API testing",
+ "nlpcloud": "NLP Cloud uses a proprietary chatbot API instead of OpenAI chat/completions. OmniRoute adapts OpenAI messages to input/context/history and exposes a local catalog of supported chatbot models.",
+ "nomic": "Get API key at atlas.nomic.ai",
+ "nous-research": "Nous exposes an OpenAI-compatible /v1 surface with a large remote /models catalog. The /chat/completions endpoint requires a valid API key for programmatic inference.",
+ "novita": "$0.50 trial credits on signup (valid about 1 year)",
+ "nscale": "$5 free credits on signup for inference testing",
+ "nube": "Connect Nube.sh with an API key.",
+ "nvidia": "Free dev access: ~40 RPM, 70+ models (Kimi K2.5, GLM 4.7, DeepSeek V3.2...)",
+ "oci": "OCI exposes OpenAI-compatible chat and responses endpoints. Project ID is optional in OmniRoute but may be required for Responses and agentic workflows.",
+ "ollama-cloud": "Connect Ollama Cloud with an API key.",
+ "openadapter": "OpenAdapter exposes an OpenAI-compatible chat completions endpoint at https://api.openadapter.in/v1/chat/completions, aggregating 70+ open-source models (DeepSeek, Qwen, Kimi, MiniMax, GLM, Llama, Mistral, …). OmniRoute uses the OpenAI protocol.",
+ "openai": "Connect OpenAI with an API key.",
+ "opencode-go": "Connect OpenCode Go with an API key.",
+ "opencode-zen": "Connect OpenCode Zen with an API key.",
+ "openrouter": "Free models at $0/token with :free suffix - 20 RPM / 200 RPD",
+ "openvecta": "Free credits on signup for OpenAI-compatible inference across LLMs, embeddings, and reasoning models",
+ "orcarouter": "Create an API key (starts with sk-orca-) at https://www.orcarouter.ai, then paste it as a Bearer token. OpenAI-compatible endpoint at https://api.orcarouter.ai/v1.",
+ "ovhcloud": "Connect OVHcloud AI with an API key.",
+ "perplexity": "Connect Perplexity with an API key.",
+ "piapi": "Connect PiAPI with an API key.",
+ "pioneer": "$75 free usage credits — no credit card required",
+ "poe": "Poe exposes OpenAI-compatible chat and responses on https://api.poe.com/v1, with authenticated balance checks on /usage/current_balance.",
+ "pollinations": "Free keyless tier: openai, openai-fast, openai-large, qwen-coder, mistral, deepseek, grok, gemini-flash-lite-3.1, perplexity-fast, perplexity-reasoning. Premium models (claude, gemini, midijourney) require a Pollinations API key from enter.pollinations.ai.",
+ "publicai": "Requires an API key — one-time signup credit, then paid",
+ "puter": "Get token at puter.com/dashboard → Copy Auth Token",
+ "qiniu": "Create a Qiniu AI inference API key at https://portal.qiniu.com/ai-inference/api-key, then paste it here as a Bearer token. OpenAI-compatible endpoint at https://api.qnaigc.com/v1, proxying DeepSeek, Claude, Kimi and more behind one key.",
+ "recraft": "Connect Recraft with an API key.",
+ "reka": "Reka Chat is OpenAI-compatible on /v1. OmniRoute probes /v1/models and routes chat traffic to /v1/chat/completions.",
+ "requesty": "Create an API key at https://app.requesty.ai, then paste it here as a Bearer token. OpenAI-compatible endpoint at https://router.requesty.ai/v1, with a live /v1/models catalog.",
+ "runwayml": "Runway video generation is task-based. OmniRoute submits text-to-video or image-to-video jobs, polls /v1/tasks/[id], and normalizes the finished video outputs back into the OpenAI-like /v1/videos/generations response.",
+ "sambanova": "$5 free credits on signup (30-day validity), no credit card required",
+ "sap": "Model discovery uses /v2/lm/scenarios/foundation-models/models on AI_API_URL. Chat requests use deploymentUrl/chat/completions and require AI-Resource-Group.",
+ "scaleway": "1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B",
+ "sensenova": "Get API key at platform.sensenova.cn",
+ "siliconflow": "$1 free credits plus permanently free models after identity verification",
+ "snowflake": "Connect Snowflake Cortex with an API key.",
+ "sparkdesk": "Get API key at console.xfyun.cn",
+ "stability-ai": "Connect Stability AI with an API key.",
+ "stepfun": "Get API key at platform.stepfun.com",
+ "sumopod": "SumoPod exposes an OpenAI-compatible chat completions endpoint at https://ai.sumopod.com/v1/chat/completions, plus a live /v1/models catalog. OmniRoute uses the OpenAI protocol and lists models via passthrough.",
+ "suno": "Paste session cookie from suno.ai (Clerk auth)",
+ "synthetic": "Connect Synthetic with an API key.",
+ "tencent": "Get API key at console.cloud.tencent.com",
+ "thebai": "Bearer API key for the TheB.AI OpenAI-compatible gateway.",
+ "tinyfish": "X-API-Key from agent.tinyfish.ai/api-keys",
+ "together": "Connect Together AI with an API key.",
+ "tokenrouter": "TokenRouter exposes an OpenAI-compatible chat completions endpoint at https://api.tokenrouter.com/v1/chat/completions, plus a working /v1/models catalog. OmniRoute uses the OpenAI protocol.",
+ "topaz": "Connect Topaz with an API key.",
+ "udio": "Paste session cookie from udio.com (Supabase auth)",
+ "uncloseai": "No auth required. API accepts any non-empty string as key for identification.",
+ "upstage": "Connect Upstage with an API key.",
+ "v0-vercel": "Connect v0 (Vercel) with an API key.",
+ "venice": "Connect Venice.ai with an API key.",
+ "vercel-ai-gateway": "Connect Vercel AI Gateway with an API key.",
+ "vertex": "Provide Service Account JSON or OAuth access_token",
+ "vertex-partner": "Provide the same Service Account JSON used for Vertex AI partner models.",
+ "volcengine": "Connect Volcengine with an API key.",
+ "voyage-ai": "Bearer API key for Voyage AI embeddings and rerank APIs.",
+ "wafer": "API key from https://wafer.ai",
+ "wandb": "Connect Weights & Biases Inference with an API key.",
+ "x5lab": "X5Lab exposes an OpenAI-compatible chat completions endpoint at https://api.x5lab.dev/v1/chat/completions, plus a live /v1/models catalog. OmniRoute uses the OpenAI protocol and lists models via passthrough.",
+ "xai": "Connect xAI (Grok) with an API key.",
+ "xiaomi-mimo": "Connect Xiaomi MiMo with an API key.",
+ "yi": "Get API key at platform.lingyiwanwu.com",
+ "zai": "API key from https://open.bigmodel.cn/usercenter/apikeys",
+ "zenmux": "ZenMux exposes an OpenAI-compatible chat completions endpoint at /api/v1/chat/completions, plus Anthropic Messages (/api/anthropic/v1/messages) and Google Gemini (/api/vertex-ai) protocol surfaces. OmniRoute uses the OpenAI protocol.",
+ "galadriel": "Connect Galadriel with an API key.",
+ "predibase": "$25 free trial credits (30-day validity)",
+ "chenzk": "OpenAI-compatible gateway with a live model catalog at chenzk.top.",
+ "freepik": "Generate images with Freepik's Mystic API.",
+ "freetheai": "Free OpenAI-compatible gateway with passthrough model support.",
+ "g4f-gemini": "Free no-key g4f.space reverse proxy to Gemini, limited to 5 requests per minute.",
+ "g4f-groq": "Free no-key g4f.space reverse proxy to Groq, limited to 5 requests per minute.",
+ "g4f-nvidia": "Free no-key g4f.space reverse proxy to NVIDIA NIM, limited to 5 requests per minute.",
+ "g4f-ollama": "Free no-key hosted Ollama gateway from g4f.space, limited to 5 requests per minute.",
+ "g4f-pollinations": "Free no-key g4f.space reverse proxy to Pollinations, limited to 5 requests per minute.",
+ "mixedbread": "Create embeddings with the Mixedbread API.",
+ "segmind": "Generate images and videos with Segmind's hosted models.",
+ "amazon-q": "Uses the same AWS Builder ID or imported refresh-token flow as Kiro, but keeps Amazon Q connections separate.",
+ "antigravity": "Connect Antigravity with the existing OAuth flow.",
+ "agy": "Import your Antigravity CLI (`agy`) login (paste/upload its token file), auto-detect a local CLI login, or sign in with Google. Shares the Antigravity backend (incl. Claude models).",
+ "claude": "Connect Claude Code with the existing OAuth flow.",
+ "cline": "Connect Cline with the existing OAuth flow.",
+ "cursor": "Connect Cursor IDE with the existing OAuth flow.",
+ "github": "Connect GitHub Copilot with the existing OAuth flow.",
+ "gitlab-duo": "OAuth application with ai_features + read_user scopes. Configure GITLAB_DUO_OAUTH_CLIENT_ID and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET on this OmniRoute instance.",
+ "kilocode": "Connect Kilo Code with the existing OAuth flow.",
+ "kimi-coding": "Connect Kimi Coding with the existing OAuth flow.",
+ "kiro": "Free tier: 50 credits/month (~25K–100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use.",
+ "codex": "Connect OpenAI Codex with the existing OAuth flow.",
+ "qwen": "Connect Qwen Code with the existing OAuth flow."
+ },
"passthroughModelsDescription": "{provider} accepts provider-native model IDs. Import from /models or add custom IDs for routing.",
"bedrockModelsDescription": "Amazon Bedrock models are scoped by AWS region. Import from /models or add Bedrock model IDs enabled in the selected region.",
"bedrockModelPlaceholder": "anthropic.claude-sonnet-4-6",
@@ -5102,6 +6097,7 @@
"logToolSourcesToggle": "Log Tool Sources",
"logToolSourcesDescription": "Emit a diagnostic log line per request summarizing tool count and MCP/hosted/client source breakdown.",
"homePinProviderQuotaToHome": "Pin Information to Home Page",
+ "homePinnedSectionsDesc": "Choose which sections to pin to the top of the Home page.",
"homeProviderQuotaLimits": "Provider Quota Limits",
"homeProviderQuotaLimitsDesc": "Pin the Provider Quota status container (with Refresh All button) to the top of the Home page.",
"homeQuickStart": "Quick Start",
@@ -5110,6 +6106,19 @@
"homeProviderTopologyDesc": "Show the Provider Topology on the Home page.",
"accountEmailVisibility": "Account email visibility",
"accountEmailVisibilityDesc": "Show full account emails across providers, combos, logs, quota, and playground screens. Turn this off to mask them by default.",
+ "comboConfigMode": "Combo configuration mode",
+ "comboConfigModeDesc": "Choose how the combo create and edit dialog is organized.",
+ "comboConfigModeGuided": "Guided",
+ "comboConfigModeGuidedDesc": "Use the current step-by-step combo builder.",
+ "comboConfigModeExpert": "Expert",
+ "comboConfigModeExpertDesc": "Show every combo option on one page and enable direct model entry.",
+ "providerQuotaAutoRefresh": "Provider Quota auto refresh",
+ "providerQuotaAutoRefreshDesc": "Refresh the Provider Limits view automatically while it stays open.",
+ "providerQuotaAutoRefreshToggle": "Automatic refresh",
+ "providerQuotaAutoRefreshToggleDesc": "Refresh the quota view every few minutes while the page is visible.",
+ "providerQuotaAutoRefreshInterval": "Refresh interval",
+ "providerQuotaAutoRefreshIntervalDesc": "How often the quota view should refresh, in seconds.",
+ "seconds": "seconds",
"sidebarVisibilityToggle": "Show Sidebar Items",
"enableCache": "Enable Cache",
"cacheTTL": "Cache TTL",
@@ -5224,6 +6233,13 @@
"uploadFavicon": "Upload Favicon",
"resetFavicon": "Reset Favicon",
"faviconPreview": "Favicon Preview",
+ "logoFileTooLarge": "Logo file must be less than 500KB",
+ "faviconFileTooLarge": "Favicon file must be less than 50KB",
+ "invalidLogoFileType": "Invalid file type. Please upload PNG, JPG, SVG, GIF, or WebP.",
+ "invalidFaviconFileType": "Invalid file type. Please upload PNG, ICO, SVG, GIF, or WebP.",
+ "failedToReadFile": "Failed to read file",
+ "startOnLogin": "Start on Login",
+ "startOnLoginDesc": "Automatically launch OmniRoute on system startup and run silently in the background tray.",
"flushCache": "Flush Cache",
"flushing": "Flushing…",
"size": "Size",
@@ -5303,6 +6319,11 @@
"proxyFreePoolQuality": "Quality",
"proxyFreePoolLatency": "Latency",
"proxyFreePoolEmpty": "No proxies found. Click Sync All to fetch from sources.",
+ "proxyToggleSources": "Toggle proxy sources",
+ "proxyFreePoolTesting": "Testing proxies...",
+ "proxyFreePoolBulkResult": "{succeeded} added, {failed} failed",
+ "proxyFreePoolPageSummary": "Page {page} of {totalPages} ({total} total proxies)",
+ "proxyFreePoolTotalSummary": "{total} total proxies",
"proxyFreePoolSearchPlaceholder": "Search host…",
"proxyFreePoolSearchLabel": "Search proxies by host",
"proxyFreePoolSortLabel": "Sort proxies",
@@ -5507,6 +6528,10 @@
"comboDefaultsGuideHint1": "Keep retries low in low-latency flows; increase timeout only for long generation tasks.",
"comboDefaultsGuideHint2": "Use provider overrides when one provider needs different timeout/retry behavior than global defaults.",
"globalComboConfig": "Global combo configuration",
+ "moveUp": "Move up",
+ "moveDown": "Move down",
+ "allProvidersAdded": "All providers added",
+ "noProvidersFound": "No providers found",
"defaultStrategy": "Default Strategy",
"defaultStrategyDesc": "Applied to new combos without explicit strategy and synced to global account fallback routing",
"comboStrategyAria": "Combo strategy",
@@ -5632,6 +6657,20 @@
"errorDuringImport": "An error occurred during import",
"modelPricing": "Model Pricing",
"modelPricingDesc": "Configure cost rates per model • All rates in $/1M tokens",
+ "pricingCoverage": "Coverage",
+ "pricingAuth": "Auth",
+ "pricingSort": "Sort",
+ "pricingAll": "All",
+ "pricingAuthUnknown": "Unknown",
+ "pricingMostModels": "Most models",
+ "pricingHighestCoverage": "Highest coverage",
+ "pricingLowestCoverage": "Lowest coverage",
+ "pricingNameAscending": "Name (A–Z)",
+ "pricingCoverageGaps": "Coverage gaps",
+ "pricingClearFilters": "Clear filters",
+ "pricingShowingProviders": "Showing {visible} of {total}",
+ "pricingFilteredFrom": "(filtered from {count})",
+ "pricingShowMoreProviders": "Show {count} more ({remaining} remaining)",
"modelOverridesTitle": "Model Overrides",
"modelOverridesDesc": "Override provider/model capabilities used by routing and request shaping. Targets use the same provider/model form as combos.",
"searchModelOverrideTargets": "Search provider/model...",
@@ -6010,6 +7049,35 @@
"resilienceDefault": "Default",
"storageDatabaseBackupRetention": "Database backup retention",
"storageDatabaseBackups": "Database backups",
+ "storageBackupRetentionDescription": "Automatic SQLite backups are stored in",
+ "storageBackupRetentionHelp": "Configure how many snapshots to keep and optionally delete backups older than a chosen number of days.",
+ "storageBackupCount": "{count} backups",
+ "storageBackupMaximum": "Max {count}",
+ "storageBackupAgeRetention": "{count}d retention",
+ "storageBackupAgeRetentionOff": "Age retention off",
+ "storageBackupKeepLatest": "Keep latest backups",
+ "storageBackupDeleteOlderThan": "Delete older than days",
+ "storageBackupSaveRetention": "Save retention",
+ "storageBackupCleanOld": "Clean old backups",
+ "storageDatabaseStatistics": "Database Statistics",
+ "storageIntegrityNotChecked": "Not checked",
+ "backupRetentionSaved": "Backup retention saved.",
+ "backupRetentionSaveFailed": "Failed to save backup retention",
+ "backupCleanupSuccess": "Deleted {backups} backup set(s) and {files} file(s).",
+ "backupCleanupFailed": "Failed to clean database backups",
+ "purgeQuotaSnapshotsSuccess": "Purged {count} quota snapshots",
+ "purgeQuotaSnapshotsFailed": "Failed to purge quota snapshots",
+ "purgeCallLogsSuccess": "Purged {count} call logs",
+ "purgeCallLogsFailed": "Failed to purge call logs",
+ "purgeDetailedLogsSuccess": "Purged {count} detailed logs",
+ "purgeDetailedLogsFailed": "Failed to purge detailed logs",
+ "vacuumCompleted": "VACUUM completed",
+ "vacuumFailed": "VACUUM failed",
+ "jsonExportFailed": "JSON export failed",
+ "invalidJsonFileType": "Invalid file type. Only .json files are allowed.",
+ "legacyJsonImportSuccess": "Legacy JSON imported successfully!",
+ "jsonImportFailed": "Failed to import JSON",
+ "jsonImportError": "Error during JSON import",
"storagePurgeData": "Purge Data",
"storagePurgeDataDesc": "Immediately delete all records without applying retention checks. Use with caution.",
"storageRetentionCleanup": "Retention Settings",
@@ -6030,6 +7098,31 @@
"storageScheduledVacuum": "Scheduled Vacuum",
"storageVacuumHour": "Vacuum Hour (0-23)",
"storagePageSize": "Page Size (bytes)",
+ "storageOptimizationSettings": "Optimization Settings",
+ "storageJournalModeNone": "None",
+ "storageJournalModeFull": "Full",
+ "storageJournalModeIncremental": "Incremental",
+ "storageVacuumNever": "Never",
+ "storageVacuumDaily": "Daily",
+ "storageVacuumWeekly": "Weekly",
+ "storageVacuumMonthly": "Monthly",
+ "storageCacheSizeKb": "Cache Size (KB)",
+ "storageOptimizeOnStartup": "Optimize on Startup",
+ "storageSaveOptimization": "Save Optimization Settings",
+ "storageCompressionAggregation": "Compression & Aggregation Settings",
+ "storageEnableAggregation": "Enable Data Aggregation",
+ "storageRawDataRetention": "Raw Data Retention (days)",
+ "storageGranularity": "Granularity",
+ "storageHourly": "Hourly",
+ "storageDaily": "Daily",
+ "storageWeekly": "Weekly",
+ "storageSaveAggregation": "Save Aggregation Settings",
+ "exportJson": "Export JSON",
+ "importJson": "Import JSON",
+ "manualVacuum": "Manual VACUUM",
+ "purgeQuotaSnapshots": "Purge Quota Snapshots",
+ "purgeCallLogs": "Purge Call Logs",
+ "purgeDetailedLogs": "Purge Detailed Logs",
"storageDatabaseSize": "Database Size",
"storagePageCount": "Page Count",
"storageFreelistCount": "Freelist Count",
@@ -6062,6 +7155,84 @@
"compressionStylesTileTitle": "Output styles",
"compressionSettingsOutputIntensity": "Output intensity",
"compressionSettingsAutoClarityBypass": "Auto clarity bypass",
+ "compressionEffectivePipeline": "Effective pipeline:",
+ "compressionDerivedOff": "off",
+ "compressionDerivedRuns": "runs: {pipeline}",
+ "compressionDerivedMode": "mode: {mode}",
+ "compressionAdaptiveOff": "Adaptive context budget: off (legacy auto-trigger)",
+ "compressionAdaptiveTarget": "Adaptive ({mode}, policy: {policy}) — target ≈ {target, number} tokens (for a {contextLimit, number}-token window)",
+ "compressionOutputStylesDescription": "Inject response-shaping instructions without rewriting provider output. Combine freely.",
+ "mcpAccessibilityDescription": "Scopes MCP tool outputs (separate store).",
+ "compressionStylesTileSummary": "{tokens, number} tokens saved · {runs, plural, one {# run styled} other {# runs styled}}",
+ "compressionStylesTileEmpty": "No styled runs yet.",
+ "compressionLevel": {
+ "minimal": "Minimal",
+ "standard": "Standard",
+ "aggressive": "Aggressive",
+ "lite": "Lite",
+ "full": "Full",
+ "ultra": "Ultra"
+ },
+ "compressionEngine": {
+ "session-dedup": {
+ "label": "Session Dedup",
+ "description": "Cross-turn block deduplication."
+ },
+ "ccr": {
+ "label": "CCR (Retrieval)",
+ "description": "Content-addressed retrieval markers."
+ },
+ "lite": {
+ "label": "Lite",
+ "description": "Whitespace and format cleanup."
+ },
+ "rtk": {
+ "label": "RTK",
+ "description": "Command-output filtering."
+ },
+ "headroom": {
+ "label": "Headroom",
+ "description": "Tabular JSON compaction."
+ },
+ "relevance": {
+ "label": "Relevance",
+ "description": "Extractive sentence scoring against the last user query."
+ },
+ "caveman": {
+ "label": "Caveman",
+ "description": "Rule-based prose compression."
+ },
+ "aggressive": {
+ "label": "Aggressive",
+ "description": "Summarize and age old turns."
+ },
+ "llmlingua": {
+ "label": "LLMLingua (SLM)",
+ "description": "Semantic pruning (ONNX)."
+ },
+ "ultra": {
+ "label": "Ultra",
+ "description": "Heuristic token pruning with an optional SLM."
+ },
+ "omniglyph": {
+ "label": "OmniGlyph",
+ "description": "Context as an image (Claude Fable 5, direct route)."
+ }
+ },
+ "compressionOutputStyle": {
+ "terse-prose": {
+ "label": "Terse prose",
+ "description": "Drop filler, articles, and hedging while keeping technical substance exact."
+ },
+ "less-code": {
+ "label": "Less code",
+ "description": "YAGNI ladder: smallest working change, no unrequested abstractions."
+ },
+ "terse-cjk": {
+ "label": "Terse CJK (文言)",
+ "description": "Classical-Chinese ultra-terse style (available only for Chinese)."
+ }
+ },
"resilienceWaitForCooldown": "Wait for Cooldown",
"resilienceEnableServerSideWait": "Enable server-side wait",
"resilienceMaximumRetries": "Maximum retries",
@@ -6110,6 +7281,15 @@
"maxResponseTokensDesc": "Maximum total tokens allowed in a single response.",
"modelCooldownsTitle": "Models in cooldown",
"modelCooldownsEmpty": "No models in cooldown right now.",
+ "modelCooldownsDescription": "Models temporarily isolated after a failure. When the cooldown expires they come back automatically.",
+ "modelCooldownsLoadFailed": "Failed to load cooldowns",
+ "modelCooldownReactivated": "Model reactivated: {model}",
+ "modelCooldownClearFailed": "Failed to clear cooldown",
+ "modelCooldownsAllReactivated": "All models in cooldown have been reactivated.",
+ "modelCooldownsClearFailed": "Failed to clear cooldowns",
+ "modelCooldownsReactivateAll": "Reactivate all",
+ "modelCooldownsReasonRemaining": "reason: {reason} • remaining: {remaining}",
+ "modelCooldownsReactivate": "Reactivate",
"responsesStateTitle": "Responses State",
"responsesStateDesc": "Control how OmniRoute forwards previous_response_id.",
"responsesStateModeLabel": "previous_response_id handling",
@@ -6300,6 +7480,18 @@
"cloudflareRelayDeployFailed": "Deploy failed",
"modelLockout": "Model Lockout",
"modelLockoutPageDescription": "Configure which HTTP error codes trigger per-model lockout and control the cooldown behavior.",
+ "modelLockoutLoadFailed": "Failed to load model lockout settings",
+ "modelLockoutSaveFailed": "Failed to save model lockout settings",
+ "modelLockoutLoading": "Loading model lockout settings...",
+ "modelLockoutBaseRangeError": "Base Cooldown must be between 5,000ms and 600,000ms",
+ "modelLockoutMaxRangeError": "Max Cooldown must be between 5,000ms and 3,600,000ms",
+ "modelLockoutOrderError": "Max Cooldown must be greater than or equal to Base Cooldown",
+ "modelLockoutStepsRangeError": "Max Backoff Steps must be between 0 and 20",
+ "resetDefaults": "Reset defaults",
+ "removeErrorCode": "Remove {code}",
+ "addErrorCode": "Add error code...",
+ "suggestions": "Suggestions:",
+ "modelLockoutMaxCooldownHint": "≥ Base Cooldown — 3,600,000ms",
"modelLockoutEnabled": "Enable Model Lockout",
"modelLockoutEnabledDescription": "When enabled, models that fail with configured error codes are temporarily locked to prevent retry loops.",
"modelLockoutErrorCodes": "Error Codes",
@@ -6403,6 +7595,119 @@
"tomlImportError": "Could not process the RTK TOML file.",
"tomlFileReadError": "Could not read the selected TOML file."
},
+ "compressionEngineConfig": {
+ "loading": "Loading…",
+ "loadFailed": "Failed to load engine information.",
+ "engineNotFound": "Engine \"{engine}\" not found.",
+ "saveFailed": "Failed to save configuration.",
+ "previewFailed": "Preview failed.",
+ "panelPointerPrefix": "Turn this layer on or off and set its level in",
+ "compressionSettings": "Compression Settings",
+ "panelPointerSuffix": ". This page edits its detailed configuration only.",
+ "configuration": "Configuration",
+ "noAdditionalConfiguration": "No additional configuration.",
+ "save": "Save",
+ "saving": "Saving…",
+ "globalSettingsOnly": "This layer is configured by the global settings; there is no per-engine override to save here yet.",
+ "preview": "Preview",
+ "previewInput": "Preview input",
+ "processing": "Processing…",
+ "previewSample": "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.",
+ "originalTokens": "Original tokens",
+ "compressedTokens": "Compressed tokens",
+ "savings": "Savings",
+ "original": "Original",
+ "compressed": "Compressed",
+ "diff": "Diff",
+ "last7Days": "Last 7 days",
+ "noDataYet": "No data yet",
+ "runs": "Runs",
+ "tokensSaved": "Tokens saved",
+ "averageSavings": "Average savings",
+ "diffLabels": {
+ "change": "Change",
+ "added": "Added",
+ "removed": "Removed",
+ "modified": "Modified"
+ },
+ "engines": {
+ "headroom": {
+ "name": "Headroom SmartCrusher",
+ "description": "Lossless tabular compaction of homogeneous JSON arrays with explicit row-count markers."
+ },
+ "session-dedup": {
+ "name": "Session Dedup",
+ "description": "Cross-turn block deduplication with recoverable content markers."
+ },
+ "ccr": {
+ "name": "CCR",
+ "description": "Content-addressed retrieval markers for repeated context blocks."
+ },
+ "llmlingua": {
+ "name": "LLMLingua-2 (Semantic Pruning)",
+ "description": "ONNX-based semantic token classification. Compresses prose only; code blocks and preserved constructs are protected. Fails open on model or worker errors."
+ },
+ "lite": {
+ "name": "Lite",
+ "description": "Fast whitespace, tool-result and image URL reduction."
+ },
+ "aggressive": {
+ "name": "Aggressive",
+ "description": "Summarization, tool-result compression and progressive aging."
+ },
+ "ultra": {
+ "name": "Ultra",
+ "description": "Heuristic token pruning with an optional local SLM fallback."
+ }
+ },
+ "fields": {
+ "minBlockChars": {
+ "label": "Minimum block characters",
+ "description": "Minimum character count for a suffix block to be a deduplication candidate."
+ },
+ "fuzzy": {
+ "label": "Fuzzy near-duplicate deduplication",
+ "description": "Opt in to replace whole messages that are at least about 85% similar to an earlier message with a recoverable CCR marker."
+ },
+ "minChars": {
+ "label": "Minimum block characters",
+ "description": "Minimum character count for a block to be a CCR candidate."
+ },
+ "retrievalRampFactor": {
+ "label": "Retrieval ramp factor (H8)",
+ "description": "Controls how strongly frequently retrieved blocks resist compression. Each prior retrieval raises the effective minimum block size linearly; 1 disables the ramp."
+ },
+ "model": { "label": "Model" },
+ "minTokens": { "label": "Minimum tokens (floor)" },
+ "compressionRate": { "label": "Compression rate" },
+ "modelPath": { "label": "Model path" },
+ "minRows": {
+ "label": "Minimum rows to compact",
+ "description": "Minimum rows in a homogeneous JSON array required to trigger tabular compaction. Default: 8."
+ },
+ "preserveSystemPrompt": { "label": "Preserve system prompt" },
+ "summarizerEnabled": { "label": "Enable summarizer" },
+ "maxTokensPerMessage": { "label": "Maximum tokens per message" },
+ "minSavingsThreshold": { "label": "Minimum savings threshold" },
+ "minScoreThreshold": { "label": "Minimum score threshold" },
+ "slmFallbackToAggressive": { "label": "Fall back to Aggressive" },
+ "intensity": { "label": "Intensity" },
+ "minMessageLength": { "label": "Minimum message length" }
+ },
+ "engineFields": {
+ "llmlingua": {
+ "compressionRate": { "label": "Compression rate (keep ratio)" },
+ "modelPath": { "label": "Model path (offline override)" }
+ }
+ },
+ "options": {
+ "model": {
+ "tinybert": "TinyBERT (57 MB, fast — default)",
+ "bert-base": "BERT-base (710 MB, higher accuracy)"
+ },
+ "intensity": { "lite": "Lite", "full": "Full", "ultra": "Ultra" }
+ }
+ },
"contextCombos": {
"title": "Compression Combos",
"description": "Define how engines are combined for different routing scenarios.",
@@ -6423,7 +7728,244 @@
"setAsDefault": "Set as Default",
"save": "Save",
"cancel": "Cancel",
- "enabled": "Enabled"
+ "enabled": "Enabled",
+ "loading": "Loading…",
+ "hubTitle": "Compression Hub",
+ "hubDescription": "Pick which compression profile runs globally.",
+ "hideExplanation": "Hide explanation",
+ "howItWorks": "How it works",
+ "saveSettingsFailed": "Failed to save settings.",
+ "explanationIntro": "Compression reduces tokens and cost by rewriting history before it is sent to the provider while preserving meaning.",
+ "explanationActiveProfile": "Active profile : chooses which compression profile runs globally — the panel-derived Default or one of your saved named combos.",
+ "explanationDefault": "Default (from panel) : derived from the master switch and per-engine toggles you configure in Compression Settings.",
+ "explanationNamedCombos": "Named combos : saved pipelines you build in the named-combo editor. Selecting one makes it the active profile for every request.",
+ "explanationPreview": "Preview : shows which engines the active profile runs, in order.",
+ "activeProfile": "Active profile",
+ "activeProfileDescription": "Pick which compression profile runs globally — the panel-derived Default or a saved named combo.",
+ "defaultFromPanel": "Default (from panel)",
+ "runs": "Runs:",
+ "defaultConfiguredPrefix": "Default — configured in",
+ "compressionSettings": "Compression Settings",
+ "providerDelegated": "Provider-delegated compression",
+ "contextEditingClaude": "Context Editing (Claude)",
+ "contextEditingDescription": "Lets the provider clear old tool-use blocks server-side, without rewriting the message.",
+ "contextEditingAria": "Context Editing",
+ "contextEditingNote": "Currently available for Claude (Anthropic) only. It is a delegated mode: the provider clears old tool-use blocks server-side — we do not rewrite the message. It does not affect other providers.",
+ "namedCombos": "Named combos",
+ "namedCombosDescription": "Save different pipelines and assign them to specific routing combos.",
+ "comboNamePlaceholder": "Combo name",
+ "descriptionPlaceholder": "Description",
+ "nameRequired": "Enter a combo name before saving.",
+ "pipelineRequired": "Add at least one pipeline step before saving.",
+ "saveComboFailed": "Failed to save combo (HTTP {status}).",
+ "deleteNamedConfirm": "Delete combo “{name}”?",
+ "intensityLite": "Lite",
+ "intensityFull": "Full",
+ "intensityUltra": "Ultra",
+ "active": "Active",
+ "languagePacksList": "Language packs: {packs}",
+ "dragToReorder": "Drag to reorder step",
+ "engine": "Engine",
+ "intensity": "Intensity"
+ },
+ "compressionStudio": {
+ "noRun": "No compression run available.",
+ "liveDataHint": "Live data arrives through the WebSocket compression channel.",
+ "cockpitView": "Cockpit view",
+ "canvas": "Canvas",
+ "waterfall": "Waterfall",
+ "replayDone": "Replay complete",
+ "pauseReplay": "Pause replay",
+ "playReplay": "Play replay",
+ "pause": "Pause",
+ "replay": "Replay",
+ "resetReplay": "Reset replay",
+ "stepProgress": "step {current}/{total}",
+ "inlineView": "Inline",
+ "splitComingSoon": "Split view (coming soon)",
+ "inputPlaceholder": "Paste a prompt, tool output, or context…",
+ "activeCombined": "Active in the combined flow",
+ "requiresOnnx": "requires an ONNX model",
+ "verifyFidelity": "Verify fidelity (reject any layer that corrupts content)",
+ "fuzzyDedup": "Fuzzy deduplication (near-duplicate → CCR)",
+ "protectSensitive": "Protect sensitive content (risk gate)",
+ "quantumLock": "QuantumLock (stabilize the cache prefix)",
+ "saliencyHeatmap": "Saliency heatmap",
+ "running": "Running…",
+ "run": "Run",
+ "laneRejected": "rejected: {reason}",
+ "error": "error",
+ "combinedFlow": "Combined flow",
+ "eachLayer": "Each layer separately",
+ "diff": "Difference",
+ "combined": "combined",
+ "skipped": "skip",
+ "input": "INPUT",
+ "output": "OUTPUT",
+ "tokenCount": "{count, number} tokens",
+ "tokenShort": "tok",
+ "provider": "Provider",
+ "judgeModel": "Judge model",
+ "judgeModelPlaceholder": "e.g. claude-haiku",
+ "maxCostUsd": "USD cap",
+ "enterJudgeModel": "Enter a judge model",
+ "verifying": "Verifying…",
+ "verifyAll": "Verify all",
+ "spent": "spent ${spent} / ${cap}",
+ "capReached": "cap reached",
+ "loadAb": "Load A/B",
+ "engine": "Engine",
+ "savings": "Savings",
+ "retention": "Retention",
+ "outputTokensShort": "Output tokens",
+ "fidelity": "Fidelity",
+ "playTab": "Play",
+ "compareTab": "Compare",
+ "encoderComparison": "Encoder A/B — {count} array(s)",
+ "encoderWinner": "winner: {winner}",
+ "encoder": "encoder",
+ "bytes": "bytes",
+ "tokensCl100k": "tokens (cl100k)"
+ },
+ "omniglyph": {
+ "preview": "Preview",
+ "description": "Context-as-image compression. Renders the system prompt, tool documentation, and dense history as compact PNG pages that Claude Fable 5 reads instead of text. Image tokens are billed by dimensions rather than characters, so the converted block costs about 10× less. Direct Anthropic route only.",
+ "economicsTitle": "The economics",
+ "economics": {
+ "fewerTokens": "fewer tokens on the converted block",
+ "savings": "end-to-end savings (measured)",
+ "imageTokens": "image tokens for a 1568×728 page (about 28k characters)",
+ "accuracy": "reading accuracy on Fable 5 (n=30)"
+ },
+ "beforeAfterTitle": "Before → after",
+ "blockSavings": "−{percent}% tokens on this block",
+ "realRender": "A real render of {characters, number} characters of dense tool documentation — not a mockup.",
+ "textTokens": "Text · ≈ {tokens, number} tokens",
+ "renderedTokens": "Rendered page · ≈ {tokens, number} tokens",
+ "renderedImageAlt": "Rendered dense page, {width}×{height}px",
+ "gatesTitle": "When it fires",
+ "gatesDescription": "Fail-closed: every gate must pass, or the request passes through untouched (each skip is recorded as skip:<reason>).",
+ "gates": {
+ "model": {
+ "label": "Model",
+ "pass": "claude-fable-5",
+ "why": "Only Fable 5 reads dense pages at 100% accuracy (measured, n=30). GPT-5.5 and Gemini 2.5 Flash are blocked."
+ },
+ "transport": {
+ "label": "Transport",
+ "pass": "direct Anthropic",
+ "why": "Aggregators resample images and destroy legibility — only the direct route is authoritative."
+ },
+ "format": {
+ "label": "Format",
+ "pass": "native Claude",
+ "why": "The body must use Claude format and must never put a system role inside messages."
+ },
+ "profitable": {
+ "label": "Profitable",
+ "pass": "dense enough",
+ "why": "The exact 28px-patch cost gate decides per request; small or sparse text passes through untouched."
+ }
+ },
+ "enableTitle": "Enable the engine",
+ "enableDescription": "Runs last in the stack (after RTK/Caveman cleans the text, OmniGlyph converts the remainder to images) and also runs standalone through omniglyph mode. This is a preview and remains off by default until end-to-end validation is complete.",
+ "saved": "Saved.",
+ "saveFailed": "Could not save.",
+ "enableAria": "Enable the OmniGlyph engine"
+ },
+ "embeddedServices": {
+ "title": "Embedded Services",
+ "description": "Local engines managed on demand — CLIProxyAPI, 9Router, Mux, and Bifrost. Accessible on loopback only.",
+ "stateRunning": "Running",
+ "stateStopped": "Stopped",
+ "stateStarting": "Starting",
+ "stateStopping": "Stopping",
+ "stateError": "Error",
+ "stateNotInstalled": "Not installed",
+ "stateUnknown": "Unknown",
+ "port": "port {port}",
+ "externalBadge": "Externally managed",
+ "externalTitle": "External 9Router detected",
+ "externalDescription": "OmniRoute found 9Router at {host}:{port}. Its lifecycle, logs, updates, and service key remain managed by the external installation.",
+ "start": "Start",
+ "starting": "Starting…",
+ "stop": "Stop",
+ "stopping": "Stopping…",
+ "restart": "Restart",
+ "restarting": "Restarting…",
+ "update": "Update",
+ "updating": "Updating…",
+ "install": "Install",
+ "installing": "Installing…",
+ "autoStart": "Auto-start",
+ "autoStartDescription": "Launch {name} automatically when OmniRoute starts",
+ "apiKey": "API Key",
+ "apiKeyDescription": "Key used by OmniRoute to authenticate with {name}",
+ "keyRotated": "Key rotated — {name} restarted to apply the new key",
+ "keyRotateFailed": "Failed to rotate key",
+ "keyRevealFailed": "Failed to reveal key",
+ "keyRevealEmpty": "Reveal failed: key not returned",
+ "reveal": "Reveal",
+ "revealing": "Revealing…",
+ "hide": "Hide",
+ "rotateKey": "Rotate key",
+ "rotating": "Rotating…",
+ "keyAutoHide": "Key will be hidden automatically in 30 seconds.",
+ "revealTitle": "Reveal API Key",
+ "revealConfirm": "Revealing the API key will be logged in the audit trail. Continue?",
+ "cancel": "Cancel",
+ "install9Router": "Install 9Router",
+ "install9RouterDescription": "Downloads and installs 9Router via npm. Requires approximately 500 MB of disk space.",
+ "version": "Version",
+ "versionHint": "Enter “latest” or a specific version (for example, 1.2.3).",
+ "servicePort": "Port",
+ "servicePortHint": "OmniRoute and 9Router must use different ports. The default 9Router port is 20130.",
+ "installationFailed": "Installation failed (HTTP {status})",
+ "installationSucceeded": "9Router installed successfully. Starting up…",
+ "networkInstallFailed": "Network error — could not reach the install endpoint",
+ "availableModels": "Available Models",
+ "modelsLoading": "Loading…",
+ "modelsDiscovered": "{count, plural, one {# model discovered} other {# models discovered}}",
+ "modelsLoadFailed": "Failed to load models",
+ "refreshing": "Refreshing…",
+ "refreshNow": "Refresh now",
+ "noModels": "No models found. Select “Refresh now” to sync from the running service.",
+ "unavailable": "Unavailable",
+ "pageOf": "Page {page} of {total}",
+ "previous": "Previous",
+ "next": "Next",
+ "providerExposure": "Provider Exposure",
+ "providerExposureDescription": "Expose 9Router models as a routing target under the 9router/ prefix.",
+ "providerExposureLabel": "Expose as 9router/… ",
+ "providerExposureHint": "When enabled, discovered models appear in provider selectors across OmniRoute.",
+ "providerExposureUpdateFailed": "Failed to update (HTTP {status})",
+ "providerExposureNetworkFailed": "Network error — could not update the provider exposure setting",
+ "webUi": "9Router Web UI",
+ "openNewTab": "Open in new tab",
+ "filterLogs": "Filter logs…",
+ "resume": "Resume",
+ "pause": "Pause",
+ "clear": "Clear",
+ "download": "Download",
+ "noLogs": "No log output yet.",
+ "logStreamFailed": "Unable to stream {name} logs. Check local access and service status.",
+ "fallbackRouting": "Fallback Routing",
+ "fallbackRoutingDescription": "Retry failed provider requests through CLIProxyAPI",
+ "enableFallback": "Enable fallback",
+ "cliproxyUrl": "CLIProxyAPI URL",
+ "fallbackCodes": "Fallback status codes (comma-separated)",
+ "invalidUrl": "Invalid URL — must start with http:// or https://",
+ "saved": "Saved",
+ "saveFailed": "Failed to save setting",
+ "modelMapping": "Model Mapping",
+ "modelMappingDescription": "Map OmniRoute model IDs to CLIProxyAPI model IDs (for example, \"gpt-4o\": \"openai-gpt-4o\" )",
+ "modelMappingEditor": "Model mapping JSON editor",
+ "mappingSaved": "Mapping saved",
+ "mappingSaveFailed": "Failed to save mapping",
+ "mappingInvalidJson": "Invalid JSON.",
+ "mappingMustBeObject": "The mapping must be a JSON object, not an array or primitive value.",
+ "mappingValueMustBeString": "The value for key “{key}” must be a string.",
+ "save": "Save"
},
"contextCaveman": {
"title": "Caveman Engine",
@@ -6450,6 +7992,7 @@
"inputCompressionDesc": "Rewrite chat history with shorter wording. Reduces input tokens by ~50%.",
"analyticsTitle": "Compression Analytics",
"noAnalytics": "No compression analytics yet.",
+ "masterDisabledWarning": "The Token Saver master switch is OFF — these settings will not affect requests until you turn it on from Compression Settings or change it here.",
"outputMode": "Output Mode",
"outputModeDesc": "Control how compressed output appears",
"outputModeTitle": "Output Mode",
@@ -6627,6 +8170,14 @@
"translationFailed": "Translation failed: {error}",
"pipelineDebugger": "Pipeline Debugger",
"translationPipeline": "Translation Pipeline",
+ "loading": "Loading…",
+ "compressionOriginal": "Original",
+ "compressionCompressed": "Compressed",
+ "compressionSaved": "Saved",
+ "compressionDuration": "Duration",
+ "pipelineStepsAria": "Pipeline steps",
+ "copyIntermediateJson": "Copy intermediate JSON",
+ "copyOutputJson": "Copy output JSON",
"pipelineVisualization": "Pipeline visualization",
"pipelineVisualizationHint": "Send a message to see how your request flows through detection → translation → provider call.",
"chatTesterDescription": "Send messages as a specific client format and inspect each step of the translation pipeline.",
@@ -6764,7 +8315,15 @@
"compressionEmptyHint": "Fill in the input field on the Translate tab (Simple Controls or Raw JSON) to enable the preview.",
"compressionModeLabel": "Compression mode",
"compressionPreviewButton": "Preview Compression",
- "compressionPreviewing": "Previewing…"
+ "compressionPreviewing": "Previewing…",
+ "compressionPreviewFailed": "Compression preview failed",
+ "tokens": "tokens",
+ "pauseAutoRefresh": "Pause auto-refresh",
+ "resumeAutoRefresh": "Resume auto-refresh",
+ "live": "Live",
+ "copyInput": "Copy input",
+ "copyOutput": "Copy output",
+ "streamTransformFailed": "Failed to transform stream"
},
"usage": {
"title": "Usage",
@@ -6963,6 +8522,7 @@
"unlimitedLabel": "Unlimited",
"refreshing": "Refreshing",
"resetsIn": "Resets in",
+ "usdCost": "USD Cost",
"editCutoffs": "Edit cutoffs",
"forceRefresh": "Refresh now",
"resetCreditsLabel": "Reset credits",
@@ -7114,6 +8674,38 @@
"budgetKpiBlocked": "Blocked",
"budgetKpiAtRisk": "At risk",
"budgetKpiActiveKeys": "Active keys",
+ "budgetPageTitle": "Budget",
+ "budgetPageDescription": "Set daily, weekly, and monthly spending limits for each API key.",
+ "budgetTemplateStorageHint": "{count, plural, one {# template} other {# templates}} · edit via localStorage:",
+ "budgetAboveLimitShort": "above limit ⚠",
+ "budgetOnTrackShort": "on track",
+ "budgetAtOrAboveWarning": "≥ warning threshold",
+ "budgetTemplates": "Templates",
+ "budgetSelectKeysFirst": "Select keys first to apply",
+ "budgetApplyToSelected": "Apply to {count, plural, one {# selected key} other {# selected keys}}",
+ "budgetSelectedTemplateHint": "{count, plural, one {# selected} other {# selected}} · click a template to apply",
+ "budgetTemplateMonthlyAmount": "${amount}/month",
+ "budgetTemplateDailyAmount": "${amount}/day",
+ "budgetNoKeysSelected": "No keys selected",
+ "budgetTemplateApplied": "Applied \"{template}\" to {count, plural, one {# key} other {# keys}}",
+ "budgetTemplateApplyFailed": "Failed to apply template",
+ "budgetTemplateNames": {
+ "tpl-prod": "Production",
+ "tpl-dev": "Development",
+ "tpl-ci": "CI"
+ },
+ "budgetStatus": {
+ "all": "All",
+ "blocked": "Blocked",
+ "alerting": "Alerting",
+ "warning": "Warning",
+ "safe": "Safe",
+ "no-limit": "No limit"
+ },
+ "budgetColumnKey": "Key",
+ "budgetColumnToday": "Today",
+ "budgetColumnMonth": "Month",
+ "budgetColumnStatus": "Status",
"budgetSearchKeysPlaceholder": "Search keys...",
"budgetSortPctUsed": "Sort: % Used ↓",
"budgetSortTodayDollar": "Sort: Today $ ↓",
@@ -7129,6 +8721,16 @@
"budgetThisMonthSoFar": "This month so far",
"budgetProjectedEndOfMonth": "Projected end of month",
"budgetByProvider": "by provider",
+ "budgetProjection": "Projection",
+ "budgetAboveMonthlyLimit": "⚠ above {limit}/month",
+ "budgetCostBreakdown30d": "Cost breakdown (30 days)",
+ "budgetLimits": "Limits",
+ "budgetResetDaily": "Daily",
+ "budgetResetWeekly": "Weekly",
+ "budgetResetMonthly": "Monthly",
+ "budgetNextReset": "Next reset",
+ "budgetHardCapComingSoon": "Hard-cap policy and email alerts are coming soon",
+ "unknownProvider": "Unknown provider",
"budgetDailyDollar": "Daily $",
"budgetWeeklyDollar": "Weekly $",
"budgetMonthlyDollar": "Monthly $",
@@ -7763,7 +9365,21 @@
"statusCancelled": "Cancelled",
"repositoryName": "Repository name",
"repositoryUrl": "Repository URL",
- "branch": "Branch"
+ "branch": "Branch",
+ "agentDescriptions": {
+ "jules": "Google's autonomous coding agent",
+ "devin": "Cognition's AI software engineer",
+ "codexCloud": "OpenAI's cloud-based coding agent",
+ "cursorCloud": "Cursor's Background / Cloud Agents (official API key)"
+ },
+ "activityTypes": {
+ "plan": "Plan",
+ "command": "Command",
+ "code_change": "Code change",
+ "message": "Message",
+ "error": "Error",
+ "completion": "Completion"
+ }
},
"templateNames": {
"simple-chat": "Simple Chat",
@@ -8049,6 +9665,8 @@
"errorResetFailed": "Failed to reset pricing"
},
"proxyRegistry": {
+ "selectAllProxies": "Select all proxies",
+ "selectProxy": "Select {name}",
"title": "Proxy Registry",
"description": "Store reusable proxies and track assignments.",
"importLegacy": "Import Legacy",
@@ -8208,14 +9826,17 @@
"endpointOptions": {
"chat": "Chat completions",
"responses": "Responses",
+ "completions": "Completions",
"images": "Image generation",
"embeddings": "Embeddings",
"speech": "Text-to-speech",
"transcription": "Audio transcription",
"video": "Video generation",
"music": "Music generation",
+ "moderations": "Moderations",
"rerank": "Rerank",
- "search": "Web search"
+ "search": "Web search",
+ "webFetch": "Web fetch"
},
"conversationalChat": "Conversational Chat",
"clearChat": "Clear chat",
@@ -8254,6 +9875,11 @@
"improvingPrompt": "Improving…",
"improvePromptTitle": "Improve your prompt with AI",
"setModelFirst": "Set a model first",
+ "setModelInConfigFirst": "Set a model in the Config pane first.",
+ "improvePromptFailed": "Improve prompt failed.",
+ "improvePromptAria": "Improve prompt using AI",
+ "confirmImprovePrompt": "Confirm improve prompt",
+ "improvePromptDescription": "This will send your current system prompt to to generate an improved version.",
"improveQuotaWarning": "This will consume quota from the model configured in Config.",
"improveConfirm": "Improve",
"exportCode": "Export code",
@@ -8275,21 +9901,82 @@
"cancelAll": "Cancel all",
"maxColumnsReached": "Maximum {max} columns",
"modelPlaceholderCompare": "Model (Cmd+K)…",
+ "noModel": "No model",
+ "statusLabel": "Status: {status}",
+ "status": {
+ "idle": "idle",
+ "streaming": "streaming",
+ "done": "done",
+ "error": "error"
+ },
+ "cancelStream": "Cancel stream",
+ "removeColumn": "Remove column",
+ "removeModelColumn": "Remove column for {model}",
+ "readyToRun": "Ready to run.",
+ "errorLabel": "Error",
+ "unknownError": "Unknown error occurred.",
+ "waitingForResponse": "Waiting for response…",
"ttft": "TTFT",
"tps": "TPS",
"metricsDisclaimer": "client-side estimate",
"tokensLabel": "Tokens",
"costLabel": "Cost",
"costEstimated": "(estimated)",
+ "ttftTitle": "Time to first token (client-side estimate)",
+ "tpsTitle": "Tokens per second (client-side estimate)",
+ "tokenCountsTitle": "Prompt tokens ↑ / Completion tokens ↓",
+ "estimatedCostTitle": "Estimated cost (not guaranteed accurate)",
"toolsLabel": "Tools",
+ "toolsCount": "Tools ({count})",
"addTool": "Add tool",
+ "editTool": "Edit tool {name}",
+ "removeTool": "Remove tool {name}",
"toolNamePlaceholder": "Function name",
+ "toolNameRequiredPlaceholder": "Function name *",
"toolDescPlaceholder": "Description (optional)",
+ "toolParamsLabel": "Parameters (JSON schema)",
+ "toolParamsJsonSchema": "JSON schema for parameters",
"toolParamsInvalid": "Parameters must be valid JSON",
"structuredOutputLabel": "Structured Output",
"enableJsonMode": "Enable JSON mode",
"disableJsonMode": "Disable JSON mode",
"invalidJson": "Invalid JSON",
+ "jsonMode": "JSON mode",
+ "jsonModeDescription": "Forces response_format: json_schema",
+ "schemaName": "Schema name",
+ "jsonSchema": "JSON schema",
+ "jsonSchemaEditor": "JSON schema editor",
+ "schemaValidated": "Schema validated",
+ "validateSchema": "Validate schema",
+ "seed": "Seed",
+ "stopSequences": "Stop sequences",
+ "stopSequencesPlaceholder": "e.g. \"\\n\\n\" or \"END\"",
+ "autoProvider": "Auto",
+ "httpError": "Error {status}",
+ "requestCancelled": "Request cancelled",
+ "networkError": "Network error",
+ "regenerateLastResponse": "Regenerate last response",
+ "regenerate": "Regenerate",
+ "startConversation": "Start a conversation — type a message below",
+ "role": {
+ "system": "system",
+ "user": "user",
+ "assistant": "assistant"
+ },
+ "generating": "Generating…",
+ "typeMessageWithShortcut": "Type a message... (Enter to send, Shift+Enter for newline)",
+ "stop": "Stop",
+ "noResponseBody": "No response body",
+ "comparePromptPlaceholder": "Enter your prompt here…",
+ "userPrompt": "User prompt",
+ "cancelAllStreams": "Cancel all streams",
+ "runAllColumns": "Run all columns",
+ "newColumnModelName": "Model name for new column",
+ "addColumn": "Add column",
+ "addModelColumn": "Add model column",
+ "columnCount": "{count}/{max} columns",
+ "addModelToCompare": "Add a model column to compare",
+ "modelsSimultaneously": "Up to {max} models simultaneously",
"running": "Running…",
"runLabel": "Run",
"enterToolResult": "Enter tool result…",
@@ -8346,10 +10033,42 @@
"duration": "Duration",
"question": "Question",
"audioFile": "Audio file",
+ "fileTooLarge25Mb": "The file is too large. Maximum size: 25 MB.",
+ "selectAudioFirst": "Select an audio file first.",
+ "speechToText": "Speech to Text",
+ "chooseFile": "Choose file",
+ "audioFormats25Mb": "MP3, WAV, M4A, OGG or FLAC · Maximum 25 MB",
+ "musicSample": "A calm ambient track with warm piano and soft strings",
+ "noAudioUrl": "The response did not include an audio URL: {response}",
+ "music": "Music",
+ "embeddingSample": "OmniRoute routes AI requests across multiple providers.",
+ "embedding": "Embedding",
+ "imageSample": "A futuristic city at sunset, cinematic lighting",
+ "image": "Image",
+ "ttsSample": "Hello from OmniRoute. This is a text-to-speech test.",
+ "textToSpeech": "Text to Speech",
+ "videoSample": "A paper airplane flying over a futuristic city",
+ "video": "Video",
+ "webFetch": "Web Fetch",
+ "webSearchSample": "What is OmniRoute?",
+ "webSearch": "Web Search",
+ "documentUrl": "Document URL",
+ "browserAudioUnsupported": "Your browser does not support audio.",
+ "browserVideoUnsupported": "Your browser does not support video.",
+ "searchResultFallback": "Result {number}",
"noKeysFound": "No API keys found. Add one in the Keys section.",
"exampleLabel": "Example",
"latency": "{ms}ms",
- "statsLine": "{ms}ms · {tokensIn} in / {tokensOut} out tokens"
+ "statsLine": "{ms}ms · {tokensIn} in / {tokensOut} out tokens",
+ "defaultKey": "(default)",
+ "clear": "Clear",
+ "emptyConversation": "Send a message to start the conversation",
+ "sendHint": "Shift+Enter for newline · Enter to send",
+ "you": "You",
+ "assistant": "Assistant",
+ "errorLabel": "Error",
+ "requestFailed": "Request failed",
+ "stop": "Stop"
},
"requestLogger": {
"recording": "Recording",
@@ -8544,6 +10263,7 @@
"moreSuffix": "+{count} more"
},
"quotaShare": {
+ "weightPercent": "Weight %",
"title": "Quota Sharing",
"description": "Share provider quotas across API keys with % limits",
"newPool": "New pool",
@@ -8566,6 +10286,9 @@
"capLabel": "cap {value}",
"notTrackedYet": "(not tracked yet)",
"policy": "Policy",
+ "apiKeyColumn": "API Key",
+ "weightColumn": "Weight",
+ "fairShareShort": "fair",
"policyHard": "hard",
"policySoft": "soft",
"policyBurst": "burst",
@@ -8770,6 +10493,12 @@
"filterQuota": "Quota",
"filterAuth": "Auth",
"filterSystem": "System",
+ "filterAria": "Filter by event type",
+ "refresh": "Refresh",
+ "refreshAria": "Refresh activity feed",
+ "loading": "Loading",
+ "loadingActivity": "Loading activity…",
+ "fetchFailed": "Failed to fetch activity",
"relative": {
"justNow": "just now",
"minutesAgo": "{n} min ago",
@@ -8918,6 +10647,7 @@
"quickLinks": "Quick links",
"quickLinkProviders": "Configure providers",
"quickLinkInspector": "View traffic in Traffic Inspector",
+ "unknownError": "Unknown error",
"maintenanceTitle": "Maintenance & Diagnostics",
"maintenanceSubtitle": "Self-test the capture pipeline, undo leftover system state, and move your setup between machines.",
"orphanedStateWarning": "A previous session left system state behind (DNS spoof, CA, or system proxy). Run Repair to clean it up.",
@@ -8955,6 +10685,67 @@
"title": "This page has moved"
}
},
+ "providerStats": {
+ "unknownError": "Unknown error",
+ "loading": "Loading provider stats...",
+ "loadFailed": "Failed to load provider stats: {error}",
+ "retry": "Retry",
+ "updated": "Updated {time}",
+ "refresh": "Refresh",
+ "totalRequests": "Total Requests",
+ "avgLatency": "Avg Latency",
+ "successRate": "Success Rate",
+ "activeProviders": "Active Providers",
+ "providerBreakdown": "Provider Breakdown",
+ "providerCount": "{count} providers",
+ "provider": "Provider",
+ "requests": "Requests",
+ "success": "Success",
+ "rate": "Rate",
+ "tokensIn": "Tokens In",
+ "tokensOut": "Tokens Out",
+ "ttftAfterTool": "TTFT After Tool",
+ "gapAfterTool": "Gap After Tool",
+ "model": "Model",
+ "noProviderData": "No provider data recorded yet.",
+ "comboMetrics": "Combo Metrics",
+ "comboMetricsDescription": "Per-combo latency and throughput from streaming",
+ "avgTtft": "Avg TTFT",
+ "avgTotal": "Avg Total",
+ "requestTelemetry": "Request Telemetry",
+ "requestTelemetryDescription": "7-phase pipeline breakdown (last 5 minutes)"
+ },
+ "relay": {
+ "title": "Serverless Relay Proxies",
+ "description": "Create public API endpoints that proxy to OmniRoute with rate limiting and access control",
+ "created": "Relay token created",
+ "createFailed": "Failed to create token",
+ "toggleFailed": "Failed to toggle token",
+ "deleteConfirm": "Delete this relay token? This cannot be undone.",
+ "deleted": "Token deleted",
+ "deleteFailed": "Failed to delete token",
+ "cancel": "Cancel",
+ "newToken": "New Relay Token",
+ "createTitle": "Create Relay Token",
+ "nameRequired": "Name *",
+ "tokenDescription": "Description",
+ "descriptionPlaceholder": "For my serverless functions",
+ "maxPerMinute": "Max Requests/Minute",
+ "maxPerDay": "Max Requests/Day",
+ "createButton": "Create Token",
+ "createdTitle": "Token Created — Copy it now!",
+ "tokenFor": "Token for {name} :",
+ "shownOnce": "This token will not be shown again. Store it securely.",
+ "dismiss": "Dismiss",
+ "usage": "Usage",
+ "usageDescription": "Send requests to your relay endpoint:",
+ "tokenCount": "Relay Tokens ({count})",
+ "loading": "Loading...",
+ "empty": "No relay tokens configured. Create one to get started.",
+ "disable": "Disable",
+ "enable": "Enable",
+ "delete": "Delete"
+ },
"trafficInspector": {
"title": "Traffic Inspector",
"subtitle": "Monitor LLM calls and debug any application's HTTPS traffic",
@@ -9050,7 +10841,40 @@
"timingRequestSize": "Request size",
"timingResponseSize": "Response size",
"pausedNewBadge": "{count} new",
- "clearContextFilter": "clear"
+ "clearContextFilter": "clear",
+ "invalidHostname": "Invalid hostname",
+ "invalidHost": "Invalid host",
+ "addHostFailed": "Failed to add host",
+ "networkError": "Network error",
+ "close": "Close",
+ "removeHost": "Remove {host}",
+ "requestDetails": "Request details",
+ "filterByContext": "Filter by this context",
+ "filteringContext": "Filtering: context {context}",
+ "clear": "clear",
+ "trafficProfile": "Traffic profile",
+ "roleSystem": "System",
+ "roleUser": "User",
+ "roleAssistant": "Assistant",
+ "roleTool": "Tool",
+ "expand": "Expand",
+ "collapse": "Collapse",
+ "systemPromptHidden": "System prompt hidden — click to expand",
+ "sessionName": "Session {id}",
+ "sessions": "Sessions",
+ "requestCountShort": "{count} reqs",
+ "deleteSession": "Delete session",
+ "saving": "Saving…",
+ "sensitiveHeaders": "Sensitive headers",
+ "show": "Show",
+ "hide": "Hide",
+ "name": "Name",
+ "value": "Value",
+ "noSseEvents": "No SSE events",
+ "noRequestBody": "No request body.",
+ "noResponseBody": "No response body.",
+ "formatted": "Formatted",
+ "raw": "Raw"
},
"cliCommon": {
"concept": {
@@ -9103,6 +10927,7 @@
"notConfigured": "Not configured",
"configure": "Configure →",
"howToInstall": "How to install →",
+ "versionNotFound": "not found",
"manualConfig": "Manual config",
"installGuide": "Install guide",
"endpointLabel": "Endpoint",
@@ -9188,8 +11013,190 @@
"cliCodeRedirectCta": "Open CLI Code"
},
"agentSkills": {
+ "catalog": {
+ "omni-auth": {
+ "name": "Authentication",
+ "description": "Manage API key authentication and session tokens. Start here to authenticate requests via Bearer token, obtain session cookies, and configure login requirements for the OmniRoute API."
+ },
+ "omni-providers": {
+ "name": "Providers",
+ "description": "Manage provider connections, API keys, OAuth flows, and connection tests via the REST API. List, add, update, remove, and test AI provider integrations (OpenAI, Anthropic, Gemini, and 160+)."
+ },
+ "omni-models": {
+ "name": "Models",
+ "description": "Query available AI models across all configured providers. List models, resolve model aliases, and browse the full model catalog including provider-specific variants."
+ },
+ "omni-combos-routing": {
+ "name": "Combos & Routing",
+ "description": "Create and manage routing combos with 14 strategies (priority, weighted, round-robin, Auto-combo, etc.). Configure fallback chains, test routing outcomes, and retrieve combo metrics."
+ },
+ "omni-api-keys": {
+ "name": "API Keys",
+ "description": "Create, list, rotate, and revoke OmniRoute API keys. Control per-key scopes, spending limits, and expiration. Keys gate access to all proxy and management endpoints."
+ },
+ "omni-usage-logs": {
+ "name": "Usage & Logs",
+ "description": "Access detailed call logs and usage analytics. Filter by provider, model, time range, status, and cost. Export logs and aggregate token usage across all connections."
+ },
+ "omni-budget": {
+ "name": "Budget & Rate Limits",
+ "description": "Configure spending limits, token quotas, and rate-limit policies per API key or globally. Inspect current consumption and enforce cost controls across providers."
+ },
+ "omni-settings": {
+ "name": "Settings",
+ "description": "Read and update global application settings: system prompts, thinking budget, IP filters, payload rules, combo defaults, and require-login configuration."
+ },
+ "omni-proxies": {
+ "name": "Proxy Configuration",
+ "description": "Configure HTTP/HTTPS/SOCKS proxies for upstream provider requests. Set per-provider or global proxy rules, test connectivity, and manage proxy rotation."
+ },
+ "omni-cache": {
+ "name": "Cache",
+ "description": "Manage the LLM response cache. View cache statistics, clear entries, configure TTL policies, and control semantic-similarity caching thresholds."
+ },
+ "omni-compression": {
+ "name": "Compression",
+ "description": "Configure RTK (command output), Caveman (prose), and stacked compression modes. Manage language packs, custom rules, and test prompt compression reducing tokens by 60–90%."
+ },
+ "omni-context-rtk": {
+ "name": "Context & RTK",
+ "description": "Configure RTK filters, context engineering rules, and context relay settings. Test compression with real prompt samples and manage context transformation pipelines."
+ },
+ "omni-resilience": {
+ "name": "Resilience & Monitoring",
+ "description": "Monitor provider health, circuit-breaker states, p50/p95/p99 latency metrics, and budget guard alerts. Inspect connection cooldowns and model lockouts in real time."
+ },
+ "omni-cli-tools": {
+ "name": "CLI Tools",
+ "description": "Manage CLI tool integrations exposed via the API. List, configure, and invoke CLI tool plugins that extend OmniRoute's automation surface."
+ },
+ "omni-tunnels": {
+ "name": "Tunnels",
+ "description": "Create and manage secure tunnels (ngrok, Cloudflare Tunnel, custom) to expose OmniRoute to the internet or share access with remote agents and CI pipelines."
+ },
+ "omni-sync-cloud": {
+ "name": "Cloud Sync",
+ "description": "Synchronise OmniRoute configuration, provider connections, and settings to/from cloud storage. Manage cloud worker authentication and remote backup targets."
+ },
+ "omni-db-backups": {
+ "name": "Database & Backups",
+ "description": "Trigger system backups, restore from backup files, and manage the SQLite database lifecycle. Supports export, import, and incremental snapshot strategies."
+ },
+ "omni-webhooks": {
+ "name": "Webhooks",
+ "description": "Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries."
+ },
+ "omni-mcp": {
+ "name": "MCP Server",
+ "description": "Connect to the OmniRoute MCP server (37 tools, 3 transports: SSE/stdio/HTTP). Covers routing, cache, compression, memory, skills, providers, and audit tools across 16 permission scopes."
+ },
+ "omni-agents-a2a": {
+ "name": "Agents & A2A Protocol",
+ "description": "Interact with OmniRoute via JSON-RPC 2.0 agent-to-agent protocol. 6 built-in A2A skills: smart-routing, quota-management, provider-discovery, cost-analysis, health-report, list-capabilities."
+ },
+ "omni-version-manager": {
+ "name": "Version Manager",
+ "description": "Install, start, stop, restart, and update embedded services (9Router, CLIProxyAPI). Monitor service status, retrieve logs, and configure auto-start for local-only service endpoints."
+ },
+ "omni-inference": {
+ "name": "Inference (OpenAI-compatible)",
+ "description": "The core OpenAI-compatible inference endpoints: chat completions, embeddings, images, audio (TTS/STT), moderations, rerank, and the Responses API. The primary integration surface for AI agents."
+ },
+ "cli-serve": {
+ "name": "CLI: Serve",
+ "description": "Start, stop, and restart the OmniRoute server from the CLI. Manage daemon mode, port configuration, auto-recovery, system tray integration, and the dashboard open shortcut."
+ },
+ "cli-health": {
+ "name": "CLI: Health",
+ "description": "Check server health, component status, and live metrics from the CLI. Run `health`, `health components`, and `health watch` for a real-time dashboard of circuit breakers and provider status."
+ },
+ "cli-providers": {
+ "name": "CLI: Providers",
+ "description": "Manage provider connections from the CLI: list available/configured providers, add, test, test-all, validate, rotate API keys, and view per-provider metrics."
+ },
+ "cli-keys": {
+ "name": "CLI: API Keys",
+ "description": "Create, list, rotate, and revoke OmniRoute API keys from the CLI. Manage OAuth flows for provider authentication and inspect key scopes and expiration."
+ },
+ "cli-models": {
+ "name": "CLI: Models",
+ "description": "Query available AI models, list model aliases, and browse the full model catalog from the CLI. Filter by provider, search by capability, and resolve model name variants."
+ },
+ "cli-chat": {
+ "name": "CLI: Chat",
+ "description": "Send chat completions, stream responses, and start an interactive REPL session from the CLI. Supports all OmniRoute providers, combo routing, and system prompt configuration."
+ },
+ "cli-routing": {
+ "name": "CLI: Routing & Combos",
+ "description": "Create, list, update, and delete routing combos from the CLI. Test routing strategies, inspect combo metrics, and configure fallback chains interactively."
+ },
+ "cli-resilience": {
+ "name": "CLI: Resilience & Quotas",
+ "description": "Inspect and manage circuit-breaker states, connection cooldowns, quota limits, and backoff levels from the CLI. Reset stuck providers and configure resilience thresholds."
+ },
+ "cli-compression": {
+ "name": "CLI: Compression",
+ "description": "Configure and test prompt compression from the CLI. Manage RTK filters, Caveman rules, stacked compression modes, and preview compression output with real prompts."
+ },
+ "cli-contexts": {
+ "name": "CLI: Contexts & Sessions",
+ "description": "Manage context engineering configurations, RTK filter sets, and conversation sessions from the CLI. Apply context-relay settings and inspect active context pipelines."
+ },
+ "cli-cost-usage": {
+ "name": "CLI: Cost & Usage",
+ "description": "View cost breakdowns, token usage, and call logs from the CLI. Filter by provider, model, or date range. Export usage reports and inspect per-connection spending."
+ },
+ "cli-mcp": {
+ "name": "CLI: MCP",
+ "description": "Inspect the MCP server status, list registered tools and scopes, run tool invocations, and manage MCP audit logs from the CLI."
+ },
+ "cli-a2a": {
+ "name": "CLI: A2A Protocol",
+ "description": "Interact with the OmniRoute A2A server from the CLI. Send tasks, inspect skill execution history, and test the JSON-RPC 2.0 agent-to-agent protocol interactively."
+ },
+ "cli-tunnel": {
+ "name": "CLI: Tunnels",
+ "description": "Start and stop tunnel connections (ngrok, Cloudflare, custom) from the CLI. Inspect active tunnel URLs, configure authentication, and test external reachability."
+ },
+ "cli-backup-sync": {
+ "name": "CLI: Backup & Sync",
+ "description": "Backup and restore OmniRoute data from the CLI. Trigger incremental snapshots, sync to cloud storage, manage backup schedules, and restore from archive files."
+ },
+ "cli-policy-audit": {
+ "name": "CLI: Policy & Audit",
+ "description": "Inspect audit logs, manage access policies, view telemetry data, and review request history from the CLI. Filter by event type, user, or time range for compliance workflows."
+ },
+ "cli-batches": {
+ "name": "CLI: Batches & Files",
+ "description": "Submit and monitor batch inference jobs from the CLI. Upload and manage files for batch processing, retrieve results, and integrate batch pipelines with CI/CD workflows."
+ },
+ "cli-eval": {
+ "name": "CLI: Evals",
+ "description": "Create and run evaluation suites, watch live benchmark progress, view scorecards, compare model performance, and integrate eval runs with CI workflows from the CLI."
+ },
+ "cli-plugins-skills": {
+ "name": "CLI: Plugins, Skills & Memory",
+ "description": "Manage Omni Skills (list, install, test, remove), plugins (create, configure), and persistent memory (search, add, clear) from the CLI."
+ },
+ "cli-setup": {
+ "name": "CLI: Setup & Config",
+ "description": "Run initial setup, configure global CLI settings, manage environment variables, check for updates, and configure autostart via the CLI setup and config commands."
+ },
+ "cli-skill-collector": {
+ "name": "CLI: Agent Skill Collector",
+ "description": "Detect installed CLI coding tools (Claude Code, Codex, Cursor, Copilot, Cline and more), search GitHub for matching agent skills, and install them to the detected tools via OmniRoute's built-in APIs."
+ },
+ "config-codex-cli": {
+ "name": "Config: Codex CLI",
+ "description": "Step-by-step agent workflow to configure the OpenAI Codex CLI on any machine (Linux, macOS, Windows) to use OmniRoute as an OpenAI-compatible backend. Detects OS and shell, writes config.toml and 7 named profiles, sets environment variables, and verifies the setup."
+ },
+ "omni-github-skills": {
+ "name": "GitHub Skill Discovery",
+ "description": "Search, score, scan, and import agent skills from GitHub repositories that contain SKILL.md, CLAUDE.md, .cursorrules, and similar agent skill files. Discover community skills across 160+ provider categories, evaluate relevance with heuristic scoring, check for malware or hardcoded secrets, and install into Hermes, Claude Code, Gemini CLI, or OpenCode agent directories."
+ }
+ },
"pageTitle": "Agent Skills",
- "pageSubtitle": "Teach your agent to operate OmniRoute — 22 API areas + 20 CLI families",
+ "pageSubtitle": "Teach your agent to operate OmniRoute — 23 API areas + 21 CLI families",
"conceptCard": {
"agent": {
"title": "Agent Skills — Outbound",
@@ -9243,6 +11250,9 @@
"coverageLabel": "Coverage",
"mcpUrl": "MCP URL",
"a2aLink": "A2A",
+ "mcpPrompt": "Add this MCP endpoint to your agent to give it 37 OmniRoute tools.",
+ "a2aPrompt": "Register this Agent Card with your orchestrator to enable A2A task delegation.",
+ "refresh": "Refresh",
"copyUrl": "Copy URL",
"viewOnGithub": "View on GitHub",
"previewLoading": "Loading skill documentation…",
@@ -9319,6 +11329,592 @@
"feasibility": "Feasibility",
"models": "Models"
},
+ "noAuthProvider": {
+ "title": "No authentication required",
+ "description": "This provider is ready to use immediately — no signup or API key needed.",
+ "accountDescription": "Ready to use — no signup needed. Add accounts for rate-limit rotation.",
+ "addAccount": "Add Account",
+ "accountName": "{provider} Account {number}",
+ "accounts": "Accounts ({count})",
+ "adding": "Adding...",
+ "autoGeneratedAccount": "Using an auto-generated account. Select “{addLabel}” for rate-limit rotation.",
+ "configureProxy": "Configure proxy",
+ "proxyConfigured": "Proxy configured: {host}",
+ "removeAccount": "Remove account",
+ "proxyForAccount": "Proxy for Account {number}",
+ "saved": "Saved",
+ "custom": "Custom",
+ "noSavedProxies": "No saved proxies — add one in Settings → Proxy",
+ "directConnection": "Direct (no proxy)",
+ "host": "Host",
+ "port": "Port",
+ "usernameOptional": "Username (optional)",
+ "passwordOptional": "Password (optional)",
+ "cancel": "Cancel",
+ "saving": "Saving...",
+ "save": "Save",
+ "createConnectionFailed": "Failed to create connection",
+ "updateConnectionFailed": "Failed to update connection",
+ "fetchProxiesFailed": "Failed to fetch proxies",
+ "noSavedProxiesError": "No saved proxies found. Add proxies in Settings → Proxy first.",
+ "updateProviderFailed": "Failed to update provider",
+ "providerEnabled": "{provider} enabled",
+ "providerDisabled": "{provider} disabled"
+ },
+ "gamification": {
+ "leaderboardScopes": {
+ "allTime": "All Time",
+ "weekly": "Weekly",
+ "monthly": "Monthly",
+ "tokensShared": "Tokens Shared"
+ },
+ "leaderboardLoadFailed": "Failed to load leaderboard (HTTP {status})",
+ "scope": "Scope",
+ "tokensShared": "tokens shared",
+ "points": "points",
+ "rank": "Rank",
+ "name": "Name",
+ "score": "Score",
+ "leaderboardEmpty": "No entries yet for this scope. Start using OmniRoute to appear on the leaderboard!",
+ "profileLoadFailed": "Failed to load profile data",
+ "levelTitles": {
+ "beginner": "Beginner",
+ "explorer": "Explorer",
+ "expert": "Expert",
+ "master": "Master",
+ "legend": "Legend"
+ },
+ "tiers": {
+ "bronze": "Bronze",
+ "silver": "Silver",
+ "gold": "Gold",
+ "platinum": "Platinum",
+ "diamond": "Diamond"
+ },
+ "tierLabel": "Tier: {tier}",
+ "dayStreak": "{count}-day streak",
+ "levelProgress": "Level {current} → {next}",
+ "totalXpEarned": "{count} total XP earned",
+ "maintainStreak": "Keep using OmniRoute every day to maintain your streak!",
+ "badgesTitle": "Badges ({earned}/{total})",
+ "noBadges": "No badges are available yet.",
+ "hiddenBadge": "Hidden achievement",
+ "earnedDate": "Earned {date}",
+ "earnedOn": "Earned on {date}",
+ "category": "Category",
+ "rarities": {
+ "common": "Common",
+ "uncommon": "Uncommon",
+ "rare": "Rare",
+ "epic": "Epic",
+ "legendary": "Legendary"
+ },
+ "categories": {
+ "usage": "Usage",
+ "sharing": "Sharing",
+ "contribution": "Contribution",
+ "streak": "Streak",
+ "rare": "Rare achievements"
+ },
+ "badges": {
+ "first-token": {
+ "name": "First Token",
+ "description": "Made your first API request",
+ "criteria": "Complete your first API request through OmniRoute."
+ },
+ "token-consumer": {
+ "name": "Token Consumer",
+ "description": "Made 1,000 API requests",
+ "criteria": "Complete 1,000 API requests through OmniRoute."
+ },
+ "token-machine": {
+ "name": "Token Machine",
+ "description": "Made 10,000 API requests",
+ "criteria": "Complete 10,000 API requests through OmniRoute."
+ },
+ "token-whale": {
+ "name": "Token Whale",
+ "description": "Made 100,000 API requests",
+ "criteria": "Complete 100,000 API requests through OmniRoute."
+ },
+ "generous": {
+ "name": "Generous",
+ "description": "Shared 1,000 tokens with others",
+ "criteria": "Share a total of 1,000 tokens with other users."
+ },
+ "philanthropist": {
+ "name": "Philanthropist",
+ "description": "Shared 10,000 tokens with others",
+ "criteria": "Share a total of 10,000 tokens with other users."
+ },
+ "token-santa": {
+ "name": "Token Santa",
+ "description": "Shared 100,000 tokens with others",
+ "criteria": "Share a total of 100,000 tokens with other users."
+ },
+ "community-hero": {
+ "name": "Community Hero",
+ "description": "Shared 1,000,000 tokens with others",
+ "criteria": "Share a total of 1,000,000 tokens with other users."
+ },
+ "explorer": {
+ "name": "Explorer",
+ "description": "Used 5 different providers",
+ "criteria": "Use at least 5 different AI providers."
+ },
+ "polyglot": {
+ "name": "Polyglot",
+ "description": "Used 10 different models",
+ "criteria": "Use at least 10 different AI models."
+ },
+ "architect": {
+ "name": "Architect",
+ "description": "Created 3 combo routes",
+ "criteria": "Create 3 combo routes."
+ },
+ "speedster": {
+ "name": "Speedster",
+ "description": "Maintained under 500 ms average latency for 100 requests",
+ "criteria": "Keep average latency below 500 ms across 100 requests."
+ },
+ "resilient": {
+ "name": "Resilient",
+ "description": "Maintained 100% uptime for 7 days",
+ "criteria": "Maintain 100% uptime for 7 consecutive days."
+ },
+ "daily-user": {
+ "name": "Daily User",
+ "description": "Active for 3 consecutive days",
+ "criteria": "Use OmniRoute for 3 consecutive days."
+ },
+ "weekly-warrior": {
+ "name": "Weekly Warrior",
+ "description": "Active for 7 consecutive days",
+ "criteria": "Use OmniRoute for 7 consecutive days."
+ },
+ "monthly-master": {
+ "name": "Monthly Master",
+ "description": "Active for 30 consecutive days",
+ "criteria": "Use OmniRoute for 30 consecutive days."
+ },
+ "unstoppable": {
+ "name": "Unstoppable",
+ "description": "Active for 365 consecutive days",
+ "criteria": "Use OmniRoute for 365 consecutive days."
+ },
+ "early-adopter": {
+ "name": "Early Adopter",
+ "description": "Joined within the first month of gamification",
+ "criteria": "Join within the first month after gamification launches."
+ },
+ "bug-hunter": {
+ "name": "Bug Hunter",
+ "description": "Reported 5 issues",
+ "criteria": "Report 5 valid issues."
+ },
+ "contributor": {
+ "name": "Contributor",
+ "description": "Merged 1 pull request",
+ "criteria": "Have 1 pull request merged into OmniRoute."
+ },
+ "community-leader": {
+ "name": "Community Leader",
+ "description": "Reached the top 10 on any leaderboard",
+ "criteria": "Reach the top 10 on any leaderboard."
+ },
+ "secret-badge": {
+ "name": "???",
+ "description": "A hidden achievement awaits...",
+ "criteria": "Complete the hidden achievement to reveal this badge."
+ }
+ }
+ },
+ "featureFlags": {
+ "title": "Feature Flags",
+ "activeCount": "{count} active",
+ "inactiveCount": "{count} inactive",
+ "dbOverrideCount": "{count} DB overrides",
+ "searchPlaceholder": "Search flags...",
+ "categories": {
+ "all": "All",
+ "security": "Security",
+ "network": "Network",
+ "policies": "Policies",
+ "runtime": "Runtime",
+ "cli": "CLI",
+ "health": "Health",
+ "requiresRestart": "Requires Restart"
+ },
+ "categoryLabel": "Category: {category}",
+ "caution": "Caution",
+ "danger": "Danger",
+ "requiresRestart": "Requires restart",
+ "source": "Source",
+ "resetFlag": "Reset {label} to default",
+ "reset": "Reset",
+ "loadFailed": "Failed to load feature flags",
+ "updateFailedHttp": "Failed to update flag: HTTP {status}",
+ "updateFailed": "Failed to update flag",
+ "restartFailedHttp": "Restart failed: HTTP {status}",
+ "restartFailed": "Restart failed",
+ "resetOverridesFailedHttp": "Failed to reset overrides: HTTP {status}",
+ "resetOverridesFailed": "Failed to reset overrides",
+ "restartRequiredCount": "{count, plural, one {# change requires} other {# changes require}} a server restart to take effect.",
+ "restartRequiredDescription": "These flags only apply after the process reloads. Restart now or continue editing — pending flags stay queued until you confirm.",
+ "restartServer": "Restart Server",
+ "cancel": "Cancel",
+ "restarting": "Restarting…",
+ "confirmRestart": "Confirm Restart",
+ "restartViewDescription": "These flags only take effect after the server restarts. Toggle them like any other flag — the change is persisted immediately, but the new value is only read at process startup. Use the Restart Server banner above to apply.",
+ "retry": "Retry",
+ "noSearchResults": "No flags match your search",
+ "resetAllOverrides": "Reset All Overrides",
+ "confirmResetOverrides": "Reset all {count} DB override(s)?",
+ "resetting": "Resetting...",
+ "confirmReset": "Confirm Reset",
+ "enumValues": {
+ "off": "Off",
+ "warn": "Warn",
+ "block": "Block",
+ "redact": "Redact",
+ "disabled": "Disabled",
+ "dual": "Dual",
+ "alias": "Alias",
+ "canonical": "Canonical"
+ },
+ "definitions": {
+ "REQUIRE_API_KEY": { "description": "Require an API key for all incoming requests." },
+ "INPUT_SANITIZER_ENABLED": { "description": "Enable input sanitization for all requests." },
+ "INJECTION_GUARD_MODE": { "description": "Set the prompt-injection guard mode." },
+ "PII_REDACTION_ENABLED": {
+ "description": "Redact personally identifiable information from requests."
+ },
+ "PII_RESPONSE_SANITIZATION": {
+ "description": "Sanitize personally identifiable information in provider responses."
+ },
+ "PII_RESPONSE_SANITIZATION_MODE": {
+ "description": "Choose how response PII is handled: redact replaces it, warn only logs it, block rejects the response, and off disables sanitization."
+ },
+ "OUTBOUND_SSRF_GUARD_ENABLED": {
+ "description": "Block outbound requests to private or internal IP ranges."
+ },
+ "ALLOW_API_KEY_REVEAL": {
+ "description": "Allow authenticated dashboard users to reveal stored API keys instead of seeing masked values only."
+ },
+ "ENABLE_TLS_FINGERPRINT": { "description": "Enable TLS fingerprint stealth mode." },
+ "ONEPROXY_ENABLED": { "description": "Enable request proxying through 1proxy." },
+ "PROXY_AUTO_SELECT_ENABLED": {
+ "description": "When no proxy is assigned to a connection, automatically select the first working registry proxy. Off by default because otherwise one registry proxy becomes a global fallback for all traffic (#3332)."
+ },
+ "OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK": {
+ "description": "Allow OAuth and provider-validation flows to bypass a pinned proxy when proxy reachability checks fail. Off by default because this can change the account egress IP."
+ },
+ "MITM_DISABLE_TLS_VERIFY": {
+ "description": "Disable TLS certificate verification for the MITM proxy."
+ },
+ "OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS": {
+ "description": "Allow provider URLs that point to private or internal networks."
+ },
+ "OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS": {
+ "description": "Allow providers on localhost, LAN, and private IP ranges. This is required for local OpenAI-compatible models and enabled by default. Cloud-metadata endpoints such as 169.254.169.254 remain blocked."
+ },
+ "ENABLE_CC_COMPATIBLE_PROVIDER": {
+ "description": "Enable Claude Code-compatible provider mode."
+ },
+ "TOOL_POLICY_MODE": { "description": "Set the tool-use policy enforcement mode." },
+ "RATE_LIMIT_AUTO_ENABLE": {
+ "description": "Automatically enable rate limiting based on usage patterns."
+ },
+ "ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE": {
+ "description": "Allow multiple connections for each compatibility node."
+ },
+ "RESPONSES_PASSTHROUGH_DROP_COMMENTARY": {
+ "description": "Remove internal commentary-phase output items from Responses API passthrough streams before forwarding them to clients. Disable this flag to receive raw upstream commentary."
+ },
+ "OMNIROUTE_MCP_ENFORCE_SCOPES": {
+ "description": "Enforce scope restrictions for MCP tool access."
+ },
+ "OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS": {
+ "description": "Compress MCP tool descriptions to reduce token usage."
+ },
+ "OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS": {
+ "description": "Enable background-task processing at runtime."
+ },
+ "OMNIROUTE_DISABLE_BACKGROUND_SERVICES": {
+ "description": "Disable all background services, including quota refresh and synchronization."
+ },
+ "OMNIROUTE_RTK_TRUST_PROJECT_FILTERS": {
+ "description": "Trust project-level RTK filters without validation."
+ },
+ "OMNIROUTE_ENABLE_LIVE_WS": {
+ "description": "Start the real-time dashboard WebSocket server on import at loopback port 20132. Set the flag to 0 or false to disable it. LAN exposure also requires LIVE_WS_HOST=0.0.0.0 and LIVE_WS_ALLOWED_ORIGINS."
+ },
+ "OMNIROUTE_CODEX_WS_ENABLED": {
+ "description": "Allow Codex to use Responses over WebSocket. When disabled, Codex falls back to HTTP Responses."
+ },
+ "OMNIROUTE_EMERGENCY_FALLBACK": {
+ "description": "Route budget-exhausted requests to the emergency free fallback provider and model."
+ },
+ "STREAM_RECOVERY_ENABLED": {
+ "description": "Retry truncated upstream SSE streams transparently before any response bytes reach the client."
+ },
+ "STREAM_RECOVERY_MIDSTREAM_ENABLED": {
+ "description": "Allow stream recovery to request the response again and stitch it after bytes have already reached the client."
+ },
+ "MODEL_CATALOG_INCLUDE_NAMES": {
+ "description": "Include display-friendly name fields in /v1/models responses. Disable this for clients that accept model IDs only."
+ },
+ "MODELS_CATALOG_PREFIX_MODE": {
+ "description": "Control model-ID prefixes in /v1/models: dual emits alias and canonical prefixes, alias emits short prefixes only, and canonical emits full provider IDs only."
+ },
+ "ARENA_ELO_SYNC_ENABLED": {
+ "description": "Periodically synchronize Arena AI leaderboard ELO data for model-intelligence rankings."
+ },
+ "CLI_COMPAT_ALL": { "description": "Enable compatibility mode for all CLI clients." },
+ "MODEL_ALIAS_COMPAT_ENABLED": {
+ "description": "Enable the model-alias compatibility layer."
+ },
+ "PRICING_SYNC_ENABLED": {
+ "description": "Synchronize pricing data automatically. The PRICING_SYNC_ENABLED environment variable must also be true."
+ },
+ "OMNIROUTE_AUTO_SYNC_CODEX_PROFILES": {
+ "description": "After provider-model synchronization, regenerate ~/.codex/*.config.toml profiles from the live catalog. This never changes the active or default Codex configuration and is off by default."
+ },
+ "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": {
+ "description": "After provider-model synchronization, regenerate ~/.claude/profiles//settings.json profiles from the live catalog. This never changes the active or default Claude configuration and is off by default."
+ },
+ "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": {
+ "description": "Disable the local instance health-check endpoint."
+ },
+ "OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK": {
+ "description": "Disable the token-validation health check."
+ },
+ "SKILLS_SANDBOX_NETWORK_ENABLED": {
+ "description": "Enable network access in the skills sandbox."
+ }
+ }
+ },
+ "comboControl": {
+ "title": "Combo Control Center",
+ "unavailable": "Combo Control Center unavailable",
+ "backToCombos": "Back to Combos",
+ "loadFailed": "Failed to load combo control center",
+ "state": {
+ "healthy": "Healthy",
+ "warning": "Needs attention",
+ "critical": "Critical",
+ "idle": "Idle"
+ },
+ "active": "Active",
+ "disabled": "Disabled",
+ "description": "Central read-only view for routing behavior, health, quota, runtime metrics and recent decisions for .",
+ "refresh": "Refresh",
+ "editInCombos": "Edit in Combos",
+ "requests": "Requests",
+ "success": "Success",
+ "latency": "Latency",
+ "quota": "Quota",
+ "rangeWindow": "{range} window",
+ "runtimeHealthBlend": "runtime/health blend",
+ "averageResponseTime": "average response time",
+ "worstQuota": "Worst quota",
+ "providerAccountTelemetry": "provider/account telemetry",
+ "overview": "Overview",
+ "overviewDescription": "Strategy, runtime status and control links for this combo.",
+ "strategy": "Strategy",
+ "targets": "Targets",
+ "providers": "Providers",
+ "targetCounts": "{configured} configured · {resolved} resolved",
+ "healthReasons": "Health reasons",
+ "healthReason": {
+ "noRecentTraffic": "No recent combo traffic",
+ "lowSuccessRate": "Low success rate",
+ "successBelowTarget": "Success rate below target",
+ "highFallbackRate": "High fallback rate",
+ "elevatedFallbackRate": "Elevated fallback rate",
+ "quotaExhausted": "At least one quota is exhausted",
+ "quotaNearlyExhausted": "Quota is nearly exhausted",
+ "quotaGettingLow": "Quota is getting low",
+ "trafficHighlySkewed": "Traffic distribution is highly skewed",
+ "comboHealthy": "Combo looks healthy"
+ },
+ "configuredTargets": "Configured targets",
+ "configuredTargetsDescription": "The saved combo steps, enriched with matching health data when available.",
+ "noConfiguredTargets": "No targets configured.",
+ "runtimeConfig": "Runtime config",
+ "runtimeConfigDescription": "Selected advanced settings for this combo.",
+ "noRuntimeConfig": "No custom runtime config.",
+ "resolvedTargets": "Resolved runtime targets",
+ "resolvedTargetsDescription": "Flattened targets after nested combo resolution and target-level metrics.",
+ "noResolvedTargetHealth": "No resolved target health yet.",
+ "quotaDistribution": "Quota and distribution",
+ "noQuotaSnapshots": "No quota snapshots for this combo window.",
+ "usageSkew": "Usage skew",
+ "recentDecisions": "Recent routing decisions",
+ "recentDecisionsDescription": "Recent call logs filtered by this combo name. Open Analytics for full explainability.",
+ "noRecentLogs": "No recent combo call logs found.",
+ "quickLinks": "Quick links",
+ "comboHealth": "Combo Health",
+ "callLogs": "Call Logs",
+ "costs": "Costs",
+ "playground": "Playground",
+ "nestedCombo": "Nested combo",
+ "modelTarget": "Model target",
+ "weight": "{value}% weight",
+ "comboReference": "Combo reference",
+ "accountShort": "account {id}",
+ "keyShort": "key {id}",
+ "stepShort": "step {id}",
+ "dynamic": "dynamic",
+ "unknown": "unknown",
+ "unknownProvider": "unknown provider",
+ "unknownModel": "unknown model",
+ "resolvedTargetMetrics": "{requests} req · {success} success · {latency} · quota {quota}"
+ },
+ "usageLimits": {
+ "usdUsageQuota": "USD usage quota",
+ "usdUsageQuotaDescription": "Blocks this key with a 400 API error after its local USD spend reaches the configured daily or weekly quota.",
+ "dailyQuotaUsd": "Daily quota (USD)",
+ "weeklyQuotaUsd": "Weekly quota (USD)",
+ "quotaWindowDescription": "Weekly quota follows the cached Claude weekly reset when available; otherwise it falls back to a rolling 7 day window. Daily quota uses the Fortaleza calendar day.",
+ "apiKeyUsdQuota": "API key USD quota",
+ "apiKeyUsdQuotaDescription": "When enabled, @@om-usage returns daily quota, weekly quota, daily spend, and weekly spend in USD. Weekly follows the cached Claude reset when available.",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "dailySpend": "Daily spend",
+ "weeklySpend": "Weekly spend",
+ "dailyQuota": "Daily quota",
+ "weeklyQuota": "Weekly quota",
+ "fallbackRollingDays": "fallback: rolling {days} days",
+ "fallbackRollingDaysShort": "Fallback rolling {days}d",
+ "resetDueNow": "reset due now",
+ "resetsInHours": "resets in {count}h",
+ "resetsInDays": "resets in {count}d",
+ "saveFailed": "Failed to save usage limits",
+ "saving": "Saving...",
+ "saveQuota": "Save quota",
+ "loadUsdCostsFailed": "Failed to load USD costs",
+ "usdCost": "USD Cost",
+ "close": "Close",
+ "loadingUsdCosts": "Loading USD costs",
+ "used": "Used",
+ "quotaUsed": "Quota used",
+ "estimatedFullQuota": "Est. 100%",
+ "rows": "Rows",
+ "window": "Window",
+ "unknown": "unknown",
+ "fromRecordedReset": "From recorded {quota} reset",
+ "fromObservedReset": "From observed {quota} reset",
+ "fromReset": "From {quota} reset",
+ "quotaEstimator": "Quota estimator",
+ "noApiKeyUsage": "No API key usage in this provider window.",
+ "requestTokenCounts": "{requests} requests · {tokens} tokens",
+ "noUsdLimit": "No USD limit"
+ },
+ "freeBudget": {
+ "title": "Free-token budget",
+ "remaining": "{remaining} remaining · {percent}% of {total}",
+ "steadyMonth": "Steady / month",
+ "firstMonth": "First month (+ credits)",
+ "usedThisMonth": "Used this month",
+ "segmentHint": "Each segment = one free pool · pool-deduped, honest counting (no inflated rate-limit ceilings).",
+ "boost": "Unlock ~{tokens} more/mo with a one-time $10 OpenRouter top-up (50 → 1000 req/day)",
+ "uncapped": "Permanently free, no published cap (rate-limited) — real access, not counted in the headline:",
+ "tosRestricted": "{count, plural, one {# model} other {# models}} flagged as ToS-restricted — you decide",
+ "provider": "Provider",
+ "model": "Model",
+ "modelName": "Model name",
+ "type": "Type",
+ "tokensMonth": "Tokens/mo",
+ "credit": "{tokens} credit",
+ "hideTosRestricted": "Hide ToS-restricted",
+ "sort": "Sort",
+ "freeType": {
+ "daily": "daily",
+ "monthly": "monthly",
+ "creditMonthly": "credit/mo",
+ "uncapped": "uncapped",
+ "signupCredit": "signup credit",
+ "keyless": "keyless",
+ "discontinued": "discontinued"
+ },
+ "tosTitle": {
+ "avoid": "ToS-restricted — review terms",
+ "caution": "Caution — personal-use / proxy clauses",
+ "ok": "Generally permissive"
+ }
+ },
+ "providerHealthAutopilot": {
+ "title": "Provider Health Autopilot",
+ "description": "Finds unstable providers, account cooldowns, stale errors, and safe manual fixes.",
+ "loadFailed": "Failed to load autopilot report",
+ "actionApplied": "{action} applied.",
+ "actionFailed": "Autopilot action failed",
+ "refresh": "Refresh",
+ "status": "Status",
+ "issues": "Issues",
+ "actions": "Actions",
+ "connections": "Connections",
+ "state": {
+ "healthy": "healthy",
+ "warning": "warning",
+ "critical": "critical",
+ "loading": "loading"
+ },
+ "loadingRecommendations": "Loading provider recommendations...",
+ "noRecommendations": "No provider health recommendations right now.",
+ "providerMetrics": "score {score}% · active {active}/{total} · cooldown {cooldown} · model lockouts {lockouts}",
+ "providerState": {
+ "healthy": "healthy",
+ "degraded": "degraded",
+ "down": "down"
+ },
+ "severity": {
+ "info": "info",
+ "warning": "warning",
+ "critical": "critical"
+ },
+ "remainingSeconds": "remaining {seconds}s",
+ "errorCode": "code {code}",
+ "applying": "Applying...",
+ "issue": {
+ "circuitOpenTitle": "Provider circuit breaker is open",
+ "circuitOpenRecommendation": "Verify upstream recovery, then reset the provider circuit breaker or wait for the retry window.",
+ "circuitRecoveryTitle": "Provider is probing recovery",
+ "circuitRecoveryRecommendation": "Let the next probe complete or reset the breaker after manual verification.",
+ "terminalTitle": "{label} has a terminal account state",
+ "terminalRecommendation": "Check billing, re-authenticate, or replace the credential before reactivating it.",
+ "cooldownTitle": "{label} is in temporary cooldown",
+ "cooldownRecommendation": "Wait for the upstream retry window or clear the cooldown after validating recovery.",
+ "staleErrorTitle": "{label} has stale error state",
+ "staleErrorRecommendation": "Clear stale error fields so the connection is eligible for normal routing again.",
+ "inactiveTitle": "{label} is disabled",
+ "inactiveRecommendation": "Reactivate only if this was not intentionally disabled.",
+ "modelLockoutTitle": "{model} is locked out for one connection",
+ "modelLockoutRecommendation": "Resolve the connection state or confirm model quota/availability has recovered before clearing the lockout.",
+ "quotaTitle": "Quota monitor reports {status}",
+ "quotaRecommendation": "Review quota usage and rotate traffic to another healthy connection if needed."
+ },
+ "action": {
+ "resetProviderBreaker": "Reset provider breaker",
+ "clearConnectionCooldown": "Clear connection cooldown",
+ "disableConnection": "Disable this connection",
+ "clearStaleError": "Clear stale error state",
+ "reactivateConnection": "Reactivate connection",
+ "clearModelLockout": "Clear model lockout"
+ }
+ },
+ "changelogPage": {
+ "newsTab": "News",
+ "changelogTab": "Changelog",
+ "loading": "Loading changelog...",
+ "announcementsLoadFailed": "Could not load announcements. Please try again later.",
+ "noAnnouncements": "No new announcements at this time.",
+ "learnMore": "Learn more",
+ "changelogLoadFailed": "Could not load the changelog. Please try again later.",
+ "retry": "Retry",
+ "viewFullHistory": "View full history on GitHub"
+ },
"reasoningRouting": {
"title": "Reasoning routing policies",
"apiKeyTitle": "Reasoning routing for this API key",
@@ -9424,6 +12020,17 @@
"keyPermissionDesc": "Allow this API key to use Chaos Mode (multi-model parallel execution)",
"testButton": "Test Chaos Mode",
"testTask": "Write a short poem about artificial intelligence",
- "loadingProviderModels": "Loading providers..."
+ "loadingProviderModels": "Loading providers...",
+ "systemPromptPlaceholder": "Optional: override the default chaos mode system prompt...",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "maxTokens": "Max Tokens",
+ "maxTokensDesc": "Maximum tokens per model response. Higher values cost more and take longer.",
+ "providerIdPlaceholder": "Provider ID (type or select)",
+ "modelIdPlaceholder": "Model ID (optional)",
+ "on": "ON",
+ "off": "OFF",
+ "availableProviders": "Available providers ({count})",
+ "noProviderOverrides": "No overrides — all active providers will participate with their default models"
}
}
diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json
index 25069c8b8f..c6912a6a27 100644
--- a/src/i18n/messages/vi.json
+++ b/src/i18n/messages/vi.json
@@ -4,11 +4,19 @@
"cancel": "Hủy bỏ",
"delete": "Xóa",
"loading": "Đang tải...",
+ "selectOption": "Chọn một tùy chọn",
"error": "Đã xảy ra lỗi",
"success": "Thành công",
"confirm": "Bạn có chắc không?",
"refresh": "Làm mới",
"close": "Đóng",
+ "previousPage": "Trang trước",
+ "nextPage": "Trang sau",
+ "capsLockOn": "Caps Lock đang bật",
+ "dismissNotification": "Đóng thông báo",
+ "toggleColumns": "Bật/tắt cột",
+ "confirmTitle": "Xác nhận",
+ "confirmAction": "Xác nhận",
"add": "Thêm",
"edit": "Chỉnh sửa",
"search": "Tìm kiếm",
@@ -25,6 +33,7 @@
"noData": "Không có dữ liệu",
"nothingHere": "Chưa có gì ở đây",
"configure": "Cấu hình",
+ "manualConfig": "Cấu hình thủ công",
"manage": "Quản lý",
"name": "Tên",
"actions": "Hành động",
@@ -33,6 +42,7 @@
"model": "Mô hình",
"models": "mô hình",
"provider": "Nhà cung cấp",
+ "unknownProvider": "Nhà cung cấp không xác định",
"account": "Tài khoản",
"time": "Thời gian",
"details": "Chi tiết",
@@ -143,6 +153,17 @@
"goToDashboard": "Đi tới bảng điều khiển",
"checkSystemStatus": "Kiểm tra trạng thái hệ thống",
"selectModel": "Chọn mô hình",
+ "addModel": "Thêm mô hình",
+ "effortNone": "Không dùng",
+ "effortLow": "Thấp",
+ "effortMedium": "Trung bình",
+ "effortHigh": "Cao",
+ "effortExtraHigh": "Rất cao",
+ "effortMax": "Tối đa",
+ "effortUltra": "Siêu cao",
+ "reasoningEffort": "Mức suy luận",
+ "wireApi": "API truyền tải",
+ "modelAliases": "Bí danh mô hình",
"combos": "Combo",
"noModelsFound": "Không tìm thấy mô hình nào",
"clear": "Xóa",
@@ -393,6 +414,7 @@
"cloudBenefitAccess": "Quyền truy cập các lợi ích của đám mây",
"maxRetries": "Số lần thử lại tối đa",
"queueTimeout": "Thời gian chờ hàng đợi",
+ "queueDepth": "Độ sâu hàng đợi",
"addProvider": "Thêm nhà cung cấp",
"realtime": "Theo thời gian thực",
"totalTranslations": "Tổng số bản dịch",
@@ -682,6 +704,42 @@
"tokensRedeemCode": "Đổi mã",
"tokensRedeemCodePlaceholder": "Nhập mã mời",
"tokensYourActiveInvites": "Lời mời hoạt động của bạn",
+ "tokensTransferSuccess": "Đã chuyển thành công ({idempotencyKey})",
+ "tokensTransferFailed": "Chuyển token thất bại",
+ "tokensCreateInviteFailed": "Không thể tạo mã mời",
+ "tokensRevokeInviteFailed": "Không thể thu hồi mã mời",
+ "tokensRedeemSuccess": "Đã đổi mã mời. Máy chủ: {server}",
+ "tokensRedeemFailed": "Không thể đổi mã mời",
+ "tokensConnectServerFailed": "Không thể kết nối máy chủ",
+ "tokensDisconnectServerFailed": "Không thể ngắt kết nối máy chủ",
+ "tokensAmount": "Số lượng",
+ "tokensSending": "Đang gửi...",
+ "tokensFrom": "Từ",
+ "tokensTo": "Đến",
+ "tokensReason": "Lý do",
+ "tokensDate": "Ngày",
+ "tokensSent": "Đã gửi",
+ "tokensReceived": "Đã nhận",
+ "tokensCreatingInvite": "Đang tạo...",
+ "tokensCreateInvite": "Tạo mã mời",
+ "tokensInviteCreated": "Đã tạo mã mời",
+ "tokensRedeeming": "Đang đổi mã...",
+ "tokensRedeem": "Đổi mã",
+ "tokensInviteUses": "Đã dùng {used}/{max} lần",
+ "tokensRevoked": "ĐÃ THU HỒI",
+ "tokensRevoke": "Thu hồi",
+ "tokensConnecting": "Đang kết nối...",
+ "tokensConnectServer": "Kết nối máy chủ",
+ "tokensNoServersConnected": "Chưa kết nối máy chủ nào. Hãy kết nối với máy chủ cộng đồng để chia sẻ bảng xếp hạng.",
+ "tokensServerStatus": {
+ "connected": "Đã kết nối",
+ "disconnected": "Đã ngắt kết nối",
+ "pending": "Đang chờ",
+ "syncing": "Đang đồng bộ",
+ "error": "Lỗi"
+ },
+ "tokensLastSync": "Đồng bộ lần cuối: {date}",
+ "tokensDisconnect": "Ngắt kết nối",
"tierCoverageTitle": "Mức độ bao phủ theo tầng",
"tierCoverageSubtitle": "Nhà cung cấp được định cấu hình cho mỗi tầng dự phòng",
"batchDetailCopyId": "Sao chép ID",
@@ -705,10 +763,26 @@
"batchFileDetailCopyId": "Sao chép ID",
"batchFileDetailClose": "Đóng",
"batchFileDetailFailedToLoad": "Không thể tải nội dung tập tin",
+ "batchFileDetailLoadError": "Lỗi khi tải nội dung tập tin",
"batchFilesListSearchPlaceholder": "Tìm kiếm theo ID hoặc tên tập tin…",
"batchFilesListFilesTable": "Tập tin",
+ "batchFilesCount": "{count} tập tin",
+ "batchFilesAllPurposes": "Tất cả mục đích",
+ "batchFilesFilename": "Tên tập tin",
+ "batchFilesPurpose": "Mục đích",
+ "batchFilesExpires": "Hết hạn",
+ "batchFilesNoneFound": "Không tìm thấy tập tin nào",
+ "batchFilesNeverExpires": "Không bao giờ",
+ "batchFileInUseByActiveBatch": "Tập tin đang được một lô hoạt động sử dụng",
+ "batchFilePurpose": {
+ "batch": "Đầu vào lô",
+ "batch-output": "Đầu ra lô",
+ "fine-tune": "Tinh chỉnh",
+ "assistants": "Trợ lý"
+ },
"batchPageLoadingMore": "Đang tải thêm…",
"recommended": "Khuyến nghị",
+ "understand": "Tôi đã hiểu",
"batchConceptTitle": "Xử lý theo lô",
"batchConceptSubtitle": "Xử lý hàng nghìn yêu cầu một cách bất đồng bộ với chi phí chỉ khoảng 50% (khung thời gian 24 giờ). Lý tưởng cho việc đánh giá, phân loại và embedding.",
"batchConceptHowItWorks": "Cách hoạt động",
@@ -740,7 +814,22 @@
"wizardDropOrPick": "Kéo thả tập tin hoặc nhấp để chọn",
"wizardCsvMappingTitle": "Ánh xạ các cột CSV → các trường trong yêu cầu",
"wizardCsvMappingAddField": "Thêm trường",
+ "wizardCsvNoColumns": "Không phát hiện cột nào trong tiêu đề CSV.",
+ "wizardCsvIgnoreColumn": "— bỏ qua —",
+ "wizardCsvCustomIdMapped": "Đã ánh xạ custom_id",
+ "wizardCsvContentMapped": "Đã ánh xạ trường nội dung (messages, input hoặc prompt)",
+ "wizardCsvApplyMapping": "Áp dụng ánh xạ",
+ "wizardCsvRowsParsed": "Đã phân tích {count} dòng",
+ "wizardCsvRowsSkipped": "Đã bỏ qua {count} dòng",
+ "wizardCsvRowError": "Dòng {row}: {reason}",
"wizardValidationOk": "Tất cả các dòng hợp lệ",
+ "wizardValidating": "Đang kiểm tra…",
+ "wizardValidationParseFailed": "Kiểm tra thất bại — không thể phân tích nội dung.",
+ "wizardValidationSummary": "{lines} dòng · {ids} custom_id duy nhất",
+ "wizardValidationErrorCount": "Phát hiện {count} lỗi",
+ "wizardValidationDuplicateIds": "Phát hiện custom_id trùng lặp:",
+ "wizardValidationFirstErrors": "Các lỗi (hiển thị {count} lỗi đầu):",
+ "wizardValidationLine": "Dòng {line}",
"wizardValidationErrors": "Lỗi kiểm tra tính hợp lệ",
"wizardValidationPreview": "Xem trước (5 yêu cầu đầu tiên)",
"wizardValidationSamplingNote": "Tập tin quá lớn — được kiểm tra bằng phương pháp lấy mẫu (1.000 dòng đầu tiên + 100 dòng cuối cùng). Việc kiểm tra đầy đủ được thực hiện phía máy chủ.",
@@ -773,36 +862,13 @@
"filesListUploadButton": "Tải lên",
"filesListUsedByColumn": "Được sử dụng bởi",
"filesListUsedByNone": "Không có",
- "filesListDelete": "Xóa",
- "filesListDownload": "Tải xuống",
- "batchDetailActionCancel": "Hủy lô",
- "batchDetailActionRetry": "Thử lại các yêu cầu thất bại",
- "batchDetailCancelling": "Đang hủy…",
- "batchDetailRetrying": "Đang chuẩn bị thử lại…",
- "batchDetailCancelConfirm": "Hủy lô này? Các yêu cầu đang xử lý sẽ ngừng lại.",
- "batchActionCancelError": "Không thể hủy lô. Vui lòng thử lại.",
- "batchActionRetryError": "Không thể thử lại các yêu cầu thất bại. Vui lòng thử lại.",
- "batchConceptRetentionNote": "Kết quả và các tệp lỗi được lưu giữ trong 30 ngày (Anthropic: 29 ngày)",
"filesListUsedByRoleInput": "đầu vào",
"filesListUsedByRoleOutput": "đầu ra",
"filesListUsedByRoleError": "lỗi",
- "batchListProgressPartial": "(một phần)",
"filesListSizeColumn": "Kích thước",
+ "batchListProgressPartial": "(một phần)",
"batchListProviderColumn": "Nhà cung cấp",
"batchListProviderUnknown": "—",
- "batchListBatchCreated": "Đã tạo lô {id} — đang làm mới danh sách…",
- "batchListBatchCreatedDismiss": "Đóng",
- "batchListRefreshing": "Đang làm mới…",
- "batchListRefresh": "Làm mới",
- "uploadFileModalRemove": "Xóa",
- "uploadFileModalUploading": "Đang tải lên…",
- "wizardInputReading": "Đang đọc tệp…",
- "wizardInputReady": "Sẵn sàng",
- "wizardInputLargeFileLabel": "Tệp lớn — xác thực bằng lấy mẫu",
- "wizardInputCsvJsonlReady": "Đã tạo JSONL — sẵn sàng để xác thực.",
- "wizardInputLargeFileWarning": "Đã phát hiện tệp lớn — quá trình xác thực chạy bằng cách lấy mẫu (5 MB đầu + 100 KB cuối). Việc xác thực đầy đủ diễn ra ở phía máy chủ.",
- "wizardCostWindow24h": "Khung thời gian hoàn thành 24 giờ",
- "wizardValidationFieldsOk": "các trường bắt buộc hợp lệ",
"batchListProviderOther": "Khác",
"batchListTitle": "Lô",
"batchListCount": "{count} lô",
@@ -840,11 +906,33 @@
"wizardDestinationSelectProvider": "Chọn một nhà cung cấp…",
"wizardDestinationSelectModel": "Chọn một mô hình…",
"wizardDestinationConnectProvider": "Kết nối một nhà cung cấp",
- "manualConfig": "Manual config",
- "unknownProvider": "Unknown provider",
- "queueDepth": "Queue Depth",
- "understand": "I understand"
+ "batchListBatchCreated": "Đã tạo lô {id} — đang làm mới danh sách…",
+ "batchListBatchCreatedDismiss": "Đóng",
+ "batchListRefreshing": "Đang làm mới…",
+ "batchListRefresh": "Làm mới",
+ "uploadFileModalRemove": "Xóa",
+ "uploadFileModalUploading": "Đang tải lên…",
+ "wizardInputReading": "Đang đọc tệp…",
+ "wizardInputReady": "Sẵn sàng",
+ "wizardInputLargeFileLabel": "Tệp lớn — xác thực bằng lấy mẫu",
+ "wizardInputCsvJsonlReady": "Đã tạo JSONL — sẵn sàng để xác thực.",
+ "wizardInputLargeFileWarning": "Đã phát hiện tệp lớn — quá trình xác thực chạy bằng cách lấy mẫu (5 MB đầu + 100 KB cuối). Việc xác thực đầy đủ diễn ra ở phía máy chủ.",
+ "wizardCostWindow24h": "Khung thời gian hoàn thành 24 giờ",
+ "wizardValidationFieldsOk": "các trường bắt buộc hợp lệ",
+ "filesListDelete": "Xóa",
+ "filesListDownload": "Tải xuống",
+ "batchDetailActionCancel": "Hủy lô",
+ "batchDetailActionRetry": "Thử lại các yêu cầu thất bại",
+ "batchDetailCancelling": "Đang hủy…",
+ "batchDetailRetrying": "Đang chuẩn bị thử lại…",
+ "batchDetailCancelConfirm": "Hủy lô này? Các yêu cầu đang xử lý sẽ ngừng lại.",
+ "batchActionCancelError": "Không thể hủy lô. Vui lòng thử lại.",
+ "batchActionRetryError": "Không thể thử lại các yêu cầu thất bại. Vui lòng thử lại.",
+ "batchConceptRetentionNote": "Kết quả và các tệp lỗi được lưu giữ trong 30 ngày (Anthropic: 29 ngày)"
},
+ "disabled": "Đã tắt",
+ "featureFlagOmnirouteEmergencyFallbackDescription": "Định tuyến các yêu cầu đã hết ngân sách đến nhà cung cấp/mô hình dự phòng khẩn cấp miễn phí.",
+ "featureFlagArenaEloSyncEnabledDescription": "Bật đồng bộ ELO định kỳ từ bảng xếp hạng Arena AI để xếp hạng năng lực của mô hình.",
"sidebar": {
"home": "Trang chủ",
"dashboard": "Trang tổng quan",
@@ -868,6 +956,7 @@
"skills": "Kỹ năng",
"omniSkills": "OmniSkills",
"agentSkills": "AgentSkills",
+ "chaosConfig": "Chế độ hỗn loạn",
"docs": "Tài liệu",
"issues": "Vấn đề",
"endpoints": "Điểm cuối",
@@ -959,7 +1048,19 @@
"contextCcr": "CCR",
"contextLlmlingua": "LLMLingua",
"combosLive": "Combo Studio",
+ "combosLiveSubtitle": "Chuỗi định tuyến trực tiếp",
"compressionStudio": "Compression Studio",
+ "contextSettingsSubtitle": "Mặc định toàn cục",
+ "contextHeadroomSubtitle": "Nén dạng bảng",
+ "contextSessionDedupSubtitle": "Khử trùng lặp giữa các lượt",
+ "contextCcrSubtitle": "Dấu mốc truy xuất",
+ "contextLlmlinguaSubtitle": "Lược bỏ theo ngữ nghĩa",
+ "contextLiteSubtitle": "Dọn khoảng trắng nhanh",
+ "contextAggressiveSubtitle": "Tóm tắt và làm cũ",
+ "contextUltraSubtitle": "Lược bỏ theo kinh nghiệm",
+ "contextOmniglyphSubtitle": "Ngữ cảnh dưới dạng hình ảnh",
+ "compressionStudioSubtitle": "Chuỗi bộ máy trực tiếp",
+ "chaosConfigSubtitle": "Thực thi song song nhiều mô hình",
"routingSection": "Định tuyến",
"protocolsSection": "Giao thức",
"agentsAiSection": "Tác nhân & AI",
@@ -973,6 +1074,7 @@
"aiFeaturesSection": "Tính năng AI",
"mcp": "Máy chủ MCP",
"a2a": "Máy chủ A2A",
+ "plugins": "Tiện ích bổ sung",
"apiEndpoints": "Các endpoint API",
"batchFiles": "Tệp",
"analyticsEvals": "Đánh giá",
@@ -981,6 +1083,10 @@
"analyticsComboHealth": "Tình trạng tổ hợp",
"analyticsCompression": "Nén dữ liệu",
"costsBudget": "Ngân sách",
+ "costsFreeTiers": "Ngân sách gói miễn phí",
+ "costsFreeTiersSubtitle": "Hạn mức token miễn phí hàng tháng",
+ "freeProviderRankings": "Xếp hạng nhà cung cấp miễn phí",
+ "freeProviderRankingsSubtitle": "Các nhà cung cấp miễn phí tốt nhất được xếp hạng theo điểm ELO của mô hình",
"costsQuotaShare": "Chia sẻ hạn mức",
"costsPricing": "Định giá",
"logsProxy": "Nhật ký proxy",
@@ -992,10 +1098,12 @@
"settingsAppearance": "Giao diện",
"settingsAi": "Cài đặt AI",
"settingsSecurity": "Bảo mật",
+ "settingsAccessTokens": "Token truy cập",
"settingsFeatureFlags": "Cờ tính năng",
"settingsAuthz": "Phân quyền",
"settingsRouting": "Định tuyến",
"settingsResilience": "Khả năng phục hồi",
+ "modelLockout": "Khóa truy cập mô hình",
"settingsAdvanced": "Nâng cao",
"omniProxySection": "OmniProxy",
"quotaTracker": "Hạn mức của nhà cung cấp",
@@ -1042,6 +1150,8 @@
"analyticsCompressionSubtitle": "Thống kê tiết kiệm token",
"analyticsSearchSubtitle": "Phân tích công cụ tìm kiếm",
"analyticsEvalsSubtitle": "Kết quả bộ đánh giá",
+ "providerStats": "Thống kê nhà cung cấp",
+ "providerStatsSubtitle": "Chỉ số độ trễ và hiệu suất",
"logsSubtitle": "Nhật ký ứng dụng",
"logsProxySubtitle": "Nhật ký lưu lượng proxy",
"consoleLogsSubtitle": "Đầu ra bảng điều khiển",
@@ -1061,6 +1171,7 @@
"agentSkillsSubtitle": "Danh mục kỹ năng A2A",
"mcpSubtitle": "Điều khiển máy chủ MCP",
"a2aSubtitle": "Máy chủ giao thức A2A",
+ "pluginsSubtitle": "Chợ và cài đặt phần bổ trợ",
"mediaSubtitle": "Tệp phương tiện được lưu trong cache",
"batchFilesSubtitle": "Tệp đầu vào/đầu ra theo lô",
"settingsSubtitle": "Tất cả cài đặt",
@@ -1071,6 +1182,7 @@
"settingsResilienceSubtitle": "Thử lại và ngắt mạch",
"settingsAdvancedSubtitle": "Tùy chọn cho người dùng nâng cao",
"settingsSecuritySubtitle": "Xác thực và mã hóa",
+ "settingsAccessTokensSubtitle": "Token CLI có phạm vi cho chế độ từ xa",
"settingsFeatureFlagsSubtitle": "Chuyển đổi các khả năng của hệ thống",
"settingsSidebar": "Thanh bên",
"settingsSidebarSubtitle": "Tùy chỉnh bố cục thanh bên",
@@ -1078,47 +1190,36 @@
"docsSubtitle": "Tài liệu",
"issuesSubtitle": "Báo lỗi",
"changelogSubtitle": "Ghi chú phát hành",
- "chaosConfig": "Chaos Mode",
- "plugins": "Plugins",
- "costsFreeTiers": "Free-Tier Budget",
- "costsFreeTiersSubtitle": "Monthly free-token allowances",
- "freeProviderRankings": "Free Provider Rankings",
- "freeProviderRankingsSubtitle": "Best free providers ranked by model ELO scores",
- "settingsAccessTokens": "Access Tokens",
- "modelLockout": "Model Lockout",
- "providerStats": "Provider Stats",
- "providerStatsSubtitle": "Latency and performance metrics",
- "pluginsSubtitle": "Plugin marketplace & installs",
- "settingsAccessTokensSubtitle": "Scoped CLI tokens for remote mode",
- "costsQuotaPlans": "Plans & Quotas",
- "costsQuotaPlansSubtitle": "Configure plans by provider",
- "activity": "Activity",
- "activitySubtitle": "Friendly feed of recent events",
- "logsGroup": "Logs",
- "systemGroup": "System",
- "costsOverview": "Overview",
- "costsOverviewSubtitle": "Consolidated cost analysis",
+ "costsQuotaPlans": "Gói & Hạn mức",
+ "costsQuotaPlansSubtitle": "Định cấu hình các gói theo nhà cung cấp",
+ "activity": "Hoạt động",
+ "activitySubtitle": "Bảng tin dễ theo dõi về các sự kiện gần đây",
+ "logsGroup": "Nhật ký",
+ "systemGroup": "Hệ thống",
+ "costsOverview": "Tổng quan",
+ "costsOverviewSubtitle": "Phân tích chi phí tổng hợp",
"agentBridge": "Agent Bridge",
- "agentBridgeSubtitle": "Intercept IDE agent traffic",
+ "agentBridgeSubtitle": "Chặn lưu lượng agent IDE",
"trafficInspector": "Traffic Inspector",
- "trafficInspectorSubtitle": "Monitor LLM calls + debug any HTTPS traffic",
+ "trafficInspectorSubtitle": "Giám sát lệnh gọi LLM + gỡ lỗi mọi lưu lượng HTTPS",
"cliCode": "CLI Code",
- "cliCodeSubtitle": "Code tools pointing to OmniRoute",
+ "cliCodeSubtitle": "Các công cụ lập trình trỏ đến OmniRoute",
"cliAgents": "CLI Agents",
- "cliAgentsSubtitle": "Autonomous CLI agents",
+ "cliAgentsSubtitle": "Tác tử CLI tự chủ",
"acpAgents": "ACP Agents",
- "acpAgentsSubtitle": "CLIs spawned by OmniRoute",
- "skipToContent": "Skip to content",
- "unpinSection": "Unpin section",
- "pinSectionOpen": "Pin section open",
- "reloadPage": "Reload Page",
- "dragReorderSection": "Drag to reorder section",
- "dragReorderItem": "Drag to reorder",
- "cannotHide": "This item cannot be hidden",
- "alwaysVisible": "Always visible",
- "groupSeparatorLabel": "Separator",
- "discovery": "Discovery",
- "discoverySubtitle": "Scan providers for free access"
+ "acpAgentsSubtitle": "Các CLI do OmniRoute khởi chạy",
+ "skipToContent": "Chuyển đến nội dung",
+ "mainNavigation": "Điều hướng chính",
+ "unpinSection": "Bỏ ghim phần",
+ "pinSectionOpen": "Ghim để giữ phần luôn mở",
+ "reloadPage": "Tải lại trang",
+ "dragReorderSection": "Kéo để sắp xếp lại phần",
+ "dragReorderItem": "Kéo để sắp xếp lại",
+ "cannotHide": "Mục này không thể bị ẩn",
+ "alwaysVisible": "Luôn hiển thị",
+ "groupSeparatorLabel": "Dấu phân cách",
+ "discovery": "Khám phá",
+ "discoverySubtitle": "Quét các nhà cung cấp để tìm quyền truy cập miễn phí"
},
"webhooks": {
"title": "Webhook",
@@ -1320,81 +1421,45 @@
"a2aArtifacts": "Tạo tác",
"a2aNoTasks": "Không có nhiệm vụ A2A nào được ghi lại.",
"a2aLoadingTasks": "Đang tải nhiệm vụ A2A...",
- "actor": "Actor",
- "actorPlaceholder": "Filter by actor",
+ "actor": "Người thực hiện",
+ "actorPlaceholder": "Lọc theo tác nhân",
"eventTypes": {
- "apiKey": {
- "activate": "API Key Activated",
- "ban": "API Key Banned",
- "deactivate": "API Key Deactivated",
- "regenerate": "API Key Regenerated",
- "scopes": {
- "grant": "API Key Scopes Granted",
- "revoke": "API Key Scopes Revoked",
- "update": "API Key Scopes Updated"
- },
- "unban": "API Key Unbanned"
- },
- "auth": {
- "login": {
- "error": "Login Error",
- "failed": "Login Failed",
- "locked": "Login Locked",
- "misconfigured": "Login Misconfigured",
- "setup_required": "Login Setup Required",
- "success": "Login Success"
- },
- "logout": {
- "success": "Logout Success"
- }
- },
- "compliance": {
- "cleanup": "Compliance Cleanup"
- },
- "provider": {
- "credentials": {
- "applied": "Provider Credentials Applied",
- "batch_revoked": "Provider Credentials Batch Revoked",
- "bulk_created": "Provider Credentials Bulk Created",
- "bulk_imported": "Provider Credentials Bulk Imported",
- "created": "Provider Credentials Created",
- "imported": "Provider Credentials Imported",
- "revoked": "Provider Credentials Revoked",
- "updated": "Provider Credentials Updated"
- },
- "validation": {
- "ssrf_blocked": "Provider SSRF Blocked"
- }
- },
- "quota": {
- "plan": {
- "updated": "Quota Plan Updated"
- },
- "pool": {
- "created": "Quota Pool Created",
- "deleted": "Quota Pool Deleted",
- "updated": "Quota Pool Updated"
- },
- "store": {
- "driver_changed": "Quota Store Driver Changed"
- }
- },
- "server": {
- "start": "Server Start"
- },
- "service": {
- "reveal_api_key": "Service API Key Revealed"
- },
- "settings": {
- "update": "Settings Updated",
- "update_failed": "Settings Update Failed"
- },
- "sync": {
- "token": {
- "created": "Sync Token Created",
- "revoked": "Sync Token Revoked"
- }
- }
+ "apiKey.activate": "Khóa API đã được kích hoạt",
+ "apiKey.ban": "Khóa API đã bị cấm",
+ "apiKey.deactivate": "Khóa API đã bị vô hiệu hóa",
+ "apiKey.regenerate": "Khóa API đã được tạo lại",
+ "apiKey.scopes.grant": "Đã cấp phạm vi khóa API",
+ "apiKey.scopes.revoke": "Đã thu hồi phạm vi khóa API",
+ "apiKey.scopes.update": "Đã cập nhật phạm vi khóa API",
+ "apiKey.unban": "Khóa API đã được gỡ cấm",
+ "auth.login.error": "Lỗi đăng nhập",
+ "auth.login.failed": "Đăng nhập thất bại",
+ "auth.login.locked": "Đăng nhập bị khóa",
+ "auth.login.misconfigured": "Đăng nhập được cấu hình sai",
+ "auth.login.setup_required": "Cần thiết lập đăng nhập",
+ "auth.login.success": "Đăng nhập thành công",
+ "auth.logout.success": "Đăng xuất thành công",
+ "compliance.cleanup": "Dọn dẹp tuân thủ",
+ "provider.credentials.applied": "Đã áp dụng thông tin xác thực nhà cung cấp",
+ "provider.credentials.batch_revoked": "Đã thu hồi hàng loạt thông tin xác thực nhà cung cấp",
+ "provider.credentials.bulk_created": "Đã tạo hàng loạt thông tin xác thực nhà cung cấp",
+ "provider.credentials.bulk_imported": "Đã nhập hàng loạt thông tin xác thực nhà cung cấp",
+ "provider.credentials.created": "Đã tạo thông tin xác thực nhà cung cấp",
+ "provider.credentials.imported": "Đã nhập thông tin xác thực nhà cung cấp",
+ "provider.credentials.revoked": "Đã thu hồi thông tin xác thực nhà cung cấp",
+ "provider.credentials.updated": "Đã cập nhật thông tin xác thực nhà cung cấp",
+ "provider.validation.ssrf_blocked": "Đã chặn SSRF của nhà cung cấp",
+ "quota.plan.updated": "Đã cập nhật gói hạn mức",
+ "quota.pool.created": "Đã tạo nhóm hạn mức",
+ "quota.pool.deleted": "Đã xóa nhóm hạn mức",
+ "quota.pool.updated": "Đã cập nhật nhóm hạn mức",
+ "quota.store.driver_changed": "Đã thay đổi trình điều khiển kho lưu trữ hạn mức",
+ "server.start": "Khởi động máy chủ",
+ "service.reveal_api_key": "Đã hiển thị khóa API của dịch vụ",
+ "settings.update": "Đã cập nhật cài đặt",
+ "settings.update_failed": "Cập nhật cài đặt thất bại",
+ "sync.token.created": "Đã tạo token đồng bộ",
+ "sync.token.revoked": "Đã thu hồi token đồng bộ"
}
},
"themesPage": {
@@ -1408,6 +1473,11 @@
},
"header": {
"logout": "Đăng xuất",
+ "quickNavigation": "Điều hướng nhanh",
+ "quickNavigationTitle": "Điều hướng nhanh (⌘K / Ctrl+K)",
+ "openQuickNavigation": "Mở điều hướng nhanh",
+ "switchToLightMode": "Chuyển sang chế độ sáng",
+ "switchToDarkMode": "Chuyển sang chế độ tối",
"language": "Ngôn ngữ",
"providers": "Nhà cung cấp",
"providerDescription": "Quản lý kết nối nhà cung cấp AI của bạn",
@@ -1505,7 +1575,109 @@
"settingsAdvancedDescription": "Các quy tắc payload nâng cao, giới hạn yêu cầu và cài đặt proxy API",
"mitmProxyDescription": "Định cấu hình cài đặt proxy MITM để kiểm tra và gỡ lỗi lưu lượng truy cập",
"oneProxyDescription": "Định cấu hình cài đặt 1Proxy cho chuỗi proxy nâng cao",
- "omniSkillsDescription": "Install and manage sandbox skills for automated prompt and tool execution"
+ "omniSkillsDescription": "Cài đặt và quản lý các kỹ năng trong môi trường hộp cát để thực thi tự động prompt và công cụ"
+ },
+ "cloudSyncStatus": {
+ "synced": "Đã đồng bộ",
+ "syncing": "Đang đồng bộ...",
+ "off": "Đã tắt đồng bộ",
+ "error": "Lỗi đồng bộ",
+ "disabled": "Đã tắt",
+ "connected": "đã kết nối",
+ "disconnected": "đã ngắt kết nối",
+ "lastSync": "Đồng bộ cài đặt từ xa {status} — Lần đồng bộ cuối: {time}",
+ "statusLabel": "Trạng thái đồng bộ cài đặt từ xa: {status}"
+ },
+ "breadcrumbs": {
+ "ariaLabel": "Đường dẫn điều hướng",
+ "dashboard": "Trang tổng quan",
+ "providers": "Nhà cung cấp",
+ "combos": "Combo",
+ "settings": "Cài đặt",
+ "general": "Lưu trữ",
+ "appearance": "Giao diện",
+ "ai": "Cài đặt AI",
+ "routing": "Định tuyến",
+ "resilience": "Khả năng phục hồi",
+ "advanced": "Nâng cao",
+ "accessTokens": "Token truy cập",
+ "featureFlags": "Cờ tính năng",
+ "logs": "Nhật ký",
+ "auditLog": "Nhật ký kiểm toán",
+ "console": "Bảng điều khiển",
+ "logger": "Trình ghi nhật ký",
+ "translator": "Trình dịch",
+ "playground": "Khu thử nghiệm",
+ "add": "Thêm",
+ "edit": "Chỉnh sửa",
+ "apiKeys": "Khóa API",
+ "models": "Mô hình",
+ "cliCode": "CLI Code",
+ "cliAgents": "CLI Agents",
+ "acpAgents": "ACP Agents",
+ "endpoint": "Điểm cuối",
+ "apiManager": "Quản lý API",
+ "context": "Ngữ cảnh",
+ "compression": "Nén",
+ "services": "Dịch vụ",
+ "analytics": "Phân tích",
+ "costs": "Chi phí",
+ "health": "Tình trạng",
+ "runtime": "Thời gian chạy",
+ "webhooks": "Webhook",
+ "home": "Trang chủ",
+ "activity": "Hoạt động",
+ "agentSkills": "AgentSkills",
+ "comboHealth": "Tình trạng tổ hợp",
+ "evals": "Đánh giá",
+ "search": "Tìm kiếm",
+ "utilization": "Mức sử dụng",
+ "apiEndpoints": "Các endpoint API",
+ "audit": "Kiểm toán",
+ "a2a": "A2A",
+ "mcp": "MCP",
+ "batch": "Tác vụ hàng loạt",
+ "files": "Tệp",
+ "media": "Đa phương tiện",
+ "cache": "Cache",
+ "changelog": "Lịch sử thay đổi",
+ "chaos": "Chế độ hỗn loạn",
+ "cloudAgents": "Cloud Agents",
+ "live": "Trực tiếp",
+ "studio": "Studio",
+ "aggressive": "Aggressive",
+ "caveman": "Caveman",
+ "ccr": "CCR",
+ "headroom": "Headroom",
+ "lite": "Lite",
+ "llmlingua": "LLMLingua",
+ "omniglyph": "OmniGlyph",
+ "rtk": "RTK",
+ "sessionDedup": "Session Dedup",
+ "ultra": "Ultra",
+ "budget": "Ngân sách",
+ "pricing": "Định giá",
+ "quotaShare": "Chia sẻ hạn mức",
+ "discovery": "Khám phá",
+ "freeProviderRankings": "Xếp hạng nhà cung cấp miễn phí",
+ "freeTiers": "Gói miễn phí",
+ "gamification": "Trò chơi hóa",
+ "leaderboard": "Bảng xếp hạng",
+ "limits": "Giới hạn",
+ "profile": "Hồ sơ",
+ "plugins": "Tiện ích bổ sung",
+ "providerStats": "Thống kê nhà cung cấp",
+ "new": "Mới",
+ "quota": "Hạn mức",
+ "relay": "Relay",
+ "searchTools": "Công cụ tìm kiếm",
+ "security": "Bảo mật",
+ "sidebar": "Thanh bên",
+ "tokens": "Token",
+ "tools": "Công cụ",
+ "agentBridge": "Agent Bridge",
+ "trafficInspector": "Traffic Inspector",
+ "usage": "Mức sử dụng"
},
"home": {
"quickStart": "Bắt đầu nhanh",
@@ -1577,6 +1749,19 @@
"chartModelUsageOverTime": "Mức sử dụng mô hình theo thời gian",
"chartNoData": "Không có dữ liệu",
"chartWeekly": "Hàng tuần",
+ "activitySummary": "{active} ngày hoạt động · {tokens} token · {days} ngày",
+ "activityCellTitle": "{date}: {tokens} token",
+ "activityLess": "Ít hơn",
+ "activityMore": "Nhiều hơn",
+ "chartApiKeyBreakdown": "Phân tích chi tiết khóa API",
+ "chartApiKey": "Khóa API",
+ "mostActiveDay": "Ngày hoạt động nhiều nhất",
+ "datedTokenCount": "{date} · {tokens} token",
+ "noDataLast7Days": "Không có dữ liệu trong 7 ngày qua",
+ "requestTokenSummary": "{requests} yêu cầu · {tokens} token",
+ "chartByAccount": "Theo tài khoản",
+ "chartByApiKey": "Theo khóa API",
+ "unknownApiKey": "Khóa API không xác định",
"chartModelBreakdown": "Phân tích chi tiết mô hình",
"chartModel": "Mô hình",
"chartProvider": "Nhà cung cấp",
@@ -1631,7 +1816,10 @@
"evalsDescription": "Chạy các bộ đánh giá để kiểm tra và xác thực các endpoint LLM của bạn. So sánh chất lượng mô hình, phát hiện lỗi hồi quy và đo chuẩn độ trễ.",
"overview": "Tổng quan",
"evals": "Đánh giá",
+ "search": "Tìm kiếm",
"utilization": "Tỷ lệ sử dụng",
+ "routeTrace": "Truy vết định tuyến",
+ "sectionsAria": "Các mục phân tích",
"utilizationDescription": "Xu hướng sử dụng hạn ngạch và theo dõi giới hạn tốc độ của nhà cung cấp",
"modelStatus": "Trạng thái mô hình",
"modelStatusCooldown": "Thời gian chờ",
@@ -1662,6 +1850,83 @@
"comboHealthTitle": "Tình trạng combo",
"comboHealthUnableToLoad": "Không thể tải tình trạng combo",
"comboHealthGettingStarted": "Bắt đầu",
+ "comboHealthForecastTitle": "Dự báo chi phí và hạn ngạch",
+ "comboHealthForecastDescription": "Dự báo tuyến tính từ lịch sử lưu lượng combo và ảnh chụp nhanh hạn ngạch.",
+ "comboHealthQuotaRisk": "Rủi ro hạn ngạch: {level}",
+ "comboHealthConfidence": "Độ tin cậy: {level}",
+ "comboHealthProjectedCost": "Chi phí dự kiến",
+ "comboHealthCostHistory": "lịch sử {total} · {daily}/ngày",
+ "comboHealthProjectedRequests": "Yêu cầu dự kiến",
+ "comboHealthRequestsInRange": "{count} trong khoảng đã chọn",
+ "comboHealthWorstProjectedQuota": "Hạn ngạch dự kiến thấp nhất",
+ "comboHealthNoDepletionEstimate": "Chưa ước tính được thời điểm cạn",
+ "comboHealthDaysToExhaust": "Còn {days} ngày trước khi cạn",
+ "comboHealthTraffic": "lưu lượng",
+ "comboHealthProjectedQuota": "Hạn ngạch dự kiến",
+ "comboHealthPricingCoverage": "Mức bao phủ giá",
+ "comboHealthAutopilotTitle": "Tự động giám sát tình trạng combo",
+ "comboHealthAutopilotDescription": "Đề xuất ưu tiên dựa trên tình trạng combo, dự báo, hạn ngạch và tình trạng nhà cung cấp.",
+ "comboHealthIssues": "Vấn đề",
+ "comboHealthActionable": "{count} mục cần xử lý",
+ "comboHealthDown": "Ngừng hoạt động",
+ "comboHealthDegraded": "Suy giảm",
+ "comboHealthHealthy": "Tốt",
+ "comboHealthNoActiveIssues": "Không phát hiện vấn đề tình trạng combo nào trong khoảng đã chọn.",
+ "comboHealthScoringInspector": "Trình kiểm tra chấm điểm thông minh",
+ "comboHealthReadOnlyRecompute": "Tính lại chỉ đọc",
+ "comboHealthScoringDescription": "Giải thích theo từng yếu tố về thứ hạng mục tiêu dựa trên tình trạng, dự báo và quy tắc định tuyến hiện tại.",
+ "comboHealthTask": "Tác vụ: {task}",
+ "comboHealthSelectedRank": "Đã chọn hạng #1",
+ "comboHealthFactor": {
+ "quota": "Hạn ngạch",
+ "health": "Tình trạng",
+ "costInv": "Chi phí",
+ "latencyInv": "Độ trễ",
+ "taskFit": "Độ phù hợp tác vụ",
+ "stability": "Độ ổn định",
+ "tierPriority": "Tầng",
+ "tierAffinity": "Độ phù hợp tầng",
+ "specificityMatch": "Độ khớp cụ thể",
+ "contextAffinity": "Ngữ cảnh",
+ "resetWindowAffinity": "Cửa sổ đặt lại"
+ },
+ "comboHealthQuotaValue": "Hạn ngạch {value}",
+ "comboHealthLatencyValue": "Độ trễ {value}ms",
+ "comboHealthIssueCount": "{count} vấn đề",
+ "comboHealthNoInspectableTargets": "Combo này không có mục tiêu nào để kiểm tra.",
+ "comboHealthAutopilotState": {
+ "down": "Ngừng hoạt động",
+ "degraded": "Cần chú ý",
+ "healthy": "Tốt"
+ },
+ "comboHealthModelProviderCount": "{models} mô hình trên {providers} nhà cung cấp",
+ "comboHealthGiniCoefficient": "Hệ số Gini",
+ "comboHealthRequestCount": "{count} yêu cầu",
+ "comboHealthQuotaHealthDescription": "Hạn ngạch còn lại thấp nhất giữa các nhà cung cấp kèm tín hiệu xu hướng ngắn hạn.",
+ "comboHealthRemainingQuota": "Hạn ngạch còn lại {value}",
+ "comboHealthTrend": {
+ "improving": "Đang cải thiện",
+ "declining": "Đang suy giảm",
+ "stable": "Ổn định"
+ },
+ "comboHealthUsageSkewDescription": "Tỷ trọng yêu cầu và token của từng mô hình trong combo này.",
+ "comboHealthShareSummary": "Tỷ trọng yêu cầu {requests} · Tỷ trọng token {tokens}",
+ "comboHealthPerformance": "Hiệu suất",
+ "comboHealthPerformanceDescription": "Độ tin cậy và thông lượng của lưu lượng được định tuyến qua combo.",
+ "comboHealthExecutionTargetsDescription": "Chỉ số runtime theo từng bước và khả năng quan sát hạn ngạch cho các mục tiêu combo có cấu trúc.",
+ "comboHealthRequestShort": "{count} yêu cầu",
+ "comboHealthQuotaScope": "Phạm vi hạn ngạch: {scope}",
+ "comboHealthTrendValue": "Xu hướng: {trend}",
+ "comboHealthFetchFailed": "Không thể tải dữ liệu tình trạng combo",
+ "unknownError": "Lỗi không xác định",
+ "comboHealthIntro": "Theo dõi áp lực hạn ngạch, độ lệch sử dụng mô hình và hiệu suất phân phối theo combo.",
+ "comboHealthForecastHorizon": "Dự báo {value}",
+ "comboHealthNoData": "Chưa có dữ liệu tình trạng combo",
+ "comboHealthNoDataDescription": "Ảnh chụp nhanh hạn ngạch combo và các yêu cầu đã định tuyến sẽ xuất hiện sau khi có lưu lượng.",
+ "comboHealthStepCreate": "Tạo Combo gồm nhiều nhà cung cấp",
+ "comboHealthStepSend": "Gửi yêu cầu tới endpoint combo để tạo dữ liệu lưu lượng",
+ "comboHealthStepAutomatic": "Chỉ số tình trạng sẽ tự động xuất hiện khi yêu cầu được định tuyến",
+ "comboHealthTracking": "Đang theo dõi {count} combo trong {range}",
"compressionAnalyticsTotalRequests": "Tổng số yêu cầu",
"compressionAnalyticsTokensSaved": "Số token tiết kiệm được",
"compressionAnalyticsAvgSavings": "Mức tiết kiệm trung bình",
@@ -1673,6 +1938,26 @@
"compressionAnalyticsTotalTokens": "Tổng số token",
"compressionAnalyticsCacheTokens": "Token trong cache",
"compressionAnalyticsNoDataYet": "Chưa có dữ liệu nén",
+ "compressionAnalyticsLoading": "Đang tải dữ liệu phân tích nén…",
+ "compressionAnalyticsNoDataDescription": "Các yêu cầu nén sẽ xuất hiện tại đây sau yêu cầu đầu tiên qua /v1/chat/completions khi đã bật nén.",
+ "rangeLast24h": "24 giờ qua",
+ "rangeLast7d": "7 ngày qua",
+ "rangeLast30d": "30 ngày qua",
+ "rangeAllTime": "Toàn bộ thời gian",
+ "compressionAnalyticsModeStats": "{count} yêu cầu · tiết kiệm {tokens} token",
+ "compressionAnalyticsSkipped": " · bỏ qua {count} lần (không thay đổi)",
+ "compressionAnalyticsRealTokens": "{count} token thực",
+ "compressionAnalyticsValidationRestores": "lần khôi phục sau xác thực",
+ "compressionAnalyticsRealUsageReceipts": "Biên nhận sử dụng thực",
+ "compressionAnalyticsSources": "Nguồn",
+ "compressionAnalyticsModeBreakdown": "Phân bổ theo chế độ",
+ "compressionAnalyticsProviderBreakdown": "Phân bổ theo nhà cung cấp",
+ "compressionAnalyticsLast24HoursActivity": "Hoạt động trong 24 giờ qua",
+ "compressionAnalyticsChartPoint": "{hour}: {count} yêu cầu, tiết kiệm {tokens} token",
+ "compressionAnalyticsMaxRequests": "Số yêu cầu/giờ cao nhất: {count}",
+ "compressionAnalyticsMaxTokens": "Số token/giờ cao nhất: {count}",
+ "compressionAnalyticsStartTracking": "Dùng POST /v1/chat/completions kèm cấu hình nén để bắt đầu theo dõi phân tích nén.",
+ "compressionAnalyticsInfo": "Phân tích nén: Theo dõi lượng token tiết kiệm theo chế độ (off, lite, standard, aggressive, ultra, RTK, stacked), engine, combo nén và nhà cung cấp. Di chuột lên biểu đồ để xem chi tiết. Dùng bộ chọn thời gian để xem các giai đoạn khác nhau.",
"searchAnalyticsTotalSearches": "Tổng số tìm kiếm",
"searchAnalyticsCacheHitRate": "Tỷ lệ trúng cache",
"searchAnalyticsTotalCost": "Tổng chi phí",
@@ -1683,7 +1968,80 @@
"providerUtilizationNoData": "Không có sẵn dữ liệu mức sử dụng",
"providerUtilizationGettingStarted": "Bắt đầu",
"providerUtilizationLatestSnapshot": "Ảnh chụp nhanh hạn ngạch mới nhất",
- "providerUtilizationRemainingCapacity": "Công suất còn lại"
+ "providerUtilizationRemainingCapacity": "Công suất còn lại",
+ "utilizationRange": {
+ "1h": "Giờ qua",
+ "24h": "24 giờ qua",
+ "7d": "7 ngày qua",
+ "30d": "30 ngày qua"
+ },
+ "providerUtilizationGlobalView": "Toàn hệ thống",
+ "providerUtilizationAccountSplit": "Theo tài khoản",
+ "providerUtilizationLoading": "Đang tải dữ liệu mức sử dụng…",
+ "retrying": "Đang thử lại…",
+ "retry": "Thử lại",
+ "providerUtilizationNoDataDescription": "Ảnh chụp nhanh hạn ngạch của nhà cung cấp sẽ xuất hiện tại đây sau khi hệ thống thu thập dữ liệu mức sử dụng.",
+ "providerUtilizationStepConnect": "Kết nối nhà cung cấp qua OAuth hoặc khóa API trong Nhà cung cấp ",
+ "providerUtilizationStepEnable": "Bật theo dõi hạn ngạch bằng cách dùng nhà cung cấp trong combo hoặc yêu cầu trực tiếp",
+ "providerUtilizationStepAutomatic": "Dữ liệu sẽ tự động xuất hiện khi hệ thống thu thập ảnh chụp nhanh hạn ngạch",
+ "statusExhausted": "Đã cạn",
+ "statusLow": "Thấp",
+ "statusHealthy": "Tốt",
+ "remainingQuota": "Hạn ngạch còn lại",
+ "routeTraceTitle": "Dấu vết định tuyến",
+ "routeTraceDescription": "Kiểm tra dấu vết yêu cầu đã lưu: đích được chọn, các yếu tố định tuyến, bằng chứng dự phòng, tính điểm lại hiện tại, độ trễ, token và tình trạng đích.",
+ "routeTraceRequestLog": "Nhật ký yêu cầu",
+ "routeWeight": "Trọng số {weight}%",
+ "routeNoRelatedEvidence": "Chưa lưu bằng chứng về đích liên quan.",
+ "unknown": "không xác định",
+ "selected": "Đã chọn",
+ "routeNoStepId": "không có ID bước",
+ "routeNoStep": "không có bước",
+ "routeMatchesTopTarget": "Khớp với đích đứng đầu hiện tại",
+ "routeDiffersFromTop": "Khác với đích đứng đầu hiện tại",
+ "routeTargetMissingNow": "Hiện không còn đích này",
+ "routeNotComboRouted": "Không định tuyến qua combo",
+ "routeWhyTarget": "Tại sao chọn đích này?",
+ "routeWhyTargetSubtitle": "Metadata runtime chính xác và lượt tính điểm lại chỉ đọc",
+ "routeExactRuntimeLog": "Nhật ký runtime chính xác",
+ "routeCallLogsExact": "call_logs chính xác",
+ "routeReadOnlyRecompute": "Tính lại chỉ đọc",
+ "routeRuntimeRankNow": "Hạng runtime hiện tại",
+ "routeRuntimeScoreNow": "Điểm runtime hiện tại",
+ "routeWouldSelectNow": "Sẽ chọn ở hiện tại",
+ "routeNoRecomputeCandidates": "Không thể tính lại thứ hạng ứng viên combo cho yêu cầu này.",
+ "runtime": "Runtime",
+ "routeTopNow": "Đứng đầu hiện tại",
+ "routeFetchLogsFailed": "Không thể tải nhật ký yêu cầu",
+ "routeExplainFailed": "Không thể giải thích định tuyến",
+ "direct": "trực tiếp",
+ "routeUnableToLoad": "Không thể tải giải thích định tuyến",
+ "routeNoRequestLogs": "Không có nhật ký yêu cầu",
+ "routeNoRequestLogsDescription": "Hãy gửi lưu lượng qua OmniRoute trước. Giải thích định tuyến được tạo từ nhật ký lệnh gọi có cấu trúc đã lưu.",
+ "routeDecisionSummary": "Tóm tắt quyết định",
+ "routeConfidence": "độ tin cậy {confidence}",
+ "routeScore": "Điểm định tuyến",
+ "latency": "Độ trễ",
+ "routeRecentSuccess": "Tỷ lệ thành công gần đây",
+ "routeAvgTargetLatency": "Độ trễ trung bình của đích",
+ "routeSelectedTarget": "Đích đã chọn",
+ "provider": "Nhà cung cấp",
+ "model": "Mô hình",
+ "account": "Tài khoản",
+ "connection": "Kết nối",
+ "combo": "Combo",
+ "routeStep": "Bước",
+ "tokens": "Token",
+ "notAvailable": "không có",
+ "routeTokenCounts": "{input} đầu vào · {output} đầu ra",
+ "routeEvidence": "Bằng chứng",
+ "routeFactors": "Các yếu tố định tuyến",
+ "routeFactorsSubtitle": "Các tín hiệu có trọng số dùng cho phần giải thích này",
+ "routeFallbackTimeline": "Dòng thời gian dự phòng và đích",
+ "routeFallbackTimelineSubtitle": "Suy ra từ nhật ký lệnh gọi đã lưu quanh yêu cầu này",
+ "routeRecommendations": "Khuyến nghị",
+ "routeLimitations": "Giới hạn",
+ "routeNoKnownLimitations": "Không có giới hạn đã biết cho phần giải thích này."
},
"apiManager": {
"title": "Khóa API",
@@ -1738,6 +2096,9 @@
"ownUsageVisibilityDesc": "Cho phép khóa này gọi endpoint trạng thái của chính nó để xem mức sử dụng tính bằng USD, phần trăm ngân sách và tổng số token.",
"sharedAccountQuotaVisibility": "Hạn mức tài khoản dùng chung",
"sharedAccountQuotaVisibilityDesc": "Cho phép khóa này xem hạn mức tài khoản dùng chung của nhà cung cấp phía thượng nguồn khi một kết nối cụ thể được cấu hình.",
+ "localUsageCommand": "Cho phép lệnh mức sử dụng cục bộ",
+ "localUsageCommandDesc": "Cho phép khóa API này sử dụng @@om-usage để truy xuất thông tin mức sử dụng và hạn mức đã lưu trong cache mà không cần gọi nhà cung cấp phía thượng nguồn.",
+ "localUsageCommandBadge": "Lệnh mức sử dụng",
"keyCreated": "Đã tạo khóa API",
"keyCreatedSuccess": "Đã tạo khóa thành công!",
"keyCreatedNote": "Sao chép và lưu trữ khóa này ngay bây giờ — nó sẽ không được hiển thị lại.",
@@ -1748,6 +2109,11 @@
"endpointsRestricted": "Bị giới hạn ở {count} endpoint{count, plural, one {} other {s}}.",
"autoResolve": "Tự động phân giải",
"autoResolveDesc": "Tự động phân giải các tên mô hình mơ hồ sang nhà cung cấp gốc cho khóa API này.",
+ "streamDefaultMode": "Khả năng tương thích mặc định khi truyền phát",
+ "streamDefaultModeDesc": "Kiểm soát các cờ `stream` bị bỏ sót cho khóa này. Chế độ JSON trả về các phản hồi không truyền phát trực tuyến trừ khi ứng dụng khách yêu cầu SSE một cách rõ ràng.",
+ "streamDefaultLegacy": "Legacy",
+ "streamDefaultJson": "Tương thích JSON",
+ "streamDefaultBadge": "Mặc định truyền phát JSON",
"keyActive": "Khóa đang hoạt động",
"keyActiveDesc": "Bật hoặc tắt khóa API này. Khóa bị vô hiệu hóa sẽ ngay lập tức bị từ chối với lỗi 403.",
"accessSchedule": "Lịch trình truy cập",
@@ -1792,7 +2158,10 @@
"copyMaskedKey": "Sao chép khóa bị che",
"keyOnlyAvailableAtCreation": "Khóa đầy đủ chỉ khả dụng tại thời điểm tạo — hãy sao chép khi bạn tạo khóa lần đầu",
"modelsCount": "{count, plural, one {# mô hình} other {# mô hình}}",
+ "devicesCount": "{count, plural, one {# thiết bị} other {# thiết bị}}",
+ "devicesTooltip": "{count, plural, one {# thiết bị có IP/User-Agent riêng biệt đã được ghi nhận với khóa này (trong 30 phút qua)} other {# thiết bị có IP/User-Agent riêng biệt đã được ghi nhận với khóa này (trong 30 phút qua)}}",
"lastUsedOn": "Lần cuối: {date}",
+ "viewCostsFor": "Xem chi phí cho {name}",
"editPermissions": "Chỉnh sửa quyền",
"deleteKey": "Xóa khóa",
"regenerateKey": "Tạo lại khóa",
@@ -1803,9 +2172,19 @@
"models": "{count} mô hình",
"permissionsTitle": "Quyền: {name}",
"allowAllDesc": "Khóa này có thể truy cập tất cả các mô hình có sẵn.",
+ "restrictLoading": "Đang tải danh mục mô hình…",
+ "restrictCatalogUnavailable": "Danh mục mô hình không khả dụng; khóa này bị giới hạn ở {selectedCount} mô hình đã chọn.",
"restrictDesc": "Khóa này có thể truy cập {selectedCount} trong số {totalModels} mô hình.",
"selectedCount": "{count} đã chọn",
"maxActiveSessions": "Số phiên hoạt động tối đa",
+ "maxActiveSessionsDescription": "0 = không giới hạn. Trả về 429 khi khóa này vượt quá số phiên sticky đồng thời.",
+ "throttleDelay": "Độ trễ điều tiết",
+ "throttleDelayDescription": "Thêm độ trễ cố định trước khi định tuyến yêu cầu của khóa này. 0 = không làm chậm.",
+ "expandClaudeCodeFamilies": "Mở rộng các họ Claude Code",
+ "removeClaudeCodeDefault": "Xóa mặc định Claude Code",
+ "allowedCombos": "Combo được phép",
+ "allCombosAllowed": "Khóa này có thể sử dụng mọi combo.",
+ "restrictedComboCount": "Được giới hạn ở {count} combo.",
"apiManagerCustomRateLimits": "Giới hạn tốc độ tùy chỉnh",
"apiManagerCustomRateLimitsDesc": "Ghi đè giới hạn mặc định toàn cục. Để trống để sử dụng mặc định.",
"apiManagerRateLimitRequestsPlaceholder": "Yêu cầu",
@@ -1834,24 +2213,13 @@
"shownOf": "Đang hiển thị {shown} trong số {total}",
"emptyFilterTitle": "Không có khóa nào khớp với bộ lọc của bạn",
"emptyFilterClear": "Xóa bộ lọc",
- "localUsageCommand": "Allow local usage command",
- "localUsageCommandDesc": "Allows this API key to use @@om-usage to retrieve cached usage and quota information without calling an upstream provider.",
- "localUsageCommandBadge": "Usage cmd",
- "streamDefaultMode": "Stream Default Compatibility",
- "streamDefaultModeDesc": "Controls omitted `stream` flags for this key. JSON mode returns non-streaming responses unless the client explicitly requests SSE.",
- "streamDefaultLegacy": "Legacy",
- "streamDefaultJson": "JSON Compatible",
- "streamDefaultBadge": "JSON stream default",
- "devicesCount": "{count, plural, one {# device} other {# devices}}",
- "devicesTooltip": "{count, plural, one {# distinct IP/User-Agent device seen with this key (last 30 min)} other {# distinct IP/User-Agent devices seen with this key (last 30 min)}}",
- "viewCostsFor": "View costs for {name}",
- "restrictLoading": "Loading model catalog…",
- "restrictCatalogUnavailable": "Model catalog unavailable; this key has {selectedCount} selected model restrictions.",
- "disableNonPublicModels": "Disable Non-Public Models",
- "disableNonPublicModelsDesc": "Reject requests for models that are not discovered or not marked as public in the provider catalog",
- "normalKeysSection": "Normal keys",
- "quotaKeysSection": "Quota keys",
- "quotaPill": "QUOTA",
+ "disableNonPublicModels": "Vô hiệu hóa mô hình không công khai",
+ "disableNonPublicModelsDesc": "Từ chối yêu cầu đối với các mô hình không được phát hiện hoặc không được đánh dấu là công khai trong danh mục của nhà cung cấp",
+ "normalKeysSection": "Khóa thông thường",
+ "quotaKeysSection": "Khóa hạn mức",
+ "bypassProviderQuota": "Bỏ qua ngưỡng hạn ngạch nhà cung cấp",
+ "bypassProviderQuotaDescription": "Cho phép khóa này bỏ qua chính sách ngưỡng của nhà cung cấp/tài khoản thượng nguồn khi định tuyến. Hạn ngạch USD của khóa API vẫn được áp dụng.",
+ "quotaPill": "HẠN MỨC",
"quotaModeOnly": "qtSd-only"
},
"auditLog": {
@@ -1908,7 +2276,58 @@
"connections": "{count} kết nối",
"noConnections": "Chưa có kết nối nào — hãy thêm kết nối từ trang nhà cung cấp.",
"loading": "Đang tải...",
- "suggestedModels": "Suggested models from provider"
+ "suggestedModels": "Các mô hình được đề xuất từ nhà cung cấp",
+ "imageGeneration": "Tạo hình ảnh",
+ "imageToText": "Chuyển hình ảnh thành văn bản",
+ "imageToTextComingSoon": "Playground chuyển hình ảnh thành văn bản trực tiếp sẽ khả dụng khi /api/v1/images/understanding được triển khai.",
+ "disabled": "Đã tắt",
+ "videoGeneration": "Tạo video",
+ "musicGeneration": "Tạo nhạc",
+ "textToSpeech": "Văn bản thành giọng nói",
+ "transcription": "Phiên âm",
+ "imagePromptPlaceholder": "Phong cảnh thanh bình với núi non lúc hoàng hôn...",
+ "videoPromptPlaceholder": "Video tua nhanh cảnh một bông hoa đang nở...",
+ "musicPromptPlaceholder": "Nhạc điện tử sôi động với âm synth pad...",
+ "speechTextPlaceholder": "Xin chào! Chào mừng bạn đến với OmniRoute, cổng AI thông minh...",
+ "transcriptionPlaceholder": "Tải lên tệp âm thanh để phiên âm...",
+ "noImagesReturned": "Không có hình ảnh nào được trả về. Nhà cung cấp có thể đã nhận yêu cầu nhưng trả về dữ liệu trống.",
+ "generatedImageAlt": "Hình ảnh đã tạo {index}",
+ "save": "Lưu",
+ "enterTextToSynthesize": "Hãy nhập văn bản cần tổng hợp giọng nói.",
+ "selectAudioToTranscribe": "Hãy chọn tệp âm thanh cần phiên âm.",
+ "noSpeechDetected": "Không phát hiện lời nói trong tệp âm thanh. Nếu bạn tải lên nhạc hoặc tệp im lặng, hãy thử tệp có lời nói. Nhà cung cấp: \"{provider}\".",
+ "emptyTranscription": "Kết quả phiên âm trống. Âm thanh có thể không chứa lời nói nhận dạng được hoặc khóa API của \"{provider}\" không hợp lệ. Xem Bảng điều khiển → Nhật ký → Proxy để biết chi tiết.",
+ "topazRequiresImage": "Topaz yêu cầu một hình ảnh đầu vào.",
+ "enterPrompt": "Hãy nhập prompt.",
+ "enhanceThisImage": "Nâng cao hình ảnh này",
+ "failedToReadFile": "Không thể đọc tệp",
+ "generationFailed": "Tạo nội dung thất bại",
+ "requestFailed": "Yêu cầu thất bại ({status})",
+ "provider": "Nhà cung cấp",
+ "credentialsRequired": "Yêu cầu khóa API trong mục Nhà cung cấp ",
+ "voice": "Giọng nói",
+ "format": "Định dạng",
+ "audioVideoFile": "Tệp âm thanh / video",
+ "fileTooLarge": "Tệp quá lớn ({size}). Kích thước tối đa: {max}.",
+ "audioVideoFileHint": "Hỗ trợ tệp âm thanh và video tối đa 4 GB",
+ "sourceImage": "Hình ảnh nguồn",
+ "sourceImageHint": "Không bắt buộc; dùng cho quy trình chuyển đổi, chỉnh sửa và nâng cấp hình ảnh.",
+ "maskImage": "Hình ảnh mặt nạ",
+ "maskImageHint": "Không bắt buộc; dùng cho các mô hình inpaint hỗ trợ mặt nạ.",
+ "text": "Văn bản",
+ "promptOptional": "Prompt (không bắt buộc)",
+ "enhancementInstructionsPlaceholder": "Hướng dẫn nâng cao không bắt buộc...",
+ "synthesizing": "Đang tổng hợp giọng nói...",
+ "transcribing": "Đang phiên âm...",
+ "synthesizeSpeech": "Tổng hợp giọng nói",
+ "transcribeAudio": "Phiên âm thanh",
+ "generateModality": "Tạo {modality}",
+ "apiKeyRequired": "Yêu cầu khóa API",
+ "configureApiKeys": "Cấu hình khóa API trong mục Nhà cung cấp",
+ "downloadFormat": "Tải xuống {format}",
+ "noTextReturned": "Không có văn bản trả về",
+ "wordTimestamps": "Mốc thời gian từng từ ({count} từ)",
+ "providerCount": "{count} nhà cung cấp"
},
"search": {
"searchQuery": "Truy vấn tìm kiếm",
@@ -1989,6 +2408,8 @@
"freeQuota": "Hạn mức miễn phí/tháng",
"scrapeUrl": "URL cần trích xuất",
"scrapeUrlPlaceholder": "https://example.com",
+ "scrapeUrlRequired": "Bắt buộc phải có URL",
+ "scrapeUrlInvalid": "URL không hợp lệ — phải bắt đầu bằng http:// hoặc https://",
"scrapeExtract": "Trích xuất",
"scrapeExtracting": "Đang trích xuất…",
"scrapeFullPage": "Toàn trang",
@@ -2002,6 +2423,8 @@
"scrapeMetadata": "Siêu dữ liệu",
"scrapeProvider": "Nhà cung cấp",
"scrapeSize": "Kích thước",
+ "scrapeEmptyState": "Nhập một URL để trích xuất nội dung",
+ "scrapeProvidersAvailable": "Các nhà cung cấp hiện có: Firecrawl, Jina Reader, Tavily, TinyFish.",
"compareRun": "So sánh",
"compareRunning": "Đang so sánh…",
"autoProvider": "Tự động (rẻ nhất)",
@@ -2012,13 +2435,78 @@
"rerankModelLabel": "Mô hình xếp hạng lại",
"noneOption": "Không có",
"size": "Kích thước",
- "scrapeUrlRequired": "URL is required",
- "scrapeUrlInvalid": "Invalid URL — it must start with http:// or https://",
- "scrapeEmptyState": "Enter a URL to extract its content",
- "scrapeProvidersAvailable": "Available providers: Firecrawl, Jina Reader, Tavily, TinyFish."
+ "configurationPane": "Ngăn cấu hình",
+ "configuration": "Cấu hình",
+ "status": "Trạng thái",
+ "compareProviderHint": "Chọn tối đa 4 nhà cung cấp trong tab So sánh để đối chiếu song song.",
+ "history": "Lịch sử",
+ "historyHint": "Lịch sử có sẵn trong tab Tìm kiếm.",
+ "noActiveProvider": "Không có nhà cung cấp tìm kiếm đang hoạt động",
+ "configureMoreProviders": "Cấu hình thêm nhà cung cấp",
+ "links": "Liên kết",
+ "contentTruncated": "Nội dung đã cắt bớt còn 256 KB (kích thước gốc: {size})",
+ "viewFullRaw": "Xem toàn bộ dữ liệu thô",
+ "rawScrapedContent": "Nội dung thô đã trích xuất",
+ "rawContent": "Nội dung thô — {size}",
+ "closeRawModal": "Đóng hộp thoại nội dung thô",
+ "httpError": "Lỗi {status}",
+ "failed": "Thất bại",
+ "requestFailed": "Yêu cầu thất bại",
+ "embedding": "Embedding",
+ "embeddingSample": "Xin chào thế giới!",
+ "image": "Hình ảnh",
+ "imageSample": "Phong cảnh yên bình với núi non lúc hoàng hôn",
+ "music": "Âm nhạc",
+ "musicSample": "Piano jazz sôi động với bộ gõ nhẹ",
+ "noAudioUrl": "Phản hồi không có URL âm thanh: {response}",
+ "documentUrl": "URL tài liệu",
+ "fileTooLarge25Mb": "Tập tin quá lớn — tối đa 25 MB",
+ "selectAudioFirst": "Vui lòng chọn tập tin âm thanh trước.",
+ "speechToText": "Chuyển giọng nói thành văn bản",
+ "chooseFile": "Chọn tập tin…",
+ "audioFormats25Mb": "mp3, wav, m4a, ogg, flac — tối đa 25 MB",
+ "textToSpeech": "Chuyển văn bản thành giọng nói",
+ "ttsSample": "Xin chào, đây là nội dung kiểm tra chuyển văn bản thành giọng nói.",
+ "video": "Video",
+ "videoSample": "Video tua nhanh mây trôi trên dãy núi",
+ "webFetch": "Tải nội dung web",
+ "webSearchSample": "Cổng AI OmniRoute là gì?",
+ "noActiveProviderDescription": "Không có nhà cung cấp tìm kiếm đang hoạt động. Hãy cấu hình trong phần Nhà cung cấp.",
+ "configureProviders": "Cấu hình nhà cung cấp",
+ "compareQuery": "Truy vấn cần so sánh",
+ "compareQueryPlaceholder": "xu hướng trí tuệ nhân tạo 2026",
+ "selectedProviders": "Nhà cung cấp (đã chọn {count}):",
+ "selectAll": "Chọn tất cả",
+ "clear": "Xóa",
+ "maxCompareProviders": "Chỉ có thể so sánh tối đa {count} nhà cung cấp cùng lúc.",
+ "compareResults": "Kết quả — “{query}”",
+ "resultCount": "{count} kết quả",
+ "noResults": "Không có kết quả",
+ "sharedResultTitle": "Trùng với nhà cung cấp khác",
+ "sharedResult": "Kết quả trùng",
+ "overlapSummary": "{first} so với {second}: trùng {overlap}",
+ "compareEmptyTitle": "Chọn nhà cung cấp và nhập truy vấn để so sánh",
+ "compareEmptyDescription": "Kết quả sẽ được hiển thị song song kèm độ trễ, chi phí và mức trùng lặp URL"
},
"cliTools": {
"title": "Công cụ CLI",
+ "classifierCompatTitle": "Tương thích bộ phân loại quyền tự động",
+ "classifierCompatDescription": "Trả về phản hồi cho phép giả lập ngay cho bộ phân loại bảo mật --permission-mode auto của Claude Code, giúp tuyến dự phòng không bị từ chối mặc định. Mặc định tắt.",
+ "classifierCompatCycle": "Chuyển lần lượt: tắt → tự động → luôn bật",
+ "classifierCompatLoadFailed": "Không thể tải cài đặt",
+ "classifierCompatMode": {
+ "off": "Tắt",
+ "auto": "Tự động",
+ "always": "Luôn bật"
+ },
+ "failedSave": "Không thể lưu",
+ "profileSyncTitle": "Tự động đồng bộ hồ sơ CLI",
+ "profileSyncDescription": "Sau khi đồng bộ mô hình của nhà cung cấp, tự động tạo lại hồ sơ công cụ CLI từ danh mục trực tiếp. Mặc định tắt — chỉ ghi các file hồ sơ, tuyệt đối không thay đổi cấu hình đang dùng hoặc cấu hình mặc định.",
+ "profileSyncLoadFailed": "Không thể tải cài đặt",
+ "codexProfiles": "Hồ sơ Codex",
+ "codexProfilesDescription": "Tạo lại ~/.codex/*.config.toml sau khi phát hiện mô hình.",
+ "claudeProfiles": "Hồ sơ Claude Code",
+ "claudeProfilesDescription": "Tạo lại từng file ~/.claude/profiles/…/settings.json sau khi phát hiện mô hình.",
"noActiveProviders": "Không có nhà cung cấp đang hoạt động",
"noActiveProvidersDesc": "Trước tiên, vui lòng thêm và kết nối các nhà cung cấp để định cấu hình các công cụ CLI.",
"mapModels": "Ánh xạ mô hình",
@@ -2027,6 +2515,18 @@
"configureEndpoint": "Cấu hình endpoint",
"instructions": "Hướng dẫn",
"modelMapping": "Ánh xạ mô hình",
+ "reasoningEffort": "Mức suy luận cho {model}",
+ "effortNone": "Không dùng",
+ "effortLow": "Thấp",
+ "effortMedium": "Trung bình",
+ "effortHigh": "Cao",
+ "effortExtraHigh": "Rất cao",
+ "effortMax": "Tối đa",
+ "effortUltra": "Ultra",
+ "wireApi": "Wire API",
+ "modelAliases": "Bí danh mô hình",
+ "addModel": "Thêm mô hình",
+ "routeModelPlaceholder": "Định tuyến {model} tới...",
"baseUrl": "URL cơ sở",
"apiKey": "Khóa API",
"configured": "Đã cấu hình",
@@ -2083,6 +2583,15 @@
"saveMappings": "Lưu ánh xạ",
"mappingsSaved": "Đã lưu ánh xạ!",
"failedSaveMappings": "Không thể lưu ánh xạ",
+ "reasoningEffortDefault": "Mặc định",
+ "reasoningEffortHint": "Mặc định sẽ giữ nguyên mức suy luận do agent gửi",
+ "reasoningEffortTier": {
+ "none": "Không có",
+ "low": "Thấp",
+ "medium": "Trung bình",
+ "high": "Cao",
+ "xhigh": "Rất cao"
+ },
"howItWorks": "Cách hoạt động:",
"antigravityHowWorksDesc": "Antigravity gửi các yêu cầu đến endpoint của Google. MITM sẽ chặn và chuyển hướng chúng đến OmniRoute.",
"antigravityStep1": "1. Khởi động MITM để định tuyến các yêu cầu qua OmniRoute.",
@@ -2191,10 +2700,12 @@
"antigravity": "Google Antigravity IDE với MITM",
"claude": "Anthropic Claude Code CLI",
"codex": "OpenAI Codex CLI",
+ "grok-build": "Tác nhân lập trình xAI Grok Build TUI hỗ trợ nhà cung cấp tùy chỉnh",
"droid": "Trợ lý AI Factory Droid",
"openclaw": "Trợ lý AI Open Claw",
"cline": "CLI trợ lý lập trình Cline AI",
"kilo": "CLI trợ lý AI Kilo Code",
+ "qwen": "CLI Qwen Code của Alibaba",
"cursor": "Trình chỉnh sửa mã AI Cursor",
"continue": "Trợ lý AI Continue",
"opencode": "Tác nhân lập trình AI OpenCode (Terminal)",
@@ -2204,7 +2715,23 @@
"amp": "CLI trợ lý lập trình Sourcegraph Amp",
"hermes": "Trợ lý AI terminal Hermes",
"hermes-agent": "Hermes Agent (bởi Nousresearch) - AI nâng cao cho terminal, hỗ trợ đa mô hình (ủy quyền, thị giác, nén, v.v.)",
- "custom": "Trình tạo cấu hình chung cho CLI hoặc SDK tương thích với OpenAI"
+ "custom": "Trình tạo cấu hình chung cho CLI hoặc SDK tương thích với OpenAI",
+ "aider": "CLI lập trình cặp Aider AI với URL cơ sở tương thích OpenAI",
+ "forge": "Tác nhân lập trình ForgeCode CLI với nhà cung cấp tùy chỉnh",
+ "cursor-cli": "Cursor Agent CLI ở chế độ tác nhân không giao diện",
+ "roo": "Trợ lý AI Roo Code cho VS Code",
+ "jcode": "Tác nhân lập trình jcode trên terminal",
+ "deepseek-tui": "Tác nhân lập trình DeepSeek TUI viết bằng Rust",
+ "codewhale": "Tác nhân lập trình CodeWhale, phiên bản kế nhiệm DeepSeek TUI",
+ "smelt": "Tác nhân lập trình Smelt CLI",
+ "pi": "Tác nhân lập trình Pi gọn nhẹ trên terminal",
+ "crush": "Tác nhân lập trình Crush trên terminal của Charm",
+ "goose": "Tác nhân tự trị Goose CLI",
+ "interpreter": "Tác nhân lập trình tự trị Open Interpreter CLI",
+ "omp": "Tác nhân lập trình Oh My Pi trên terminal",
+ "letta": "Tác nhân Letta CLI có bộ nhớ lâu dài và khả năng dùng công cụ",
+ "warp": "Terminal Warp AI hỗ trợ nhà cung cấp tùy chỉnh",
+ "agent-deck": "Trình điều phối đa tác nhân Agent Deck"
},
"guides": {
"cursor": {
@@ -2355,25 +2882,58 @@
"customCliEndpointHint": "Cấu hình bất kỳ ứng dụng khách tương thích với OpenAI nào sử dụng URL cơ sở OmniRoute /v1. Endpoint chat completions thô là {endpoint}. Sử dụng khối JSON khi công cụ yêu cầu một đối tượng nhà cung cấp hoặc tập lệnh môi trường khi công cụ đọc các biến môi trường OPENAI_*.",
"customCliEnvBlockTitle": "Đoạn mã môi trường / shell",
"customCliJsonBlockTitle": "Khối JSON nhà cung cấp",
+ "networkError": "Lỗi mạng",
+ "other": "Khác",
+ "preview": "Xem trước",
+ "refreshAll": "Làm mới tất cả",
+ "hermesRoleDefault": "Mặc định (chính)",
+ "hermesRoleDefaultDesc": "Mô hình hội thoại chính",
+ "hermesRoleDelegation": "Ủy quyền (tác nhân phụ)",
+ "hermesRoleDelegationDesc": "Mô hình điều phối và tác nhân phụ",
+ "hermesRoleVision": "Thị giác",
+ "hermesRoleVisionDesc": "Hiểu hình ảnh và ảnh chụp màn hình",
+ "hermesRoleCompression": "Nén",
+ "hermesRoleCompressionDesc": "Nén và tóm tắt prompt",
+ "hermesRoleWebExtract": "Trích xuất web",
+ "hermesRoleWebExtractDesc": "Trích xuất nội dung trang web",
+ "hermesRoleSkillsHub": "Trung tâm kỹ năng",
+ "hermesRoleSkillsHubDesc": "Suy luận về kỹ năng và cách dùng công cụ",
+ "hermesRoleApproval": "Phê duyệt",
+ "hermesRoleApprovalDesc": "Quyết định về an toàn và phê duyệt",
+ "hermesSelectBeforePreview": "Hãy chọn mô hình cho các vai trò hoặc bảo đảm vai trò đã được tải trước khi xem trước.",
+ "hermesPreviewFailed": "Không thể tạo bản xem trước",
+ "hermesSavedTo": "Đã lưu vào {path}",
+ "hermesFirstSetupTitle": "Thiết lập lần đầu qua OmniRoute vào {date}",
+ "hermesSinceSetup": "Đã thiết lập được {time}",
+ "hermesConfiguredRoles": "{configured}/{total} vai trò",
+ "hermesQuickApply": "Áp dụng nhanh cùng một mô hình cho mọi vai trò:",
+ "hermesApplyModelToAll": "Áp dụng {model} cho mọi vai trò",
+ "hermesViaOmniRoute": "{provider} (qua OmniRoute)",
+ "hermesNotOmniRoute": "{provider} (không qua OmniRoute)",
+ "hermesRemovePendingRole": "Bỏ vai trò này khỏi các thay đổi đang chờ",
+ "hermesApply": "Áp dụng cho Hermes Agent",
+ "hermesRolesWillUpdate": "{count} vai trò sẽ được cập nhật",
+ "hermesPreviewPath": "Xem trước — sẽ ghi vào ~/.hermes/config.yaml",
+ "hermesSaveDescription": "Lưu mô hình đã chọn cho từng vai trò vào",
"copilotConfigGenerator": "Trình tạo cấu hình GitHub Copilot",
+ "copilotGeneratorDescriptionPrefix": "Tạo khối",
+ "copilotGeneratorDescriptionSuffix": "cho GitHub Copilot trên VS Code theo mẫu nhà cung cấp Azure. Chọn các mô hình cần dùng rồi sao chép JSON vào tệp cấu hình.",
+ "copilotCompatibilityWarning": "Cấu hình này sử dụng giải pháp nhà cung cấp Azure để hỗ trợ danh sách mô hình tùy chỉnh. Đã kiểm thử với VS Code ≥ 1.109 và GitHub Copilot Chat ≥ v0.37 . Các bản cập nhật tiện ích trong tương lai có thể thay đổi hành vi này.",
"copilotApiKey": "Khóa API",
+ "copilotSelectModels": "Chọn mô hình ({selected}/{total})",
+ "selectAll": "Chọn tất cả",
+ "loadingModels": "Đang tải mô hình...",
+ "advancedOptions": "Tùy chọn nâng cao",
+ "vision": "Thị giác",
+ "copilotCopyConfigForModels": "Sao chép cấu hình ({count} mô hình)",
"copilotFilterModelsPlaceholder": "Lọc mô hình...",
"copilotMaxInputTokens": "Token đầu vào tối đa",
"copilotMaxOutputTokens": "Token đầu ra tối đa",
"copilotToolCalling": "Gọi công cụ",
"copilotPasteInto": "Dán vào:",
+ "copilotReloadInstruction": "Sau đó tải lại VS Code và nhập khóa API khi được yêu cầu.",
"wireApiChatCompletions": "Chat Completions (/chat/completions)",
- "wireApiResponses": "Responses API (/responses)",
- "reasoningEffort": "Mức suy luận cho {model}",
- "reasoningEffortDefault": "Mặc định",
- "reasoningEffortHint": "Mặc định sẽ giữ nguyên mức suy luận do agent gửi",
- "reasoningEffortTier": {
- "none": "Không có",
- "low": "Thấp",
- "medium": "Trung bình",
- "high": "Cao",
- "xhigh": "Rất cao"
- }
+ "wireApiResponses": "Responses API (/responses)"
},
"combos": {
"title": "Combo",
@@ -2401,6 +2961,8 @@
"leastLatency": "Độ trễ thấp nhất",
"comboName": "Tên combo",
"comboNamePlaceholder": "my-combo",
+ "comboDescription": "Mô tả",
+ "comboDescriptionPlaceholder": "Ghi chú tùy chọn mô tả combo này",
"deleteConfirm": "Xóa combo này?",
"noCombosYet": "Chưa có combo nào",
"comboCreated": "Tạo combo thành công",
@@ -2440,6 +3002,16 @@
"resetAwareDesc": "Cân bằng hạn ngạch còn lại với các mốc đặt lại sau 5 giờ và hằng tuần, sau đó xoay vòng các mô hình có điểm số tương tự",
"strictRandom": "Ngẫu nhiên nghiêm ngặt",
"strictRandomDesc": "Trộn bài — sử dụng mỗi mô hình một lần trước khi trộn lại",
+ "fusion": "Hợp nhất",
+ "fusionDesc": "Gửi prompt song song đến mọi mô hình trong nhóm, sau đó một mô hình giám khảo sẽ tổng hợp thành một phản hồi cuối cùng",
+ "fusionJudgeModel": "Mô hình giám khảo",
+ "fusionJudgeModelHelp": "Mô hình tổng hợp các phản hồi từ nhóm thành một phản hồi cuối cùng. Để trống để sử dụng mô hình đầu tiên trong nhóm.",
+ "fusionMinPanel": "Số mô hình tối thiểu trong nhóm",
+ "fusionMinPanelHelp": "Số phản hồi thành công từ nhóm cần có trước khi các mô hình chậm được thêm thời gian chờ (mặc định là 2).",
+ "fusionStragglerGraceMs": "Thời gian chờ thêm cho mô hình chậm (ms)",
+ "fusionStragglerGraceMsHelp": "Thời gian chờ các mô hình chậm trong nhóm sau khi đã đạt số lượng tối thiểu (mặc định là 8000).",
+ "fusionPanelHardTimeoutMs": "Thời gian chờ tối đa của nhóm (ms)",
+ "fusionPanelHardTimeoutMsHelp": "Giới hạn tuyệt đối để một mô hình bị treo không làm toàn bộ nhóm bị đình trệ (mặc định là 90000).",
"models": "Mô hình",
"autoBalance": "Tự động cân bằng",
"advancedSettings": "Cài đặt nâng cao",
@@ -2461,6 +3033,45 @@
"moveDown": "Di chuyển xuống",
"removeModel": "Xóa",
"saving": "Đang lưu...",
+ "liveNoProviders": "Chưa quan sát thấy nhà cung cấp nào.",
+ "liveFleetDataHint": "Dữ liệu toàn cụm được nhận qua các sự kiện combo trực tiếp.",
+ "liveActiveCount": "Đang hoạt động ({count})",
+ "liveErrorCount": "Lỗi ({count})",
+ "liveInactiveCount": "Không hoạt động ({count})",
+ "liveNoRun": "Chưa có lần chạy combo nào.",
+ "liveDataHint": "Dữ liệu trực tiếp được nhận qua kênh combo WebSocket.",
+ "liveDisconnected": "Đã tắt chế độ trực tiếp — WebSocket đã ngắt kết nối. Đang hiển thị trạng thái gần nhất.",
+ "liveSelectCombo": "Chọn combo",
+ "liveSelectComboPlaceholder": "— chọn combo —",
+ "liveTargetCount": "{count} mục tiêu",
+ "liveSingle": "Đơn lẻ",
+ "liveFleet": "Toàn cụm",
+ "playgroundStatusAvailable": "Khả dụng",
+ "playgroundStatusNoQuota": "Hết hạn ngạch",
+ "playgroundStatusDegraded": "Suy giảm",
+ "playgroundStatusError": "Lỗi",
+ "playgroundStatusUnknown": "Không xác định",
+ "playgroundNetworkError": "Lỗi mạng khi mô phỏng",
+ "playgroundTitle": "Khu mô phỏng combo",
+ "playgroundDescription": "Mô phỏng cách các yêu cầu được định tuyến qua combo",
+ "playgroundConfiguration": "Cấu hình",
+ "playgroundNoCombosConfigured": "Chưa cấu hình combo nào",
+ "active": "đang hoạt động",
+ "inactive": "không hoạt động",
+ "playgroundEstimatedPromptTokens": "Số token prompt ước tính",
+ "playgroundSimulating": "Đang mô phỏng...",
+ "playgroundSimulateRoute": "Mô phỏng tuyến",
+ "playgroundRoutingPath": "Đường định tuyến",
+ "playgroundStrategy": "Chiến lược",
+ "playgroundEstimatedCost": "Chi phí ước tính",
+ "playgroundEstimatedLatency": "Độ trễ ước tính",
+ "playgroundFallback": "dự phòng",
+ "playgroundWeight": "trọng số {value}",
+ "playgroundWarningCount": "Cảnh báo ({count})",
+ "playgroundErrorCount": "Lỗi ({count})",
+ "playgroundEmptyHint": "Chọn một combo và nhấp Mô phỏng tuyến để xem đường định tuyến.",
+ "playgroundNoCombosYet": "Chưa cấu hình combo nào.",
+ "playgroundCreateFirst": "Tạo combo trước",
"weighted": "Có trọng số",
"leastUsed": "Ít được sử dụng nhất",
"costOpt": "Tối ưu hóa chi phí",
@@ -2550,7 +3161,7 @@
"failoverBeforeRetry": "Khi được bật, bất kỳ lỗi nào từ thượng nguồn cũng sẽ kích hoạt chuyển đổi dự phòng ngay lập tức sang mục tiêu combo tiếp theo, bỏ qua tất cả các lần thử lại và URL dự phòng.",
"maxSetRetries": "Số lần thử lại toàn bộ tập hợp mục tiêu khi mọi mục tiêu đều thất bại. 0 = không thử lại ở cấp tập hợp.",
"setRetryDelayMs": "Độ trễ giữa các lần thử lại ở cấp tập hợp, giúp các sự cố tạm thời có thời gian tự khắc phục.",
- "disableSessionStickiness": "Rotate to a different connection on every request instead of pinning a whole conversation to one connection by the first-message hash. Overrides the global default. Leave on Inherit to preserve prompt-cache hits for multi-turn chats."
+ "disableSessionStickiness": "Chuyển sang một kết nối khác cho mỗi yêu cầu thay vì ghim toàn bộ cuộc hội thoại vào một kết nối dựa trên giá trị băm của tin nhắn đầu tiên. Ghi đè cài đặt mặc định toàn cục. Hãy để ở chế độ Kế thừa để duy trì các lượt trúng cache prompt cho các cuộc trò chuyện nhiều lượt."
},
"templatesTitle": "Mẫu nhanh",
"templatesDescription": "Áp dụng một cấu hình ban đầu, sau đó điều chỉnh các mô hình và cấu hình.",
@@ -2640,6 +3251,9 @@
"budgetCapLabel": "Hạn mức ngân sách (USD / yêu cầu)",
"budgetCapPlaceholder": "Không giới hạn",
"advancedWeightsTitle": "Nâng cao: Trọng số chấm điểm",
+ "nestedComboFlatten": "Làm phẳng combo lồng nhau",
+ "nestedComboExecute": "Thực thi combo lồng nhau dưới dạng mục tiêu",
+ "effectiveRoutingShare": "Tỷ lệ định tuyến hiệu dụng (trọng số ÷ tổng)",
"weightQuota": "Hạn mức",
"weightHealth": "Trạng thái hoạt động",
"weightCostInv": "Chi phí",
@@ -2844,6 +3458,13 @@
"browseLegacyCatalog": "Duyệt qua danh mục combo cũ",
"agentFeaturesTitle": "Tính năng tác nhân",
"agentFeaturesDescription": "Kích hoạt các tính năng nâng cao cho các tác nhân sử dụng combo này",
+ "responseValidationTitle": "Xác thực phản hồi",
+ "responseValidationHelp": "Chuyển sang mục tiêu tiếp theo khi nội dung phản hồi 200 OK không vượt qua các kiểm tra này (nội dung của trợ lý).",
+ "responseValidationForbidden": "Các chuỗi con bị cấm (mỗi dòng một chuỗi)",
+ "responseValidationRequired": "Các chuỗi con bắt buộc (mỗi dòng một chuỗi)",
+ "responseValidationMinLength": "Độ dài nội dung tối thiểu (ký tự)",
+ "responseValidationJsonPaths": "Các kiểm tra đường dẫn JSON",
+ "responseValidationAddCheck": "+ Thêm kiểm tra",
"agentFeaturesSystemMessageOverride": "Ghi đè thông báo hệ thống",
"agentFeaturesSystemMessagePlaceholder": "Bạn là một trợ lý chuyên nghiệp...",
"agentFeaturesSystemMessageHint": "Ghi đè thông báo hệ thống cho tác nhân",
@@ -2858,28 +3479,9 @@
"agentFeaturesContextLengthErrorRange": "Độ dài ngữ cảnh phải nằm trong khoảng từ 1000 đến 2000000",
"compressionOverride": "Ghi đè nén",
"modePack": "Gói chế độ",
- "comboDescription": "Description",
- "comboDescriptionPlaceholder": "Optional note describing this combo",
- "fusion": "Fusion",
- "fusionDesc": "Fans the prompt to every panel model in parallel, then a judge model synthesizes one final answer",
- "fusionJudgeModel": "Judge model",
- "fusionJudgeModelHelp": "Model that synthesizes the panel answers into one final response. Leave empty to use the first panel model.",
- "fusionMinPanel": "Min panel",
- "fusionMinPanelHelp": "Successful panel answers required before stragglers get a grace window (default 2).",
- "fusionStragglerGraceMs": "Straggler grace (ms)",
- "fusionStragglerGraceMsHelp": "How long to wait for slow panel models once quorum is reached (default 8000).",
- "fusionPanelHardTimeoutMs": "Panel hard timeout (ms)",
- "fusionPanelHardTimeoutMsHelp": "Absolute cap so one hung model can't stall the whole panel (default 90000).",
- "responseValidationTitle": "Response validation",
- "responseValidationHelp": "Fail over to the next target when a 200 OK body fails these checks (assistant content).",
- "responseValidationForbidden": "Forbidden substrings (one per line)",
- "responseValidationRequired": "Required substrings (one per line)",
- "responseValidationMinLength": "Minimum content length (chars)",
- "responseValidationJsonPaths": "JSON-path checks",
- "responseValidationAddCheck": "+ Add check",
- "disableSessionStickiness": "Disable session stickiness",
- "sessionStickinessEnabled": "Stickiness on",
- "sessionStickinessDisabled": "Stickiness off"
+ "disableSessionStickiness": "Tắt tính năng liên kết phiên",
+ "sessionStickinessEnabled": "Bật liên kết phiên",
+ "sessionStickinessDisabled": "Tắt liên kết phiên"
},
"costs": {
"title": "Chi phí",
@@ -3167,6 +3769,59 @@
"apiEndpointsSearchPlaceholder": "Tìm kiếm endpoint...",
"apiEndpointsRequiresAuth": "Yêu cầu xác thực",
"apiEndpointsNoMatch": "Không có endpoint nào khớp với bộ lọc của bạn",
+ "endpointSections": "Các phần endpoint",
+ "tabMcp": "MCP",
+ "tabA2a": "A2A",
+ "tabContextSources": "Nguồn ngữ cảnh",
+ "activeEndpoints": "Endpoint đang hoạt động",
+ "activeLocal": "Cục bộ",
+ "activeCloud": "Cloud",
+ "copyUrlTitle": "Sao chép {url}",
+ "statusRunning": "Đang chạy",
+ "tunnels": "Đường hầm",
+ "activeTunnelCount": "{active} / {total} đang hoạt động",
+ "badgeLocal": "CỤC BỘ",
+ "badgeProtected": "ĐƯỢC BẢO VỆ",
+ "badgeInternal": "NỘI BỘ",
+ "catalogLoadFailed": "Yêu cầu danh mục API thất bại với HTTP {status}",
+ "catalogLoadFailedGeneric": "Không thể tải danh mục API",
+ "apiKeysLoadFailed": "Không thể tải khóa API ({status})",
+ "apiKeysLoadFailedGeneric": "Không thể tải khóa API",
+ "apiKeyRevealDisabled": "Tính năng hiển thị khóa API đang tắt (ALLOW_API_KEY_REVEAL). Hãy thay đổi trong trang Cờ tính năng hoặc dán khóa API thủ công.",
+ "apiKeyRevealFailed": "Không thể hiển thị khóa API ({status})",
+ "apiKeyRevealInvalid": "Phản hồi hiển thị khóa API không hợp lệ",
+ "apiKeyRequired": "Endpoint này yêu cầu khóa API.",
+ "requestFailed": "Yêu cầu thất bại ({status})",
+ "errorStatus": "Lỗi",
+ "catalogStats": "{endpoints} endpoint trong {categories} danh mục",
+ "catalogUnavailableDescription": "Không thể tải đặc tả OpenAPI.",
+ "openJsonResponse": "Mở phản hồi JSON",
+ "all": "Tất cả",
+ "more": "+{count} mục khác",
+ "showInternalTooltip": "Hiện hoặc ẩn các tuyến nội bộ (mặc định bị ẩn)",
+ "bearerAuth": "Xác thực Bearer",
+ "requestBody": "Nội dung yêu cầu",
+ "close": "Đóng",
+ "tryIt": "Dùng thử",
+ "example": "Ví dụ",
+ "apiKey": "Khóa API",
+ "switchToSelection": "Chuyển sang lựa chọn",
+ "enterManually": "Nhập thủ công",
+ "pasteApiKey": "Dán khóa API tại đây",
+ "noActiveApiKeys": "Không tìm thấy khóa API đang hoạt động. Hãy chuyển sang nhập thủ công để dán khóa.",
+ "requestBodyJson": "Nội dung yêu cầu (JSON)",
+ "sending": "Đang gửi...",
+ "sendRequest": "Gửi yêu cầu",
+ "dataSchemas": "Lược đồ dữ liệu",
+ "vscodeAliasTitle": "Bí danh token VS Code",
+ "vscodeAliasDescriptionReady": "Các URL tương thích đã sẵn sàng để dán sử dụng endpoint /api/v1/vscode/TOKEN/...",
+ "vscodeAliasDescriptionError": "Đang hiển thị các URL tạm thời vì không thể tải các khóa CLI trong phiên làm việc này.",
+ "vscodeAliasDescriptionLoading": "Đang tải các khóa CLI. Các URL tạm thời sẽ hiển thị cho đến khi có khóa khả dụng.",
+ "vscodeAliasDescriptionPlaceholder": "Đang hiển thị các URL tạm thời. Hãy tạo hoặc kích hoạt một khóa API trong CLI Tools để thay thế TOKEN.",
+ "vscodeAliasManage": "CLI Tools",
+ "vscodeAliasBaseLabel": "URL cơ sở VS Code",
+ "vscodeAliasModelsLabel": "Mô hình VS Code",
+ "vscodeAliasChatLabel": "VS Code chat",
"localServer": "Máy chủ cục bộ",
"cloudOmniroute": "Cloud OmniRoute",
"copyUrl": "Sao chép URL",
@@ -3183,15 +3838,39 @@
"customSystemPromptTitle": "Lời nhắc hệ thống tùy chỉnh",
"customSystemPromptDescription": "Chèn lời nhắc hệ thống tùy chỉnh vào mọi yêu cầu mô hình",
"customSystemPromptPlaceholder": "Ví dụ: Luôn trả lời bằng giọng cướp biển...",
- "vscodeAliasTitle": "VS Code Token Alias",
- "vscodeAliasDescriptionReady": "Ready-to-paste compatibility URLs using the /api/v1/vscode/TOKEN/... endpoint.",
- "vscodeAliasDescriptionError": "Showing placeholder URLs because CLI keys could not be loaded in this session.",
- "vscodeAliasDescriptionLoading": "Loading CLI keys. Placeholder URLs are shown until a key is available.",
- "vscodeAliasDescriptionPlaceholder": "Showing placeholder URLs. Create or activate an API key in CLI Tools to replace TOKEN.",
- "vscodeAliasManage": "CLI Tools",
- "vscodeAliasBaseLabel": "VS Code base",
- "vscodeAliasModelsLabel": "VS Code models",
- "vscodeAliasChatLabel": "VS Code chat"
+ "obsidianEnterToken": "Hãy nhập token API Obsidian",
+ "obsidianConnectFailed": "Không thể kết nối",
+ "obsidianConnectionFailed": "Kết nối thất bại",
+ "obsidianDisconnectFailed": "Không thể ngắt kết nối",
+ "obsidianEnterVaultPath": "Hãy nhập đường dẫn thư mục vault",
+ "obsidianWebdavEnabledMessage": "Đã bật đồng bộ WebDAV. Hãy cấu hình thiết bị di động bên dưới.",
+ "obsidianEnableWebdavFailed": "Không thể bật WebDAV",
+ "obsidianWebdavDisabledMessage": "Đã tắt đồng bộ WebDAV",
+ "obsidianDisableWebdavFailed": "Không thể tắt WebDAV",
+ "obsidianConnected": "Đã kết nối",
+ "obsidianNotConnected": "Chưa kết nối",
+ "obsidianWebdavSync": "Đồng bộ WebDAV",
+ "obsidianDescription": "Tìm kiếm, đọc, ghi và quản lý ghi chú trong Obsidian thông qua các mô hình AI được định tuyến",
+ "obsidianRestToken": "Token Local REST API của Obsidian",
+ "obsidianApiKeyPlaceholder": "Khóa API Obsidian",
+ "obsidianConnect": "Kết nối",
+ "obsidianBaseUrlOptional": "URL cơ sở (không bắt buộc)",
+ "obsidianPortWarning": "Cổng 27124 là endpoint MCP (HTTPS, chứng chỉ tự ký). REST API dùng HTTP trên cổng 27123.",
+ "obsidianRemoteVaultHint": "Mặc định: {defaultUrl}. Với vault từ xa, hãy nhập IP Tailscale + cổng (ví dụ: http://100.x.x.x:27123). Bật plugin Local REST API trên máy đang chạy Obsidian.",
+ "obsidianTokenConfigured": "Đã cấu hình token. Các công cụ Obsidian hiện có thể dùng qua MCP.",
+ "obsidianDisconnect": "Ngắt kết nối",
+ "obsidianVaultSync": "Đồng bộ vault (WebDAV)",
+ "obsidianVaultSyncDescription": "Đồng bộ vault với Obsidian trên di động bằng WebDAV qua Tailscale. Obsidian di động hỗ trợ WebDAV tích hợp — không cần plugin.",
+ "obsidianVaultDirectoryPath": "Đường dẫn thư mục vault",
+ "obsidianEnable": "Bật",
+ "obsidianWebdavEnabled": "Đã bật đồng bộ WebDAV",
+ "obsidianDisable": "Tắt",
+ "obsidianConfigureMobile": "Cấu hình Obsidian trên di động",
+ "obsidianMobileInstructions": "Trong Obsidian di động: Cài đặt → Đồng bộ → WebDAV → nhập thông tin sau:",
+ "obsidianWebdavUrl": "URL WebDAV",
+ "obsidianUsername": "Tên người dùng",
+ "obsidianPassword": "Mật khẩu",
+ "obsidianTailscaleHint": "Dùng IP Tailscale thay cho localhost khi kết nối từ di động. Cả hai thiết bị phải cùng mạng Tailscale."
},
"endpoints": {
"tabProxy": "Endpoint Proxy",
@@ -3367,203 +4046,214 @@
"a2aStep3": "Theo dõi và hủy các nhiệm vụ bằng {code1} và {code2}."
},
"memory": {
- "title": "Quản lý ký ức",
- "description": "Xem và quản lý các mục bộ nhớ đã lưu trữ",
- "memories": "Bộ nhớ",
- "totalEntries": "Tổng số mục",
- "tokensUsed": "Số token đã dùng",
- "hitRate": "Tỷ lệ khớp",
- "loading": "Đang tải...",
- "noMemories": "Không tìm thấy bộ nhớ nào",
- "search": "Tìm kiếm ký ức...",
- "allTypes": "Tất cả các loại",
- "export": "Xuất",
- "import": "Nhập",
- "addMemory": "Thêm bộ nhớ",
- "type": "Loại",
- "key": "Khóa",
- "content": "Nội dung",
- "created": "Đã tạo",
- "actions": "Hành động",
- "delete": "Xóa",
- "factual": "Thực tế",
- "episodic": "Tình tiết",
- "procedural": "Quy trình",
- "semantic": "Ngữ nghĩa",
"a": "A",
- "pipelineOk": "Quy trình xử lý hoạt động tốt ({latencyMs}ms)",
- "pipelineError": "Lỗi quy trình xử lý",
- "healthUnknown": "Không rõ trạng thái hoạt động",
- "checkingHealth": "Đang kiểm tra…",
- "checkHealth": "Kiểm tra tình trạng",
- "pageInfo": "Trang {page} trên {totalPages} (tổng cộng {total})",
- "previous": "Trước đó",
- "next": "Tiếp theo",
+ "actions": "Hành động",
+ "addMemory": "Thêm bộ nhớ",
+ "allTypes": "Tất cả các loại",
"cancel": "Hủy",
- "save": "Lưu",
- "keyPlaceholder": "ví dụ: user.preferences.theme",
- "contentPlaceholder": "Giá trị hoặc nội dung JSON cần ghi nhớ",
- "compactOld": "Compact old",
+ "checkHealth": "Kiểm tra tình trạng",
+ "checkingHealth": "Đang kiểm tra…",
+ "compactOld": "Nén dữ liệu cũ",
"concept": {
- "title": "Conversational Memory",
- "description": "OmniRoute learns from every conversation, remembering facts, episodes, procedures, and semantic concepts that make responses more accurate and context-aware.",
- "howWorksToggle": "How it works",
- "howWorksContent": "1. Automatic extraction: at the end of each response, facts and episodes are detected and saved automatically.\n2. Retrieval: before each response, the most relevant memories are searched via FTS5 (exact), vector (semantic), or hybrid RRF.\n3. Injection: relevant memories are injected into the assistant context to improve response quality.\n4. Management: use this page to view, edit, export, and compact old memories."
+ "title": "Bộ nhớ hội thoại",
+ "description": "OmniRoute học hỏi từ mỗi cuộc trò chuyện, ghi nhớ các dữ kiện, sự kiện, quy trình và khái niệm ngữ nghĩa để giúp phản hồi chính xác hơn và nhận biết ngữ cảnh tốt hơn.",
+ "howWorksToggle": "Cách thức hoạt động",
+ "howWorksContent": "1. Tự động trích xuất: vào cuối mỗi phản hồi, các dữ kiện và sự kiện sẽ được phát hiện và lưu tự động.\n2. Truy xuất: trước mỗi phản hồi, các bộ nhớ liên quan nhất sẽ được tìm kiếm qua FTS5 (khớp chính xác), vector (ngữ nghĩa) hoặc RRF kết hợp.\n3. Nhúng: các bộ nhớ liên quan sẽ được đưa vào ngữ cảnh của trợ lý để cải thiện chất lượng phản hồi.\n4. Quản lý: sử dụng trang này để xem, chỉnh sửa, xuất và nén các bộ nhớ cũ."
},
- "deleteConfirmDesc": "This action cannot be undone. The memory will be permanently removed.",
- "deleteConfirmTitle": "Delete memory?",
- "editMemory": "Edit memory",
+ "content": "Nội dung",
+ "contentPlaceholder": "Giá trị hoặc nội dung JSON cần ghi nhớ",
+ "created": "Đã tạo",
+ "delete": "Xóa",
+ "deleteConfirmDesc": "Hành động này không thể hoàn tác. Bộ nhớ sẽ bị xóa vĩnh viễn.",
+ "deleteConfirmTitle": "Xóa bộ nhớ?",
+ "description": "Xem và quản lý các mục bộ nhớ đã lưu trữ",
+ "editMemory": "Chỉnh sửa bộ nhớ",
"editModal": {
- "title": "Edit Memory",
- "metadataLabel": "Metadata (JSON)",
- "metadataInvalid": "Invalid JSON",
- "saveFailed": "Failed to save memory"
+ "title": "Chỉnh sửa bộ nhớ",
+ "metadataLabel": "Siêu dữ liệu (JSON)",
+ "metadataInvalid": "JSON không hợp lệ",
+ "saveFailed": "Không thể lưu bộ nhớ"
},
"embedding": {
- "autoLabel": "Automatic",
- "autoDesc": "Uses best available: remote provider > static > transformers",
- "remoteLabel": "Remote provider",
- "remoteDesc": "Uses embedding via provider API (requires API key)",
- "staticLabel": "Static local (potion)",
- "staticDesc": "Local embedding without WASM or external dependencies",
+ "autoLabel": "Tự động",
+ "autoDesc": "Sử dụng tùy chọn tốt nhất hiện có: nhà cung cấp từ xa > tĩnh > transformers",
+ "remoteLabel": "Nhà cung cấp từ xa",
+ "remoteDesc": "Tạo vector nhúng qua API của nhà cung cấp (cần khóa API)",
+ "staticLabel": "Tĩnh cục bộ (potion)",
+ "staticDesc": "Tạo vector nhúng cục bộ không cần WASM hoặc các phần phụ thuộc bên ngoài",
"transformersLabel": "Transformers.js (MiniLM)",
- "transformersDesc": "Local embedding via @huggingface/transformers (~400MB RAM)",
- "providerModelLabel": "Provider / Model",
- "noRemoteProviders": "No providers with configured API key",
- "selectProviderModel": "Select a model",
- "staticEnabledLabel": "Enable Static Potion",
- "staticEnabledDesc": "Download and use potion-base-8M model locally",
- "transformersEnabledLabel": "Enable Transformers.js",
- "transformersEnabledDesc": "Opt-in for local MiniLM (~400MB RAM, ~3s cold start)",
- "transformersWarning": "Requires ~400MB RAM and ~3s cold start on the first semantic query."
+ "transformersDesc": "Tạo vector nhúng cục bộ qua @huggingface/transformers (~400MB RAM)",
+ "providerModelLabel": "Nhà cung cấp / Mô hình",
+ "noRemoteProviders": "Không có nhà cung cấp nào đã cấu hình khóa API",
+ "selectProviderModel": "Chọn một mô hình",
+ "staticEnabledLabel": "Kích hoạt Static Potion",
+ "staticEnabledDesc": "Tải xuống và sử dụng mô hình potion-base-8M cục bộ",
+ "transformersEnabledLabel": "Kích hoạt Transformers.js",
+ "transformersEnabledDesc": "Chọn sử dụng MiniLM cục bộ (~400MB RAM, thời gian khởi động nguội khoảng 3 giây)",
+ "transformersWarning": "Yêu cầu khoảng ~400MB RAM và ~3 giây khởi động nguội trong truy vấn ngữ nghĩa đầu tiên."
},
"emptyState": {
- "title": "No memories yet",
- "description": "Memories are created automatically from conversations. You can also add them manually using the button above."
+ "title": "Chưa có bộ nhớ nào",
+ "description": "Bộ nhớ được tạo tự động từ các cuộc trò chuyện. Bạn cũng có thể thêm chúng theo cách thủ công bằng nút bên trên."
},
"engine": {
- "statusTitle": "Engine Status",
- "embeddingTitle": "Embedding Source",
- "rerankTitle": "Rerank (optional)",
- "reindexNow": "Reindex now",
- "reindexStarted": "Reindexing started ({pending} pending)",
- "reindexFailed": "Failed to start reindexing",
- "keywordLabel": "Keyword (FTS5)",
- "keywordReason": "Keyword search always available",
+ "statusTitle": "Trạng thái công cụ",
+ "embeddingTitle": "Nguồn tạo vector nhúng",
+ "rerankTitle": "Xếp hạng lại (tùy chọn)",
+ "reindexNow": "Lập chỉ mục lại ngay",
+ "reindexStarted": "Đã bắt đầu lập chỉ mục lại ({pending} đang chờ xử lý)",
+ "reindexFailed": "Không thể bắt đầu lập chỉ mục lại",
+ "keywordLabel": "Từ khóa (FTS5)",
+ "keywordReason": "Tìm kiếm từ khóa luôn khả dụng",
"embeddingLabel": "Embedding",
- "vectorStoreLabel": "Vector Store",
+ "vectorStoreLabel": "Kho lưu trữ vector",
"qdrantLabel": "Qdrant",
- "rerankLabel": "Rerank",
- "qdrantDisabled": "Disabled",
- "qdrantOk": "Healthy ({latencyMs}ms)",
- "qdrantError": "Connection error",
- "needsReindex": "{count} memory(ies) need reindexing",
- "configureCta": "Configure",
- "vectorStoreInstallHint": "To enable vector search: npm install sqlite-vec (needs native Node.js, not WASM), then restart."
+ "rerankLabel": "Xếp hạng lại",
+ "qdrantDisabled": "Đã vô hiệu hóa",
+ "qdrantOk": "Hoạt động tốt ({latencyMs}ms)",
+ "qdrantError": "Lỗi kết nối",
+ "needsReindex": "{count} bộ nhớ cần lập chỉ mục lại",
+ "configureCta": "Cấu hình",
+ "vectorStoreInstallHint": "Để kích hoạt tìm kiếm vector: chạy npm install sqlite-vec (cần Node.js gốc, không phải WASM), sau đó khởi động lại."
},
- "importError": "Failed to import file",
- "importResult": "{imported} imported, {skipped} skipped",
+ "episodic": "Tình tiết",
+ "export": "Xuất",
+ "factual": "Thực tế",
+ "healthUnknown": "Không rõ trạng thái hoạt động",
+ "hitRate": "Tỷ lệ khớp",
+ "import": "Nhập",
+ "importError": "Không thể nhập tệp",
+ "importResult": "Đã nhập {imported}, đã bỏ qua {skipped}",
+ "key": "Khóa",
+ "keyPlaceholder": "ví dụ: user.preferences.theme",
+ "loading": "Đang tải...",
+ "memories": "Bộ nhớ",
+ "next": "Tiếp theo",
+ "noMemories": "Không tìm thấy bộ nhớ nào",
+ "pageInfo": "Trang {page} trên {totalPages} (tổng cộng {total})",
+ "pipelineError": "Lỗi quy trình xử lý",
+ "pipelineOk": "Quy trình xử lý hoạt động tốt ({latencyMs}ms)",
"playground": {
- "infoTitle": "Memory Playground",
- "infoDesc": "Simulate what would be retrieved for a given query. No memories are modified — read-only preview.",
- "queryLabel": "Test query",
- "queryPlaceholder": "Type a question or test phrase...",
- "strategyLabel": "Strategy",
- "strategyExact": "Exact (FTS5)",
- "strategySemantic": "Semantic (vector)",
- "strategyHybrid": "Hybrid (RRF)",
- "budgetLabel": "Budget (tokens)",
- "simulate": "Simulate",
- "resultsTitle": "{count} result(s)",
- "resolutionTitle": "Search resolution",
+ "infoTitle": "Khu thử nghiệm bộ nhớ",
+ "infoDesc": "Mô phỏng những gì sẽ được truy xuất cho một truy vấn nhất định. Không có bộ nhớ nào bị sửa đổi — chế độ xem thử chỉ đọc.",
+ "queryLabel": "Truy vấn thử nghiệm",
+ "queryPlaceholder": "Nhập câu hỏi hoặc cụm từ thử nghiệm...",
+ "strategyLabel": "Chiến lược",
+ "strategyExact": "Khớp chính xác (FTS5)",
+ "strategySemantic": "Ngữ nghĩa (vector)",
+ "strategyHybrid": "Hỗn hợp (RRF)",
+ "budgetLabel": "Ngân sách (token)",
+ "simulate": "Mô phỏng",
+ "resultsTitle": "{count} kết quả",
+ "resolutionTitle": "Xử lý tìm kiếm",
"resolutionEmbedding": "Embedding",
- "resolutionStore": "Vector store",
- "resolutionStrategy": "Strategy used",
- "rerankApplied": "Rerank applied",
- "fallback": "Fallback",
- "noResults": "No memories found for this query.",
- "tokensUsed": "tokens used",
- "none": "none",
- "errorFetch": "Failed to fetch preview"
+ "resolutionStore": "Kho lưu trữ vector",
+ "resolutionStrategy": "Chiến lược đã dùng",
+ "rerankApplied": "Đã áp dụng xếp hạng lại",
+ "fallback": "Dự phòng",
+ "noResults": "Không tìm thấy bộ nhớ nào cho truy vấn này.",
+ "tokensUsed": "token đã dùng",
+ "none": "không có",
+ "errorFetch": "Không thể tải bản xem trước"
},
+ "previous": "Trước đó",
+ "procedural": "Quy trình",
"qdrant": {
- "title": "Qdrant (Vector Store Tier 2)",
- "description": "Optional Qdrant integration for scalable semantic search",
- "enableLabel": "Enable Qdrant",
- "enableDesc": "When enabled, Qdrant is used as the primary vector store",
- "banner": "Tier 2 vector store — an external, scalable alternative to the built-in sqlite-vec (Tier 1). Enable it only if you have a very large memory set or want shared memory across instances; most users are fine on sqlite-vec. When enabled it becomes the primary store and automatically falls back to sqlite-vec if unreachable.",
- "hostHelp": "Local Docker: http://localhost:6333 · Qdrant Cloud: your cluster URL",
- "collectionHelp": "Any name — OmniRoute creates it on first use",
- "embeddingModelHelp": "Sets the vector dimension automatically on first use. Existing memories are not back-filled, and changing the model after data exists needs a fresh collection.",
- "testConnection": "Test connection",
- "testing": "Testing...",
- "statusActive": "Active",
- "statusError": "Error",
- "statusDisabled": "Disabled",
- "healthOk": "Connection OK ({latencyMs}ms)",
- "healthError": "Connection error",
- "saved": "Settings saved",
- "saveError": "Failed to save",
- "portLabel": "Port",
- "embeddingModelLabel": "Embedding Model",
- "optional": "optional",
- "apiKeyKeepPlaceholder": "Leave blank to keep current key",
- "apiKeyOptional": "Leave blank if not using authentication",
- "removeApiKey": "Remove",
- "quickSelectModel": "Quick select",
- "searchTestTitle": "Semantic search test",
- "searchPlaceholder": "Type a test query...",
- "searching": "Searching...",
- "search": "Search",
- "cleanupTitle": "Clean up old points",
- "cleanupDesc": "Remove Qdrant points for expired memories (retention period).",
- "cleaning": "Cleaning...",
- "cleanNow": "Clean now",
- "cleanupSuccess": "{count} point(s) removed",
- "cleanupFailed": "Cleanup failed"
+ "title": "Qdrant (Kho lưu trữ vector cấp 2)",
+ "description": "Tích hợp Qdrant tùy chọn để tìm kiếm ngữ nghĩa có khả năng mở rộng",
+ "enableLabel": "Kích hoạt Qdrant",
+ "enableDesc": "Khi được kích hoạt, Qdrant sẽ được sử dụng làm kho lưu trữ vector chính",
+ "banner": "Kho lưu trữ vector cấp 2 — một giải pháp thay thế bên ngoài, có khả năng mở rộng cho sqlite-vec tích hợp sẵn (cấp 1). Chỉ kích hoạt nếu bạn có tập bộ nhớ rất lớn hoặc muốn chia sẻ bộ nhớ giữa các phiên bản; hầu hết người dùng đều hoạt động tốt với sqlite-vec. Khi được kích hoạt, nó sẽ trở thành kho lưu trữ chính và tự động chuyển sang sqlite-vec dự phòng nếu không thể kết nối.",
+ "hostHelp": "Docker cục bộ: http://localhost:6333 · Qdrant Cloud: URL cụm của bạn",
+ "collectionHelp": "Tên bất kỳ — OmniRoute sẽ tạo nó trong lần sử dụng đầu tiên",
+ "embeddingModelHelp": "Tự động thiết lập chiều vector trong lần sử dụng đầu tiên. Các bộ nhớ hiện có sẽ không được bổ sung, và việc thay đổi mô hình sau khi dữ liệu đã tồn tại sẽ yêu cầu một bộ sưu tập mới.",
+ "testConnection": "Kiểm tra kết nối",
+ "testing": "Đang kiểm tra...",
+ "statusActive": "Hoạt động",
+ "statusError": "Lỗi",
+ "statusDisabled": "Đã tắt",
+ "healthOk": "Kết nối OK ({latencyMs}ms)",
+ "healthError": "Lỗi kết nối",
+ "saved": "Đã lưu cài đặt",
+ "saveError": "Không thể lưu",
+ "portLabel": "Cổng",
+ "embeddingModelLabel": "Mô hình nhúng",
+ "optional": "không bắt buộc",
+ "apiKeyKeepPlaceholder": "Để trống để giữ khóa hiện tại",
+ "apiKeyOptional": "Để trống nếu không sử dụng xác thực",
+ "removeApiKey": "Xóa",
+ "quickSelectModel": "Chọn nhanh",
+ "searchTestTitle": "Kiểm tra tìm kiếm ngữ nghĩa",
+ "searchPlaceholder": "Nhập truy vấn thử nghiệm...",
+ "searching": "Đang tìm kiếm...",
+ "search": "Tìm kiếm",
+ "cleanupTitle": "Dọn dẹp các điểm dữ liệu cũ",
+ "cleanupDesc": "Xóa các điểm Qdrant của các ký ức đã hết hạn (thời gian lưu trữ).",
+ "cleaning": "Đang dọn dẹp...",
+ "cleanNow": "Dọn dẹp ngay",
+ "cleanupSuccess": "Đã xóa {count} điểm",
+ "cleanupFailed": "Dọn dẹp thất bại"
},
"rerank": {
- "enableLabel": "Enable Rerank",
- "enableDesc": "Reorders results with a reranking model after search",
- "warning": "Rerank adds +200-500ms latency and additional cost per request. Use sparingly.",
- "providerModelLabel": "Rerank Provider / Model",
- "noProviderWithKey": "No provider with configured API key. Configure a provider to use rerank.",
- "selectProviderModel": "Select a provider/model"
+ "enableLabel": "Bật xếp hạng lại",
+ "enableDesc": "Sắp xếp lại kết quả bằng mô hình xếp hạng lại sau khi tìm kiếm",
+ "warning": "Xếp hạng lại làm tăng thêm 200-500ms độ trễ và chi phí cho mỗi yêu cầu. Hãy hạn chế sử dụng.",
+ "providerModelLabel": "Nhà cung cấp/mô hình xếp hạng lại",
+ "noProviderWithKey": "Không có nhà cung cấp nào có khóa API đã được cấu hình. Vui lòng cấu hình nhà cung cấp để sử dụng tính năng xếp hạng lại.",
+ "selectProviderModel": "Chọn một nhà cung cấp/mô hình"
},
- "saving": "Saving...",
+ "save": "Lưu",
+ "saving": "Đang lưu...",
+ "search": "Tìm kiếm ký ức...",
+ "semantic": "Ngữ nghĩa",
"summarize": {
- "title": "Compact old memories",
- "noCandidates": "No memories eligible for compaction (criteria: >30 days old).",
- "candidatesDesc": "{count} memories will be compacted into summaries. This action cannot be undone.",
- "confirm": "Compact now"
+ "title": "Thu gọn ký ức cũ",
+ "noCandidates": "Không có ký ức nào đủ điều kiện để thu gọn (tiêu chí: cũ hơn 30 ngày).",
+ "candidatesDesc": "{count} ký ức sẽ được thu gọn thành các bản tóm tắt. Hành động này không thể hoàn tác.",
+ "confirm": "Thu gọn ngay"
},
"tabs": {
- "memories": "Memories",
- "playground": "Playground",
- "engine": "Engine"
+ "memories": "Ký ức",
+ "playground": "Khu thử nghiệm",
+ "engine": "Công cụ"
},
+ "title": "Quản lý ký ức",
+ "tokensUsed": "Số token đã dùng",
"tooltip": {
- "totalEntries": "Total memories stored for this API key",
- "tokensUsed": "Estimated total tokens occupied by active memories",
- "hitRate": "Read-by-ID cache hit rate (not semantic recall accuracy)",
- "factual": "Objective, permanent facts from user context",
- "episodic": "Events and experiences from conversation history",
- "procedural": "Procedures, workflows, and instructions the assistant should follow",
- "semantic": "Concepts, preferences, and domain knowledge"
+ "totalEntries": "Tổng số ký ức được lưu trữ cho khóa API này",
+ "tokensUsed": "Tổng số token ước tính được chiếm bởi các ký ức đang hoạt động",
+ "hitRate": "Tỷ lệ trúng cache đọc theo ID (không phải độ chính xác truy hồi ngữ nghĩa)",
+ "factual": "Các sự thật khách quan, vĩnh viễn từ ngữ cảnh người dùng",
+ "episodic": "Các sự kiện và trải nghiệm từ lịch sử trò chuyện",
+ "procedural": "Quy trình, luồng công việc và hướng dẫn mà trợ lý nên tuân theo",
+ "semantic": "Khái niệm, sở thích và kiến thức chuyên ngành"
},
- "memoryEnabled": "Memory enabled"
+ "totalEntries": "Tổng số mục",
+ "type": "Loại",
+ "memoryEnabled": "Đã bật bộ nhớ"
},
"skills": {
"title": "Kỹ năng",
"description": "Quản lý và giám sát các kỹ năng AI",
"skillsTab": "Kỹ năng",
"executionsTab": "Lần thực thi",
+ "selectSkillToInspect": "Chọn một skill ở bên trái để kiểm tra.",
+ "schemaTab": "Schema",
+ "handlerTab": "Handler",
+ "inputSchema": "Schema đầu vào",
+ "outputSchema": "Schema đầu ra",
+ "handlerCode": "Mã handler",
+ "handlerUnavailable": "Không có handler",
+ "runTestPlaceholder": "Chạy kiểm tra (chưa triển khai)",
+ "setModeAria": "Đặt chế độ {mode}",
+ "uninstallSkill": "Gỡ cài đặt skill",
"sandboxTab": "Hộp cát",
"loading": "Đang tải các kỹ năng...",
"noSkills": "Không tìm thấy kỹ năng nào",
"noExecutions": "Không tìm thấy lượt thực thi nào",
"enabled": "Đã bật",
"disabled": "Đã tắt",
+ "delete": "Xóa",
"version": "Phiên bản",
"tableDescription": "Mô tả",
"skill": "Kỹ năng",
@@ -3619,8 +4309,7 @@
"installSkillModalDesc": "Dán tệp kê khai JSON của kỹ năng hoặc tải tệp .json lên.",
"uploadJson": "Tải JSON lên",
"cancel": "Hủy",
- "installSkill": "Cài đặt kỹ năng",
- "delete": "Delete"
+ "installSkill": "Cài đặt kỹ năng"
},
"health": {
"title": "Trạng thái hệ thống",
@@ -3701,6 +4390,27 @@
"throttleStatus": "Điều tiết: {value}",
"lastHeaderUpdate": "Cập nhật header: {age}",
"databaseHealth": "Tình trạng cơ sở dữ liệu",
+ "databaseHealthDescription": "Chẩn đoán và sửa các dòng hạn ngạch/miền lỗi thời cùng các tham chiếu combo bị hỏng.",
+ "status": "Trạng thái",
+ "attentionNeeded": "Cần chú ý",
+ "repairs": "Mục đã sửa",
+ "repairing": "Đang sửa...",
+ "runAutoRepair": "Chạy tự động sửa",
+ "repairBackupCreated": "Đã tạo bản sao lưu trước khi sửa dữ liệu.",
+ "sessionActivity": "Hoạt động phiên",
+ "activeCount": "{count} đang hoạt động",
+ "requestCount": "{count} yêu cầu",
+ "idleSeconds": "nhàn rỗi {count} giây",
+ "ageSeconds": "đã tồn tại {count} giây",
+ "quotaMonitors": "Trình giám sát hạn ngạch",
+ "alerting": "Đang cảnh báo",
+ "errors": "Lỗi",
+ "degradationFull": "Đầy đủ",
+ "degradationReduced": "Giảm bớt",
+ "degradationMinimal": "Tối thiểu",
+ "degradationDefault": "Mặc định",
+ "sinceTime": "Từ {time}",
+ "retryIn": "thử lại sau {duration}",
"stickyBoundSessions": "Các phiên liên kết cố định",
"sessionsByApiKey": "Phiên theo khóa API",
"noActiveSessionsTracked": "Chưa có phiên hoạt động nào được theo dõi.",
@@ -3883,28 +4593,42 @@
"export": "Xuất",
"exporting": "Đang xuất...",
"exportFailed": "Xuất thất bại",
+ "cleanHistoryButton": "Xóa lịch sử",
+ "cleanHistoryTitle": "Xóa lịch sử nhật ký?",
+ "cleanHistoryMessage": "Thao tác này sẽ xóa vĩnh viễn tất cả các dòng nhật ký yêu cầu, các dòng chi tiết cũ và các tệp tạo tác cục bộ trong DATA_DIR/call_logs. Trang đang mở sẽ làm mới sau khi dọn dẹp.",
+ "cleanHistoryConfirm": "Xóa lịch sử",
+ "cleanHistoryCancel": "Hủy",
+ "cleanHistorySuccess": "Đã xóa {deleted} mục nhật ký, {deletedArtifacts} tệp tạo tác và {deletedDetailedLogs} dòng chi tiết cũ.",
+ "cleanHistoryEmpty": "Không tìm thấy lịch sử nhật ký yêu cầu nào.",
+ "cleanHistoryFailed": "Không thể xóa lịch sử nhật ký.",
"timeRange": "Khoảng thời gian",
"lastNHours": "{hours} gần đây",
"defaultRange": "mặc định",
"consoleViewer": {
"fetchFailed": "Không thể tải nhật ký",
"copyFailed": "Không thể sao chép mục nhật ký",
- "copyLogEntry": "Sao chép mục nhật ký"
+ "copyLogEntry": "Sao chép mục nhật ký",
+ "filterByLevel": "Lọc theo cấp độ nhật ký",
+ "searchPlaceholder": "Tìm kiếm nhật ký…",
+ "searchAria": "Tìm kiếm các mục nhật ký",
+ "disableAutoScroll": "Tắt tự động cuộn",
+ "enableAutoScroll": "Bật tự động cuộn",
+ "autoScroll": "Tự động cuộn",
+ "entryCount": "{count} mục",
+ "lastHour": "1 giờ gần đây",
+ "updatedAt": "Cập nhật lúc {time}",
+ "fileLoggingRequired": "Hãy đảm bảo ứng dụng đang ghi nhật ký vào tập tin (APP_LOG_TO_FILE=true)",
+ "consoleAria": "Nhật ký bảng điều khiển ứng dụng",
+ "applicationConsole": "Bảng điều khiển ứng dụng",
+ "emptyFileLoggingHint": "Hãy đặt APP_LOG_TO_FILE=true trong tập tin .env"
},
- "cleanHistoryButton": "Clean history",
- "cleanHistoryTitle": "Clean log history?",
- "cleanHistoryMessage": "This permanently clears all request log rows, legacy detail rows, and local artifact files under DATA_DIR/call_logs. The live page will refresh after cleanup.",
- "cleanHistoryConfirm": "Clean history",
- "cleanHistoryCancel": "Cancel",
- "cleanHistorySuccess": "Cleaned {deleted} log entries, {deletedArtifacts} artifacts, and {deletedDetailedLogs} legacy detail rows.",
- "cleanHistoryEmpty": "No request log history was found.",
- "cleanHistoryFailed": "Failed to clean log history.",
- "compressionLogTitle": "Compression Log",
- "compressionLogEmpty": "No compressed requests yet. Compression stats will appear here when requests are processed with compression enabled.",
+ "compressionLogTitle": "Nhật ký nén",
+ "compressionLogEmpty": "Chưa có yêu cầu nào được nén. Số liệu thống kê nén sẽ xuất hiện ở đây khi các yêu cầu được xử lý với tính năng nén được bật.",
"tokens": "tokens"
},
"onboarding": {
"welcome": "Chào mừng",
+ "tiers": "Các cấp",
"security": "Bảo mật",
"test": "Kiểm tra",
"ready": "Sẵn sàng!",
@@ -3947,6 +4671,8 @@
"apiKeyHelp": "Khóa API là mật khẩu cho các dịch vụ AI. Hãy lấy một khóa từ trang web của nhà cung cấp của bạn (ví dụ: platform.openai.com, console.anthropic.com).",
"tier": {
"subtitle": "OmniRoute sắp xếp các nhà cung cấp thành ba cấp để việc định tuyến ưu tiên tuyến đáng tin cậy nhất và có chi phí thấp nhất.",
+ "flowCaption": "Yêu cầu sẽ đi qua hạn ngạch gói đăng ký trước, sau đó đến các nhà cung cấp giá rẻ tính theo token, rồi mới đến các nhà cung cấp miễn phí — hoàn toàn tự động, không cần cấu hình.",
+ "afterSetup": "sau khi thiết lập.",
"tier1": {
"label": "Ứng dụng khách cao cấp",
"description": "Các CLI hàng đầu với luồng xác thực tích hợp sẵn và các mô hình suy luận."
@@ -3959,16 +4685,14 @@
"label": "Dự phòng & chuyên biệt",
"description": "Các endpoint được lưu trữ cục bộ hoặc chuyên biệt được sử dụng làm phương án dự phòng."
},
- "configure": "Cấu hình nhà cung cấp",
- "flowCaption": "Requests flow through your subscription quotas first, then pay-per-token cheap providers, then free-tier providers — automatic, zero-config.",
- "afterSetup": "after setup."
+ "configure": "Cấu hình nhà cung cấp"
},
"tierFlowDiagramAlt": "Sơ đồ dự phòng 3 cấp của OmniRoute",
- "apiKeyMgmt": "Quản lý khóa API",
- "tiers": "Tiers"
+ "apiKeyMgmt": "Quản lý khóa API"
},
"providers": {
"title": "Nhà cung cấp",
+ "Account Deactivated": "Tài khoản đã bị vô hiệu hóa",
"allProviders": "Tất cả nhà cung cấp",
"audioProviders": "Nhà cung cấp âm thanh",
"showFreeOnly": "Chỉ miễn phí",
@@ -3976,6 +4700,51 @@
"addFirstProvider": "Thêm nhà cung cấp đầu tiên của bạn",
"addFirstProviderDesc": "Kết nối một nhà cung cấp AI để bắt đầu định tuyến các yêu cầu thông qua OmniRoute. Bạn có thể sử dụng các nhà cung cấp miễn phí, khóa API hoặc tài khoản OAuth.",
"learnMore": "Tìm hiểu thêm",
+ "importFromFile": "Nhập từ tệp",
+ "importFromFileTitle": "Nhập nhà cung cấp từ tệp",
+ "importFromFileDescription": "Tải lên tệp CSV hoặc JSON liệt kê nhiều nhà cung cấp (mỗi hàng có thể là một loại nhà cung cấp khác nhau). Xem lại các hàng đã phân tích bên dưới và chọn những hàng bạn muốn nhập.",
+ "importFromFileChoose": "Chọn tệp",
+ "importFromFileSelectHint": "Đã chọn {count}/{total}",
+ "importFromFileColProvider": "Nhà cung cấp",
+ "importFromFileColName": "Tên",
+ "importFromFileColBaseUrl": "URL cơ sở",
+ "importFromFileErrorLine": "Hàng {line}: {reason}",
+ "importErrorMissingProvider": "thiếu nhà cung cấp",
+ "importErrorMissingName": "thiếu tên",
+ "importErrorMissingApiKey": "thiếu khóa API",
+ "importErrorInvalidPriority": "mức độ ưu tiên không hợp lệ (phải từ 1-100)",
+ "importErrorMalformedRow": "hàng không đúng định dạng",
+ "importErrorNotArray": "tệp phải chứa một mảng JSON",
+ "importFromFileImporting": "Đang nhập…",
+ "importFromFileImport": "Nhập {count} nhà cung cấp",
+ "importFromFileResult": "Đã nhập {success} nhà cung cấp ({failed} không thành công)",
+ "adaptaTutorial": {
+ "title": "How to connect Adapta Web",
+ "introPrefix": "Adapta authenticates through Clerk. The token",
+ "introSuffix": "is a long-lived JWT that lets OmniRoute refresh sessions automatically.",
+ "or": "or",
+ "step1Title": "Open Adapta chat",
+ "step1DescPrefix": "Open",
+ "step1DescSuffix": "and sign in with your Gold or Business account.",
+ "step2Title": "Open DevTools",
+ "step2DescPrefix": "Press",
+ "step2DescSuffix": "to open Developer Tools.",
+ "step3Title": "Go to Application → Cookies",
+ "step3DescPrefix": "In the",
+ "step3DescMiddle": "expand",
+ "step3DescSuffix": "and click",
+ "step4Title": "Copy the cookie value for",
+ "step4DescPrefix": "Find the cookie named",
+ "step4DescMiddle": "in the list. Click it and copy the content from the",
+ "step4DescSuffix": "column. It starts with",
+ "step5Title": "Paste it here and save",
+ "step5DescPrefix": "Click",
+ "step5DescMiddle": "paste the",
+ "step5DescSuffix": "value into the API Key field, then save. OmniRoute will refresh the session automatically.",
+ "tipLabel": "Tip:",
+ "tipPrefix": "The",
+ "tipSuffix": "cookie is long-lived, usually for months. You only need to renew it if you sign out or Adapta invalidates the session."
+ },
"editProvider": "Chỉnh sửa nhà cung cấp",
"deleteProvider": "Xóa nhà cung cấp",
"noProviders": "Chưa cấu hình nhà cung cấp nào",
@@ -4004,13 +4773,38 @@
"reorderByAvailability": "Sắp xếp lại",
"reorderByAvailabilityTitle": "Sắp xếp lại các kết nối theo tình trạng khả dụng",
"reorderByAvailabilityError": "Không thể sắp xếp lại các kết nối theo tình trạng khả dụng",
+ "distributeProxies": "Phân phối proxy",
+ "distributing": "Đang phân phối...",
+ "selectedCount": "{count, plural, one {# đã chọn} other {# đã chọn}}",
+ "accountsCount": "{count, plural, one {# tài khoản} other {# tài khoản}}",
"testAllOAuth": "Kiểm tra tất cả kết nối OAuth",
"testAllFree": "Kiểm tra tất cả kết nối miễn phí",
"testAllApiKey": "Kiểm tra tất cả kết nối API Key",
"testAllCompatible": "Kiểm tra tất cả kết nối tương thích",
+ "testAllModels": "Kiểm tra tất cả mô hình",
+ "testAllModelsConfirm": "Kiểm tra tất cả mô hình đang hiển thị? Các mô hình thất bại sẽ bị ẩn nếu tính năng tự động ẩn được bật.",
+ "testingAllModels": "Đang kiểm tra {done}/{total}...",
+ "testAllResults": "Có {ok} trong số {total} mô hình hoạt động",
+ "noModelsToTest": "Không có mô hình nào khớp với bộ lọc hiện tại",
+ "showAllModels": "Tất cả",
+ "showVisibleOnly": "Chỉ các mô hình đang hiển thị",
+ "freeFilterAll": "Tất cả",
+ "freeFilterFreeOnly": "Chỉ miễn phí",
+ "freeFilterPaidOnly": "Chỉ trả phí",
+ "sortFreeFirst": "Miễn phí trước",
+ "showHiddenOnly": "Chỉ các mô hình bị ẩn",
+ "filterByVisibility": "Lọc theo trạng thái hiển thị",
+ "autoHideFailed": "Tự động ẩn các mô hình bị lỗi",
+ "autoHideFailedHint": "Khi được bật, tính năng Kiểm tra tất cả sẽ ẩn các mô hình gặp lỗi không tạm thời khỏi các danh mục công khai như /v1/models. Các bài kiểm tra mô hình đơn lẻ không bao giờ tự động ẩn.",
"connected": "{count} đã kết nối",
"errorCount": "{count} Lỗi ({code})",
"errorCountNoCode": "{count} Lỗi",
+ "testAllCompleted": "Đã kiểm tra {total} mô hình: {ok} thành công, {failed} thất bại",
+ "modelAutoHidden": "Mô hình {model} đã tự động bị ẩn (kiểm tra thất bại)",
+ "filterAll": "Tất cả",
+ "filterVisible": "Chỉ hiện các mục đang hiển thị",
+ "filterHidden": "Chỉ hiện các mục đã ẩn",
+ "modelsHiddenCount": "{count} bị ẩn",
"warningCount": "{count} Cảnh báo",
"noConnections": "Không có kết nối",
"expiredBadge": "Đã hết hạn",
@@ -4096,6 +4890,8 @@
"prefixHint": "Bắt buộc. Tiền tố duy nhất cho tên mô hình.",
"nameHint": "Bắt buộc. Nhãn dễ nhận biết cho nút này.",
"baseUrlHint": "Bắt buộc. URL cơ sở API của nhà cung cấp.",
+ "iconUrlLabel": "URL biểu tượng",
+ "iconUrlHint": "Tùy chọn. URL hình ảnh được hiển thị làm biểu tượng của nhà cung cấp này.",
"anthropicPrefixPlaceholder": "ac-prod",
"openaiPrefixPlaceholder": "oc-prod",
"anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1",
@@ -4121,7 +4917,16 @@
"batchUpdateNone": "Không có kết nối nào được cập nhật (có thể chúng không còn tồn tại)",
"batchRetestLimit": "Kiểm tra lại tối đa {max} kết nối cùng lúc",
"noConnectionsToTest": "Không có kết nối phù hợp để kiểm tra",
+ "batchDeleteConfirmTitle": "Xóa kết nối",
+ "batchDeleteConfirmButton": "Xóa",
+ "filterActive": "Hoạt động",
+ "filterError": "Lỗi",
+ "filterBanned": "Bị cấm",
+ "filterCreditsExhausted": "Hết tín dụng",
+ "noFilteredConnections": "Không có kết nối nào khớp với bộ lọc hiện tại.",
"failedSetAlias": "Không thể đặt bí danh",
+ "setAliasSuccess": "Đã đặt bí danh {alias}",
+ "deleteAliasSuccess": "Đã xóa bí danh {alias}",
"failedSaveConnection": "Không thể lưu kết nối",
"failedSaveConnectionRetry": "Không thể lưu kết nối. Vui lòng thử lại.",
"failedRetestConnection": "Không thể kiểm tra lại kết nối",
@@ -4155,6 +4960,9 @@
"connectionCount": "{count} kết nối",
"fetchingModels": "Đang tìm nạp các mô hình khả dụng...",
"failedFetchModels": "Không thể tìm nạp các mô hình",
+ "noFreeModelsFound": "Không tìm thấy mô hình miễn phí nào cho nhà cung cấp này — không có gì được nhập.",
+ "fetchModelsSuccess": "Đã tìm thấy {count} mô hình mới",
+ "fetchModelsFailed": "Không thể tự động tìm nạp các mô hình (bạn có thể thử lại từ phần cài đặt)",
"noModelsFound": "Không tìm thấy mô hình nào",
"importFailed": "Nhập thất bại",
"noNewModelsAdded": "Không có mô hình mới nào được thêm vào.",
@@ -4163,14 +4971,25 @@
"importingModelsTitle": "Nhập mô hình",
"copyModel": "Sao chép mô hình",
"filterModels": "Lọc mô hình...",
+ "testAllRateLimited": "Đã đạt giới hạn tần suất yêu cầu, dừng sớm",
+ "hideFailedAuto": "Tự động ẩn các mô hình bị lỗi",
+ "selectAllModels": "Chọn tất cả",
+ "hideAllModels": "Ẩn tất cả",
"modelsActive": "Đang hoạt động",
"showModel": "Hiển thị mô hình",
"hideModel": "Ẩn mô hình",
"removeModel": "Xóa mô hình",
"rateLimitProtected": "Được bảo vệ",
"rateLimitUnprotected": "Không được bảo vệ",
+ "providerQuotaShort": "Hạn mức",
+ "hideConnectionFromProviderQuota": "Ẩn tài khoản này khỏi Hạn mức nhà cung cấp",
+ "showConnectionInProviderQuota": "Hiện tài khoản này trong Hạn mức nhà cung cấp",
+ "quotaVisibilityUpdateFailed": "Không thể cập nhật chế độ hiển thị trong Hạn mức nhà cung cấp",
"enableRateLimitProtection": "Nhấp để bật bảo vệ giới hạn tần suất yêu cầu",
"disableRateLimitProtection": "Nhấp để tắt bảo vệ giới hạn tần suất yêu cầu",
+ "testAllProgress": "Đã kiểm tra {done}/{total}",
+ "testAllFailedHidden": "Đã ẩn {count} mô hình bị lỗi",
+ "testAllDone": "Đã kiểm tra tất cả mô hình",
"productionKey": "Khóa môi trường production",
"enterNewApiKey": "Nhập khóa API mới",
"codexApplyModalTitle": "Áp dụng cho Local Codex",
@@ -4189,6 +5008,8 @@
"optional": "Tùy chọn",
"anthropicCompatibleName": "Tương thích Anthropic",
"openaiCompatibleName": "Tương thích OpenAI",
+ "compatibleDefaultModelLabel": "Mô hình mặc định",
+ "compatibleDefaultModelHint": "Nhập ID mô hình chính xác như endpoint tương thích yêu cầu. Mô hình này sẽ được lưu làm mặc định cho kết nối.",
"failedImportModels": "Không thể nhập các mô hình",
"noModelsReturnedFromEndpoint": "Không có mô hình nào được trả về từ endpoint /models.",
"importingModelsProgress": "Đang nhập {current} trên {total} mô hình...",
@@ -4246,10 +5067,20 @@
"errorTypeNetworkError": "Lỗi mạng",
"errorTypeTestUnsupported": "Không hỗ trợ kiểm tra",
"errorTypeUpstreamError": "Lỗi từ máy chủ phía trên",
+ "errorTypeCreditsExhausted": "Hết tín dụng",
+ "errorTypeBanned": "403 Bị cấm",
"proxySourceGlobal": "Toàn cầu",
"proxySourceProvider": "Nhà cung cấp",
"proxySourceKey": "Khóa",
"proxyConfiguredBySource": "Proxy ({source}): {host}",
+ "proxyOn": "Bật proxy",
+ "proxyOff": "Trực tiếp (bỏ qua proxy)",
+ "proxyEnabledTitle": "Proxy đã được kích hoạt cho kết nối này",
+ "proxyDisabledTitle": "Kết nối trực tiếp — bỏ qua mọi proxy (bao gồm cả proxy toàn cầu) cho kết nối này",
+ "perKeyProxyOn": "Theo từng khóa",
+ "perKeyProxyOff": "Kết nối",
+ "perKeyProxyEnabledTitle": "Đã bật tính năng gán proxy theo từng key cho nhà cung cấp này",
+ "perKeyProxyDisabledTitle": "Đã tắt tính năng gán proxy theo từng key cho nhà cung cấp này",
"autoPriority": "Tự động: {priority}",
"proxy": "Proxy",
"retestAuthentication": "Kiểm tra lại xác thực",
@@ -4280,6 +5111,44 @@
"compatProtocolOpenAI": "OpenAI Chat Completions",
"compatProtocolOpenAIResponses": "OpenAI Responses API",
"compatProtocolClaude": "Anthropic Messages",
+ "targetFormatLabel": "Định dạng đích",
+ "targetFormatHint": "Ghi đè định dạng truyền tải ở phía thượng nguồn. Sử dụng 'Anthropic Messages' để định tuyến các nhà cung cấp tương thích với OpenAI (ví dụ: opencode-go) qua endpoint /v1/messages.",
+ "targetFormatAuto": "Mặc định (tự động)",
+ "targetFormatGemini": "Gemini",
+ "targetFormatAntigravity": "Antigravity",
+ "contextWindowOverrideLabel": "Ghi đè cửa sổ ngữ cảnh",
+ "contextWindowOverridePlaceholder": "Ví dụ: 131072",
+ "contextWindowOverrideHint": "Đặt thủ công cửa sổ ngữ cảnh thực tế của mô hình (token) khi nhà cung cấp báo sai. Giá trị này được ưu tiên hơn giá trị tự động phát hiện hoặc danh mục và ngăn định tuyến combo loại bỏ mô hình.",
+ "contextWindowOverrideInvalid": "Giá trị ghi đè cửa sổ ngữ cảnh phải là số token nguyên dương",
+ "visionCapableLabel": "Hỗ trợ thị giác",
+ "visionCapableHint": "Đánh dấu thủ công mô hình là có khả năng thị giác khi metadata khám phá của nhà cung cấp không báo phương thức đầu vào hình ảnh (thường gặp ở backend tự lưu trữ hoặc cục bộ).",
+ "compatParamFiltersLabel": "Bộ lọc tham số",
+ "compatBlockedParamsHint": "Các tham số bị chặn (bị loại bỏ khỏi các yêu cầu)",
+ "compatAllowedParamsHint": "Các tham số được phép (được thêm lại sau khi bị chặn)",
+ "paramFiltersSectionTitle": "Bộ lọc tham số",
+ "paramFiltersSectionHint": "Loại bỏ hoặc thêm lại các tham số của yêu cầu trước khi gửi đến nhà cung cấp này. Tính năng này được dùng để tránh lỗi 400 từ các nhà cung cấp từ chối một số tham số nhất định (ví dụ: NVIDIA NIM từ chối thinking).",
+ "paramFiltersBlockedLabel": "Các tham số bị chặn",
+ "paramFiltersBlockedHint": "Các tham số này bị loại bỏ khỏi các yêu cầu gửi đi (danh sách chặn).",
+ "paramFiltersAllowedLabel": "Các tham số được phép",
+ "paramFiltersAllowedHint": "Các tham số này được thêm lại sau khi các tham số trong danh sách chặn bị loại bỏ (chỉ khi máy khách đã gửi chúng).",
+ "paramFiltersAutoLearnLabel": "Tự động học từ lỗi 400",
+ "paramFiltersAutoLearnHint": "Khi được bật, nếu máy chủ thượng nguồn trả về lỗi 400 với thông báo \"Unsupported parameter: X\", tham số đó sẽ tự động được thêm vào danh sách chặn và yêu cầu sẽ được thử lại.",
+ "paramFiltersSaving": "Đang lưu…",
+ "paramFiltersSaveChanges": "Lưu thay đổi",
+ "paramFiltersResetToDefault": "Đặt lại về mặc định",
+ "paramFiltersLoadError": "Không thể tải cấu hình bộ lọc tham số: {error}",
+ "paramFiltersSaveSuccess": "Đã lưu cấu hình bộ lọc tham số",
+ "paramFiltersSaveError": "Không thể lưu cấu hình bộ lọc tham số: {error}",
+ "paramFiltersResetSuccess": "Đã đặt lại cấu hình bộ lọc tham số về mặc định",
+ "paramFiltersResetError": "Không thể đặt lại cấu hình bộ lọc tham số: {error}",
+ "interceptionSectionTitle": "Chặn công cụ Web",
+ "interceptionSectionHint": "Định tuyến các lệnh gọi công cụ web_search / web_fetch gốc của nhà cung cấp này qua các điểm cuối tìm kiếm và tìm nạp riêng của OmniRoute thay vì để nhà cung cấp tự chạy chúng. Tắt theo mặc định — hành vi hiện tại không thay đổi.",
+ "interceptSearchLabel": "Chặn web_search",
+ "interceptSearchHint": "Ghi đè các lệnh gọi công cụ web_search gốc sang /v1/search của OmniRoute.",
+ "interceptFetchLabel": "Chặn web_fetch",
+ "interceptFetchHint": "Ghi đè các lệnh gọi công cụ web_fetch gốc sang /v1/web/fetch của OmniRoute.",
+ "interceptionLoadError": "Không thể tải cài đặt chặn: {error}",
+ "interceptionSaveError": "Không thể lưu cài đặt chặn: {error}",
"compatUpstreamHeadersLabel": "Các header upstream bổ sung",
"compatUpstreamHeadersHint": "Cài đặt có đặc quyền cao — có cùng mức độ tin cậy như khi chỉnh sửa thông tin xác thực API của nhà cung cấp; chỉ quản trị viên đáng tin cậy mới nên sử dụng. Các header này được hợp nhất sau khi OmniRoute thêm thông tin xác thực từ khóa API của nhà cung cấp. Nếu một header tùy chỉnh có cùng tên với header hiện có (ví dụ: Authorization), giá trị của bạn sẽ thay thế hoàn toàn header được tạo tự động (bao gồm cả token Bearer) — máy chủ thượng nguồn chỉ nhận được nội dung bạn đã nhập, không phải khóa trong phần cài đặt. Cấu hình sai có thể gây ra lỗi 401 hoặc làm hỏng quá trình xác thực với máy chủ thượng nguồn. Mỗi hàng tương ứng với một header (ví dụ: header Authentication bổ sung cho một số cổng). Di chuột hoặc đặt tiêu điểm vào giá trị để xem trước. Tự động lưu khi mất tiêu điểm, nhấp ra ngoài hoặc đóng bảng điều khiển này.",
"compatUpstreamHeaderName": "Tên header",
@@ -4289,6 +5158,8 @@
"compatBadgeUpstreamHeaders": "Header",
"perModelQuotaLabel": "Hạn ngạch cho mỗi mô hình",
"perModelQuotaDescription": "Khi được bật, lỗi 429/404 chỉ khóa mô hình cụ thể đó chứ không khóa toàn bộ kết nối. Sử dụng cho các nhà cung cấp có giới hạn tốc độ cho từng mô hình (ví dụ: ModelScope).",
+ "importFreeModelsOnlyLabel": "Chỉ nhập các mô hình miễn phí",
+ "importFreeModelsOnlyHint": "Khi được bật, chỉ các mô hình miễn phí của nhà cung cấp này được nhập. Các mô hình trả phí sẽ bị bỏ qua.",
"perModelQuotaToggle": "Bật/tắt hạn ngạch cho mỗi mô hình",
"modelId": "ID mô hình",
"customModelPlaceholder": "ví dụ: gpt-4.5-turbo",
@@ -4324,7 +5195,6 @@
"email": "Email",
"healthCheckMinutes": "Kiểm tra sức khỏe (phút)",
"healthCheckHint": "Khoảng thời gian chủ động làm mới token. 0 = vô hiệu hóa.",
- "selectAllModels": "Chọn tất cả",
"deselectAllModels": "Bỏ chọn tất cả",
"modelsActiveCount": "{active}/{total} đang hoạt động",
"noModelsMatch": "Không có mô hình nào khớp với \"{filter}\"",
@@ -4336,6 +5206,9 @@
"editCompatibleTitle": "Chỉnh sửa cấu hình tương thích với {type}",
"compatibleBaseUrlHint": "URL gốc của API tương thích với {type} của bạn. Sử dụng Cài đặt nâng cao cho các đường dẫn endpoint tùy chỉnh.",
"apiKeyForCheck": "API key (để kiểm tra)",
+ "testModelIdLabel": "ID mô hình (tùy chọn)",
+ "testModelIdPlaceholder": "Ví dụ: my-model-id",
+ "testModelIdHint": "Nếu nhà cung cấp thiếu endpoint /models, hãy nhập ID mô hình để xác thực qua /chat/completions thay thế.",
"compatibleProdPlaceholder": "Cấu hình tương thích với {type} (môi trường sản xuất)",
"tokenRefreshed": "Đã làm mới token thành công",
"tokenRefreshFailed": "Làm mới token thất bại",
@@ -4419,6 +5292,19 @@
"codexAuthApplyFailed": "Không thể áp dụng Codex auth.json cục bộ",
"codexAuthExported": "Đã xuất Codex auth.json",
"codexAuthExportFailed": "Không thể xuất Codex auth.json",
+ "codexCliGuideButton": "Hướng dẫn Codex CLI",
+ "codexCliGuideTitle": "Hướng dẫn Codex CLI",
+ "codexCliGuideLoading": "Đang tải hướng dẫn...",
+ "codexCliGuideLoadFailed": "Không thể tải hướng dẫn.",
+ "codexExternalLinkButton": "Liên kết Codex bên ngoài",
+ "codexExternalLinkModalTitle": "Liên kết Codex bên ngoài",
+ "codexExternalLinkModalDescription": "Chia sẻ liên kết dùng một lần này với người sẽ xác thực tài khoản Codex. Họ sẽ mở liên kết trong trình duyệt của họ, hoàn tất đăng nhập OpenAI, và kết nối sẽ được đăng ký tại đây. Liên kết sẽ hết hạn sau 15 phút.",
+ "codexExternalLinkGenerating": "Đang tạo liên kết...",
+ "codexExternalLinkWaiting": "Đang chờ xác thực từ trình duyệt. Cửa sổ này sẽ tự động làm mới.",
+ "codexExternalLinkCreateFailed": "Không thể tạo liên kết.",
+ "codexExternalLinkNetworkError": "Không thể kết nối với máy chủ.",
+ "codexExternalLinkConnected": "Tài khoản Codex đã được kết nối thông qua liên kết bên ngoài.",
+ "codexExternalLinkExpired": "Liên kết đã hết hạn trước khi hoàn tất.",
"importCodexAuth": "Nhập thông tin xác thực",
"codexImportModalTitle": "Nhập thông tin xác thực Codex",
"codexImportTabSingle": "Đơn lẻ",
@@ -4459,6 +5345,8 @@
"modelsPathLabel": "Đường dẫn endpoint mô hình",
"modelsPathPlaceholder": "/models",
"modelsPathHint": "Đường dẫn mô hình tùy chỉnh để xác thực (ví dụ: /v4/models)",
+ "clientIdentityLabel": "Danh tính máy khách",
+ "clientIdentityHint": "Tùy chọn. Thêm các header dấu vân tay máy khách (ví dụ: User-Agent) khớp với một CLI đã biết cho các cổng kết nối tương thích yêu cầu chúng.",
"statusDeactivated": "Đã hủy kích hoạt (Thủ công)",
"statusBanned": "Bị cấm / Vi phạm môi trường hộp cát",
"statusCreditsExhausted": "Không đủ số dư / Hết hạn ngạch",
@@ -4494,8 +5382,11 @@
"blackboxWebCookiePlaceholder": "Văn bản giữ chỗ cho cookie web Blackbox",
"t3ChatWebCookieHint": "Mở t3.chat → DevTools → Application → Local Storage → https://t3.chat, sao chép 'convex-session-id'. Sau đó mở DevTools → Network, sao chép toàn bộ tiêu đề Cookie từ bất kỳ yêu cầu chat nào. Dán cả hai giá trị vào các trường bên dưới.",
"t3ChatWebCookiePlaceholder": "convex-session-id=abc123...",
+ "grokWebCookieHint": "Gợi ý Grok Web Cookie",
"blockClaudeExtraUsageDescription": "Khi được bật, OmniRoute sẽ đánh dấu kết nối Claude này là không khả dụng ngay khi API mức sử dụng báo cáo mức sử dụng bổ sung đang xếp hàng, để chuyển dự phòng sang kết nối khác thay vì tiếp tục tính phí bổ sung theo mức sử dụng.",
"blockClaudeExtraUsageLabel": "Chặn mức sử dụng Claude bổ sung",
+ "disableCoolingDescription": "Bỏ qua thời gian chờ tạm thời để kết nối này vẫn đủ điều kiện hoạt động ngay cả sau các lỗi có thể khôi phục (các trạng thái cuối cùng như bị cấm hoặc hết hạn vẫn áp dụng).",
+ "disableCoolingLabel": "Vô hiệu hóa thời gian chờ cho kết nối này",
"bulkPasteAdded": "{count, plural, one {Đã thêm 1 khóa} other {Đã thêm # khóa}}",
"bulkPasteDuplicatesIgnored": "{count, plural, one {Đã bỏ qua 1 khóa trùng lặp} other {Đã bỏ qua # khóa trùng lặp}}",
"bulkPasteHint": "Dán một khóa API trên mỗi dòng. Các dòng trống sẽ bị bỏ qua và các khóa trùng lặp sẽ bị bỏ qua.",
@@ -4569,7 +5460,6 @@
"tierFast": "Nhanh",
"antigravityProjectIdLabel": "Google Cloud Project ID",
"antigravityProjectIdPlaceholder": "my-gcp-project-id",
- "grokWebCookieHint": "Gợi ý Grok Web Cookie",
"grokWebCookiePlaceholder": "Gợi ý nhập Grok Web Cookie",
"herokuBaseUrlHint": "Gợi ý Heroku Base URL",
"hideEmail": "Ẩn email",
@@ -4582,6 +5472,17 @@
"localProviderBaseUrlHint": "Gợi ý URL cơ sở của nhà cung cấp cục bộ",
"localProviders": "Nhà cung cấp cục bộ",
"maxConcurrentWholeNumberError": "Lỗi số nguyên cho số lượng đồng thời tối đa",
+ "m365TierLabel": "Gói Copilot",
+ "m365TierHint": "Chọn giao diện Microsoft 365 Copilot mà kết nối này sử dụng. Individual là BizChat mặc định dành cho người dùng cá nhân; Education và Enterprise (work) sử dụng các giao diện dành cho đối tượng thuê của họ.",
+ "m365TierIndividualOption": "Cá nhân (mặc định)",
+ "m365TierEduOption": "Giáo dục",
+ "m365TierEnterpriseOption": "Doanh nghiệp / Công việc",
+ "tierOverrideLabel": "Ghi đè tầng định tuyến",
+ "tierOverrideAuto": "Tự động (định tuyến quyết định)",
+ "tierOverrideFree": "Miễn phí",
+ "tierOverrideCheap": "Giá rẻ",
+ "tierOverridePremium": "Cao cấp",
+ "tierOverrideHelpText": "Ghim nhà cung cấp này vào một tầng định tuyến thay vì để OmniRoute suy ra từ giá mô hình.",
"museSparkWebCookieHint": "Gợi ý cookie web Muse Spark",
"museSparkWebCookiePlaceholder": "Trình giữ chỗ cookie web Muse Spark",
"oauth": "OAuth",
@@ -4595,6 +5496,17 @@
"personalAccessTokenLabel": "Mã truy cập cá nhân",
"qoderPatHint": "Dán mã truy cập cá nhân (PAT) Qoder của bạn. Tạo một mã trong Qoder → Cài đặt → Mã truy cập cá nhân.",
"qoderPatPlaceholder": "Mã truy cập cá nhân Qoder của bạn",
+ "rateLimitOverridesSection": "Ghi đè giới hạn tốc độ",
+ "rateLimitOverridesMaxConcurrentHint": "Ghi đè số yêu cầu đồng thời tối đa cho kết nối này. Ghi đè giới hạn ở cấp tài khoản.",
+ "rateLimitOverridesMaxConcurrentLabel": "Đồng thời tối đa (Giới hạn tốc độ)",
+ "rateLimitOverridesMinTimeHint": "Thời gian tối thiểu (ms) giữa các yêu cầu. Ghi đè độ trễ bộ giới hạn tốc độ mặc định.",
+ "rateLimitOverridesMinTimeLabel": "Khoảng thời gian tối thiểu (ms)",
+ "rateLimitOverridesRpmHint": "Số yêu cầu tối đa mỗi phút cho kết nối này. Ghi đè giá trị mặc định của nhà cung cấp.",
+ "rateLimitOverridesRpmLabel": "RPM (Số yêu cầu/phút)",
+ "rateLimitOverridesTpdHint": "Số token tối đa mỗi ngày cho kết nối này. Ghi đè giá trị mặc định của nhà cung cấp.",
+ "rateLimitOverridesTpdLabel": "TPD (Số token/ngày)",
+ "rateLimitOverridesTpmHint": "Số token tối đa mỗi phút cho kết nối này. Ghi đè giá trị mặc định của nhà cung cấp.",
+ "rateLimitOverridesTpmLabel": "TPM (Số token/phút)",
"refreshOauthTokenTitle": "Tiêu đề làm mới token OAuth",
"regionHint": "Gợi ý vùng",
"regionLabel": "Nhãn vùng",
@@ -4609,6 +5521,7 @@
"searchProvider": "Nhà cung cấp tìm kiếm",
"searchProviderDesc": "Mô tả nhà cung cấp tìm kiếm",
"searchProviders": "Các nhà cung cấp tìm kiếm",
+ "searchByModel": "Tìm kiếm theo mô hình…",
"searchProvidersHeading": "Tiêu đề tìm kiếm nhà cung cấp",
"searxngBaseUrlHint": "Gợi ý URL cơ sở Searxng",
"searxngInfo": "Thông tin Searxng",
@@ -4641,13 +5554,45 @@
"webCookieProviders": "Nhà cung cấp Web Cookie",
"weeklyShort": "Hàng tuần",
"xiaomiMimoBaseUrlHint": "Gợi ý URL cơ sở Xiaomi Mimo",
+ "globalCodexServiceMode": "Chế độ dịch vụ Codex toàn cục",
+ "connect": "Kết nối",
+ "manualApiKey": "Khóa API thủ công",
+ "addPat": "Thêm PAT",
+ "experimentalOauth": "OAuth thử nghiệm",
+ "importAuth": "Nhập thông tin xác thực",
+ "importGrokAuth": "Nhập thông tin xác thực Grok Build",
+ "zedImportTitle": "Nhập từ chùm khóa Zed",
+ "zedImportDescription": "Phát hiện thông tin xác thực nhà cung cấp AI (OpenAI, Anthropic, Google, Mistral, xAI) do Zed IDE lưu trong chùm khóa hệ điều hành và nhập thành các kết nối. Máy này phải cài đặt Zed IDE.",
"zedImportButton": "Nhập từ Zed",
"zedImportFailed": "Không thể nhập từ Zed",
"zedImportHint": "Gợi ý nhập từ Zed",
"zedImportNetworkError": "Lỗi mạng khi nhập từ Zed",
"zedImportNone": "Không có dữ liệu nhập từ Zed",
- "zedImportSuccess": "Zed Import Success",
+ "zedImportSuccess": "Đã nhập {credentials} thông tin xác thực từ Zed cho {providers} nhà cung cấp",
"zedImporting": "Đang nhập…",
+ "zedNoCredentials": "Không tìm thấy thông tin xác thực Zed trong chùm khóa",
+ "zedUnsupportedCredentials": "Tìm thấy {count} thông tin xác thực trong chùm khóa nhưng không có mục nào khớp với nhà cung cấp được hỗ trợ",
+ "zedManualTitle": "Nhập token thủ công",
+ "zedManualDescription": "Dùng cách này khi OmniRoute chạy trong Docker hoặc không thể truy cập chùm khóa. Dán khóa API mà Zed lưu tại ~/.config/zed/settings.json , hoặc sao chép từ bảng cài đặt Zed AI.",
+ "zedPasteApiKey": "Dán khóa API…",
+ "zedSaving": "Đang lưu…",
+ "zedImportAction": "Nhập",
+ "zedManualImportFailed": "Không thể nhập thủ công",
+ "zedManualImportSuccess": "Đã nhập token {provider} từ Zed",
+ "grokImportTitle": "Nhập thông tin xác thực Grok Build",
+ "grokImportDescription": "Nhập tệp Grok Build ~/.grok/auth.json . Bạn có thể lấy tệp bằng cách chạy grok login trong terminal.",
+ "grokUploadFile": "Tải tệp lên",
+ "grokPasteJson": "Dán JSON",
+ "grokInvalidAuth": "Đây không phải tệp auth.json Grok Build hợp lệ. Cần một đối tượng có khóa chứa JWT.",
+ "grokParseFailed": "Không thể phân tích JSON",
+ "grokImportFailed": "Không thể nhập thông tin xác thực Grok Build",
+ "grokImportSuccess": "Đã nhập kết nối Grok Build thành công",
+ "grokValidToken": "Đã phát hiện token Grok Build hợp lệ",
+ "grokRefreshIncluded": "Có refresh token — đã bật tự động gia hạn token",
+ "grokRefreshMissing": "Không tìm thấy refresh token — cần nhập lại khi token hết hạn",
+ "grokConnectionName": "Tên kết nối (không bắt buộc)",
+ "grokSaving": "Đang lưu…",
+ "grokSaveConnection": "Lưu kết nối",
"freeTierProviders": "Nhà cung cấp có gói miễn phí",
"freeTierLabel": "Có gói miễn phí",
"freeTierProvidersDesc": "Các nhà cung cấp có gói miễn phí — một số yêu cầu đăng ký khóa API, số khác hoàn toàn không cần thông tin xác thực.",
@@ -4664,6 +5609,16 @@
"providerDetailPathAutoDetectedAllOs": "Đường dẫn được tự động phát hiện cho từng hệ điều hành (Linux/Mac/Windows).",
"providerDetailMyClaudeAccountPlaceholder": "Tài khoản Claude của tôi",
"providerDetailPathAutoDetected": "Đường dẫn được tự động phát hiện cho từng hệ điều hành (Linux/Mac).",
+ "compatBlockedParamsPlaceholder": "thinking, … (phân cách bằng dấu phẩy)",
+ "compatAllowedParamsPlaceholder": "reasoning, … (phân cách bằng dấu phẩy)",
+ "compatSaving": "đang lưu…",
+ "paramFiltersBlockedPlaceholder": "thinking, reasoning_budget, … (phân cách bằng dấu phẩy)",
+ "paramFiltersAllowedPlaceholder": "reasoning, … (phân cách bằng dấu phẩy)",
+ "customGatewayNamePlaceholder": "Gateway của tôi",
+ "inherit": "Kế thừa",
+ "claudeImportEntryName": "mục {number}",
+ "claudeImportParseError": "lỗi phân tích cú pháp",
+ "claudeImportNoValidEntries": "Không có mục hợp lệ để nhập",
"webFetch": "Thu thập dữ liệu web",
"webFetchTooltip": "Các nhà cung cấp trích xuất nội dung từ URL web (HTML → Markdown, cào dữ liệu, chụp ảnh màn hình)",
"webFetchProvidersHeading": "Nhà cung cấp thu thập dữ liệu web",
@@ -4749,10 +5704,204 @@
"onboardingConnectProvider": "Kết nối {provider}",
"onboardingOAuthFlowDescription": "OmniRoute sẽ mở luồng OAuth hiện có cho nhà cung cấp này. Sau khi đăng nhập, trình hướng dẫn sẽ tải lại kết nối đã lưu và chạy kiểm tra kết nối tương tự như trang nhà cung cấp.",
"onboardingStartOAuthFlow": "Bắt đầu luồng OAuth",
+ "onboardingProviderDescriptions": {
+ "360ai": "Lấy khóa API tại ai.360.cn",
+ "agentrouter": "Nhận 200 USD tín dụng miễn phí tại https://agentrouter.org/register — không cần thẻ tín dụng.",
+ "agnes": "Lấy khóa API tại agnes-ai.com",
+ "aimlapi": "Gói miễn phí đã tạm dừng (2026) — AI/ML API hiện chỉ tính phí theo mức sử dụng (nạp tối thiểu 20 USD); không còn tín dụng miễn phí định kỳ.",
+ "ai21": "10 USD tín dụng dùng thử khi đăng ký (có hiệu lực 3 tháng), không cần thẻ tín dụng",
+ "alibaba": "Kết nối Alibaba bằng khóa API.",
+ "alibaba-cn": "Kết nối Alibaba (China) bằng khóa API.",
+ "bailian-coding-plan": "Kết nối Alibaba Coding Plan bằng khóa API.",
+ "bedrock": "Tích hợp Bedrock gốc: khám phá mô hình dùng các mô hình nền tảng và hồ sơ suy luận Bedrock, còn trò chuyện dùng API Bedrock Runtime Converse/ConverseStream theo khu vực.",
+ "anthropic": "Kết nối Anthropic bằng khóa API.",
+ "api-airforce": "Lấy khóa API của bạn từ https://panel.api.airforce — OpenAI-compatible endpoint at https://api.airforce/v1",
+ "arcee-ai": "Lấy khóa API tại arcee.ai",
+ "azure-ai": "Foundry dùng giao diện OpenAI v1 với tên deployment làm mô hình. OmniRoute chuẩn hóa URL tài nguyên gốc thành các endpoint trò chuyện v1 và /models.",
+ "azure-openai": "Dùng khóa API Azure OpenAI của bạn. URL cơ sở phải là endpoint tài nguyên, ví dụ https://my-resource.openai.azure.com.",
+ "bai": "Khóa API Bearer cho cổng LLM tương thích OpenAI của b.ai (khác TheB.AI). Tạo khóa tại https://docs.b.ai, rồi dùng https://api.b.ai/v1 làm URL cơ sở tương thích OpenAI.",
+ "baichuan": "Lấy khóa API tại platform.baichuan-ai.com",
+ "baidu": "Lấy khóa API tại console.bce.baidu.com",
+ "qianfan": "Dùng khóa API Qianfan từ Baidu AI Cloud. Endpoint mặc định tương thích OpenAI v2.",
+ "baseten": "30 USD tín dụng dùng thử miễn phí cho suy luận GPU",
+ "bazaarlink": "Tạo khóa API miễn phí tại https://bazaarlink.ai — mô hình 'auto:free' định tuyến tới suy luận không tính phí. Mọi mô hình dùng định dạng provider/model-name, ví dụ xiaomi/mimo-v2.5-pro.",
+ "black-forest-labs": "Kết nối Black Forest Labs bằng khóa API.",
+ "blackbox": "Gói miễn phí: trò chuyện cơ bản không giới hạn cùng Minimax-M2.5, không cần thẻ tín dụng",
+ "bluesminds": "Lấy khóa API tại https://www.bluesminds.com — endpoint tương thích OpenAI tại https://api.bluesminds.com/v1 kèm tín dụng miễn phí hằng ngày. Mô hình VIP (Claude Opus 4.5, Gemini 2.5 Pro) tiêu thụ tín dụng pi.",
+ "byteplus": "Kết nối BytePlus ModelArk bằng khóa API.",
+ "bytez": "1 USD tín dụng miễn phí, làm mới mỗi 4 tuần",
+ "cerebras": "Dùng thử miễn phí: 1 triệu token/ngày, 30 nghìn TPM, 5 RPM — không cần thẻ tín dụng.",
+ "charm-hyper": "Tạo khóa API tại https://hyper.charm.land, rồi dán vào đây dưới dạng token Bearer.",
+ "chutes": "Khóa API Bearer cho cổng Chutes tương thích OpenAI.",
+ "clarifai": "Clarifai cung cấp trò chuyện, Responses và /models tương thích OpenAI tại /v2/ext/openai/v1. Mô hình công khai/cộng đồng thường yêu cầu PAT; khóa theo phạm vi ứng dụng chỉ dùng được cho tài nguyên trong ứng dụng đó.",
+ "cloudflare-ai": "Yêu cầu cả API Token VÀ Account ID (xem tại dash.cloudflare.com)",
+ "codestral": "Kết nối Codestral bằng khóa API.",
+ "cohere": "Dùng thử miễn phí: 1.000 lệnh gọi API/tháng để kiểm thử, không cần thẻ tín dụng",
+ "command-code": "Tạo hoặc sao chép khóa API từ Command Code, rồi dán vào đây dưới dạng token Bearer.",
+ "coze": "Lấy khóa API tại coze.com/open/api",
+ "crof": "Kết nối CrofAI bằng khóa API.",
+ "databricks": "Kết nối Databricks bằng khóa API.",
+ "datarobot": "Cổng mặc định lập danh mục mô hình đang hoạt động từ /genai/llmgw/catalog/. URL deployment cũng được hỗ trợ cho yêu cầu trò chuyện trực tiếp tương thích OpenAI.",
+ "deepinfra": "Tín dụng đăng ký miễn phí để kiểm thử API và khám phá mô hình",
+ "deepseek": "5 triệu token miễn phí khi đăng ký — không cần thẻ tín dụng",
+ "dgrid": "Tạo khóa API DGrid tại https://dgrid.ai, rồi dùng https://api.dgrid.ai/v1 làm URL cơ sở tương thích OpenAI.",
+ "dify": "Lấy khóa API từ instance Dify của bạn.",
+ "digitalocean": "Kết nối DigitalOcean bằng khóa API.",
+ "dit": "dit.ai (Distributed Intelligence Trade) là router/cổng tương thích OpenAI với giá động theo từng yêu cầu, cung cấp /v1/chat/completions tại https://api.dit.ai/v1. OmniRoute dùng giao thức OpenAI; số liệu chi tiêu/tiết kiệm nằm trong bảng điều khiển dit.ai.",
+ "doubao": "Lấy khóa API tại console.volcengine.com",
+ "empower": "Empower cung cấp trò chuyện tương thích OpenAI tại https://app.empower.dev/api/v1, hỗ trợ gọi công cụ trên empower-functions.",
+ "factory": "Lấy khóa API Factory tại https://app.factory.ai/settings/api-keys, rồi dán dưới dạng token Bearer. Endpoint tương thích OpenAI: https://api.factory.ai/v1.",
+ "fal-ai": "Kết nối Fal.ai bằng khóa API.",
+ "featherless-ai": "Có gói miễn phí — không cần thẻ tín dụng",
+ "fenayai": "Khóa API Bearer cho cổng FenayAI tương thích OpenAI.",
+ "firecrawl": "Kết nối Firecrawl bằng khóa API.",
+ "fireworks": "1 USD tín dụng khởi đầu miễn phí khi đăng ký để kiểm thử API",
+ "freeaiapikey": "Proxy API giảm giá cho hơn 40 mô hình, gồm GPT-5, Claude Opus 4.6, Claude Sonnet 4.6 và Qwen 3.5. Lấy khóa API tại https://freeaiapikey.com/dashboard. URL cơ sở: https://freeaiapikey.com/v1.",
+ "freemodel-dev": "Nhận 300 USD tín dụng API miễn phí tại https://freemodel.dev — không cần thông tin thanh toán. Endpoint tương thích OpenAI. Có GPT-5.4 và GPT-5.5.",
+ "friendliai": "Gói miễn phí cho suy luận serverless — không cần thẻ tín dụng",
+ "gemini": "Miễn phí vĩnh viễn: 1.500 yêu cầu/ngày cho Gemini 2.5 Flash — không cần thẻ tín dụng, lấy khóa tại aistudio.google.com",
+ "gigachat": "Kết nối GigaChat (Sber) bằng khóa API.",
+ "github-models": "Tạo GitHub PAT với phạm vi 'models: read' tại github.com/settings/tokens",
+ "gitlab": "Personal access token GitLab cho API Code Suggestions công khai. Cấu hình URL cơ sở tự lưu trữ khi không dùng gitlab.com.",
+ "gitlawb-gmi": "Lấy khóa API của bạn từ Gitlawb Opengateway dashboard.",
+ "gitlawb": "Lấy khóa API của bạn từ Gitlawb Opengateway dashboard.",
+ "glm": "Kết nối GLM Coding bằng khóa API.",
+ "glm-cn": "Kết nối GLM Coding (China) bằng khóa API.",
+ "glmt": "Hồ sơ GLM đặt sẵn với ngân sách token cao hơn, bật thinking và thời gian chờ dài hơn.",
+ "getgoapi": "Kết nối GoAPI bằng khóa API.",
+ "groq": "Gói miễn phí: 30 RPM / 14,4 nghìn RPD — không cần thẻ tín dụng",
+ "hackclub": "Đăng nhập bằng tài khoản Hack Club tại ai.hackclub.com.",
+ "haiper": "Lấy khóa API tại haiper.ai/haiper-api",
+ "heroku": "Kết nối Heroku AI bằng khóa API.",
+ "hcnsec": "Lấy khóa API tại api.hcnsec.cn",
+ "huggingface": "API suy luận miễn phí cho hàng nghìn mô hình (Whisper, VITS, SDXL…)",
+ "hyperbolic": "1–5 USD tín dụng dùng thử khi đăng ký suy luận serverless",
+ "watsonx": "Cổng mô hình watsonx cung cấp /chat/completions và /models tương thích OpenAI trong /ml/gateway/v1.",
+ "ideogram": "Lấy khóa API tại ideogram.ai/docs/api",
+ "iflytek": "Lấy khóa API tại console.xfyun.cn",
+ "inference-net": "25 USD tín dụng miễn phí khi đăng ký và có các khoản tài trợ nghiên cứu",
+ "jina-ai": "Khóa API Bearer cho API xếp hạng lại của Jina AI.",
+ "jina-reader": "Kết nối Jina Reader bằng khóa API.",
+ "kenari": "Kenari cung cấp endpoint chat completions tương thích OpenAI tại https://kenari.id/v1/chat/completions cùng danh mục /v1/models trực tiếp gồm Claude, GPT, DeepSeek, GLM, Kimi và nhiều mô hình khác. OmniRoute dùng giao thức OpenAI và liệt kê mô hình qua passthrough.",
+ "kie": "Kết nối KIE.AI bằng khóa API.",
+ "kilo-gateway": "Kết nối Kilo Gateway bằng khóa API.",
+ "kimi": "Kết nối Kimi bằng khóa API.",
+ "kimi-coding-apikey": "Kết nối Kimi Coding (API Key) bằng khóa API.",
+ "lambda-ai": "Kết nối Lambda AI bằng khóa API.",
+ "laozhang": "Kết nối LaoZhang AI bằng khóa API.",
+ "leonardo": "Lấy khóa API tại leonardo.ai/developer",
+ "liquid": "Lấy khóa API tại liquid.ai",
+ "llamagate": "Kết nối LlamaGate bằng khóa API.",
+ "llm7": "Hoạt động không cần khóa API (dùng 'unused' làm khóa). Lấy token miễn phí tại token.llm7.io để có giới hạn cao hơn.",
+ "longcat": "Miễn phí: cấp một lần 10 triệu token sau khi đăng ký và xác minh KYC (LongCat-2.0). Chỉ cấp một lần — không phải hạn mức định kỳ hằng ngày/hằng tháng.",
+ "maritalk": "Kết nối Maritalk bằng khóa API.",
+ "meta-llama": "Kết nối Meta Llama API bằng khóa API.",
+ "minimax-cn": "Kết nối Minimax (China) bằng khóa API.",
+ "minimax": "Kết nối Minimax Coding bằng khóa API.",
+ "mistral": "Gói Experiment miễn phí: truy cập mọi mô hình với giới hạn tốc độ, không cần thẻ tín dụng",
+ "modal": "Modal thường phục vụ ứng dụng tương thích OpenAI do người dùng tự lưu trữ trên /v1. OmniRoute sẽ thăm dò /v1/models và định tuyến trò chuyện tới /v1/chat/completions.",
+ "modelscope": "Gói miễn phí qua ModelScope API-Inference — yêu cầu tài khoản Alibaba.",
+ "monsterapi": "Lấy khóa API tại monsterapi.ai",
+ "moonshot": "Kết nối Moonshot AI bằng khóa API.",
+ "morph": "Gói miễn phí: 250 nghìn tín dụng/tháng, 0 USD",
+ "nanogpt": "Kết nối NanoGPT bằng khóa API.",
+ "nebius": "Khoảng 1 USD tín dụng dùng thử khi đăng ký để kiểm thử API",
+ "nlpcloud": "NLP Cloud dùng API chatbot độc quyền thay cho OpenAI chat/completions. OmniRoute chuyển đổi message OpenAI thành input/context/history và cung cấp danh mục cục bộ các mô hình chatbot được hỗ trợ.",
+ "nomic": "Lấy khóa API tại atlas.nomic.ai",
+ "nous-research": "Nous cung cấp giao diện /v1 tương thích OpenAI với danh mục /models từ xa lớn. Endpoint /chat/completions yêu cầu khóa API hợp lệ để suy luận bằng chương trình.",
+ "novita": "0,50 USD tín dụng dùng thử khi đăng ký (có hiệu lực khoảng 1 năm)",
+ "nscale": "5 USD tín dụng miễn phí khi đăng ký để kiểm thử suy luận",
+ "nube": "Kết nối Nube.sh bằng khóa API.",
+ "nvidia": "Quyền truy cập dev miễn phí: khoảng 40 RPM, hơn 70 mô hình (Kimi K2.5, GLM 4.7, DeepSeek V3.2...)",
+ "oci": "OCI cung cấp các endpoint trò chuyện và Responses tương thích OpenAI. Project ID là tùy chọn trong OmniRoute nhưng có thể bắt buộc cho Responses và quy trình agent.",
+ "ollama-cloud": "Kết nối Ollama Cloud bằng khóa API.",
+ "openadapter": "OpenAdapter cung cấp endpoint chat completions tương thích OpenAI tại https://api.openadapter.in/v1/chat/completions, tổng hợp hơn 70 mô hình mã nguồn mở (DeepSeek, Qwen, Kimi, MiniMax, GLM, Llama, Mistral, …). OmniRoute dùng giao thức OpenAI.",
+ "openai": "Kết nối OpenAI bằng khóa API.",
+ "opencode-go": "Kết nối OpenCode Go bằng khóa API.",
+ "opencode-zen": "Kết nối OpenCode Zen bằng khóa API.",
+ "openrouter": "Mô hình miễn phí 0 USD/token với hậu tố :free — 20 RPM / 200 RPD",
+ "openvecta": "Tín dụng miễn phí khi đăng ký để suy luận tương thích OpenAI trên LLM, embedding và mô hình reasoning",
+ "orcarouter": "Tạo khóa API (bắt đầu bằng sk-orca-) tại https://www.orcarouter.ai, rồi dán dưới dạng token Bearer. Endpoint tương thích OpenAI: https://api.orcarouter.ai/v1.",
+ "ovhcloud": "Kết nối OVHcloud AI bằng khóa API.",
+ "perplexity": "Kết nối Perplexity bằng khóa API.",
+ "piapi": "Kết nối PiAPI bằng khóa API.",
+ "pioneer": "75 USD tín dụng sử dụng miễn phí — không cần thẻ tín dụng",
+ "poe": "Poe cung cấp trò chuyện và Responses tương thích OpenAI tại https://api.poe.com/v1, kèm kiểm tra số dư có xác thực tại /usage/current_balance.",
+ "pollinations": "Gói miễn phí không cần khóa: openai, openai-fast, openai-large, qwen-coder, mistral, deepseek, grok, gemini-flash-lite-3.1, perplexity-fast, perplexity-reasoning. Mô hình cao cấp (claude, gemini, midijourney) yêu cầu khóa API Pollinations từ enter.pollinations.ai.",
+ "publicai": "Yêu cầu khóa API — nhận tín dụng một lần khi đăng ký, sau đó trả phí",
+ "puter": "Lấy token tại puter.com/dashboard → Copy Auth Token",
+ "qiniu": "Tạo khóa API suy luận Qiniu AI tại https://portal.qiniu.com/ai-inference/api-key, rồi dán dưới dạng token Bearer. Endpoint tương thích OpenAI tại https://api.qnaigc.com/v1, làm proxy cho DeepSeek, Claude, Kimi và nhiều mô hình khác sau một khóa.",
+ "recraft": "Kết nối Recraft bằng khóa API.",
+ "reka": "Reka Chat tương thích OpenAI trên /v1. OmniRoute thăm dò /v1/models và định tuyến trò chuyện tới /v1/chat/completions.",
+ "requesty": "Tạo khóa API tại https://app.requesty.ai, rồi dán dưới dạng token Bearer. Endpoint tương thích OpenAI tại https://router.requesty.ai/v1, kèm danh mục /v1/models trực tiếp.",
+ "runwayml": "Tạo video Runway hoạt động theo tác vụ. OmniRoute gửi tác vụ chuyển văn bản hoặc hình ảnh thành video, thăm dò /v1/tasks/[id], rồi chuẩn hóa đầu ra hoàn tất về phản hồi /v1/videos/generations kiểu OpenAI.",
+ "sambanova": "5 USD tín dụng miễn phí khi đăng ký (có hiệu lực 30 ngày), không cần thẻ tín dụng",
+ "sap": "Khám phá mô hình dùng /v2/lm/scenarios/foundation-models/models trên AI_API_URL. Yêu cầu trò chuyện dùng deploymentUrl/chat/completions và yêu cầu AI-Resource-Group.",
+ "scaleway": "1 triệu token miễn phí cho tài khoản mới — tuân thủ EU/GDPR (Paris), Qwen3 235B và Llama 70B",
+ "sensenova": "Lấy khóa API tại platform.sensenova.cn",
+ "siliconflow": "1 USD tín dụng miễn phí cùng các mô hình miễn phí vĩnh viễn sau khi xác minh danh tính",
+ "snowflake": "Kết nối Snowflake Cortex bằng khóa API.",
+ "sparkdesk": "Lấy khóa API tại console.xfyun.cn",
+ "stability-ai": "Kết nối Stability AI bằng khóa API.",
+ "stepfun": "Lấy khóa API tại platform.stepfun.com",
+ "sumopod": "SumoPod cung cấp endpoint chat completions tương thích OpenAI tại https://ai.sumopod.com/v1/chat/completions cùng danh mục /v1/models trực tiếp. OmniRoute dùng giao thức OpenAI và liệt kê mô hình qua passthrough.",
+ "suno": "Dán cookie phiên từ suno.ai (xác thực Clerk)",
+ "synthetic": "Kết nối Synthetic bằng khóa API.",
+ "tencent": "Lấy khóa API tại console.cloud.tencent.com",
+ "thebai": "Khóa API Bearer cho cổng TheB.AI tương thích OpenAI.",
+ "tinyfish": "X-API-Key từ agent.tinyfish.ai/api-keys",
+ "together": "Kết nối Together AI bằng khóa API.",
+ "tokenrouter": "TokenRouter cung cấp endpoint chat completions tương thích OpenAI tại https://api.tokenrouter.com/v1/chat/completions cùng danh mục /v1/models hoạt động. OmniRoute dùng giao thức OpenAI.",
+ "topaz": "Kết nối Topaz bằng khóa API.",
+ "udio": "Dán cookie phiên từ udio.com (xác thực Supabase)",
+ "uncloseai": "Không yêu cầu xác thực. API chấp nhận mọi chuỗi không rỗng làm khóa để nhận dạng.",
+ "upstage": "Kết nối Upstage bằng khóa API.",
+ "v0-vercel": "Kết nối v0 (Vercel) bằng khóa API.",
+ "venice": "Kết nối Venice.ai bằng khóa API.",
+ "vercel-ai-gateway": "Kết nối Vercel AI Gateway bằng khóa API.",
+ "vertex": "Cung cấp Service Account JSON hoặc OAuth access_token",
+ "vertex-partner": "Cung cấp cùng Service Account JSON được dùng cho các mô hình đối tác Vertex AI.",
+ "volcengine": "Kết nối Volcengine bằng khóa API.",
+ "voyage-ai": "Khóa API Bearer cho các API embedding và xếp hạng lại của Voyage AI.",
+ "wafer": "Lấy khóa API từ https://wafer.ai",
+ "wandb": "Kết nối Weights & Biases Inference bằng khóa API.",
+ "x5lab": "X5Lab cung cấp endpoint chat completions tương thích OpenAI tại https://api.x5lab.dev/v1/chat/completions cùng danh mục /v1/models trực tiếp. OmniRoute dùng giao thức OpenAI và liệt kê mô hình qua passthrough.",
+ "xai": "Kết nối xAI (Grok) bằng khóa API.",
+ "xiaomi-mimo": "Kết nối Xiaomi MiMo bằng khóa API.",
+ "yi": "Lấy khóa API tại platform.lingyiwanwu.com",
+ "zai": "Lấy khóa API từ https://open.bigmodel.cn/usercenter/apikeys",
+ "zenmux": "ZenMux cung cấp endpoint chat completions tương thích OpenAI tại /api/v1/chat/completions, cùng các giao diện Anthropic Messages (/api/anthropic/v1/messages) và Google Gemini (/api/vertex-ai). OmniRoute dùng giao thức OpenAI.",
+ "galadriel": "Kết nối Galadriel bằng khóa API.",
+ "predibase": "25 USD tín dụng dùng thử miễn phí (có hiệu lực 30 ngày)",
+ "chenzk": "Gateway tương thích OpenAI với danh mục mô hình trực tiếp tại chenzk.top.",
+ "freepik": "Tạo hình ảnh bằng API Mystic của Freepik.",
+ "freetheai": "Gateway miễn phí tương thích OpenAI, hỗ trợ chuyển tiếp mô hình.",
+ "g4f-gemini": "Proxy ngược g4f.space miễn phí, không cần khóa cho Gemini, giới hạn 5 yêu cầu mỗi phút.",
+ "g4f-groq": "Proxy ngược g4f.space miễn phí, không cần khóa cho Groq, giới hạn 5 yêu cầu mỗi phút.",
+ "g4f-nvidia": "Proxy ngược g4f.space miễn phí, không cần khóa cho NVIDIA NIM, giới hạn 5 yêu cầu mỗi phút.",
+ "g4f-ollama": "Gateway Ollama được lưu trữ miễn phí, không cần khóa của g4f.space, giới hạn 5 yêu cầu mỗi phút.",
+ "g4f-pollinations": "Proxy ngược g4f.space miễn phí, không cần khóa cho Pollinations, giới hạn 5 yêu cầu mỗi phút.",
+ "mixedbread": "Tạo embedding bằng API Mixedbread.",
+ "segmind": "Tạo hình ảnh và video bằng các mô hình được lưu trữ trên Segmind.",
+ "amazon-q": "Dùng cùng AWS Builder ID hoặc luồng refresh-token đã nhập như Kiro, nhưng giữ các kết nối Amazon Q riêng biệt.",
+ "antigravity": "Kết nối Antigravity bằng luồng OAuth hiện có.",
+ "agy": "Nhập đăng nhập Antigravity CLI (`agy`) (dán/tải lên tập tin token), tự phát hiện đăng nhập CLI cục bộ hoặc đăng nhập bằng Google. Dùng chung backend Antigravity (gồm các mô hình Claude).",
+ "claude": "Kết nối Claude Code bằng luồng OAuth hiện có.",
+ "cline": "Kết nối Cline bằng luồng OAuth hiện có.",
+ "cursor": "Kết nối Cursor IDE bằng luồng OAuth hiện có.",
+ "github": "Kết nối GitHub Copilot bằng luồng OAuth hiện có.",
+ "gitlab-duo": "Ứng dụng OAuth với phạm vi ai_features + read_user. Cấu hình GITLAB_DUO_OAUTH_CLIENT_ID và tùy chọn GITLAB_DUO_OAUTH_CLIENT_SECRET trên instance OmniRoute này.",
+ "kilocode": "Kết nối Kilo Code bằng luồng OAuth hiện có.",
+ "kimi-coding": "Kết nối Kimi Coding bằng luồng OAuth hiện có.",
+ "kiro": "Gói miễn phí: 50 tín dụng/tháng (khoảng 25–100 nghìn token). ⚠️ Điều khoản Kiro cấm sử dụng proxy/harness của bên thứ ba.",
+ "codex": "Kết nối OpenAI Codex bằng luồng OAuth hiện có.",
+ "qwen": "Kết nối Qwen Code bằng luồng OAuth hiện có."
+ },
"passthroughModelsDescription": "{provider} chấp nhận ID mô hình gốc của nhà cung cấp. Nhập từ /models hoặc thêm ID tùy chỉnh để định tuyến.",
"bedrockModelsDescription": "Các mô hình Amazon Bedrock được giới hạn theo vùng AWS. Nhập từ /models hoặc thêm ID mô hình Bedrock được bật trong vùng đã chọn.",
"bedrockModelPlaceholder": "anthropic.claude-sonnet-4-6",
"addProviderSessionCookieTitle": "Thêm cookie phiên {provider}",
+ "openWebProviderSite": "Mở {host}",
"addProviderWebTokenTitle": "Thêm token web {provider}",
"addProviderConnectionTitle": "Thêm kết nối {provider}",
"webTokenCredentialLabel": "Token phiên web",
@@ -4775,215 +5924,24 @@
"webSessionCredentialValidationFailed": "Xác thực thông tin xác thực phiên thất bại. Hãy đăng nhập lại, sao chép thông tin xác thực mới và thử lại.",
"checkCookie": "Kiểm tra cookie",
"checkWebToken": "Kiểm tra token",
- "filterAll": "Tất cả",
- "filterActive": "Hoạt động",
- "filterError": "Lỗi",
- "filterBanned": "Bị cấm",
- "filterCreditsExhausted": "Hết tín dụng",
- "contextWindowOverrideLabel": "Ghi đè cửa sổ ngữ cảnh",
- "contextWindowOverridePlaceholder": "Ví dụ: 131072",
- "contextWindowOverrideHint": "Đặt thủ công cửa sổ ngữ cảnh thực tế của mô hình (token) khi nhà cung cấp báo sai. Giá trị này được ưu tiên hơn giá trị tự động phát hiện hoặc danh mục và ngăn định tuyến combo loại bỏ mô hình.",
- "contextWindowOverrideInvalid": "Giá trị ghi đè cửa sổ ngữ cảnh phải là số token nguyên dương",
- "visionCapableLabel": "Hỗ trợ thị giác",
- "visionCapableHint": "Đánh dấu thủ công mô hình là có khả năng thị giác khi metadata khám phá của nhà cung cấp không báo phương thức đầu vào hình ảnh (thường gặp ở backend tự lưu trữ hoặc cục bộ).",
- "kimiOfficialSupporterBadge": "Official Supporter",
- "kimiOfficialSupporterTooltip": "Kimi (Moonshot AI) is an official OmniRoute launch partner",
- "Account Deactivated": "Account Deactivated",
- "importFromFile": "Import from file",
- "importFromFileTitle": "Import providers from file",
- "importFromFileDescription": "Upload a CSV or JSON file listing multiple providers (each row can be a different provider type). Review the parsed rows below and pick which ones to import.",
- "importFromFileChoose": "Choose file",
- "importFromFileSelectHint": "{count} of {total} selected",
- "importFromFileColProvider": "Provider",
- "importFromFileColName": "Name",
- "importFromFileColBaseUrl": "Base URL",
- "importFromFileErrorLine": "Row {line}: {reason}",
- "importErrorMissingProvider": "missing provider",
- "importErrorMissingName": "missing name",
- "importErrorMissingApiKey": "missing API key",
- "importErrorInvalidPriority": "invalid priority (must be 1-100)",
- "importErrorMalformedRow": "malformed row",
- "importErrorNotArray": "file must contain a JSON array",
- "importFromFileImporting": "Importing…",
- "importFromFileImport": "Import {count} providers",
- "importFromFileResult": "Imported {success} providers ({failed} failed)",
- "adaptaTutorial": {
- "title": "How to connect Adapta Web",
- "introPrefix": "Adapta authenticates through Clerk. The token",
- "introSuffix": "is a long-lived JWT that lets OmniRoute refresh sessions automatically.",
- "or": "or",
- "step1Title": "Open Adapta chat",
- "step1DescPrefix": "Open",
- "step1DescSuffix": "and sign in with your Gold or Business account.",
- "step2Title": "Open DevTools",
- "step2DescPrefix": "Press",
- "step2DescSuffix": "to open Developer Tools.",
- "step3Title": "Go to Application → Cookies",
- "step3DescPrefix": "In the",
- "step3DescMiddle": "expand",
- "step3DescSuffix": "and click",
- "step4Title": "Copy the cookie value for",
- "step4DescPrefix": "Find the cookie named",
- "step4DescMiddle": "in the list. Click it and copy the content from the",
- "step4DescSuffix": "column. It starts with",
- "step5Title": "Paste it here and save",
- "step5DescPrefix": "Click",
- "step5DescMiddle": "paste the",
- "step5DescSuffix": "value into the API Key field, then save. OmniRoute will refresh the session automatically.",
- "tipLabel": "Tip:",
- "tipPrefix": "The",
- "tipSuffix": "cookie is long-lived, usually for months. You only need to renew it if you sign out or Adapta invalidates the session."
- },
- "distributeProxies": "Distribute Proxies",
- "distributing": "Distributing...",
- "selectedCount": "{count, plural, one {# selected} other {# selected}}",
- "accountsCount": "{count, plural, one {# account} other {# accounts}}",
- "testAllModels": "Test all models",
- "testAllModelsConfirm": "Test all visible models? Failed models will be hidden if auto-hide is enabled.",
- "testingAllModels": "Testing {done}/{total}...",
- "testAllResults": "{ok} of {total} models working",
- "noModelsToTest": "No models match the current filter",
- "showAllModels": "All",
- "showVisibleOnly": "Visible only",
- "freeFilterAll": "All",
- "freeFilterFreeOnly": "Free only",
- "freeFilterPaidOnly": "Paid only",
- "sortFreeFirst": "Free first",
- "showHiddenOnly": "Hidden only",
- "filterByVisibility": "Filter by visibility",
- "autoHideFailed": "Auto-hide failed models",
- "autoHideFailedHint": "When enabled, Test all hides non-transient failures from public catalogs such as /v1/models. Single-model tests never auto-hide.",
- "testAllCompleted": "Tested {total} models: {ok} OK, {failed} failed",
- "modelAutoHidden": "Model {model} auto-hidden (test failed)",
- "filterVisible": "Visible only",
- "filterHidden": "Hidden only",
- "modelsHiddenCount": "{count} hidden",
- "iconUrlLabel": "Icon URL",
- "iconUrlHint": "Optional. Image URL shown as this provider's icon.",
- "batchDeleteConfirmTitle": "Delete connections",
- "batchDeleteConfirmButton": "Delete",
- "noFilteredConnections": "No connections match the current filter.",
- "setAliasSuccess": "Alias {alias} set",
- "deleteAliasSuccess": "Alias {alias} deleted",
- "noFreeModelsFound": "No free models found for this provider — nothing imported.",
- "fetchModelsSuccess": "Found {count} new models",
- "fetchModelsFailed": "Could not auto-fetch models (you can retry from settings)",
- "testAllRateLimited": "Rate limit reached, stopped early",
- "hideFailedAuto": "Auto-hide failed models",
- "hideAllModels": "Hide all",
- "providerQuotaShort": "Quota",
- "hideConnectionFromProviderQuota": "Hide this account from Provider Quota",
- "showConnectionInProviderQuota": "Show this account in Provider Quota",
- "quotaVisibilityUpdateFailed": "Failed to update Provider Quota visibility",
- "testAllProgress": "Tested {done}/{total}",
- "testAllFailedHidden": "{count} failed models hidden",
- "testAllDone": "All models tested",
- "compatibleDefaultModelLabel": "Default Model",
- "compatibleDefaultModelHint": "Enter the model ID exactly as your compatible endpoint expects it. This model will be saved as the connection default.",
- "errorTypeCreditsExhausted": "No credits",
- "errorTypeBanned": "403 Banned",
- "proxyOn": "Proxy On",
- "proxyOff": "Direct (bypass proxy)",
- "proxyEnabledTitle": "Proxy is enabled for this connection",
- "proxyDisabledTitle": "Direct connection — bypasses every proxy (including the global proxy) for this connection",
- "perKeyProxyOn": "Per-key",
- "perKeyProxyOff": "Connection",
- "perKeyProxyEnabledTitle": "Per-key proxy assignment enabled for this provider",
- "perKeyProxyDisabledTitle": "Per-key proxy assignment disabled for this provider",
- "targetFormatLabel": "Target Format",
- "targetFormatHint": "Override the upstream wire format. Use 'Anthropic Messages' to route OpenAI-compatible providers (e.g. opencode-go) through the /v1/messages endpoint.",
- "targetFormatAuto": "Default (auto)",
- "targetFormatGemini": "Gemini",
- "targetFormatAntigravity": "Antigravity",
- "compatParamFiltersLabel": "Param Filters",
- "compatBlockedParamsHint": "Blocked params (stripped from requests)",
- "compatAllowedParamsHint": "Allowed params (re-added after deny)",
- "paramFiltersSectionTitle": "Param Filters",
- "paramFiltersSectionHint": "Strip or re-add request parameters before sending to this provider. Used to avoid 400 errors from providers that reject certain params (e.g. NVIDIA NIM rejects thinking).",
- "paramFiltersBlockedLabel": "Blocked parameters",
- "paramFiltersBlockedHint": "These params are stripped from outgoing requests (denylist).",
- "paramFiltersAllowedLabel": "Allowed parameters",
- "paramFiltersAllowedHint": "These params are re-added after denylist stripping (only if the client sent them).",
- "paramFiltersAutoLearnLabel": "Auto-learn from 400 errors",
- "paramFiltersAutoLearnHint": "When enabled, if the upstream returns a 400 with \"Unsupported parameter: X\", the param is automatically added to the block list and the request retried.",
- "paramFiltersSaving": "Saving…",
- "paramFiltersSaveChanges": "Save Changes",
- "paramFiltersResetToDefault": "Reset to Default",
- "paramFiltersLoadError": "Failed to load param filter config: {error}",
- "paramFiltersSaveSuccess": "Param filter config saved",
- "paramFiltersSaveError": "Failed to save param filter config: {error}",
- "paramFiltersResetSuccess": "Param filter config reset to defaults",
- "paramFiltersResetError": "Failed to reset param filter config: {error}",
- "interceptionSectionTitle": "Web Tool Interception",
- "interceptionSectionHint": "Route this provider's native web_search / web_fetch tool calls through OmniRoute's own search and fetch endpoints instead of letting the provider run them natively. Off by default — existing behavior is unchanged.",
- "interceptSearchLabel": "Intercept web_search",
- "interceptSearchHint": "Rewrite native web_search tool calls to OmniRoute's /v1/search.",
- "interceptFetchLabel": "Intercept web_fetch",
- "interceptFetchHint": "Rewrite native web_fetch tool calls to OmniRoute's /v1/web/fetch.",
- "interceptionLoadError": "Failed to load interception settings: {error}",
- "interceptionSaveError": "Failed to save interception settings: {error}",
- "importFreeModelsOnlyLabel": "Import only free models",
- "importFreeModelsOnlyHint": "When enabled, only this provider's free models are imported. Paid models are skipped.",
- "testModelIdLabel": "Model ID (optional)",
- "testModelIdPlaceholder": "e.g. my-model-id",
- "testModelIdHint": "If the provider lacks a /models endpoint, enter a model ID to validate via /chat/completions instead.",
- "codexCliGuideButton": "Codex CLI Guide",
- "codexCliGuideTitle": "Codex CLI Guide",
- "codexCliGuideLoading": "Loading guide...",
- "codexCliGuideLoadFailed": "Could not load the guide.",
- "codexExternalLinkButton": "External Codex link",
- "codexExternalLinkModalTitle": "External Codex link",
- "codexExternalLinkModalDescription": "Share this single-use link with the person who will authenticate the Codex account. They open it in their own browser, complete the OpenAI login, and the connection is registered here. The link expires in 15 minutes.",
- "codexExternalLinkGenerating": "Generating link...",
- "codexExternalLinkWaiting": "Waiting for browser authentication. This window refreshes automatically.",
- "codexExternalLinkCreateFailed": "Failed to generate the link.",
- "codexExternalLinkNetworkError": "Could not contact the server.",
- "codexExternalLinkConnected": "Codex account connected through the external link.",
- "codexExternalLinkExpired": "The link expired before completion.",
- "clientIdentityLabel": "Client Identity",
- "clientIdentityHint": "Optional. Adds client fingerprint headers (e.g. User-Agent) matching a known CLI for compatible gateways that expect one.",
- "disableCoolingDescription": "Skip the transient cooldown so this connection stays eligible even after recoverable errors (terminal states like banned/expired still apply).",
- "disableCoolingLabel": "Disable cooldown for this connection",
- "m365TierLabel": "Copilot tier",
- "m365TierHint": "Choose which Microsoft 365 Copilot surface this connection uses. Individual is the default consumer BizChat; Education and Enterprise (work) opt into their tenant surfaces.",
- "m365TierIndividualOption": "Individual (default)",
- "m365TierEduOption": "Education",
- "m365TierEnterpriseOption": "Enterprise / Work",
- "tierOverrideLabel": "Ghi đè tầng định tuyến",
- "tierOverrideAuto": "Tự động (định tuyến quyết định)",
- "tierOverrideFree": "Miễn phí",
- "tierOverrideCheap": "Giá rẻ",
- "tierOverridePremium": "Cao cấp",
- "tierOverrideHelpText": "Ghim nhà cung cấp này vào một tầng định tuyến thay vì để OmniRoute suy ra từ giá mô hình.",
- "rateLimitOverridesSection": "Rate Limit Overrides",
- "rateLimitOverridesMaxConcurrentHint": "Max concurrent requests override for this connection. Overrides the account-level cap.",
- "rateLimitOverridesMaxConcurrentLabel": "Max Concurrent (Rate Limit)",
- "rateLimitOverridesMinTimeHint": "Minimum time (ms) between requests. Overrides the default rate limiter delay.",
- "rateLimitOverridesMinTimeLabel": "Min Interval (ms)",
- "rateLimitOverridesRpmHint": "Max requests per minute for this connection. Overrides the provider default.",
- "rateLimitOverridesRpmLabel": "RPM (Requests/min)",
- "rateLimitOverridesTpdHint": "Max tokens per day for this connection. Overrides the provider default.",
- "rateLimitOverridesTpdLabel": "TPD (Tokens/day)",
- "rateLimitOverridesTpmHint": "Max tokens per minute for this connection. Overrides the provider default.",
- "rateLimitOverridesTpmLabel": "TPM (Tokens/min)",
- "searchByModel": "Search by model…",
- "openWebProviderSite": "Open {host}",
- "huggingchatLabel": "HuggingChat (Free)",
- "huggingchatDesc": "Free LLM chat via huggingface.co/chat",
+ "huggingchatLabel": "HuggingChat (Miễn phí)",
+ "huggingchatDesc": "Trò chuyện LLM miễn phí qua huggingface.co/chat",
"poeWebLabel": "Poe Web",
- "poeWebDesc": "Multi-model chat via poe.com",
+ "poeWebDesc": "Trò chuyện đa mô hình qua poe.com",
"veniceWebLabel": "Venice Web",
- "veniceWebDesc": "Privacy-focused AI chat",
+ "veniceWebDesc": "Trò chuyện AI tập trung vào quyền riêng tư",
"v0VercelWebLabel": "v0 Vercel Web",
- "v0VercelWebDesc": "AI code generation via v0.dev",
+ "v0VercelWebDesc": "Tạo mã bằng AI qua v0.dev",
"kimiWebLabel": "Kimi Web",
- "kimiWebDesc": "Moonshot AI consumer chat via www.kimi.com (international, Connect-RPC API)",
+ "kimiWebDesc": "Trò chuyện AI dành cho người dùng của Moonshot AI qua www.kimi.com (quốc tế, API Connect-RPC)",
"doubaoWebLabel": "Dola Web",
- "doubaoWebDesc": "ByteDance AI chat via dola.com",
- "overrideBaseUrlAdvanced": "Advanced: override base URL",
- "overrideBaseUrlHint": "Advanced: point this built-in provider at a custom endpoint. Leave blank to use the default.",
- "bulkAddFormatHintCloudflare": "One key per line. Format: name|accountId|apiKey (Cloudflare account ID + API token).",
- "lmarenaWebCookieHint": "Open arena.ai, sign in, then copy the full Cookie header from a Network request. Include arena-auth-prod-v1.0 and arena-auth-prod-v1.1 (and further chunks if present), preferably with cf_clearance. Do not paste only the empty arena-auth-prod-v1 cookie. Optional: providerSpecificData.recaptchaV3Token if create-evaluation still returns 403."
+ "doubaoWebDesc": "Trò chuyện AI của ByteDance qua dola.com",
+ "overrideBaseUrlAdvanced": "Nâng cao: ghi đè URL cơ sở",
+ "overrideBaseUrlHint": "Nâng cao: trỏ nhà cung cấp tích hợp này đến một endpoint tùy chỉnh. Để trống để sử dụng mặc định.",
+ "bulkAddFormatHintCloudflare": "Mỗi dòng một khóa. Định dạng: name|accountId|apiKey (Cloudflare account ID + API token).",
+ "lmarenaWebCookieHint": "Mở arena.ai, đăng nhập, sau đó sao chép toàn bộ header Cookie từ một yêu cầu mạng. Bao gồm arena-auth-prod-v1.0 và arena-auth-prod-v1.1 (và các phần tiếp theo nếu có), ưu tiên kèm theo cf_clearance. Không chỉ dán cookie arena-auth-prod-v1 trống. Tùy chọn: providerSpecificData.recaptchaV3Token nếu create-evaluation vẫn trả về 403.",
+ "kimiOfficialSupporterBadge": "Nhà tài trợ chính thức",
+ "kimiOfficialSupporterTooltip": "Kimi (Moonshot AI) là đối tác ra mắt chính thức của OmniRoute"
},
"settings": {
"title": "Cài đặt",
@@ -4993,6 +5951,10 @@
"routing": "Định tuyến",
"cache": "Cache",
"resilience": "Khả năng phục hồi",
+ "description": "Mô tả",
+ "enable": "Bật",
+ "disable": "Tắt",
+ "update": "Cập nhật",
"routingSettingsIntro": "Kiểm soát cách các yêu cầu của bạn được định tuyến, chuyển đổi và gửi đến các nhà cung cấp AI.",
"routingOpDropParagraphContainsLabel": "Loại bỏ đoạn văn (chứa)",
"routingOpDropParagraphStartsWithLabel": "Loại bỏ đoạn văn (bắt đầu bằng)",
@@ -5089,6 +6051,7 @@
"memoryDesc": "Bộ nhớ hội thoại liên tục qua các phiên",
"memoryEnabled": "Bật bộ nhớ",
"memoryEnabledDesc": "Khi được bật, OmniRoute sẽ chèn ngữ cảnh trước đây có liên quan.",
+ "memoryTokenCostWarning": "Lưu ý: khi bật bộ nhớ, OmniRoute sẽ chèn tối đa {tokens} token ngữ cảnh được truy xuất vào mỗi yêu cầu chat — điều này làm tăng mức sử dụng token và chi phí. Để bỏ qua việc chèn ngữ cảnh cho một yêu cầu cụ thể, hãy gửi header \"x-omniroute-no-memory: true\".",
"maxTokens": "Token tối đa",
"retentionDays": "Thời gian lưu trữ",
"recent": "Gần đây",
@@ -5127,6 +6090,31 @@
"modelsDevInfoOrder": "Ghi đè của người dùng → models.dev → LiteLLM → Mặc định mã hóa cứng",
"systemTheme": "Chủ đề hệ thống",
"debugToggle": "Bật chế độ gỡ lỗi",
+ "logToolSourcesToggle": "Ghi nhật ký nguồn công cụ",
+ "logToolSourcesDescription": "Ghi một dòng nhật ký chẩn đoán cho mỗi yêu cầu, tóm tắt số lượng công cụ và phân loại nguồn MCP/được lưu trữ/phía máy khách.",
+ "homePinProviderQuotaToHome": "Ghim thông tin vào trang chủ",
+ "homePinnedSectionsDesc": "Chọn các phần cần ghim lên đầu trang chủ.",
+ "homeProviderQuotaLimits": "Giới hạn hạn ngạch nhà cung cấp",
+ "homeProviderQuotaLimitsDesc": "Ghim khung trạng thái hạn ngạch nhà cung cấp (kèm nút Làm mới tất cả) lên đầu trang chủ.",
+ "homeQuickStart": "Bắt đầu nhanh",
+ "homeQuickStartDesc": "Hiển thị bảng Bắt đầu nhanh trên trang chủ.",
+ "homeProviderTopology": "Cấu trúc liên kết nhà cung cấp",
+ "homeProviderTopologyDesc": "Hiển thị cấu trúc liên kết nhà cung cấp trên trang chủ.",
+ "accountEmailVisibility": "Hiển thị email tài khoản",
+ "accountEmailVisibilityDesc": "Hiển thị đầy đủ email tài khoản trên các màn hình nhà cung cấp, combo, nhật ký, hạn ngạch và thử nghiệm. Tắt tính năng này để che email theo mặc định.",
+ "comboConfigMode": "Chế độ cấu hình combo",
+ "comboConfigModeDesc": "Chọn cách bố trí hộp thoại tạo và chỉnh sửa combo.",
+ "comboConfigModeGuided": "Có hướng dẫn",
+ "comboConfigModeGuidedDesc": "Dùng trình tạo combo theo từng bước hiện tại.",
+ "comboConfigModeExpert": "Chuyên gia",
+ "comboConfigModeExpertDesc": "Hiển thị mọi tùy chọn combo trên một trang và cho phép nhập trực tiếp mô hình.",
+ "providerQuotaAutoRefresh": "Tự động làm mới hạn ngạch nhà cung cấp",
+ "providerQuotaAutoRefreshDesc": "Tự động làm mới màn hình Giới hạn nhà cung cấp khi trang đang mở.",
+ "providerQuotaAutoRefreshToggle": "Tự động làm mới",
+ "providerQuotaAutoRefreshToggleDesc": "Làm mới hạn ngạch sau mỗi vài phút khi trang đang hiển thị.",
+ "providerQuotaAutoRefreshInterval": "Khoảng thời gian làm mới",
+ "providerQuotaAutoRefreshIntervalDesc": "Tần suất làm mới hạn ngạch, tính bằng giây.",
+ "seconds": "giây",
"sidebarVisibilityToggle": "Hiển thị các mục trên thanh bên",
"enableCache": "Bật cache",
"cacheTTL": "Cache TTL",
@@ -5160,11 +6148,17 @@
"autoDisableDescription": "Đánh dấu vĩnh viễn các kết nối nhà cung cấp là đã vô hiệu hóa nếu chúng trả về các tín hiệu cấm không thể khắc phục cụ thể (ví dụ: HTTP 403 'verify your account'). Điều này sẽ loại bỏ chúng khỏi vòng xoay combo.",
"autoDisableThreshold": "Ngưỡng cấm",
"autoDisableThresholdDesc": "Số lượng tín hiệu cấm liên tiếp cần thiết trước khi hủy kích hoạt vĩnh viễn.",
+ "customBannedSignals": "Từ khóa cấm",
+ "customBannedSignalsDesc": "Các từ khóa bổ sung kích hoạt việc phát hiện tài khoản bị cấm vĩnh viễn. Các từ khóa tích hợp sẵn luôn được áp dụng.",
+ "customBannedSignalsPlaceholder": "ví dụ: api key revoked",
+ "noCustomBannedSignals": "Không có từ khóa tùy chỉnh. Chỉ các từ khóa tích hợp sẵn được áp dụng.",
"resilienceStructureTitle": "Cấu trúc chịu lỗi",
"resilienceStructureDesc": "Trang này chỉ cấu hình hành vi. Trạng thái bộ ngắt mạch theo thời gian thực được hiển thị trên trang Sức khỏe. Các cài đặt thử lại và kiểm soát khe round-robin riêng cho từng tổ hợp vẫn nằm trong cài đặt tổ hợp.",
"enableThinking": "Kích hoạt tư duy",
"maxThinkingTokens": "Số token tư duy tối đa",
"enableProxy": "Kích hoạt proxy",
+ "perKeyProxyEnabled": "Kích hoạt gán proxy cho từng khóa",
+ "perKeyProxyEnabledDesc": "Khi được kích hoạt, mỗi kết nối nhà cung cấp có thể sử dụng cài đặt proxy riêng của mình",
"proxyUrl": "Proxy URL",
"pricingRates": "Định dạng mức giá",
"currentPricing": "Tổng quan về giá hiện tại",
@@ -5235,6 +6229,13 @@
"uploadFavicon": "Tải lên favicon",
"resetFavicon": "Đặt lại favicon",
"faviconPreview": "Xem trước favicon",
+ "logoFileTooLarge": "Tệp logo phải nhỏ hơn 500KB",
+ "faviconFileTooLarge": "Tệp favicon phải nhỏ hơn 50KB",
+ "invalidLogoFileType": "Loại tệp không hợp lệ. Hãy tải lên PNG, JPG, SVG, GIF hoặc WebP.",
+ "invalidFaviconFileType": "Loại tệp không hợp lệ. Hãy tải lên PNG, ICO, SVG, GIF hoặc WebP.",
+ "failedToReadFile": "Không thể đọc tệp",
+ "startOnLogin": "Khởi chạy khi đăng nhập",
+ "startOnLoginDesc": "Tự động chạy OmniRoute khi hệ thống khởi động và chạy ẩn trong khay nền.",
"flushCache": "Xóa cache",
"flushing": "Đang xóa cache…",
"size": "Kích thước",
@@ -5244,6 +6245,13 @@
"globalProxy": "Proxy toàn cầu",
"globalProxyDesc": "Định cấu hình proxy gửi đi toàn cầu cho tất cả các lệnh gọi API. Các nhà cung cấp, tổ hợp và khóa riêng lẻ có thể ghi đè cài đặt này.",
"noGlobalProxy": "Chưa định cấu hình proxy toàn cầu",
+ "proxyPool": "Nhóm proxy",
+ "freePool": "Nhóm miễn phí",
+ "proxyDocumentation": "Tài liệu về proxy",
+ "proxyGlobalConfigTab": "Cấu hình toàn cầu",
+ "proxyPoolTab": "Nhóm proxy",
+ "freePoolTab": "Nhóm miễn phí",
+ "proxyDocumentationTab": "Tài liệu",
"bulkHealthcheck": "Kiểm tra tình trạng hàng loạt",
"bulkHealthcheckDesc": "Kiểm tra tất cả proxy đã cấu hình với một URL đích để xác định proxy nào hoạt động.",
"healthcheckTesting": "Đang kiểm tra...",
@@ -5256,6 +6264,64 @@
"healthcheckStatus": "Trạng thái",
"healthcheckProxyUrl": "URL proxy",
"healthcheckLatency": "Độ trễ",
+ "proxyDocumentationScopeTitle": "Thứ tự ưu tiên phạm vi proxy",
+ "proxyDocumentationScopeDescBefore": "OmniRoute xác định proxy gửi đi theo thứ tự ưu tiên:",
+ "proxyDocumentationScopeOrder": "combo → account → provider → global",
+ "proxyDocumentationScopeDescAfter": ". Phạm vi cụ thể nhất sẽ được ưu tiên.",
+ "proxyDocumentationAddTitle": "Thêm proxy tùy chỉnh",
+ "proxyDocumentationAddDescBefore": "Đi tới tab",
+ "proxyDocumentationAddDescMiddle": "→ nhấp",
+ "proxyDocumentationAddCta": "+ Thêm proxy",
+ "proxyDocumentationAddDescAfter": ". Điền loại (http/https/socks5), máy chủ và cổng. Bạn cũng có thể gán proxy này cho một phạm vi.",
+ "proxyDocumentationBulkTitle": "Định dạng nhập hàng loạt",
+ "proxyDocumentationBulkDesc": "Các trường phân cách bằng dấu gạch đứng (|):",
+ "proxyDocumentationSocks5DescBefore": "Proxy SOCKS5 bị vô hiệu hóa theo mặc định. Hãy đặt",
+ "proxyDocumentationFreePoolDesc": "Tab Nhóm miễn phí tổng hợp proxy từ 1proxy, Proxifly và IPLocate. Dùng nút ⊕ để kiểm tra và chuyển một proxy vào danh mục của bạn. Chỉ những proxy vượt qua bài kiểm tra kết nối mới được thêm vào.",
+ "proxyDocumentationVercelRelayDescBefore": "Vercel Relay là một dịch vụ chuyển tiếp biên cho lưu lượng gửi đi — không phải đường hầm cho lưu lượng đến. Việc triển khai một relay sẽ gửi các lệnh gọi API LLM qua các IP động của Vercel, vượt qua các chặn theo vị trí địa lý của trung tâm dữ liệu và giới hạn tốc độ. Relay được bảo vệ bằng một header khóa bí mật được tạo tự động",
+ "proxyDocumentationVercelRelayDescAfter": "Token Vercel của bạn chỉ được sử dụng trong quá trình triển khai và không bao giờ được lưu trữ.",
+ "vercelRelayTokenRequired": "Yêu cầu phải có token",
+ "vercelRelayDeployFailed": "Triển khai thất bại",
+ "vercelRelayTokenHint": "Token chỉ được sử dụng trong quá trình triển khai — không bao giờ được lưu trữ.",
+ "denoRelayTokenRequired": "Yêu cầu phải có token Deno Deploy",
+ "denoRelayOrgDomainRequired": "Yêu cầu phải có tên miền tổ chức",
+ "denoRelayDeployFailed": "Triển khai Deno Deploy thất bại",
+ "denoRelayTokenHint": "Token tổ chức (tiền tố ddo_) lấy từ console.deno.com → Organization → Settings → Organization Tokens. Chỉ được sử dụng một lần để triển khai và không bao giờ được lưu trữ.",
+ "denoRelayOrgDomainHint": "Tên miền mặc định của tổ chức Deno Deploy của bạn (ví dụ: acme.deno.net). Dịch vụ chuyển tiếp sẽ có thể truy cập tại https://..deno.net.",
+ "proxyFreePoolFilterProtocol": "Lọc theo giao thức",
+ "proxyFreePoolProtocol": "Giao thức",
+ "proxyFreePoolCountryPlaceholder": "Quốc gia (ví dụ: US)",
+ "proxyFreePoolFilterCountry": "Lọc theo quốc gia",
+ "proxyFreePoolMinQualityPlaceholder": "Chất lượng tối thiểu",
+ "proxyFreePoolMinQualityLabel": "Điểm chất lượng tối thiểu",
+ "proxyFreePoolSyncAll": "Đồng bộ tất cả",
+ "proxyFreePoolTotal": "Tổng số",
+ "proxyFreePoolInPool": "Trong nhóm proxy",
+ "proxyFreePoolAvgQuality": "Chất lượng trung bình",
+ "proxyFreePoolSelected": "Đã chọn {count}",
+ "proxyFreePoolAddSelected": "Thêm các mục đã chọn vào nhóm proxy",
+ "proxyFreePoolAddVisible": "Thêm tất cả các mục đang hiển thị vào nhóm proxy",
+ "proxyFreePoolSource": "Nguồn",
+ "proxyFreePoolHostPort": "Host:Port",
+ "proxyFreePoolType": "Loại",
+ "proxyFreePoolCountry": "Quốc gia",
+ "proxyFreePoolQuality": "Chất lượng",
+ "proxyFreePoolLatency": "Độ trễ",
+ "proxyFreePoolEmpty": "Không tìm thấy proxy nào. Nhấp vào Đồng bộ tất cả để tải từ các nguồn.",
+ "proxyToggleSources": "Bật hoặc tắt các nguồn proxy",
+ "proxyFreePoolTesting": "Đang kiểm tra proxy...",
+ "proxyFreePoolBulkResult": "Đã thêm {succeeded}, thất bại {failed}",
+ "proxyFreePoolPageSummary": "Trang {page}/{totalPages} (tổng cộng {total} proxy)",
+ "proxyFreePoolTotalSummary": "Tổng cộng {total} proxy",
+ "proxyFreePoolSearchPlaceholder": "Tìm kiếm host…",
+ "proxyFreePoolSearchLabel": "Tìm kiếm proxy theo host",
+ "proxyFreePoolSortLabel": "Sắp xếp proxy",
+ "proxyFreePoolSortQuality": "Chất lượng",
+ "proxyFreePoolSortLatency": "Độ trễ",
+ "proxyFreePoolSortRecent": "Đã xác thực gần đây",
+ "proxyFreePoolListTotal": "Đã hiển thị",
+ "proxyFreePoolLoadMore": "Tải thêm",
+ "close": "Đóng",
+ "cancel": "Hủy",
"globalLabel": "Toàn cục",
"configure": "Cấu hình",
"globalSystemPrompt": "Prompt hệ thống toàn cục",
@@ -5352,6 +6418,22 @@
"strictRandomDesc": "Trộn bộ bài — sử dụng mỗi tài khoản một lần trước khi trộn lại",
"stickyLimit": "Sticky Limit",
"stickyLimitDesc": "Số cuộc gọi trên mỗi tài khoản trước khi chuyển sang tài khoản khác khi cơ chế dự phòng tài khoản toàn cầu sử dụng luân phiên",
+ "routingStrategyTitle": "Chiến lược định tuyến",
+ "routingStrategySubtitle": "Tương ứng với 9router: luân phiên tài khoản, giới hạn cố định và luân phiên combo",
+ "accountRoundRobin": "Luân phiên",
+ "accountRoundRobinDesc": "Xoay vòng qua các tài khoản để phân bổ tải",
+ "comboRoundRobin": "Luân phiên combo",
+ "comboRoundRobinDesc": "Xoay vòng qua các mục tiêu combo thay vì luôn bắt đầu với mục tiêu đầu tiên",
+ "comboStickyLimit": "Giới hạn cố định cho combo",
+ "comboStickyLimitDesc": "Số cuộc gọi trên mỗi mục tiêu combo trước khi chuyển đổi",
+ "routingStrategyAccountSummary": "Phân bổ các yêu cầu trên các tài khoản với {limit} cuộc gọi mỗi tài khoản.",
+ "routingStrategyFillFirstSummary": "Sử dụng các tài khoản theo thứ tự ưu tiên (Lấp đầy trước).",
+ "routingStrategyComboSummary": " Các combo được luân phiên sau {limit} cuộc gọi cho mỗi mục tiêu.",
+ "routingStrategyComboFallbackSummary": " Các combo sử dụng chiến lược đã cấu hình của từng combo (mặc định là ưu tiên/dự phòng).",
+ "providerAccountRoutingTitle": "Định tuyến đa tài khoản",
+ "providerAccountRoutingDesc": "Ghi đè chiến lược tài khoản toàn cầu cho nhà cung cấp này (tương thích với 9router).",
+ "providerRoutingStrategy": "Chiến lược tài khoản",
+ "providerRoutingInheritGlobal": "Kế thừa mặc định toàn cầu",
"modelAliases": "Bí danh mô hình",
"modelAliasesTitle": "Bí danh mô hình",
"modelAliasesDesc": "Ánh xạ lại tên mô hình bằng cách khớp chính xác hoặc mẫu ký tự đại diện.",
@@ -5434,6 +6516,10 @@
"comboDefaultsGuideHint1": "Giữ số lần thử lại ở mức thấp trong các luồng có độ trễ thấp; chỉ tăng thời gian chờ cho các tác vụ tạo nội dung dài.",
"comboDefaultsGuideHint2": "Sử dụng ghi đè nhà cung cấp khi một nhà cung cấp cần hành vi hết thời gian chờ/thử lại khác với mặc định chung.",
"globalComboConfig": "Cấu hình tổ hợp toàn cục",
+ "moveUp": "Di chuyển lên",
+ "moveDown": "Di chuyển xuống",
+ "allProvidersAdded": "Đã thêm tất cả nhà cung cấp",
+ "noProvidersFound": "Không tìm thấy nhà cung cấp",
"defaultStrategy": "Chiến lược mặc định",
"defaultStrategyDesc": "Áp dụng cho các tổ hợp mới chưa được chỉ định chiến lược và đồng bộ với định tuyến dự phòng toàn cục của tài khoản",
"comboStrategyAria": "Chiến lược tổ hợp",
@@ -5453,6 +6539,9 @@
"providerMaxRetriesAria": "Số lần thử lại tối đa của {provider}",
"providerTimeoutAria": "Thời gian chờ của {provider} (ms)",
"removeProviderOverrideAria": "Xóa ghi đè của {provider}",
+ "selectProviderPlaceholder": "Chọn nhà cung cấp...",
+ "searchProviderPlaceholder": "Tìm kiếm nhà cung cấp...",
+ "searchProviderAria": "Tìm kiếm nhà cung cấp",
"newProviderNamePlaceholder": "ví dụ: google, openai...",
"newProviderNameAria": "Tên nhà cung cấp mới",
"retries": "lần thử lại",
@@ -5461,6 +6550,7 @@
"maxNestingDepth": "Độ sâu lồng nhau tối đa",
"concurrencyPerModel": "Đồng thời trên mỗi mô hình",
"queueTimeout": "Thời gian chờ hàng đợi (ms)",
+ "queueDepth": "Độ sâu hàng đợi",
"contextRelayHandoffThreshold": "Ngưỡng chuyển giao",
"contextRelayMaxMessages": "Số lượng tin nhắn tối đa để tóm tắt",
"contextRelaySummaryModel": "Mô hình tóm tắt",
@@ -5512,6 +6602,7 @@
"systemStorage": "Lưu trữ",
"allDataLocal": "Tất cả dữ liệu được lưu trữ cục bộ trên máy của bạn",
"databasePath": "Đường dẫn cơ sở dữ liệu",
+ "export": "Xuất",
"exportDatabase": "Xuất cơ sở dữ liệu",
"exportAll": "Xuất tất cả (.tar.gz)",
"importDatabase": "Nhập cơ sở dữ liệu",
@@ -5554,6 +6645,34 @@
"errorDuringImport": "Đã xảy ra lỗi trong quá trình nhập",
"modelPricing": "Giá mô hình",
"modelPricingDesc": "Định cấu hình mức chi phí cho mỗi mô hình • Tất cả mức giá tính theo $/1M token",
+ "pricingCoverage": "Độ phủ",
+ "pricingAuth": "Xác thực",
+ "pricingSort": "Sắp xếp",
+ "pricingAll": "Tất cả",
+ "pricingAuthUnknown": "Không xác định",
+ "pricingMostModels": "Nhiều mô hình nhất",
+ "pricingHighestCoverage": "Độ phủ cao nhất",
+ "pricingLowestCoverage": "Độ phủ thấp nhất",
+ "pricingNameAscending": "Tên (A–Z)",
+ "pricingCoverageGaps": "Thiếu dữ liệu giá",
+ "pricingClearFilters": "Xóa bộ lọc",
+ "pricingShowingProviders": "Đang hiển thị {visible}/{total}",
+ "pricingFilteredFrom": "(đã lọc từ {count})",
+ "pricingShowMoreProviders": "Hiển thị thêm {count} mục (còn {remaining})",
+ "modelOverridesTitle": "Ghi đè mô hình",
+ "modelOverridesDesc": "Ghi đè các khả năng của nhà cung cấp/mô hình mà tính năng định tuyến và định hình yêu cầu sử dụng. Các mục tiêu sử dụng cùng định dạng nhà cung cấp/mô hình như các tổ hợp.",
+ "searchModelOverrideTargets": "Tìm kiếm nhà cung cấp/mô hình...",
+ "selectedModel": "Mô hình đã chọn",
+ "configured": "đã định cấu hình",
+ "none": "Không có",
+ "modelOverrideValuePlaceholder": "Giá trị số",
+ "addKeyValue": "Thêm cặp khóa-giá trị",
+ "noModelOverrides": "Không có ghi đè nào được định cấu hình cho mô hình này.",
+ "modelOverrideLoadFailed": "Tải ghi đè mô hình thất bại",
+ "modelOverrideSaved": "Đã lưu ghi đè mô hình",
+ "modelOverrideRemoved": "Đã xóa ghi đè mô hình",
+ "modelOverrideSaveFailed": "Lưu ghi đè mô hình thất bại",
+ "modelOverrideRemoveFailed": "Xóa ghi đè mô hình thất bại",
"registry": "Danh mục",
"priced": "Đã định giá",
"searchProvidersModels": "Tìm kiếm nhà cung cấp hoặc mô hình...",
@@ -5599,6 +6718,12 @@
"adaptiveVolumeRoutingDesc": "Tự động điều chỉnh số lượng kết nối dựa trên khối lượng dữ liệu tải và áp lực thông lượng.",
"lkgpToggleTitle": "Nhà cung cấp hoạt động tốt gần đây nhất (LKGP)",
"lkgpToggleDesc": "Khi được bật, bộ định tuyến sẽ ghi nhớ nhà cung cấp nào đã phản hồi thành công gần nhất và thử nhà cung cấp đó trước tiên trong các yêu cầu tiếp theo.",
+ "echoRequestedModelTitle": "Trả lại tên mô hình được yêu cầu trong phản hồi",
+ "echoRequestedModelDesc": "Khi được bật, trường `model` trong phản hồi sẽ trả về tên bí danh hoặc tên combo mà ứng dụng khách yêu cầu thay vì tên mô hình nguồn. Khắc phục vấn đề với các ứng dụng khách nghiêm ngặt (ví dụ: Claude Desktop) từ chối phản hồi có mô hình không khớp với yêu cầu.",
+ "webSearchRouteTitle": "Định tuyến tìm kiếm web",
+ "webSearchRouteDesc": "Khi một yêu cầu bao gồm công cụ web_search gốc, hãy định tuyến toàn bộ yêu cầu đến mô hình này thay vì mô hình mặc định — hữu ích cho các nhà cung cấp không triển khai công cụ máy chủ web_search của Anthropic. Để trống để tắt.",
+ "webSearchRoutePlaceholder": "ví dụ: openrouter,anthropic/claude-3.5-sonnet",
+ "paidModelPatternWarning": "Mẫu này chỉ khớp với các model trả phí — hãy bật model trả phí hoặc điều chỉnh mẫu.",
"clearLkgpCache": "Xóa cache LKGP",
"lkgpCacheCleared": "Đã xóa cache LKGP thành công",
"lkgpCacheClearFailed": "Không thể xóa cache LKGP",
@@ -5608,6 +6733,22 @@
"maintenance": "Bảo trì",
"purgeExpiredLogs": "Dọn sạch log đã hết hạn",
"purgeLogsFailed": "Không thể dọn sạch log",
+ "logsDeleted": "{count, plural, =0 {Không có log hết hạn nào được dọn sạch} one {Đã dọn sạch # log hết hạn} other {Đã dọn sạch # log hết hạn}}",
+ "resetUsageData": "Đặt lại dữ liệu mức sử dụng",
+ "resetUsageDataDesc": "Chọn khoảng thời gian trước đó mà bạn muốn xóa dữ liệu mức sử dụng. Hành động này không thể hoàn tác.",
+ "resetUsagePeriod_5m": "5 phút",
+ "resetUsagePeriod_1h": "1 giờ",
+ "resetUsagePeriod_3h": "3 giờ",
+ "resetUsagePeriod_6h": "6 giờ",
+ "resetUsagePeriod_12h": "12 giờ",
+ "resetUsagePeriod_1d": "1 ngày",
+ "resetUsagePeriod_7d": "7 ngày",
+ "resetUsagePeriod_30d": "30 ngày",
+ "resetUsagePeriod_all": "Tất cả thời gian",
+ "resetUsageSuccess": "{count, plural, =0 {Không có hàng dữ liệu mức sử dụng nào bị xóa} one {Đặt lại dữ liệu mức sử dụng (đã xóa # hàng)} other {Đặt lại dữ liệu mức sử dụng (đã xóa # hàng)}}",
+ "resetUsageFailed": "Không thể đặt lại dữ liệu mức sử dụng",
+ "reset": "Đặt lại",
+ "resetting": "Đang đặt lại...",
"contextOpt": "Tối ưu hóa ngữ cảnh",
"contextOptDesc": "Định tuyến dựa trên yêu cầu cửa sổ ngữ cảnh và độ dài hội thoại",
"priorityDesc": "Dự phòng tuần tự - thử nhà cung cấp 1 trước, sau đó là nhà cung cấp 2, v.v.",
@@ -5663,6 +6804,21 @@
"clearSyncedPricing": "Xóa giá đã đồng bộ hóa",
"compressionTitle": "Nén prompt",
"compressionDesc": "Giảm mức sử dụng token bằng cách nén các prompt trước khi gửi đến các nhà cung cấp",
+ "compressionGuidanceFullGuideLink": "Hướng dẫn nén đầy đủ",
+ "compressionGuidanceShow": "Chi tiết",
+ "compressionGuidanceHide": "Ẩn chi tiết",
+ "compressionGuidanceSafeDefault": "Mặc định an toàn",
+ "compressionGuidanceCacheImpact": "Ảnh hưởng đến bộ nhớ đệm",
+ "tokenSaverTitle": "Tiết kiệm token",
+ "tokenSaverSubtitle": "Tiêu thụ ít token hơn trên mỗi yêu cầu.",
+ "tokenSaverToolOutput": "Đầu ra của công cụ",
+ "tokenSaverToolOutputDesc": "Làm sạch đầu ra của git, grep, ls, tree và log trước khi gửi tới nhà cung cấp.",
+ "tokenSaverLlmOutput": "Đầu ra LLM",
+ "tokenSaverLlmOutputDesc": "Chèn các hướng dẫn phản hồi ngắn gọn mà không cần viết lại đầu ra của nhà cung cấp.",
+ "tokenSaverInputCompression": "Nén đầu vào",
+ "tokenSaverInputCompressionDesc": "Viết lại lịch sử trò chuyện phù hợp trong khi vẫn bảo toàn mã, URL và ý định.",
+ "tokenSaverFineTunePrefix": "Tinh chỉnh từng bộ máy trên",
+ "tokenSaverFineTuneSuffix": "hoặc kết hợp các bộ máy cho mỗi yêu cầu trên",
"compressionMode": "Chế độ nén",
"compressionModeOff": "Tắt",
"compressionModeOffDesc": "Không áp dụng nén",
@@ -5682,6 +6838,11 @@
"compressionUltraMinScore": "Ngưỡng điểm tối thiểu",
"compressionUltraSlmFallback": "Dự phòng sang Mạnh",
"compressionUltraModelPath": "Đường dẫn mô hình SLM",
+ "compressionUltraEngine": "Cấp độ siêu mạnh",
+ "compressionUltraEngineHeuristic": "Theo quy tắc kinh nghiệm (Cấp A, mặc định)",
+ "compressionUltraEngineSlm": "SLM (LLMLingua-2, cần bật thủ công)",
+ "compressionUltraSlmHint": "SLM sẽ tải xuống một mô hình ONNX nhỏ trong lần đầu sử dụng (khởi động nguội) và tự động chuyển sang phương pháp theo quy tắc kinh nghiệm khi hết thời gian chờ hoặc không khả dụng.",
+ "compressionUltraSlmPrewarm": "Làm nóng trước mô hình SLM khi kích hoạt",
"compressionSummarizerEnabled": "Bật trình tóm tắt",
"compressionMaxTokensPerMessage": "Token tối đa cho mỗi tin nhắn",
"compressionMinSavings": "Ngưỡng tiết kiệm tối thiểu",
@@ -5693,8 +6854,14 @@
"compressionAutoTrigger": "Ngưỡng tự động kích hoạt",
"compressionCacheTTL": "Cache TTL",
"compressionPreserveSystem": "Giữ lại prompt hệ thống",
+ "compressionPreserveSystemAlways": "Luôn luôn",
+ "compressionPreserveSystemWhenNoCache": "Khi không có cache",
+ "compressionPreserveSystemNever": "Không bao giờ",
+ "compressionLiveZoneTitle": "Vùng hoạt động theo bộ nhớ đệm",
+ "compressionLiveZoneDesc": "Giữ ổn định tiền tố hội thoại đã nén và chỉ xử lý các mục mới được thêm vào.",
"compressionCavemanConfig": "Cấu hình động cơ Caveman",
"compressionCavemanConfigDesc": "Tinh chỉnh động cơ nén dựa trên quy tắc",
+ "compressionCavemanPanelHint": "Trạng thái bật/tắt và cấp độ của nó được thiết lập trong bảng điều khiển:",
"compressionRoles": "Nén vai trò tin nhắn",
"compressionRoleUser": "Người dùng",
"compressionRoleAssistant": "Trợ lý",
@@ -5869,17 +7036,81 @@
"resilienceNo": "Không",
"resilienceDefault": "Mặc định",
"storageDatabaseBackupRetention": "Thời gian lưu trữ bản sao lưu cơ sở dữ liệu",
+ "storageDatabaseBackups": "Bản sao lưu cơ sở dữ liệu",
+ "storageBackupRetentionDescription": "Các bản sao lưu SQLite tự động được lưu trong",
+ "storageBackupRetentionHelp": "Cấu hình số ảnh chụp nhanh cần giữ và tùy chọn xóa các bản sao lưu cũ hơn số ngày đã chọn.",
+ "storageBackupCount": "{count} bản sao lưu",
+ "storageBackupMaximum": "Tối đa {count}",
+ "storageBackupAgeRetention": "Lưu giữ {count} ngày",
+ "storageBackupAgeRetentionOff": "Đã tắt lưu giữ theo tuổi",
+ "storageBackupKeepLatest": "Giữ các bản sao lưu mới nhất",
+ "storageBackupDeleteOlderThan": "Xóa bản sao lưu cũ hơn (ngày)",
+ "storageBackupSaveRetention": "Lưu thời hạn giữ",
+ "storageBackupCleanOld": "Dọn bản sao lưu cũ",
+ "storageDatabaseStatistics": "Thống kê cơ sở dữ liệu",
+ "storageIntegrityNotChecked": "Chưa kiểm tra",
+ "backupRetentionSaved": "Đã lưu thời hạn giữ bản sao lưu.",
+ "backupRetentionSaveFailed": "Không thể lưu thời hạn giữ bản sao lưu",
+ "backupCleanupSuccess": "Đã xóa {backups} bộ sao lưu và {files} tệp.",
+ "backupCleanupFailed": "Không thể dọn dẹp bản sao lưu cơ sở dữ liệu",
+ "purgeQuotaSnapshotsSuccess": "Đã xóa {count} ảnh chụp nhanh hạn ngạch",
+ "purgeQuotaSnapshotsFailed": "Không thể xóa ảnh chụp nhanh hạn ngạch",
+ "purgeCallLogsSuccess": "Đã xóa {count} nhật ký cuộc gọi",
+ "purgeCallLogsFailed": "Không thể xóa nhật ký cuộc gọi",
+ "purgeDetailedLogsSuccess": "Đã xóa {count} nhật ký chi tiết",
+ "purgeDetailedLogsFailed": "Không thể xóa nhật ký chi tiết",
+ "vacuumCompleted": "Đã hoàn tất VACUUM",
+ "vacuumFailed": "VACUUM thất bại",
+ "jsonExportFailed": "Xuất JSON thất bại",
+ "invalidJsonFileType": "Loại tệp không hợp lệ. Chỉ cho phép tệp .json.",
+ "legacyJsonImportSuccess": "Đã nhập JSON kiểu cũ thành công!",
+ "jsonImportFailed": "Không thể nhập JSON",
+ "jsonImportError": "Đã xảy ra lỗi khi nhập JSON",
"storagePurgeData": "Xóa sạch dữ liệu",
+ "storagePurgeDataDesc": "Xóa ngay lập tức tất cả các bản ghi mà không áp dụng kiểm tra thời gian lưu trữ. Hãy thận trọng khi sử dụng.",
+ "storageRetentionCleanup": "Cài đặt thời hạn lưu giữ",
+ "storageRetentionCleanupDesc": "Cấu hình việc lưu giữ bản ghi vận hành và dọn dẹp các bản sao lưu cơ sở dữ liệu.",
+ "retentionCallDays": "Cuộc gọi {count} ngày",
+ "retentionAppDays": "Ứng dụng {count} ngày",
+ "retentionRows": "{count} hàng",
"retentionQuotaSnapshots": "Ảnh chụp nhanh hạn ngạch (ngày)",
+ "retentionCompressionAnalytics": "Phân tích nén (ngày)",
"retentionMcpAudit": "Kiểm toán MCP (ngày)",
"retentionA2aEvents": "Sự kiện A2A (ngày)",
"retentionCallLogs": "Nhật ký cuộc gọi (ngày)",
"retentionUsageHistory": "Lịch sử sử dụng (ngày)",
"retentionMemoryEntries": "Mục nhập bộ nhớ (ngày)",
+ "retentionXpAuditLog": "Nhật ký kiểm toán XP (ngày)",
+ "saveRetentionSettings": "Lưu cài đặt thời hạn lưu giữ",
"storageAutoVacuumMode": "Chế độ dọn dẹp tự động",
"storageScheduledVacuum": "Dọn dẹp theo lịch",
"storageVacuumHour": "Giờ dọn dẹp (0-23)",
"storagePageSize": "Kích thước trang (byte)",
+ "storageOptimizationSettings": "Cài đặt tối ưu hóa",
+ "storageJournalModeNone": "Không dùng",
+ "storageJournalModeFull": "Toàn bộ",
+ "storageJournalModeIncremental": "Tăng dần",
+ "storageVacuumNever": "Không bao giờ",
+ "storageVacuumDaily": "Hàng ngày",
+ "storageVacuumWeekly": "Hàng tuần",
+ "storageVacuumMonthly": "Hàng tháng",
+ "storageCacheSizeKb": "Kích thước cache (KB)",
+ "storageOptimizeOnStartup": "Tối ưu hóa khi khởi động",
+ "storageSaveOptimization": "Lưu cài đặt tối ưu hóa",
+ "storageCompressionAggregation": "Cài đặt nén và tổng hợp",
+ "storageEnableAggregation": "Bật tổng hợp dữ liệu",
+ "storageRawDataRetention": "Thời hạn giữ dữ liệu thô (ngày)",
+ "storageGranularity": "Độ chi tiết",
+ "storageHourly": "Theo giờ",
+ "storageDaily": "Theo ngày",
+ "storageWeekly": "Theo tuần",
+ "storageSaveAggregation": "Lưu cài đặt tổng hợp",
+ "exportJson": "Xuất JSON",
+ "importJson": "Nhập JSON",
+ "manualVacuum": "Chạy VACUUM thủ công",
+ "purgeQuotaSnapshots": "Xóa ảnh chụp nhanh hạn ngạch",
+ "purgeCallLogs": "Xóa nhật ký cuộc gọi",
+ "purgeDetailedLogs": "Xóa nhật ký chi tiết",
"storageDatabaseSize": "Kích thước cơ sở dữ liệu",
"storagePageCount": "Số lượng trang",
"storageFreelistCount": "Số trang trống",
@@ -5889,12 +7120,107 @@
"storageIntegrityOk": "✓ OK",
"storageIntegrityError": "✗ Lỗi",
"storageUsageTokenBuffer": "Bộ đệm token cho mức sử dụng",
+ "storageUsageTokenBufferDesc": "Các token bổ sung được thêm vào mức sử dụng được báo cáo để tính đến chi phí prompt hệ thống.",
+ "storageUsageTokenBufferHint": "Đặt thành 0 để báo cáo số lượng token gốc của nhà cung cấp. Mặc định: 2000.",
+ "storageUsageTokenBufferCurrent": "Hiện tại: {value}",
+ "redisLauncherTitle": "Redis cục bộ",
+ "redisLauncherDesc": "Khởi chạy nhanh một container Redis 7 chỉ với một cú nhấp chuột (Podman hoặc Docker) để làm bộ nhớ đệm phản hồi, theo dõi hạn ngạch và giới hạn tốc độ.",
+ "redisLauncherRefresh": "Làm mới",
+ "redisLauncherStop": "Dừng",
+ "redisLauncherLaunching": "Đang khởi chạy...",
+ "redisLauncherLaunch": "Khởi chạy Redis",
+ "redisLauncherContainer": "Vùng chứa",
+ "redisLauncherRunning": "Đang chạy",
+ "redisLauncherReachable": "Có thể kết nối",
+ "redisLauncherError": "Lỗi: {message}",
+ "redisLauncherHint": "Tương đương với việc chạy lệnh `omniroute redis up`. Vùng chứa có tên là `omniroute-redis` và lắng nghe tại 127.0.0.1:6379.",
"compressionSettingsAutoTriggerMode": "Chế độ kích hoạt tự động",
"compressionSettingsMcpDescriptionCompression": "Nén mô tả MCP",
+ "mcpAccessibilityTitle": "Đầu ra trợ năng MCP",
"compressionSettingsCavemanIntensity": "Cường độ Caveman",
"compressionSettingsCavemanOutputMode": "Chế độ đầu ra Caveman",
+ "compressionSettingsOutputStyles": "Kiểu đầu ra",
+ "compressionStylesTileTitle": "Kiểu đầu ra",
"compressionSettingsOutputIntensity": "Cường độ đầu ra",
"compressionSettingsAutoClarityBypass": "Bỏ qua tính năng tự động làm rõ",
+ "compressionEffectivePipeline": "Quy trình có hiệu lực:",
+ "compressionDerivedOff": "đã tắt",
+ "compressionDerivedRuns": "chạy: {pipeline}",
+ "compressionDerivedMode": "chế độ: {mode}",
+ "compressionAdaptiveOff": "Ngân sách ngữ cảnh thích ứng: đã tắt (dùng ngưỡng tự động kích hoạt cũ)",
+ "compressionAdaptiveTarget": "Thích ứng ({mode}, chính sách: {policy}) — mục tiêu ≈ {target, number} token (với cửa sổ {contextLimit, number} token)",
+ "compressionOutputStylesDescription": "Chèn hướng dẫn định hình phản hồi mà không viết lại đầu ra của nhà cung cấp. Có thể kết hợp tự do.",
+ "mcpAccessibilityDescription": "Giới hạn phạm vi đầu ra của công cụ MCP (được lưu riêng).",
+ "compressionStylesTileSummary": "Đã tiết kiệm {tokens, number} token · {runs, number} lượt áp dụng kiểu",
+ "compressionStylesTileEmpty": "Chưa có lượt chạy nào áp dụng kiểu đầu ra.",
+ "compressionLevel": {
+ "minimal": "Tối thiểu",
+ "standard": "Tiêu chuẩn",
+ "aggressive": "Mạnh",
+ "lite": "Nhẹ",
+ "full": "Đầy đủ",
+ "ultra": "Tối đa"
+ },
+ "compressionEngine": {
+ "session-dedup": {
+ "label": "Khử trùng lặp phiên",
+ "description": "Loại bỏ các khối trùng lặp giữa nhiều lượt trò chuyện."
+ },
+ "ccr": {
+ "label": "CCR (Truy xuất)",
+ "description": "Dùng dấu mốc truy xuất được định địa chỉ theo nội dung."
+ },
+ "lite": {
+ "label": "Nhẹ",
+ "description": "Dọn dẹp khoảng trắng và định dạng."
+ },
+ "rtk": {
+ "label": "RTK",
+ "description": "Lọc đầu ra của lệnh."
+ },
+ "headroom": {
+ "label": "Headroom",
+ "description": "Nén gọn JSON dạng bảng."
+ },
+ "relevance": {
+ "label": "Độ liên quan",
+ "description": "Chấm điểm và trích xuất câu theo truy vấn gần nhất của người dùng."
+ },
+ "caveman": {
+ "label": "Caveman",
+ "description": "Nén văn xuôi theo quy tắc."
+ },
+ "aggressive": {
+ "label": "Mạnh",
+ "description": "Tóm tắt và làm gọn các lượt trò chuyện cũ."
+ },
+ "llmlingua": {
+ "label": "LLMLingua (SLM)",
+ "description": "Cắt tỉa theo ngữ nghĩa (ONNX)."
+ },
+ "ultra": {
+ "label": "Tối đa",
+ "description": "Cắt tỉa token theo quy tắc kinh nghiệm, có thể kết hợp SLM."
+ },
+ "omniglyph": {
+ "label": "OmniGlyph",
+ "description": "Chuyển ngữ cảnh thành hình ảnh (Claude Fable 5, định tuyến trực tiếp)."
+ }
+ },
+ "compressionOutputStyle": {
+ "terse-prose": {
+ "label": "Văn phong súc tích",
+ "description": "Lược bỏ từ đệm, mạo từ và cách nói dè dặt nhưng giữ nguyên nội dung kỹ thuật."
+ },
+ "less-code": {
+ "label": "Ít mã hơn",
+ "description": "Theo nguyên tắc YAGNI: thay đổi nhỏ nhất có thể chạy được, không thêm lớp trừu tượng ngoài yêu cầu."
+ },
+ "terse-cjk": {
+ "label": "CJK súc tích (文言)",
+ "description": "Văn phong Hán cổ cực kỳ súc tích (chỉ khả dụng với tiếng Trung)."
+ }
+ },
"resilienceWaitForCooldown": "Chờ thời gian hồi",
"resilienceEnableServerSideWait": "Bật tính năng chờ phía máy chủ",
"resilienceMaximumRetries": "Số lần thử lại tối đa",
@@ -5908,6 +7234,9 @@
"cliproxyapiUrl": "URL CLIProxyAPI",
"cliproxyapiStatus": "Trạng thái CLIProxyAPI",
"cliproxyapiNotDetected": "Không phát hiện thấy",
+ "cliproxyapiImportAuthTitle": "Nhập tài khoản từ CLIProxyAPI",
+ "cliproxyapiImportAuthDesc": "Nhập các tài khoản OAuth mà CLIProxyAPI đã lưu trong ~/.cli-proxy-api/ dưới dạng các kết nối OmniRoute, nhờ đó bạn không cần phải đăng nhập lại vào từng tài khoản. Các loại tài khoản được hỗ trợ (Gemini, Codex, Claude, Antigravity, Qwen, Kimi) sẽ được nhập; các loại tài khoản khác sẽ bị bỏ qua.",
+ "cliproxyapiImportAuthButton": "Nhập tài khoản",
"payloadRulesTitle": "Quy tắc payload",
"payloadRulesDesc": "Cấu hình việc biến đổi payload yêu cầu theo mô hình và giao thức. Các thay đổi được lưu trong cài đặt và được tải lại nóng vào môi trường chạy ngay sau khi lưu.",
"payloadRuleDefaultTitle": "default",
@@ -5940,10 +7269,32 @@
"maxResponseTokensDesc": "Tổng số token tối đa được phép trong một phản hồi đơn lẻ.",
"modelCooldownsTitle": "Các mô hình đang trong thời gian chờ",
"modelCooldownsEmpty": "Hiện không có mô hình nào đang trong thời gian chờ.",
+ "modelCooldownsDescription": "Các mô hình bị cô lập tạm thời sau khi xảy ra lỗi. Khi hết thời gian chờ, chúng sẽ tự động hoạt động lại.",
+ "modelCooldownsLoadFailed": "Không thể tải danh sách thời gian chờ",
+ "modelCooldownReactivated": "Đã kích hoạt lại mô hình: {model}",
+ "modelCooldownClearFailed": "Không thể xóa thời gian chờ",
+ "modelCooldownsAllReactivated": "Đã kích hoạt lại tất cả mô hình đang trong thời gian chờ.",
+ "modelCooldownsClearFailed": "Không thể xóa các thời gian chờ",
+ "modelCooldownsReactivateAll": "Kích hoạt lại tất cả",
+ "modelCooldownsReasonRemaining": "lý do: {reason} • còn lại: {remaining}",
+ "modelCooldownsReactivate": "Kích hoạt lại",
+ "responsesStateTitle": "Trạng thái Responses",
+ "responsesStateDesc": "Kiểm soát cách OmniRoute chuyển tiếp previous_response_id.",
+ "responsesStateModeLabel": "Xử lý previous_response_id",
+ "responsesStateModeAuto": "Tự động",
+ "responsesStateModeStrip": "Loại bỏ",
+ "responsesStateModePreserve": "Giữ lại",
+ "responsesStateHint": "Tự động loại bỏ previous_response_id, trừ khi một kết nối bật rõ ràng tính năng lưu trữ OpenAI Responses. Loại bỏ là lựa chọn an toàn nhất cho các ứng dụng khách không trạng thái như VS Code Custom Endpoint; khi đó, ngữ cảnh phụ thuộc vào việc ứng dụng khách gửi toàn bộ lịch sử.",
+ "responsesStateSaveError": "Không thể cập nhật cài đặt trạng thái Responses",
"codexFastTierTitle": "Codex Fast Tier",
"codexFastTierDesc": "Thêm service_tier=priority trên toàn cục cho các yêu cầu OpenAI Codex.",
"codexFastTierHint": "Khi được bật, OmniRoute sẽ thêm service_tier=priority vào các yêu cầu Codex gửi đi của những kết nối chưa chỉ định cấp dịch vụ. Cấp Priority yêu cầu khóa API OpenAI Enterprise hoặc đường dẫn Codex xác thực bằng ChatGPT; các loại khóa khác sẽ nhận được lỗi liên quan đến cấp từ OpenAI. Cài đặt riêng cho từng kết nối trên trang nhà cung cấp Codex được ưu tiên.",
"codexFastTierSaveError": "Không thể cập nhật cài đặt Codex Fast Tier",
+ "codexAutoPingTitle": "Tự động ping hạn ngạch Codex",
+ "codexAutoPingDesc": "Tùy chọn theo từng kết nối: gửi một yêu cầu nhỏ ngay sau khi cửa sổ phiên Codex đặt lại để kết nối sẵn sàng khi cần.",
+ "codexAutoPingWarning": "Mỗi lần ping sẽ dùng một lượng nhỏ hạn ngạch Codex thực. Mặc định tắt — chỉ bật cho các kết nối bạn thường xuyên sử dụng.",
+ "codexAutoPingSaveError": "Không thể cập nhật cài đặt tự động ping Codex",
+ "codexAutoPingToggleAria": "Bật hoặc tắt tự động ping hạn ngạch Codex cho {connection}",
"codexFastTierTierLabel": "Cấp dịch vụ",
"codexFastTierTierPriority": "Ưu tiên (Priority)",
"codexFastTierTierFlex": "Linh hoạt (Flex)",
@@ -5958,6 +7309,12 @@
"claudeFastModeModelCheckbox": "Bật Fast Mode cho {model}",
"claudeFastModeSaveError": "Không thể cập nhật cài đặt Claude Fast Mode",
"authz": {
+ "cors": {
+ "wildcard": {
+ "title": "CORS được mở cho mọi nguồn gốc (CORS_ALLOW_ALL=true)",
+ "desc": "Bất kỳ trang web nào cũng có thể gọi API của máy chủ này từ trình duyệt của khách truy cập. Chỉ sử dụng trên các mạng đáng tin cậy — hãy đặt các nguồn gốc cụ thể trong ALLOWED_ORIGINS và vô hiệu hóa CORS_ALLOW_ALL trong môi trường sản xuất."
+ }
+ },
"title": "Danh mục ủy quyền",
"description": "Phân loại định tuyến theo 5 cấp với chính sách bỏ qua đang áp dụng. Chế độ đọc hiển thị toàn bộ hệ thống phân loại; các thay đổi sẽ yêu cầu nhập lại mật khẩu quản lý.",
"loading": "Đang tải danh mục…",
@@ -6010,12 +7367,6 @@
"INSUFFICIENT_SCOPE": "Khóa API không có phạm vi quản lý.",
"BYPASS_PREFIX_NOT_ALLOWED": "Một hoặc nhiều tiền tố nhắm đến các tuyến có khả năng tạo tiến trình và không thể bỏ qua.",
"GENERIC": "Không thể cập nhật cài đặt phân quyền."
- },
- "cors": {
- "wildcard": {
- "title": "CORS is open to every origin (CORS_ALLOW_ALL=true)",
- "desc": "Any website can call this server's API from a visitor's browser. Use only on trusted networks — set explicit origins in ALLOWED_ORIGINS and disable CORS_ALLOW_ALL in production."
- }
}
},
"resilienceBaseCooldownLabel": "Thời gian hồi cơ bản",
@@ -6052,8 +7403,28 @@
"resilienceEnableServerWaitDesc": "Khi được bật, OmniRoute sẽ đợi thời gian chờ đầu tiên kết thúc và tự động thử lại.",
"resilienceMaxAttempts": "Số lần thử tối đa",
"resilienceMaxWaitPerAttempt": "Thời gian chờ tối đa cho mỗi lần thử",
+ "resilienceComboCooldownWaitTitle": "Chờ thời gian chờ cho combo chia sẻ hạn ngạch",
+ "resilienceComboCooldownWaitDesc": "Chỉ dành cho các combo chia sẻ hạn ngạch: chờ hết một khoảng thời gian chờ tạm thời ngắn rồi điều phối lại yêu cầu thay vì trả về lỗi 429 ngay lập tức. Không bao giờ chờ đối với quota_exhausted.",
+ "resilienceComboCooldownWaitToggleDesc": "Chỉ dành cho các combo chia sẻ hạn ngạch; không bao giờ chờ đối với lỗi quota_exhausted.",
+ "resilienceComboCooldownMaxWaitMs": "Thời gian chờ tối đa cho mỗi lần thử",
+ "resilienceComboCooldownBudgetMs": "Tổng thời gian chờ cho phép",
+ "resilienceQuotaShareConcurrencyTitle": "Số lượng yêu cầu đồng thời trên mỗi kết nối chia sẻ hạn ngạch",
+ "resilienceQuotaShareConcurrencyDesc": "Chỉ dành cho các combo chia sẻ hạn ngạch: khi một kết nối đặt giới hạn Max Concurrent, các yêu cầu đồng thời đến tài khoản đăng ký đó sẽ được xử lý tuần tự để tài khoản không bao giờ bị quá tải vượt quá giới hạn. Các yêu cầu vượt mức sẽ chờ trong hàng đợi thay vì nhận lỗi 429. Giới hạn này lấy từ trường Max Concurrent của từng kết nối; công tắc này chỉ bật hoặc tắt việc tuân thủ giới hạn đó.",
+ "resilienceQuotaShareConcurrencyToggleDesc": "Chỉ dành cho các combo chia sẻ hạn ngạch; áp dụng giới hạn Max Concurrent của mỗi kết nối.",
+ "resilienceProviderCooldownTitle": "Thời gian chờ của nhà cung cấp",
+ "resilienceProviderCooldownScope": "Tất cả yêu cầu combo",
+ "resilienceProviderCooldownTrigger": "Khi nhà cung cấp/kết nối bị lỗi",
+ "resilienceProviderCooldownEffect": "Bỏ qua nhà cung cấp bị lỗi trong một thời gian chờ trước khi thử lại",
+ "resilienceProviderCooldownDesc": "Ngăn các yêu cầu tiếp theo duyệt lại những nhà cung cấp đang gặp lỗi. Thời gian chờ sẽ tăng theo cấp số nhân theo số lỗi liên tiếp.",
+ "resilienceProviderCooldownEnabled": "Bật thời gian chờ nhà cung cấp toàn cục",
+ "resilienceProviderCooldownEnabledDesc": "Khi được bật, các nhà cung cấp bị lỗi sẽ được theo dõi toàn cục và bị bỏ qua trong một khoảng thời gian chờ.",
+ "resilienceProviderCooldownMin": "Thời gian chờ tối thiểu",
+ "resilienceProviderCooldownMax": "Thời gian chờ tối đa",
"forcedFingerprintTitle": "Luôn được bật cho {provider} — bắt buộc để đảm bảo an toàn tài khoản OAuth; không thể tắt.",
"forcedFingerprintBadge": "Bắt buộc",
+ "sessionAffinityTitle": "Liên kết phiên",
+ "sessionAffinityDesc": "Giữ một cuộc trò chuyện trên cùng một tài khoản trong số giây này đối với mọi nhà cung cấp. Giá trị 0 sẽ vô hiệu hóa tính năng.",
+ "sessionAffinityTtl": "TTL liên kết (giây)",
"resetAwareQuotaCacheTitle": "Bộ nhớ đệm hạn ngạch theo thời điểm đặt lại",
"resetAwareQuotaCacheDesc": "Chỉ lưu vào bộ nhớ đệm dữ liệu đo từ xa về hạn ngạch để sắp xếp theo thời điểm đặt lại. Việc kiểm tra trước hạn ngạch vẫn bảo vệ các yêu cầu. 0/0 sẽ tiếp tục tìm nạp theo thời gian thực.",
"resetAwareQuotaCacheTtl": "Thời gian dữ liệu được coi là mới (giây)",
@@ -6070,279 +7441,70 @@
"vercelRelayFreeTierNote": "Relay là các endpoint proxy gọn nhẹ được triển khai trên gói miễn phí của Vercel để vượt qua các hạn chế về mạng hoặc khu vực cục bộ.",
"vercelRelayDeploying": "Đang triển khai...",
"vercelRelayDeploy": "Triển khai",
- "homePinProviderQuotaToHome": "Ghim thông tin vào trang chủ",
- "homeProviderQuotaLimits": "Giới hạn hạn ngạch nhà cung cấp",
- "homeProviderQuotaLimitsDesc": "Ghim khung trạng thái hạn ngạch nhà cung cấp (kèm nút Làm mới tất cả) lên đầu trang chủ.",
- "homeQuickStart": "Bắt đầu nhanh",
- "homeQuickStartDesc": "Hiển thị bảng Bắt đầu nhanh trên trang chủ.",
- "homeProviderTopology": "Cấu trúc liên kết nhà cung cấp",
- "homeProviderTopologyDesc": "Hiển thị cấu trúc liên kết nhà cung cấp trên trang chủ.",
- "codexAutoPingTitle": "Tự động ping hạn ngạch Codex",
- "codexAutoPingDesc": "Tùy chọn theo từng kết nối: gửi một yêu cầu nhỏ ngay sau khi cửa sổ phiên Codex đặt lại để kết nối sẵn sàng khi cần.",
- "codexAutoPingWarning": "Mỗi lần ping sẽ dùng một lượng nhỏ hạn ngạch Codex thực. Mặc định tắt — chỉ bật cho các kết nối bạn thường xuyên sử dụng.",
- "codexAutoPingSaveError": "Không thể cập nhật cài đặt tự động ping Codex",
- "codexAutoPingToggleAria": "Bật hoặc tắt tự động ping hạn ngạch Codex cho {connection}",
- "description": "Description",
- "enable": "Enable",
- "disable": "Disable",
- "update": "Update",
- "memoryTokenCostWarning": "Heads up: with memory enabled, OmniRoute injects up to {tokens} tokens of retrieved context into every chat request — this increases token usage and cost. To skip injection for a specific request, send the \"x-omniroute-no-memory: true\" header.",
- "logToolSourcesToggle": "Log Tool Sources",
- "logToolSourcesDescription": "Emit a diagnostic log line per request summarizing tool count and MCP/hosted/client source breakdown.",
- "accountEmailVisibility": "Account email visibility",
- "accountEmailVisibilityDesc": "Show full account emails across providers, combos, logs, quota, and playground screens. Turn this off to mask them by default.",
- "customBannedSignals": "Banned Keywords",
- "customBannedSignalsDesc": "Additional keywords that trigger permanent account ban detection. Built-in keywords always apply.",
- "customBannedSignalsPlaceholder": "e.g. api key revoked",
- "noCustomBannedSignals": "No custom keywords. Only built-in keywords are active.",
- "perKeyProxyEnabled": "Enable per-key proxy assignment",
- "perKeyProxyEnabledDesc": "When enabled, each provider connection can use its own proxy assignment",
- "proxyPool": "Proxy Pool",
- "freePool": "Free Pool",
- "proxyDocumentation": "Proxy Documentation",
- "proxyGlobalConfigTab": "Global Config",
- "proxyPoolTab": "Proxy Pool",
- "freePoolTab": "Free Pool",
- "proxyDocumentationTab": "Documentation",
- "proxyDocumentationScopeTitle": "Proxy scope resolution",
- "proxyDocumentationScopeDescBefore": "OmniRoute resolves the outbound proxy in priority order:",
- "proxyDocumentationScopeOrder": "combo → account → provider → global",
- "proxyDocumentationScopeDescAfter": ". The most specific scope wins.",
- "proxyDocumentationAddTitle": "Adding a custom proxy",
- "proxyDocumentationAddDescBefore": "Go to the",
- "proxyDocumentationAddDescMiddle": "tab → click",
- "proxyDocumentationAddCta": "+ Add proxy",
- "proxyDocumentationAddDescAfter": ". Fill in type (http/https/socks5), host, and port. Optionally assign it to a scope.",
- "proxyDocumentationBulkTitle": "Bulk import format",
- "proxyDocumentationBulkDesc": "Pipe-delimited fields:",
- "proxyDocumentationSocks5DescBefore": "SOCKS5 proxies are disabled by default. Set",
- "proxyDocumentationFreePoolDesc": "The Free Pool tab aggregates proxies from 1proxy, Proxifly, and IPLocate. Use ⊕ to test and promote a proxy to your registry. Only proxies that pass the connectivity test are added.",
- "proxyDocumentationVercelRelayDescBefore": "The Vercel Relay is an outbound edge relay — not an inbound tunnel. Deploying one sends LLM API calls through Vercel's dynamic IPs, bypassing datacenter geo-blocks and rate limits. The relay is protected by a generated secret header",
- "proxyDocumentationVercelRelayDescAfter": "Your Vercel token is used only during deploy and is never stored.",
- "vercelRelayTokenRequired": "Token is required",
- "vercelRelayDeployFailed": "Deploy failed",
- "vercelRelayTokenHint": "Token is used only during deploy — never stored.",
- "denoRelayTokenRequired": "Deno Deploy token is required",
- "denoRelayOrgDomainRequired": "Organization domain is required",
- "denoRelayDeployFailed": "Deno Deploy failed",
- "denoRelayTokenHint": "Organization token (prefix ddo_) from console.deno.com → Organization → Settings → Organization Tokens. Used once for deploy and never stored.",
- "denoRelayOrgDomainHint": "Your Deno Deploy organization's default domain (e.g. acme.deno.net). The relay will be reachable at https://..deno.net.",
- "proxyFreePoolFilterProtocol": "Filter by protocol",
- "proxyFreePoolProtocol": "Protocol",
- "proxyFreePoolCountryPlaceholder": "Country (e.g. US)",
- "proxyFreePoolFilterCountry": "Filter by country",
- "proxyFreePoolMinQualityPlaceholder": "Min quality",
- "proxyFreePoolMinQualityLabel": "Minimum quality score",
- "proxyFreePoolSyncAll": "Sync All",
- "proxyFreePoolTotal": "Total",
- "proxyFreePoolInPool": "In pool",
- "proxyFreePoolAvgQuality": "Avg quality",
- "proxyFreePoolSelected": "{count} selected",
- "proxyFreePoolAddSelected": "Add selected to pool",
- "proxyFreePoolAddVisible": "Add all visible to pool",
- "proxyFreePoolSource": "Source",
- "proxyFreePoolHostPort": "Host:Port",
- "proxyFreePoolType": "Type",
- "proxyFreePoolCountry": "Country",
- "proxyFreePoolQuality": "Quality",
- "proxyFreePoolLatency": "Latency",
- "proxyFreePoolEmpty": "No proxies found. Click Sync All to fetch from sources.",
- "proxyFreePoolSearchPlaceholder": "Search host…",
- "proxyFreePoolSearchLabel": "Search proxies by host",
- "proxyFreePoolSortLabel": "Sort proxies",
- "proxyFreePoolSortQuality": "Quality",
- "proxyFreePoolSortLatency": "Latency",
- "proxyFreePoolSortRecent": "Recently validated",
- "proxyFreePoolListTotal": "Listed",
- "proxyFreePoolLoadMore": "Load more",
- "close": "Close",
- "cancel": "Cancel",
- "routingStrategyTitle": "Routing Strategy",
- "routingStrategySubtitle": "Match 9router: account round-robin, sticky limits, and combo rotation",
- "accountRoundRobin": "Round Robin",
- "accountRoundRobinDesc": "Cycle through accounts to distribute load",
- "comboRoundRobin": "Combo Round Robin",
- "comboRoundRobinDesc": "Cycle through combo targets instead of always starting with the first",
- "comboStickyLimit": "Combo Sticky Limit",
- "comboStickyLimitDesc": "Calls per combo target before switching",
- "routingStrategyAccountSummary": "Distributing requests across accounts with {limit} calls per account.",
- "routingStrategyFillFirstSummary": "Using accounts in priority order (Fill First).",
- "routingStrategyComboSummary": " Combos rotate after {limit} call(s) per target.",
- "routingStrategyComboFallbackSummary": " Combos use each combo's configured strategy (default priority/fallback).",
- "providerAccountRoutingTitle": "Multi-account routing",
- "providerAccountRoutingDesc": "Override global account strategy for this provider (9router parity).",
- "providerRoutingStrategy": "Account strategy",
- "providerRoutingInheritGlobal": "Inherit global default",
- "selectProviderPlaceholder": "Select provider...",
- "searchProviderPlaceholder": "Search providers...",
- "searchProviderAria": "Search providers",
- "queueDepth": "Queue Depth",
- "export": "Export",
- "modelOverridesTitle": "Model Overrides",
- "modelOverridesDesc": "Override provider/model capabilities used by routing and request shaping. Targets use the same provider/model form as combos.",
- "searchModelOverrideTargets": "Search provider/model...",
- "selectedModel": "Selected model",
- "configured": "configured",
- "none": "None",
- "modelOverrideValuePlaceholder": "Numeric value",
- "addKeyValue": "Add key value",
- "noModelOverrides": "No overrides configured for this model.",
- "modelOverrideLoadFailed": "Failed to load model overrides",
- "modelOverrideSaved": "Model override saved",
- "modelOverrideRemoved": "Model override removed",
- "modelOverrideSaveFailed": "Failed to save model override",
- "modelOverrideRemoveFailed": "Failed to remove model override",
- "echoRequestedModelTitle": "Echo requested model name in responses",
- "echoRequestedModelDesc": "When enabled, the response `model` field echoes the alias or combo name the client requested instead of the upstream model name. Fixes strict clients (e.g. Claude Desktop) that reject a response whose model does not match the request.",
- "webSearchRouteTitle": "Web search routing",
- "webSearchRouteDesc": "When a request includes a native web_search tool, route the whole request to this model instead of the default — useful for providers that don't implement Anthropic's web_search server tool. Leave blank to disable.",
- "webSearchRoutePlaceholder": "Search or select a model…",
- "paidModelPatternWarning": "This pattern only matches paid models — enable paid models or adjust the pattern.",
- "logsDeleted": "{count, plural, =0 {No expired logs purged} one {Purged # expired log} other {Purged # expired logs}}",
- "resetUsageData": "Reset Usage Data",
- "resetUsageDataDesc": "Select how far back you want to delete usage, request logs, and analytics data. Provider configuration, connections, API keys, combos, and settings are preserved. This action cannot be undone.",
- "resetUsagePeriod_5m": "5 minutes",
- "resetUsagePeriod_1h": "1 hour",
- "resetUsagePeriod_3h": "3 hours",
- "resetUsagePeriod_6h": "6 hours",
- "resetUsagePeriod_12h": "12 hours",
- "resetUsagePeriod_1d": "1 day",
- "resetUsagePeriod_7d": "7 days",
- "resetUsagePeriod_30d": "30 days",
- "resetUsagePeriod_all": "All time",
- "resetUsageSuccess": "{count, plural, =0 {No usage data rows deleted} one {Reset usage data (# row deleted)} other {Reset usage data (# rows deleted)}}",
- "resetUsageFailed": "Failed to reset usage data",
- "reset": "Reset",
- "resetting": "Resetting...",
- "compressionGuidanceFullGuideLink": "Full compression guide",
- "compressionGuidanceShow": "Details",
- "compressionGuidanceHide": "Hide details",
- "compressionGuidanceSafeDefault": "Safe default",
- "compressionGuidanceCacheImpact": "Cache impact",
- "tokenSaverTitle": "Token Saver",
- "tokenSaverSubtitle": "Spend fewer tokens on every request.",
- "tokenSaverToolOutput": "Tool output",
- "tokenSaverToolOutputDesc": "Clean git, grep, ls, tree, and log output before it reaches the provider.",
- "tokenSaverLlmOutput": "LLM output",
- "tokenSaverLlmOutputDesc": "Inject terse response instructions without rewriting provider output.",
- "tokenSaverInputCompression": "Input compression",
- "tokenSaverInputCompressionDesc": "Rewrite eligible chat history while preserving code, URLs, and intent.",
- "tokenSaverFineTunePrefix": "Fine-tune each engine on",
- "tokenSaverFineTuneSuffix": "or combine engines per request on",
- "compressionUltraEngine": "Ultra tier",
- "compressionUltraEngineHeuristic": "Heuristic (Tier-A, default)",
- "compressionUltraEngineSlm": "SLM (LLMLingua-2, opt-in)",
- "compressionUltraSlmHint": "SLM downloads a small ONNX model on first use (cold-start) and transparently falls back to the heuristic on timeout or if unavailable.",
- "compressionUltraSlmPrewarm": "Pre-warm SLM model on enable",
- "compressionPreserveSystemAlways": "Always",
- "compressionPreserveSystemWhenNoCache": "When no cache",
- "compressionPreserveSystemNever": "Never",
- "compressionLiveZoneTitle": "Cache-aligned Live Zone",
- "compressionLiveZoneDesc": "Keep the compressed conversation prefix stable and process only newly appended items.",
- "compressionCavemanPanelHint": "Its on/off and level are set in the panel:",
- "storageDatabaseBackups": "Database backups",
- "storagePurgeDataDesc": "Immediately delete all records without applying retention checks. Use with caution.",
- "storageRetentionCleanup": "Retention Settings",
- "storageRetentionCleanupDesc": "Configure operational record retention and database backup cleanup.",
- "retentionCallDays": "Call {count}d",
- "retentionAppDays": "App {count}d",
- "retentionRows": "{count} rows",
- "retentionCompressionAnalytics": "Compression Analytics (days)",
- "retentionXpAuditLog": "XP Audit Log (days)",
- "saveRetentionSettings": "Save retention settings",
- "storageUsageTokenBufferDesc": "Extra tokens added to reported usage to account for system prompt overhead.",
- "storageUsageTokenBufferHint": "Set to 0 to report raw provider token counts. Default: 2000.",
- "storageUsageTokenBufferCurrent": "Current: {value}",
- "redisLauncherTitle": "Local Redis",
- "redisLauncherDesc": "One-click launch a Redis 7 container (Podman or Docker) for response cache, quota tracking, and rate limiting.",
- "redisLauncherRefresh": "Refresh",
- "redisLauncherStop": "Stop",
- "redisLauncherLaunching": "Launching...",
- "redisLauncherLaunch": "Launch Redis",
- "redisLauncherContainer": "Container",
- "redisLauncherRunning": "Running",
- "redisLauncherReachable": "Reachable",
- "redisLauncherError": "Error: {message}",
- "redisLauncherHint": "Equivalent to running `omniroute redis up`. The container is named `omniroute-redis` and listens on 127.0.0.1:6379.",
- "mcpAccessibilityTitle": "MCP accessibility output",
- "compressionSettingsOutputStyles": "Output styles",
- "compressionStylesTileTitle": "Output styles",
- "cliproxyapiImportAuthTitle": "Import accounts from CLIProxyAPI",
- "cliproxyapiImportAuthDesc": "Import the OAuth accounts CLIProxyAPI already saved in ~/.cli-proxy-api/ as OmniRoute connections, so you don't have to log in to each account again. Supported account types (Gemini, Codex, Claude, Antigravity, Qwen, Kimi) are imported; others are skipped.",
- "cliproxyapiImportAuthButton": "Import accounts",
- "responsesStateTitle": "Responses State",
- "responsesStateDesc": "Control how OmniRoute forwards previous_response_id.",
- "responsesStateModeLabel": "previous_response_id handling",
- "responsesStateModeAuto": "Auto",
- "responsesStateModeStrip": "Strip",
- "responsesStateModePreserve": "Preserve",
- "responsesStateHint": "Auto strips previous_response_id unless a connection explicitly enables OpenAI Responses storage. Strip is safest for stateless clients such as VS Code Custom Endpoint; context then depends on the client sending full history.",
- "responsesStateSaveError": "Failed to update Responses state setting",
- "resilienceComboCooldownWaitTitle": "Quota-share combo cooldown wait",
- "resilienceComboCooldownWaitDesc": "For quota-share combos only: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.",
- "resilienceComboCooldownWaitToggleDesc": "Quota-share combos only; never waits on quota_exhausted.",
- "resilienceComboCooldownMaxWaitMs": "Maximum wait per attempt",
- "resilienceComboCooldownBudgetMs": "Total wait budget",
- "resilienceQuotaShareConcurrencyTitle": "Quota-share per-connection concurrency",
- "resilienceQuotaShareConcurrencyDesc": "For quota-share combos only: when a connection sets a Max Concurrent cap, serialize concurrent requests to that subscription account so it is never flooded past its ceiling. Excess requests wait in the queue instead of getting a 429. The cap comes from each connection's Max Concurrent field; this switch only enables or disables honoring it.",
- "resilienceQuotaShareConcurrencyToggleDesc": "Quota-share combos only; honors each connection's Max Concurrent cap.",
- "resilienceProviderCooldownTitle": "Provider Cooldown",
- "resilienceProviderCooldownScope": "All combo requests",
- "resilienceProviderCooldownTrigger": "When a provider/connection fails",
- "resilienceProviderCooldownEffect": "Skips the failed provider for a cooldown period before retrying",
- "resilienceProviderCooldownDesc": "Prevents subsequent requests from re-walking the same failing providers. Cooldown scales exponentially with consecutive failures.",
- "resilienceProviderCooldownEnabled": "Enable global provider cooldown",
- "resilienceProviderCooldownEnabledDesc": "When enabled, failed providers are tracked globally and skipped for a cooldown period.",
- "resilienceProviderCooldownMin": "Minimum cooldown",
- "resilienceProviderCooldownMax": "Maximum cooldown",
- "sessionAffinityTitle": "Session affinity",
- "sessionAffinityDesc": "Keeps one conversation on the same account for this many seconds, for any provider. 0 disables it.",
- "sessionAffinityTtl": "Affinity TTL (seconds)",
- "deployRelayButton": "Deploy Relay",
- "denoRelayButton": "Deploy Deno Relay",
- "denoRelayModalTitle": "Deploy Deno Relay",
- "denoRelayWarning": "Warning: A Deno Deploy organization token is required. This token will only be used to create the relay app and will never be stored by OmniRoute.",
- "denoRelayTokenLabel": "Deno Deploy Organization Token",
- "denoRelayOrgDomainLabel": "Organization Domain",
- "denoRelayProjectNameLabel": "Deno App Name",
- "denoRelayFreeTierNote": "Deno Deploy v2 runs on a global edge network. Free tier: 1M requests & 100GiB outbound traffic per month, no per-request CPU limits, up to 20 active apps & 50 custom domains.",
- "denoRelayDeploying": "Deploying...",
- "denoRelayDeploy": "Deploy",
- "cloudflareRelaySuccess": "Cloudflare Relay deployed successfully",
- "cloudflareRelayButton": "Deploy Cloudflare Relay",
- "cloudflareRelayModalTitle": "Deploy Cloudflare Worker Relay",
- "cloudflareRelayWarning": "Deploys a Cloudflare Worker that proxies LLM requests through Cloudflare's edge network — masking the host IP behind dynamic Cloudflare addresses. The Worker enforces a one-time auth secret so the public workers.dev URL cannot be reused as an open relay.",
- "cloudflareRelayTokenHowto": "Create the API token under My Profile -> API Tokens -> Create Token -> Custom Token -> Account / Workers Scripts / Edit.",
- "cloudflareRelayAccountIdLabel": "Cloudflare Account ID",
- "cloudflareRelayAccountIdHint": "Found on the right side of the Cloudflare dashboard overview page.",
+ "deployRelayButton": "Triển khai Relay",
+ "denoRelayButton": "Triển khai Deno Relay",
+ "denoRelayModalTitle": "Triển khai Deno Relay",
+ "denoRelayWarning": "Cảnh báo: Cần có token tổ chức Deno Deploy. Token này chỉ được sử dụng để tạo ứng dụng relay và OmniRoute sẽ không bao giờ lưu trữ token này.",
+ "denoRelayTokenLabel": "Token tổ chức Deno Deploy",
+ "denoRelayOrgDomainLabel": "Tên miền tổ chức",
+ "denoRelayProjectNameLabel": "Tên ứng dụng Deno",
+ "denoRelayFreeTierNote": "Deno Deploy v2 chạy trên mạng biên toàn cầu. Gói miễn phí: 1 triệu yêu cầu và 100 GiB lưu lượng đi ra mỗi tháng, không có giới hạn CPU cho mỗi yêu cầu, tối đa 20 ứng dụng đang hoạt động và 50 tên miền tùy chỉnh.",
+ "denoRelayDeploying": "Đang triển khai...",
+ "denoRelayDeploy": "Triển khai",
+ "cloudflareRelaySuccess": "Đã triển khai Cloudflare Relay thành công",
+ "cloudflareRelayButton": "Triển khai Cloudflare Relay",
+ "cloudflareRelayModalTitle": "Triển khai Cloudflare Worker Relay",
+ "cloudflareRelayWarning": "Triển khai một Cloudflare Worker để proxy các yêu cầu LLM qua mạng biên của Cloudflare — ẩn IP máy chủ lưu trữ sau các địa chỉ Cloudflare động. Worker yêu cầu khóa bí mật xác thực dùng một lần để URL workers.dev công khai không thể bị tái sử dụng làm relay mở.",
+ "cloudflareRelayTokenHowto": "Tạo token API trong mục Hồ sơ của tôi -> Token API -> Tạo token -> Token tùy chỉnh -> Tài khoản / Workers Scripts / Chỉnh sửa.",
+ "cloudflareRelayAccountIdLabel": "ID tài khoản Cloudflare",
+ "cloudflareRelayAccountIdHint": "Có thể tìm thấy ở phía bên phải trang tổng quan Cloudflare.",
"cloudflareRelayApiTokenLabel": "Cloudflare API Token",
- "cloudflareRelayApiTokenHint": "Requires 'Workers Scripts: Edit' permission. The token is used only at deploy time and is never stored.",
- "cloudflareRelayProjectNameLabel": "Worker Name",
- "cloudflareRelayFreeTierNote": "Free tier: 100,000 requests/day per Cloudflare account.",
- "cloudflareRelayDeploying": "Deploying...",
- "cloudflareRelayDeploy": "Deploy",
- "cloudflareRelayCredsRequired": "Account ID and API Token are required",
- "cloudflareRelayDeployFailed": "Deploy failed",
- "modelLockout": "Model Lockout",
- "modelLockoutPageDescription": "Configure which HTTP error codes trigger per-model lockout and control the cooldown behavior.",
- "modelLockoutEnabled": "Enable Model Lockout",
- "modelLockoutEnabledDescription": "When enabled, models that fail with configured error codes are temporarily locked to prevent retry loops.",
- "modelLockoutErrorCodes": "Error Codes",
- "modelLockoutErrorCodesDescription": "HTTP status codes that trigger model lockout. Type a code and click Add, or select from suggestions.",
- "modelLockoutBaseCooldown": "Base Cooldown (ms)",
- "modelLockoutBaseCooldownDescription": "Initial cooldown duration in milliseconds before a model can be retried.",
- "modelLockoutMaxCooldown": "Max Cooldown (ms)",
- "modelLockoutMaxCooldownDescription": "Maximum cooldown duration in milliseconds. Prevents excessively long lockouts.",
- "modelLockoutExponentialBackoff": "Exponential Backoff",
- "modelLockoutExponentialBackoffDescription": "When enabled, each consecutive failure increases the cooldown duration exponentially.",
- "modelLockoutMaxBackoffSteps": "Max Backoff Steps",
- "modelLockoutMaxBackoffStepsDescription": "Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.",
- "disableSessionStickiness": "Disable session stickiness",
- "disableSessionStickinessDesc": "Round-robin and random combos rotate to a different connection on every request instead of pinning a whole conversation to one connection by the first-message hash. Leave off to preserve prompt-cache hits for multi-turn chats. Per-combo overrides take precedence.",
+ "cloudflareRelayApiTokenHint": "Yêu cầu quyền 'Workers Scripts: Edit'. Token chỉ được sử dụng tại thời điểm triển khai và không bao giờ được lưu trữ.",
+ "cloudflareRelayProjectNameLabel": "Tên Worker",
+ "cloudflareRelayFreeTierNote": "Gói miễn phí: 100.000 yêu cầu/ngày cho mỗi tài khoản Cloudflare.",
+ "cloudflareRelayDeploying": "Đang triển khai...",
+ "cloudflareRelayDeploy": "Triển khai",
+ "cloudflareRelayCredsRequired": "Cần có ID tài khoản và token API",
+ "cloudflareRelayDeployFailed": "Triển khai không thành công",
+ "modelLockout": "Khóa mô hình",
+ "modelLockoutPageDescription": "Cấu hình các mã lỗi HTTP kích hoạt khóa theo từng mô hình và thiết lập cách hoạt động của thời gian chờ.",
+ "modelLockoutLoadFailed": "Không thể tải cài đặt khóa mô hình",
+ "modelLockoutSaveFailed": "Không thể lưu cài đặt khóa mô hình",
+ "modelLockoutLoading": "Đang tải cài đặt khóa mô hình...",
+ "modelLockoutBaseRangeError": "Cooldown cơ sở phải nằm trong khoảng 5.000ms đến 600.000ms",
+ "modelLockoutMaxRangeError": "Cooldown tối đa phải nằm trong khoảng 5.000ms đến 3.600.000ms",
+ "modelLockoutOrderError": "Cooldown tối đa phải lớn hơn hoặc bằng cooldown cơ sở",
+ "modelLockoutStepsRangeError": "Số bước backoff tối đa phải nằm trong khoảng 0 đến 20",
+ "removeErrorCode": "Xóa mã {code}",
+ "addErrorCode": "Thêm mã lỗi...",
+ "suggestions": "Gợi ý:",
+ "modelLockoutMaxCooldownHint": "≥ cooldown cơ sở — 3.600.000ms",
+ "modelLockoutEnabled": "Bật khóa mô hình",
+ "modelLockoutEnabledDescription": "Khi được bật, các mô hình gặp lỗi với các mã lỗi đã cấu hình sẽ tạm thời bị khóa để ngăn vòng lặp thử lại.",
+ "modelLockoutErrorCodes": "Mã lỗi",
+ "modelLockoutErrorCodesDescription": "Các mã trạng thái HTTP kích hoạt khóa mô hình. Nhập một mã rồi nhấp vào Thêm, hoặc chọn từ các gợi ý.",
+ "modelLockoutBaseCooldown": "Thời gian chờ cơ bản (ms)",
+ "modelLockoutBaseCooldownDescription": "Thời gian chờ ban đầu tính bằng mili giây trước khi mô hình có thể được thử lại.",
+ "modelLockoutMaxCooldown": "Thời gian chờ tối đa (ms)",
+ "modelLockoutMaxCooldownDescription": "Thời gian chờ tối đa tính bằng mili giây. Giúp ngăn chặn việc khóa quá lâu.",
+ "modelLockoutExponentialBackoff": "Tăng thời gian chờ theo cấp số nhân",
+ "modelLockoutExponentialBackoffDescription": "Khi được bật, mỗi thất bại liên tiếp sẽ tăng thời gian chờ theo cấp số nhân.",
+ "modelLockoutMaxBackoffSteps": "Số bước tăng thời gian chờ tối đa",
+ "modelLockoutMaxBackoffStepsDescription": "Số bước tăng thời gian chờ tối đa trước khi thời gian chờ ngừng tăng. Trong hầu hết cấu hình, giới hạn Thời gian chờ tối đa sẽ được đạt đến trước, vì vậy đây là giới hạn an toàn trong trường hợp tăng Thời gian chờ tối đa.",
+ "disableSessionStickiness": "Tắt tính năng gắn kết phiên",
+ "disableSessionStickinessDesc": "Các tổ hợp luân phiên và ngẫu nhiên sẽ chuyển sang một kết nối khác với mỗi yêu cầu, thay vì gắn toàn bộ cuộc trò chuyện với một kết nối dựa trên mã băm của tin nhắn đầu tiên. Hãy để tùy chọn này tắt để duy trì các lần trúng cache prompt cho các cuộc trò chuyện nhiều lượt. Các thiết lập ghi đè theo từng tổ hợp được ưu tiên.",
"credentialRedaction": "Credential Redaction",
"credentialRedactionDesc": "Redact API keys, tokens, and secrets from context sent to providers and from responses.",
"enableCredentialRedaction": "Enable credential redaction",
- "enableCredentialRedactionDesc": "Scrubs API keys, tokens, private keys, and JWTs from messages, tool calls, and responses."
+ "enableCredentialRedactionDesc": "Scrubs API keys, tokens, private keys, and JWTs from messages, tool calls, and responses.",
+ "proxySubscriptionsTab": "Gói đăng ký",
+ "proxySubscription": {
+ "error": {
+ "LOCAL_CORE_ENDPOINT_INVALID": "Điểm cuối proxy-core cục bộ không hợp lệ; các nút SS/VMess/Trojan/VLESS đã bị bỏ qua.",
+ "NEEDS_CORE_NOT_CONFIGURED": "Gói đăng ký này có các nút cần một proxy core cục bộ (SS/VMess/Trojan/VLESS); chúng sẽ không được định tuyến cho đến khi bạn cấu hình điểm cuối SOCKS5 của core cục bộ.",
+ "NO_USABLE_NODES": "Gói đăng ký không tạo ra nút nào có thể sử dụng (http/https/socks5 hoặc các nút có điểm cuối core cục bộ)."
+ }
+ }
},
"contextRtk": {
"title": "RTK Engine",
@@ -6394,39 +7556,184 @@
"tooltipDedup": "Mức độ triệt để khi loại bỏ các dòng lặp lại.",
"tooltipMaxChars": "Số ký tự tối đa trên mỗi khối đầu ra.",
"tooltipMaxLines": "Số dòng tối đa cần giữ lại từ đầu ra của lệnh. Phần thừa sẽ bị cắt bớt.",
- "learnDiscoverTitle": "Learn & Discover filters",
- "learnDiscoverDesc": "Mine your captured command output (Raw Output retention must be on) to suggest new RTK filters. Suggestions only — you review and save them.",
- "discoverHeading": "Discover repeated noise",
- "discoverButton": "Scan samples",
- "discoverScanning": "Scanning…",
- "discoverEmpty": "No repeated noise found. Enable Raw Output retention and run a few commands first.",
- "discoverSamples": "{count} sample(s) scanned",
- "discoverHits": "{hits}× across samples",
- "learnHeading": "Learn a filter for a command",
- "learnCommandPlaceholder": "e.g. npm install",
- "learnButton": "Suggest filter",
- "learnEmpty": "No suggestion yet. Type a command you have run and scan.",
- "learnSamplesUsed": "Learned from {count} matching sample(s)",
- "suggestionError": "Could not load suggestions. Check management auth and try again.",
- "tomlImportTitle": "Import RTK TOML filters",
- "tomlImportDesc": "Validate RTK TOML schema v1 filters and install them globally. Installation writes DATA_DIR/rtk/filters.toml; existing files are replaced only after explicit confirmation.",
- "tomlChooseFile": "Choose a TOML file",
- "tomlImportPlaceholder": "Paste RTK filters.toml content here...",
- "tomlValidate": "Validate",
- "tomlValidating": "Validating…",
- "tomlInstall": "Install globally",
- "tomlInstalling": "Installing…",
- "tomlConfirmOverwrite": "Replace the existing global file and create a backup",
- "tomlValidationPassed": "Validation passed",
- "tomlValidationFailed": "Validation completed with failing inline tests",
- "tomlValidationSummary": "{filters} filter(s), {tests} inline test(s)",
- "tomlInstalled": "Installed at {path}",
- "tomlBackupCreated": "A backup of the previous file was created.",
- "tomlTestCount": "{count} test(s)",
- "tomlTestPassed": "Passed",
- "tomlTestFailed": "Failed",
- "tomlImportError": "Could not process the RTK TOML file.",
- "tomlFileReadError": "Could not read the selected TOML file."
+ "learnDiscoverTitle": "Tìm hiểu và khám phá các bộ lọc",
+ "learnDiscoverDesc": "Khai thác đầu ra lệnh đã thu thập của bạn (phải bật tính năng lưu giữ đầu ra thô) để gợi ý các bộ lọc RTK mới. Đây chỉ là các gợi ý — bạn cần xem xét và lưu chúng.",
+ "discoverHeading": "Phát hiện nhiễu lặp lại",
+ "discoverButton": "Quét mẫu",
+ "discoverScanning": "Đang quét…",
+ "discoverEmpty": "Không tìm thấy nhiễu lặp lại. Hãy bật tính năng lưu giữ đầu ra thô và chạy một vài lệnh trước.",
+ "discoverSamples": "Đã quét {count} mẫu",
+ "discoverHits": "{hits} lần xuất hiện trên các mẫu",
+ "learnHeading": "Học bộ lọc cho một lệnh",
+ "learnCommandPlaceholder": "Ví dụ: npm install",
+ "learnButton": "Gợi ý bộ lọc",
+ "learnEmpty": "Chưa có gợi ý nào. Nhập một lệnh bạn đã chạy và quét.",
+ "learnSamplesUsed": "Đã học từ {count} mẫu khớp",
+ "suggestionError": "Không thể tải các gợi ý. Vui lòng kiểm tra thông tin xác thực quản lý và thử lại.",
+ "tomlImportTitle": "Nhập bộ lọc TOML của RTK",
+ "tomlImportDesc": "Xác thực bộ lọc theo lược đồ RTK TOML v1 và cài đặt trên toàn hệ thống. Quá trình cài đặt ghi vào DATA_DIR/rtk/filters.toml; tệp hiện có chỉ được thay thế sau khi xác nhận rõ ràng.",
+ "tomlChooseFile": "Chọn tệp TOML",
+ "tomlImportPlaceholder": "Dán nội dung RTK filters.toml vào đây...",
+ "tomlValidate": "Xác thực",
+ "tomlValidating": "Đang xác thực…",
+ "tomlInstall": "Cài đặt toàn cục",
+ "tomlInstalling": "Đang cài đặt…",
+ "tomlConfirmOverwrite": "Thay thế tệp toàn cục hiện có và tạo bản sao lưu",
+ "tomlValidationPassed": "Xác thực thành công",
+ "tomlValidationFailed": "Đã xác thực xong nhưng có kiểm thử nội tuyến không đạt",
+ "tomlValidationSummary": "{filters} bộ lọc, {tests} kiểm thử nội tuyến",
+ "tomlInstalled": "Đã cài đặt tại {path}",
+ "tomlBackupCreated": "Đã tạo bản sao lưu của tệp trước đó.",
+ "tomlTestCount": "{count} kiểm thử",
+ "tomlTestPassed": "Đạt",
+ "tomlTestFailed": "Không đạt",
+ "tomlImportError": "Không thể xử lý tệp RTK TOML.",
+ "tomlFileReadError": "Không thể đọc tệp TOML đã chọn."
+ },
+ "compressionEngineConfig": {
+ "loading": "Đang tải…",
+ "loadFailed": "Không thể tải thông tin bộ máy.",
+ "engineNotFound": "Không tìm thấy bộ máy \"{engine}\".",
+ "saveFailed": "Không thể lưu cấu hình.",
+ "previewFailed": "Không thể tạo bản xem trước.",
+ "panelPointerPrefix": "Bật, tắt và đặt cấp độ cho lớp này trong",
+ "compressionSettings": "Cài đặt nén",
+ "panelPointerSuffix": ". Trang này chỉ chỉnh sửa cấu hình chi tiết của lớp.",
+ "configuration": "Cấu hình",
+ "noAdditionalConfiguration": "Không có cấu hình bổ sung.",
+ "save": "Lưu",
+ "saving": "Đang lưu…",
+ "globalSettingsOnly": "Lớp này dùng cấu hình toàn cục; hiện chưa có cấu hình ghi đè riêng cho từng bộ máy để lưu tại đây.",
+ "preview": "Xem trước",
+ "previewInput": "Nội dung xem trước",
+ "processing": "Đang xử lý…",
+ "previewSample": "Con cáo nâu nhanh nhẹn nhảy qua chú chó lười. Đây là tin nhắn mẫu dùng để xem trước quá trình nén. Nội dung đủ dài để thể hiện lượng token tiết kiệm được.",
+ "originalTokens": "Token ban đầu",
+ "compressedTokens": "Token sau khi nén",
+ "savings": "Mức tiết kiệm",
+ "original": "Ban đầu",
+ "compressed": "Đã nén",
+ "diff": "Khác biệt",
+ "last7Days": "7 ngày qua",
+ "noDataYet": "Chưa có dữ liệu",
+ "runs": "Số lượt chạy",
+ "tokensSaved": "Token đã tiết kiệm",
+ "averageSavings": "Mức tiết kiệm trung bình",
+ "diffLabels": {
+ "change": "Thay đổi",
+ "added": "Đã thêm",
+ "removed": "Đã loại bỏ",
+ "modified": "Đã sửa"
+ },
+ "engines": {
+ "headroom": {
+ "name": "Headroom SmartCrusher",
+ "description": "Nén không mất dữ liệu các mảng JSON đồng nhất thành dạng bảng kèm dấu mốc số hàng rõ ràng."
+ },
+ "session-dedup": {
+ "name": "Session Dedup",
+ "description": "Khử trùng lặp các khối giữa nhiều lượt bằng dấu mốc nội dung có thể khôi phục."
+ },
+ "ccr": {
+ "name": "CCR",
+ "description": "Dùng dấu mốc truy xuất theo địa chỉ nội dung cho các khối ngữ cảnh lặp lại."
+ },
+ "llmlingua": {
+ "name": "LLMLingua-2 (Lược bỏ theo ngữ nghĩa)",
+ "description": "Phân loại token theo ngữ nghĩa bằng ONNX. Chỉ nén văn xuôi; khối mã và các cấu trúc cần giữ nguyên được bảo vệ. Khi mô hình hoặc worker lỗi, dữ liệu vẫn được chuyển tiếp nguyên vẹn."
+ },
+ "lite": {
+ "name": "Lite",
+ "description": "Giảm nhanh khoảng trắng, kết quả công cụ và URL hình ảnh."
+ },
+ "aggressive": {
+ "name": "Aggressive",
+ "description": "Tóm tắt, nén kết quả công cụ và làm cũ nội dung theo từng bước."
+ },
+ "ultra": {
+ "name": "Ultra",
+ "description": "Lược bỏ token theo kinh nghiệm, có thể dự phòng bằng SLM cục bộ."
+ }
+ },
+ "fields": {
+ "minBlockChars": {
+ "label": "Số ký tự tối thiểu của khối",
+ "description": "Số ký tự tối thiểu để một khối hậu tố trở thành ứng viên khử trùng lặp."
+ },
+ "fuzzy": {
+ "label": "Khử trùng lặp gần đúng",
+ "description": "Khi bật, tin nhắn giống khoảng 85% trở lên so với tin nhắn trước sẽ được thay bằng dấu mốc CCR có thể khôi phục."
+ },
+ "minChars": {
+ "label": "Số ký tự tối thiểu của khối",
+ "description": "Số ký tự tối thiểu để một khối trở thành ứng viên CCR."
+ },
+ "retrievalRampFactor": {
+ "label": "Hệ số tăng theo truy xuất (H8)",
+ "description": "Quy định mức độ chống nén của các khối được truy xuất thường xuyên. Mỗi lần truy xuất trước đó làm tăng tuyến tính kích thước khối tối thiểu hiệu dụng; giá trị 1 sẽ tắt cơ chế tăng."
+ },
+ "model": {
+ "label": "Mô hình"
+ },
+ "minTokens": {
+ "label": "Số token tối thiểu"
+ },
+ "compressionRate": {
+ "label": "Tỷ lệ nén"
+ },
+ "modelPath": {
+ "label": "Đường dẫn mô hình"
+ },
+ "minRows": {
+ "label": "Số hàng tối thiểu để nén",
+ "description": "Số hàng tối thiểu trong một mảng JSON đồng nhất để kích hoạt nén dạng bảng. Mặc định: 8."
+ },
+ "preserveSystemPrompt": {
+ "label": "Giữ nguyên prompt hệ thống"
+ },
+ "summarizerEnabled": {
+ "label": "Bật trình tóm tắt"
+ },
+ "maxTokensPerMessage": {
+ "label": "Số token tối đa mỗi tin nhắn"
+ },
+ "minSavingsThreshold": {
+ "label": "Ngưỡng tiết kiệm tối thiểu"
+ },
+ "minScoreThreshold": {
+ "label": "Ngưỡng điểm tối thiểu"
+ },
+ "slmFallbackToAggressive": {
+ "label": "Dự phòng bằng Aggressive"
+ },
+ "intensity": {
+ "label": "Cường độ"
+ },
+ "minMessageLength": {
+ "label": "Độ dài tin nhắn tối thiểu"
+ }
+ },
+ "engineFields": {
+ "llmlingua": {
+ "compressionRate": {
+ "label": "Tỷ lệ nén (tỷ lệ giữ lại)"
+ },
+ "modelPath": {
+ "label": "Đường dẫn mô hình (ghi đè ngoại tuyến)"
+ }
+ }
+ },
+ "options": {
+ "model": {
+ "tinybert": "TinyBERT (57 MB, nhanh — mặc định)",
+ "bert-base": "BERT-base (710 MB, độ chính xác cao hơn)"
+ },
+ "intensity": {
+ "lite": "Lite",
+ "full": "Đầy đủ",
+ "ultra": "Ultra"
+ }
+ }
},
"contextCombos": {
"title": "Tổ hợp nén",
@@ -6448,7 +7755,244 @@
"setAsDefault": "Đặt làm mặc định",
"save": "Lưu",
"cancel": "Hủy",
- "enabled": "Đã bật"
+ "enabled": "Đã bật",
+ "loading": "Đang tải…",
+ "hubTitle": "Trung tâm nén",
+ "hubDescription": "Chọn hồ sơ nén được áp dụng trên toàn hệ thống.",
+ "hideExplanation": "Ẩn giải thích",
+ "howItWorks": "Cách hoạt động",
+ "saveSettingsFailed": "Không thể lưu cài đặt.",
+ "explanationIntro": "Tính năng nén giúp giảm token và chi phí bằng cách viết lại lịch sử trước khi gửi tới nhà cung cấp nhưng vẫn giữ nguyên ý nghĩa.",
+ "explanationActiveProfile": "Hồ sơ đang dùng : chọn hồ sơ nén được áp dụng trên toàn hệ thống — cấu hình Mặc định từ bảng điều khiển hoặc một tổ hợp có tên đã lưu.",
+ "explanationDefault": "Mặc định (từ bảng điều khiển) : được tạo từ công tắc chính và công tắc của từng bộ máy trong phần Cài đặt nén.",
+ "explanationNamedCombos": "Tổ hợp có tên : các quy trình đã lưu trong trình chỉnh sửa tổ hợp. Khi chọn, tổ hợp đó trở thành hồ sơ đang dùng cho mọi yêu cầu.",
+ "explanationPreview": "Xem trước : hiển thị theo thứ tự các bộ máy mà hồ sơ đang dùng sẽ chạy.",
+ "activeProfile": "Hồ sơ đang dùng",
+ "activeProfileDescription": "Chọn hồ sơ nén được áp dụng trên toàn hệ thống — cấu hình Mặc định từ bảng điều khiển hoặc một tổ hợp có tên đã lưu.",
+ "defaultFromPanel": "Mặc định (từ bảng điều khiển)",
+ "runs": "Chạy:",
+ "defaultConfiguredPrefix": "Mặc định — được cấu hình trong",
+ "compressionSettings": "Cài đặt nén",
+ "providerDelegated": "Nén do nhà cung cấp thực hiện",
+ "contextEditingClaude": "Chỉnh sửa ngữ cảnh (Claude)",
+ "contextEditingDescription": "Cho phép nhà cung cấp xóa các khối sử dụng công cụ cũ ở phía máy chủ mà không viết lại tin nhắn.",
+ "contextEditingAria": "Chỉnh sửa ngữ cảnh",
+ "contextEditingNote": "Hiện chỉ khả dụng với Claude (Anthropic). Đây là chế độ ủy quyền: nhà cung cấp xóa các khối sử dụng công cụ cũ ở phía máy chủ — OmniRoute không viết lại tin nhắn. Chế độ này không ảnh hưởng đến các nhà cung cấp khác.",
+ "namedCombos": "Tổ hợp có tên",
+ "namedCombosDescription": "Lưu các quy trình khác nhau và gán chúng cho từng tổ hợp định tuyến cụ thể.",
+ "comboNamePlaceholder": "Tên tổ hợp",
+ "descriptionPlaceholder": "Mô tả",
+ "nameRequired": "Hãy nhập tên tổ hợp trước khi lưu.",
+ "pipelineRequired": "Hãy thêm ít nhất một bước vào quy trình trước khi lưu.",
+ "saveComboFailed": "Không thể lưu tổ hợp (HTTP {status}).",
+ "deleteNamedConfirm": "Xóa tổ hợp “{name}”?",
+ "intensityLite": "Nhẹ",
+ "intensityFull": "Đầy đủ",
+ "intensityUltra": "Tối đa",
+ "active": "Đang dùng",
+ "languagePacksList": "Gói ngôn ngữ: {packs}",
+ "dragToReorder": "Kéo để sắp xếp lại bước",
+ "engine": "Bộ máy",
+ "intensity": "Cường độ"
+ },
+ "compressionStudio": {
+ "noRun": "Chưa có lượt nén nào.",
+ "liveDataHint": "Dữ liệu trực tiếp được truyền qua kênh nén WebSocket.",
+ "cockpitView": "Chế độ xem buồng điều khiển",
+ "canvas": "Sơ đồ",
+ "waterfall": "Thác nước",
+ "replayDone": "Đã phát lại xong",
+ "pauseReplay": "Tạm dừng phát lại",
+ "playReplay": "Phát lại",
+ "pause": "Tạm dừng",
+ "replay": "Phát lại",
+ "resetReplay": "Đặt lại phát lại",
+ "stepProgress": "bước {current}/{total}",
+ "inlineView": "Nội tuyến",
+ "splitComingSoon": "Chế độ xem chia đôi (sắp ra mắt)",
+ "inputPlaceholder": "Dán prompt, đầu ra công cụ hoặc ngữ cảnh…",
+ "activeCombined": "Đang hoạt động trong luồng kết hợp",
+ "requiresOnnx": "cần mô hình ONNX",
+ "verifyFidelity": "Kiểm tra độ trung thực (từ chối lớp làm sai lệch nội dung)",
+ "fuzzyDedup": "Khử trùng lặp gần đúng (gần trùng lặp → CCR)",
+ "protectSensitive": "Bảo vệ nội dung nhạy cảm (cổng rủi ro)",
+ "quantumLock": "QuantumLock (ổn định tiền tố bộ nhớ đệm)",
+ "saliencyHeatmap": "Bản đồ nhiệt độ nổi bật",
+ "running": "Đang chạy…",
+ "run": "Chạy",
+ "laneRejected": "bị từ chối: {reason}",
+ "error": "lỗi",
+ "combinedFlow": "Luồng kết hợp",
+ "eachLayer": "Từng lớp riêng biệt",
+ "diff": "Khác biệt",
+ "combined": "kết hợp",
+ "skipped": "bỏ qua",
+ "input": "ĐẦU VÀO",
+ "output": "ĐẦU RA",
+ "tokenCount": "{count, number} token",
+ "tokenShort": "token",
+ "provider": "Nhà cung cấp",
+ "judgeModel": "Mô hình đánh giá",
+ "judgeModelPlaceholder": "ví dụ: claude-haiku",
+ "maxCostUsd": "Giới hạn USD",
+ "enterJudgeModel": "Hãy nhập mô hình đánh giá",
+ "verifying": "Đang kiểm tra…",
+ "verifyAll": "Kiểm tra tất cả",
+ "spent": "đã dùng ${spent} / ${cap}",
+ "capReached": "đã đạt giới hạn",
+ "loadAb": "Tải so sánh A/B",
+ "engine": "Bộ máy",
+ "savings": "Mức tiết kiệm",
+ "retention": "Tỷ lệ giữ lại",
+ "outputTokensShort": "Token đầu ra",
+ "fidelity": "Độ trung thực",
+ "playTab": "Chạy thử",
+ "compareTab": "So sánh",
+ "encoderComparison": "So sánh encoder A/B — {count} mảng",
+ "encoderWinner": "tốt nhất: {winner}",
+ "encoder": "encoder",
+ "bytes": "byte",
+ "tokensCl100k": "token (cl100k)"
+ },
+ "omniglyph": {
+ "preview": "Xem trước",
+ "description": "Nén ngữ cảnh thành hình ảnh. Kết xuất prompt hệ thống, tài liệu công cụ và lịch sử dày đặc thành các trang PNG gọn nhẹ để Claude Fable 5 đọc thay cho văn bản. Token hình ảnh được tính phí theo kích thước thay vì số ký tự, nên khối đã chuyển đổi có chi phí thấp hơn khoảng 10 lần. Chỉ dùng định tuyến Anthropic trực tiếp.",
+ "economicsTitle": "Hiệu quả kinh tế",
+ "economics": {
+ "fewerTokens": "ít token hơn cho khối đã chuyển đổi",
+ "savings": "mức tiết kiệm toàn quy trình (đã đo)",
+ "imageTokens": "token hình ảnh cho trang 1568×728 (khoảng 28 nghìn ký tự)",
+ "accuracy": "độ chính xác khi đọc trên Fable 5 (n=30)"
+ },
+ "beforeAfterTitle": "Trước → sau",
+ "blockSavings": "Giảm {percent}% token trên khối này",
+ "realRender": "Bản kết xuất thực tế của {characters, number} ký tự tài liệu công cụ dày đặc — không phải bản mô phỏng.",
+ "textTokens": "Văn bản · ≈ {tokens, number} token",
+ "renderedTokens": "Trang đã kết xuất · ≈ {tokens, number} token",
+ "renderedImageAlt": "Trang nội dung dày đặc đã kết xuất, {width}×{height}px",
+ "gatesTitle": "Điều kiện kích hoạt",
+ "gatesDescription": "Đóng khi không đạt: mọi điều kiện đều phải đạt, nếu không yêu cầu sẽ được chuyển tiếp nguyên trạng (mỗi lần bỏ qua được ghi là skip:<reason>).",
+ "gates": {
+ "model": {
+ "label": "Mô hình",
+ "pass": "claude-fable-5",
+ "why": "Chỉ Fable 5 đọc chính xác 100% các trang dày đặc (đã đo, n=30). GPT-5.5 và Gemini 2.5 Flash bị chặn."
+ },
+ "transport": {
+ "label": "Đường truyền",
+ "pass": "Anthropic trực tiếp",
+ "why": "Các dịch vụ tổng hợp lấy mẫu lại hình ảnh và làm mất độ rõ nét — chỉ định tuyến trực tiếp mới được coi là nguồn đáng tin cậy."
+ },
+ "format": {
+ "label": "Định dạng",
+ "pass": "Claude nguyên bản",
+ "why": "Nội dung yêu cầu phải dùng định dạng Claude và tuyệt đối không đặt vai trò hệ thống trong danh sách tin nhắn."
+ },
+ "profitable": {
+ "label": "Có lợi",
+ "pass": "đủ dày đặc",
+ "why": "Cổng chi phí chính xác theo mảng 28px quyết định trên từng yêu cầu; văn bản ngắn hoặc thưa sẽ được chuyển tiếp nguyên trạng."
+ }
+ },
+ "enableTitle": "Bật bộ máy",
+ "enableDescription": "Chạy cuối cùng trong ngăn xếp (sau khi RTK/Caveman làm sạch văn bản, OmniGlyph chuyển phần còn lại thành hình ảnh) và cũng có thể chạy độc lập qua chế độ omniglyph. Đây là bản xem trước và mặc định vẫn tắt cho đến khi hoàn tất kiểm thử đầu cuối.",
+ "saved": "Đã lưu.",
+ "saveFailed": "Không thể lưu.",
+ "enableAria": "Bật bộ máy OmniGlyph"
+ },
+ "embeddedServices": {
+ "title": "Dịch vụ nhúng",
+ "description": "Các bộ máy cục bộ được quản lý theo nhu cầu — CLIProxyAPI, 9Router, Mux và Bifrost. Chỉ có thể truy cập qua loopback.",
+ "stateRunning": "Đang chạy",
+ "stateStopped": "Đã dừng",
+ "stateStarting": "Đang khởi động",
+ "stateStopping": "Đang dừng",
+ "stateError": "Lỗi",
+ "stateNotInstalled": "Chưa cài đặt",
+ "stateUnknown": "Không xác định",
+ "port": "cổng {port}",
+ "externalBadge": "Được quản lý bên ngoài",
+ "externalTitle": "Đã phát hiện 9Router bên ngoài",
+ "externalDescription": "OmniRoute đã tìm thấy 9Router tại {host}:{port}. Vòng đời, nhật ký, bản cập nhật và khóa dịch vụ của nó vẫn do bản cài đặt bên ngoài quản lý.",
+ "start": "Khởi động",
+ "starting": "Đang khởi động…",
+ "stop": "Dừng",
+ "stopping": "Đang dừng…",
+ "restart": "Khởi động lại",
+ "restarting": "Đang khởi động lại…",
+ "update": "Cập nhật",
+ "updating": "Đang cập nhật…",
+ "install": "Cài đặt",
+ "installing": "Đang cài đặt…",
+ "autoStart": "Tự động khởi động",
+ "autoStartDescription": "Tự động chạy {name} khi OmniRoute khởi động",
+ "apiKey": "Khóa API",
+ "apiKeyDescription": "Khóa OmniRoute dùng để xác thực với {name}",
+ "keyRotated": "Đã xoay vòng khóa — {name} đã khởi động lại để áp dụng khóa mới",
+ "keyRotateFailed": "Không thể xoay vòng khóa",
+ "keyRevealFailed": "Không thể hiển thị khóa",
+ "keyRevealEmpty": "Không thể hiển thị: phản hồi không chứa khóa",
+ "reveal": "Hiển thị",
+ "revealing": "Đang hiển thị…",
+ "hide": "Ẩn",
+ "rotateKey": "Xoay vòng khóa",
+ "rotating": "Đang xoay vòng…",
+ "keyAutoHide": "Khóa sẽ tự động được ẩn sau 30 giây.",
+ "revealTitle": "Hiển thị khóa API",
+ "revealConfirm": "Việc hiển thị khóa API sẽ được ghi vào nhật ký kiểm toán. Tiếp tục?",
+ "cancel": "Hủy",
+ "install9Router": "Cài đặt 9Router",
+ "install9RouterDescription": "Tải xuống và cài đặt 9Router qua npm. Cần khoảng 500 MB dung lượng đĩa.",
+ "version": "Phiên bản",
+ "versionHint": "Nhập “latest” hoặc một phiên bản cụ thể (ví dụ: 1.2.3).",
+ "servicePort": "Cổng",
+ "servicePortHint": "OmniRoute và 9Router phải dùng các cổng khác nhau. Cổng mặc định của 9Router là 20130.",
+ "installationFailed": "Cài đặt thất bại (HTTP {status})",
+ "installationSucceeded": "Đã cài đặt 9Router thành công. Đang khởi động…",
+ "networkInstallFailed": "Lỗi mạng — không thể kết nối tới endpoint cài đặt",
+ "availableModels": "Các mô hình khả dụng",
+ "modelsLoading": "Đang tải…",
+ "modelsDiscovered": "Đã phát hiện {count} mô hình",
+ "modelsLoadFailed": "Không thể tải danh sách mô hình",
+ "refreshing": "Đang làm mới…",
+ "refreshNow": "Làm mới ngay",
+ "noModels": "Không tìm thấy mô hình nào. Chọn “Làm mới ngay” để đồng bộ từ dịch vụ đang chạy.",
+ "unavailable": "Không khả dụng",
+ "pageOf": "Trang {page}/{total}",
+ "previous": "Trước",
+ "next": "Tiếp",
+ "providerExposure": "Công bố nhà cung cấp",
+ "providerExposureDescription": "Công bố các mô hình 9Router thành mục tiêu định tuyến với tiền tố 9router/ .",
+ "providerExposureLabel": "Công bố dưới dạng 9router/… ",
+ "providerExposureHint": "Khi bật, các mô hình đã phát hiện sẽ xuất hiện trong bộ chọn nhà cung cấp trên toàn OmniRoute.",
+ "providerExposureUpdateFailed": "Không thể cập nhật (HTTP {status})",
+ "providerExposureNetworkFailed": "Lỗi mạng — không thể cập nhật cài đặt công bố nhà cung cấp",
+ "webUi": "Giao diện web 9Router",
+ "openNewTab": "Mở trong thẻ mới",
+ "filterLogs": "Lọc nhật ký…",
+ "resume": "Tiếp tục",
+ "pause": "Tạm dừng",
+ "clear": "Xóa",
+ "download": "Tải xuống",
+ "noLogs": "Chưa có đầu ra nhật ký.",
+ "logStreamFailed": "Không thể truyền trực tiếp nhật ký {name}. Hãy kiểm tra quyền truy cập cục bộ và trạng thái dịch vụ.",
+ "fallbackRouting": "Định tuyến dự phòng",
+ "fallbackRoutingDescription": "Thử lại các yêu cầu nhà cung cấp bị lỗi qua CLIProxyAPI",
+ "enableFallback": "Bật dự phòng",
+ "cliproxyUrl": "URL CLIProxyAPI",
+ "fallbackCodes": "Mã trạng thái kích hoạt dự phòng (phân tách bằng dấu phẩy)",
+ "invalidUrl": "URL không hợp lệ — phải bắt đầu bằng http:// hoặc https://",
+ "saved": "Đã lưu",
+ "saveFailed": "Không thể lưu cài đặt",
+ "modelMapping": "Ánh xạ mô hình",
+ "modelMappingDescription": "Ánh xạ ID mô hình OmniRoute sang ID mô hình CLIProxyAPI (ví dụ: \"gpt-4o\": \"openai-gpt-4o\" )",
+ "modelMappingEditor": "Trình chỉnh sửa JSON ánh xạ mô hình",
+ "mappingSaved": "Đã lưu ánh xạ",
+ "mappingSaveFailed": "Không thể lưu ánh xạ",
+ "mappingInvalidJson": "JSON không hợp lệ.",
+ "mappingMustBeObject": "Ánh xạ phải là một đối tượng JSON, không được là mảng hoặc giá trị nguyên thủy.",
+ "mappingValueMustBeString": "Giá trị của khóa “{key}” phải là chuỗi.",
+ "save": "Lưu"
},
"contextCaveman": {
"title": "Công cụ Caveman",
@@ -6475,6 +8019,7 @@
"inputCompressionDesc": "Viết lại lịch sử trò chuyện với từ ngữ ngắn gọn hơn. Giảm khoảng 50% lượng token đầu vào.",
"analyticsTitle": "Phân tích nén",
"noAnalytics": "Chưa có phân tích nén nào.",
+ "masterDisabledWarning": "Công tắc tổng Token Saver đang TẮT — các cài đặt này sẽ không ảnh hưởng tới yêu cầu cho đến khi anh bật nó trong Cài đặt nén hoặc thay đổi ngay tại đây.",
"outputMode": "Chế độ đầu ra",
"outputModeDesc": "Kiểm soát cách hiển thị của đầu ra đã nén",
"outputModeTitle": "Chế độ đầu ra",
@@ -6652,6 +8197,14 @@
"translationFailed": "Không thể dịch: {error}",
"pipelineDebugger": "Trình gỡ lỗi quy trình",
"translationPipeline": "Quy trình dịch thuật",
+ "loading": "Đang tải…",
+ "compressionOriginal": "Ban đầu",
+ "compressionCompressed": "Đã nén",
+ "compressionSaved": "Đã tiết kiệm",
+ "compressionDuration": "Thời lượng",
+ "pipelineStepsAria": "Các bước trong quy trình",
+ "copyIntermediateJson": "Sao chép JSON trung gian",
+ "copyOutputJson": "Sao chép JSON đầu ra",
"pipelineVisualization": "Trực quan hóa quy trình",
"pipelineVisualizationHint": "Gửi tin nhắn để xem yêu cầu của bạn đi qua quá trình phát hiện → dịch thuật → gọi nhà cung cấp như thế nào.",
"chatTesterDescription": "Gửi tin nhắn theo một định dạng ứng dụng khách cụ thể và kiểm tra từng bước của quy trình dịch thuật.",
@@ -6717,79 +8270,87 @@
"scenarioVision": "Vision (hiểu hình ảnh)",
"scenarioSchemaCoercion": "Ép kiểu lược đồ (đầu ra có cấu trúc)",
"techniques": "Kỹ thuật:",
- "friendlyTitle": "Translator",
- "friendlySubtitle": "Use your existing app with any provider — without rewriting code.",
- "conceptHeadline": "Your app speaks one API's \"language\". The Translator converts it to use another provider.",
- "conceptDiagramAppLabel": "Your app",
- "conceptDiagramSourceLabel": "Source format",
+ "friendlyTitle": "Trình chuyển đổi",
+ "friendlySubtitle": "Sử dụng ứng dụng hiện tại của bạn với bất kỳ nhà cung cấp nào — không cần viết lại mã nguồn.",
+ "conceptHeadline": "Ứng dụng của bạn nói \"ngôn ngữ\" của một API. Trình chuyển đổi sẽ chuyển đổi ngôn ngữ đó để sử dụng nhà cung cấp khác.",
+ "conceptDiagramAppLabel": "Ứng dụng của bạn",
+ "conceptDiagramSourceLabel": "Định dạng nguồn",
"conceptDiagramHubLabel": "OpenAI (hub)",
- "conceptDiagramTargetLabel": "Target provider",
- "conceptDiagramExampleApp": "e.g. Anthropic SDK",
+ "conceptDiagramTargetLabel": "Nhà cung cấp đích",
+ "conceptDiagramExampleApp": "Ví dụ: Anthropic SDK",
"conceptDiagramExampleSource": "claude",
"conceptDiagramExampleTarget": "Gemini",
- "conceptHowItWorksToggle": "How it works",
- "conceptHowItWorksBody": "Your app sends a request in its own format. The Translator detects the format, converts it through OpenAI as an intermediate hub (or directly when a direct translator is available), sends it to the chosen provider, and returns the response converted back to your app's format.",
- "tabTranslate": "Translate",
- "tabMonitor": "Monitor",
- "tabTranslateAriaLabel": "Go to the Translate tab",
- "tabMonitorAriaLabel": "Go to the Monitor tab",
- "simpleAppUsesLabel": "My app uses",
- "simpleAppUsesHint": "The API format your app speaks (e.g. Anthropic SDK = claude).",
- "simpleSendToLabel": "Send to",
- "simpleSendToHint": "Where to actually send the request (a provider connected in OmniRoute).",
- "simpleStartWithLabel": "Start with",
- "simpleStartWithExamplePlaceholder": "Select a ready-made example",
- "simpleStartWithCustomOption": "Paste your request (advanced)",
- "simpleModeLabel": "Mode",
- "simpleModePreview": "Preview translation only",
- "simpleModeSend": "Send and see response",
- "simpleAdvancedToggle": "Advanced",
- "simpleInputPanelTitle": "Input",
- "simpleInputPanelHint": "Free-text message or ready-made example",
- "simpleResultPanelTitle": "Translation + Response",
- "narratedDetected": "✓ Detected: {format}",
- "narratedTranslating": "Translating to {target}...",
- "narratedSending": "Sending to {target}...",
- "narratedSuccess": "→ translated to {target} · response in {latency}ms",
- "narratedError": "Failed: {reason}",
- "narratedSeeTranslatedJson": "see translated JSON",
- "narratedSeePipeline": "see pipeline",
- "advancedSectionTitle": "Advanced",
- "advancedSectionSubtitle": "Raw JSON, pipeline and technical tools. Everything here is the same as the old tabs — just reorganized.",
- "advancedRawJsonTitle": "Raw JSON (auto-detect + Monaco)",
- "advancedRawJsonSubtitle": "Paste a JSON request; the format is detected automatically.",
- "advancedPipelineTitle": "OpenAI intermediate pipeline",
- "advancedPipelineSubtitle": "Visualize each translation step (hub-and-spoke).",
- "advancedStreamTransformTitle": "Stream Transformer (Chat → Responses SSE)",
- "advancedStreamTransformSubtitle": "Converts Chat Completions SSE into Responses API.",
- "advancedTestBenchTitle": "Test Bench (8 scenarios)",
- "advancedTestBenchSubtitle": "Runs all scenarios and reports pass/fail + compatibility %.",
- "advancedCompressionTitle": "Compression Preview",
- "advancedCompressionSubtitle": "Estimate token savings across different compression modes.",
- "monitorOriginHint": "Events generated by Translate or the main pipeline appear here in real time.",
- "monitorEmptyCta": "Go to the Translate tab and send a request — it will appear here.",
- "monitorOpenTranslateButton": "Go to Translate",
- "pipelineStepClientRequest": "Client Request",
- "pipelineStepClientRequestDesc": "Request received in client format",
- "pipelineStepFormatDetected": "Format Detected",
- "pipelineStepFormatDetectedDesc": "Auto-detected source format",
- "pipelineStepOpenAIIntermediate": "OpenAI Intermediate",
- "pipelineStepOpenAIIntermediateDesc": "Translated to OpenAI hub format",
- "pipelineStepProviderFormat": "Provider Format",
- "pipelineStepProviderFormatDesc": "Translated to provider target format",
- "pipelineStepProviderResponse": "Provider Response",
- "pipelineStepProviderResponseDesc": "Streaming response from provider",
- "conceptDiagramArrow1": "speaks",
- "conceptDiagramArrow2": "translates",
- "conceptDiagramArrow3": "converts",
+ "conceptHowItWorksToggle": "Cách thức hoạt động",
+ "conceptHowItWorksBody": "Ứng dụng của bạn gửi một yêu cầu theo định dạng riêng. Trình chuyển đổi sẽ phát hiện định dạng này, chuyển đổi nó thông qua OpenAI làm trung tâm trung gian (hoặc trực tiếp nếu có trình chuyển đổi trực tiếp), gửi yêu cầu đến nhà cung cấp đã chọn và trả về phản hồi được chuyển đổi về định dạng của ứng dụng.",
+ "tabTranslate": "Chuyển đổi",
+ "tabMonitor": "Giám sát",
+ "tabTranslateAriaLabel": "Đi tới tab Chuyển đổi",
+ "tabMonitorAriaLabel": "Đi tới tab Giám sát",
+ "simpleAppUsesLabel": "Ứng dụng của tôi sử dụng",
+ "simpleAppUsesHint": "Định dạng API mà ứng dụng của bạn sử dụng (ví dụ: Anthropic SDK = claude).",
+ "simpleSendToLabel": "Gửi tới",
+ "simpleSendToHint": "Nơi thực tế gửi yêu cầu đến (một nhà cung cấp được kết nối trong OmniRoute).",
+ "simpleStartWithLabel": "Bắt đầu với",
+ "simpleStartWithExamplePlaceholder": "Chọn một ví dụ có sẵn",
+ "simpleStartWithCustomOption": "Dán yêu cầu của bạn (nâng cao)",
+ "simpleModeLabel": "Chế độ",
+ "simpleModePreview": "Chỉ xem trước kết quả chuyển đổi",
+ "simpleModeSend": "Gửi và xem phản hồi",
+ "simpleAdvancedToggle": "Nâng cao",
+ "simpleInputPanelTitle": "Đầu vào",
+ "simpleInputPanelHint": "Tin nhắn văn bản tự do hoặc ví dụ có sẵn",
+ "simpleResultPanelTitle": "Chuyển đổi + Phản hồi",
+ "narratedDetected": "✓ Đã phát hiện: {format}",
+ "narratedTranslating": "Đang chuyển đổi sang {target}...",
+ "narratedSending": "Đang gửi đến {target}...",
+ "narratedSuccess": "→ đã dịch sang {target} · phản hồi trong {latency}ms",
+ "narratedError": "Thất bại: {reason}",
+ "narratedSeeTranslatedJson": "xem JSON đã dịch",
+ "narratedSeePipeline": "xem quy trình xử lý",
+ "advancedSectionTitle": "Nâng cao",
+ "advancedSectionSubtitle": "JSON gốc, quy trình xử lý và các công cụ kỹ thuật. Mọi thứ ở đây đều giống như các tab cũ — chỉ được sắp xếp lại.",
+ "advancedRawJsonTitle": "JSON gốc (tự động phát hiện + Monaco)",
+ "advancedRawJsonSubtitle": "Dán một yêu cầu JSON; định dạng sẽ tự động được phát hiện.",
+ "advancedPipelineTitle": "Quy trình xử lý trung gian của OpenAI",
+ "advancedPipelineSubtitle": "Trực quan hóa từng bước dịch (trung tâm-vệ tinh).",
+ "advancedStreamTransformTitle": "Bộ chuyển đổi luồng (Chat → Responses SSE)",
+ "advancedStreamTransformSubtitle": "Chuyển đổi Chat Completions SSE thành Responses API.",
+ "advancedTestBenchTitle": "Bộ kiểm thử (8 kịch bản)",
+ "advancedTestBenchSubtitle": "Chạy tất cả các kịch bản và báo cáo đạt/không đạt cùng tỷ lệ tương thích.",
+ "advancedCompressionTitle": "Xem trước nén",
+ "advancedCompressionSubtitle": "Ước tính lượng token tiết kiệm được giữa các chế độ nén khác nhau.",
+ "monitorOriginHint": "Các sự kiện được tạo bởi Translate hoặc pipeline chính sẽ xuất hiện ở đây theo thời gian thực.",
+ "monitorEmptyCta": "Đi tới tab Translate và gửi một yêu cầu — nó sẽ xuất hiện tại đây.",
+ "monitorOpenTranslateButton": "Đi tới Translate",
+ "pipelineStepClientRequest": "Yêu cầu từ máy khách",
+ "pipelineStepClientRequestDesc": "Yêu cầu nhận được theo định dạng của máy khách",
+ "pipelineStepFormatDetected": "Đã phát hiện định dạng",
+ "pipelineStepFormatDetectedDesc": "Định dạng nguồn được tự động phát hiện",
+ "pipelineStepOpenAIIntermediate": "Trung gian của OpenAI",
+ "pipelineStepOpenAIIntermediateDesc": "Đã dịch sang định dạng trung tâm của OpenAI",
+ "pipelineStepProviderFormat": "Định dạng của nhà cung cấp",
+ "pipelineStepProviderFormatDesc": "Đã dịch sang định dạng đích của nhà cung cấp",
+ "pipelineStepProviderResponse": "Phản hồi từ nhà cung cấp",
+ "pipelineStepProviderResponseDesc": "Phản hồi truyền trực tuyến từ nhà cung cấp",
+ "conceptDiagramArrow1": "nói",
+ "conceptDiagramArrow2": "dịch",
+ "conceptDiagramArrow3": "chuyển đổi",
"conceptDiagramExampleHub": "OpenAI",
- "conceptDiagramHubTooltip": "Intermediate hub used by the translator to convert between formats that don't have a direct mapping.",
- "conceptDiagramSourceTooltip": "The API format your app speaks (e.g., Anthropic SDK = claude).",
- "conceptDiagramTargetTooltip": "The provider where the request will actually be sent.",
- "compressionEmptyHint": "Fill in the input field on the Translate tab (Simple Controls or Raw JSON) to enable the preview.",
- "compressionModeLabel": "Compression mode",
- "compressionPreviewButton": "Preview Compression",
- "compressionPreviewing": "Previewing…"
+ "conceptDiagramHubTooltip": "Trung tâm trung gian được bộ dịch sử dụng để chuyển đổi giữa các định dạng không có ánh xạ trực tiếp.",
+ "conceptDiagramSourceTooltip": "Định dạng API mà ứng dụng của bạn sử dụng (ví dụ: Anthropic SDK = claude).",
+ "conceptDiagramTargetTooltip": "Nhà cung cấp nơi yêu cầu sẽ thực sự được gửi đến.",
+ "compressionEmptyHint": "Điền vào trường nhập liệu trên tab Translate (Điều khiển đơn giản hoặc JSON gốc) để bật tính năng xem trước.",
+ "compressionModeLabel": "Chế độ nén",
+ "compressionPreviewButton": "Xem trước nén",
+ "compressionPreviewing": "Đang xem trước…",
+ "compressionPreviewFailed": "Không thể xem trước kết quả nén",
+ "tokens": "token",
+ "pauseAutoRefresh": "Tạm dừng tự động làm mới",
+ "resumeAutoRefresh": "Tiếp tục tự động làm mới",
+ "live": "Trực tiếp",
+ "copyInput": "Sao chép đầu vào",
+ "copyOutput": "Sao chép đầu ra",
+ "streamTransformFailed": "Không thể chuyển đổi luồng"
},
"usage": {
"title": "Mức sử dụng",
@@ -6934,6 +8495,12 @@
"autoRefresh": "Tự động làm mới",
"refreshAll": "Làm mới tất cả",
"loadingQuotas": "Đang tải...",
+ "showMoreQuotas": "Hiển thị thêm {count}",
+ "showLessQuotas": "Hiển thị ít hơn",
+ "hideQuotaRow": "Ẩn hàng hạn mức này",
+ "showQuotaRow": "Hiện hàng hạn mức này",
+ "hiddenQuotaRowsLabel": "Đã ẩn:",
+ "quotaVisibilityUpdateFailed": "Không thể cập nhật hiển thị hạn mức",
"account": "Tài khoản",
"modelQuotas": "Hạn ngạch mô hình",
"lastUsed": "Lần làm mới cuối",
@@ -6946,6 +8513,8 @@
"notApplicable": "N/A",
"rawPlanWithValue": "Gói gốc: {plan}",
"noPlanFromProvider": "Không có gói dịch vụ từ nhà cung cấp",
+ "tokenExpiresIn": "Token hết hạn sau {time}",
+ "tokenExpired": "Token đã hết hạn",
"noQuotaData": "Không có dữ liệu hạn ngạch",
"cardExpand": "Mở rộng",
"cardCollapse": "Thu gọn",
@@ -6980,8 +8549,28 @@
"unlimitedLabel": "Không giới hạn",
"refreshing": "Đang làm mới",
"resetsIn": "Đặt lại sau",
+ "usdCost": "Chi phí USD",
"editCutoffs": "Chỉnh sửa ngưỡng",
"forceRefresh": "Làm mới ngay",
+ "resetCreditsLabel": "Đặt lại tín dụng",
+ "redeemResetCredit": "Đổi lượt đặt lại",
+ "manageResetCredits": "Xem tín dụng",
+ "viewResetCredits": "Xem tín dụng đặt lại",
+ "resetCreditsModalTitle": "Tín dụng đặt lại Codex",
+ "resetCreditsModalExplainer": "Tín dụng được sắp xếp theo ngày hết hạn. Việc đổi tự động luôn dùng tín dụng hết hạn sớm nhất.",
+ "resetCreditsLoadFailed": "Không thể tải tín dụng đặt lại",
+ "resetCreditsDetailsUnavailable": "Chi tiết tín dụng hiện không khả dụng. Hãy làm mới và thử lại.",
+ "noResetCreditsAvailable": "Không có tín dụng đặt lại nào.",
+ "resetCreditDefaultTitle": "Đặt lại toàn bộ",
+ "resetCreditExpiresFirst": "Hết hạn sớm nhất",
+ "resetCreditExpiresAt": "Hết hạn {date}",
+ "resetCreditNoExpiry": "Không có ngày hết hạn",
+ "redeemThisResetCredit": "Đổi",
+ "confirmRedeemResetCreditTitle": "Đổi tín dụng đặt lại này?",
+ "confirmRedeemResetCredit": "Đổi một tín dụng đặt lại Codex cho tài khoản này? Thao tác này sẽ tiêu tốn một tín dụng đặt lại.",
+ "confirmRedeemResetCreditButton": "Đổi tín dụng",
+ "resetCreditRedeemed": "Đã đổi lượt đặt lại",
+ "resetCreditRedeemFailed": "Không thể đổi tín dụng đặt lại",
"suiteBuilderSaveFailed": "Không thể lưu bộ đánh giá",
"clone": "Nhân bản",
"exportSuite": "Xuất",
@@ -7095,6 +8684,11 @@
"quotaCutoffsButtonDefault": "Mặc định",
"quotaCutoffsButtonHelp": "Chỉnh sửa các giới hạn hạn ngạch còn lại tối thiểu cho tài khoản này.",
"quotaCutoffsButtonDisabled": "Chưa có khoảng thời gian hạn ngạch nào khả dụng cho tài khoản này.",
+ "deactivateAccount": "Vô hiệu hóa tài khoản (dừng định tuyến)",
+ "activateAccount": "Kích hoạt tài khoản (tiếp tục định tuyến)",
+ "accountActivated": "Đã kích hoạt tài khoản",
+ "accountDeactivated": "Đã vô hiệu hóa tài khoản",
+ "toggleActiveFailed": "Không thể cập nhật trạng thái tài khoản",
"quotaCutoffsTitle": "Giới hạn hạn ngạch cho {name} ({provider})",
"quotaCutoffsExplainer": "Ghi đè phần trăm hạn ngạch còn lại tối thiểu mà tại đó tài khoản này ngừng được chọn cho mỗi khoảng thời gian hạn ngạch. Để trống để kế thừa mặc định của nhà cung cấp.",
"quotaCutoffsDefaultHint": "Hạn ngạch còn lại tối thiểu mặc định: {default}%",
@@ -7107,6 +8701,38 @@
"budgetKpiBlocked": "Bị chặn",
"budgetKpiAtRisk": "Có rủi ro",
"budgetKpiActiveKeys": "Khóa hoạt động",
+ "budgetPageTitle": "Ngân sách",
+ "budgetPageDescription": "Đặt giới hạn chi tiêu hằng ngày, hằng tuần và hằng tháng cho từng khóa API.",
+ "budgetTemplateStorageHint": "{count} mẫu · chỉnh sửa qua localStorage:",
+ "budgetAboveLimitShort": "vượt giới hạn ⚠",
+ "budgetOnTrackShort": "đúng tiến độ",
+ "budgetAtOrAboveWarning": "≥ ngưỡng cảnh báo",
+ "budgetTemplates": "Mẫu",
+ "budgetSelectKeysFirst": "Hãy chọn khóa trước khi áp dụng",
+ "budgetApplyToSelected": "Áp dụng cho {count} khóa đã chọn",
+ "budgetSelectedTemplateHint": "Đã chọn {count} khóa · nhấp vào một mẫu để áp dụng",
+ "budgetTemplateMonthlyAmount": "{amount} US$/tháng",
+ "budgetTemplateDailyAmount": "{amount} US$/ngày",
+ "budgetNoKeysSelected": "Chưa chọn khóa nào",
+ "budgetTemplateApplied": "Đã áp dụng \"{template}\" cho {count} khóa",
+ "budgetTemplateApplyFailed": "Không thể áp dụng mẫu",
+ "budgetTemplateNames": {
+ "tpl-prod": "Production",
+ "tpl-dev": "Phát triển",
+ "tpl-ci": "CI"
+ },
+ "budgetStatus": {
+ "all": "Tất cả",
+ "blocked": "Bị chặn",
+ "alerting": "Đang cảnh báo",
+ "warning": "Cảnh báo",
+ "safe": "An toàn",
+ "no-limit": "Không giới hạn"
+ },
+ "budgetColumnKey": "Khóa",
+ "budgetColumnToday": "Hôm nay",
+ "budgetColumnMonth": "Tháng",
+ "budgetColumnStatus": "Trạng thái",
"budgetSearchKeysPlaceholder": "Tìm kiếm khóa...",
"budgetSortPctUsed": "Sắp xếp: % đã dùng ↓",
"budgetSortTodayDollar": "Sắp xếp: $ hôm nay ↓",
@@ -7115,12 +8741,23 @@
"budgetColDailyLim": "Hạn mức hằng ngày",
"budgetColMonthlyLim": "Hạn mức hằng tháng",
"budgetColUsedPct": "% đã dùng",
+ "percentUsed": "Đã dùng {pct}%",
"budgetLoading": "Đang tải…",
"budgetNoKeysMatch": "Không có khóa nào khớp với bộ lọc",
"budgetLinearExtrapolation": "ngoại suy tuyến tính",
"budgetThisMonthSoFar": "Tính đến hiện tại trong tháng này",
"budgetProjectedEndOfMonth": "Dự kiến cuối tháng",
"budgetByProvider": "theo nhà cung cấp",
+ "budgetProjection": "Dự kiến",
+ "budgetAboveMonthlyLimit": "⚠ vượt {limit}/tháng",
+ "budgetCostBreakdown30d": "Phân tích chi phí (30 ngày)",
+ "budgetLimits": "Giới hạn",
+ "budgetResetDaily": "Hằng ngày",
+ "budgetResetWeekly": "Hằng tuần",
+ "budgetResetMonthly": "Hằng tháng",
+ "budgetNextReset": "Lần đặt lại tiếp theo",
+ "budgetHardCapComingSoon": "Chính sách giới hạn cứng và cảnh báo qua email sắp được hỗ trợ",
+ "unknownProvider": "Nhà cung cấp không xác định",
"budgetDailyDollar": "$ hằng ngày",
"budgetWeeklyDollar": "$ hằng tuần",
"budgetMonthlyDollar": "$ hằng tháng",
@@ -7131,40 +8768,7 @@
"updatedShort": "Đã cập nhật",
"lastRefreshed": "Cập nhật lần cuối",
"providerQuota": "Hạn ngạch nhà cung cấp",
- "providerQuotaHomeHint": "Trạng thái theo thời gian thực của các tài khoản đã kết nối",
- "hideQuotaRow": "Ẩn hàng hạn mức này",
- "showQuotaRow": "Hiện hàng hạn mức này",
- "hiddenQuotaRowsLabel": "Đã ẩn:",
- "quotaVisibilityUpdateFailed": "Không thể cập nhật hiển thị hạn mức",
- "showMoreQuotas": "Show {count} more",
- "showLessQuotas": "Show less",
- "tokenExpiresIn": "Token expires in {time}",
- "tokenExpired": "Token expired",
- "resetCreditsLabel": "Reset credits",
- "redeemResetCredit": "Redeem reset",
- "manageResetCredits": "View credits",
- "viewResetCredits": "View reset credits",
- "resetCreditsModalTitle": "Codex reset credits",
- "resetCreditsModalExplainer": "Credits are ordered by expiration. Automatic redemption always uses the credit that expires first.",
- "resetCreditsLoadFailed": "Failed to load reset credits",
- "resetCreditsDetailsUnavailable": "Credit details are currently unavailable. Refresh and try again.",
- "noResetCreditsAvailable": "No reset credits are available.",
- "resetCreditDefaultTitle": "Full reset",
- "resetCreditExpiresFirst": "Expires first",
- "resetCreditExpiresAt": "Expires {date}",
- "resetCreditNoExpiry": "No expiration date",
- "redeemThisResetCredit": "Redeem",
- "confirmRedeemResetCreditTitle": "Redeem this reset credit?",
- "confirmRedeemResetCredit": "Redeeming immediately resets the eligible Codex usage windows and permanently consumes this credit.",
- "confirmRedeemResetCreditButton": "Redeem credit",
- "resetCreditRedeemed": "Reset redeemed",
- "resetCreditRedeemFailed": "Failed to redeem reset credit",
- "deactivateAccount": "Deactivate account (stop routing)",
- "activateAccount": "Activate account (resume routing)",
- "accountActivated": "Account activated",
- "accountDeactivated": "Account deactivated",
- "toggleActiveFailed": "Failed to update account status",
- "percentUsed": "{pct}% used"
+ "providerQuotaHomeHint": "Trạng thái theo thời gian thực của các tài khoản đã kết nối"
},
"modals": {
"waitingAuth": "Đang chờ ủy quyền",
@@ -7788,7 +9392,21 @@
"statusCancelled": "Đã hủy",
"repositoryName": "Tên kho lưu trữ",
"repositoryUrl": "URL kho lưu trữ",
- "branch": "Nhánh"
+ "branch": "Nhánh",
+ "agentDescriptions": {
+ "jules": "Tác nhân lập trình tự chủ của Google",
+ "devin": "Kỹ sư phần mềm AI của Cognition",
+ "codexCloud": "Tác nhân lập trình trên đám mây của OpenAI",
+ "cursorCloud": "Background / Cloud Agents của Cursor (dùng khóa API chính thức)"
+ },
+ "activityTypes": {
+ "plan": "Kế hoạch",
+ "command": "Lệnh",
+ "code_change": "Thay đổi mã nguồn",
+ "message": "Tin nhắn",
+ "error": "Lỗi",
+ "completion": "Hoàn tất"
+ }
},
"templateNames": {
"simple-chat": "Trò chuyện đơn giản",
@@ -8074,6 +9692,8 @@
"errorResetFailed": "Không thể đặt lại giá"
},
"proxyRegistry": {
+ "selectAllProxies": "Chọn tất cả proxy",
+ "selectProxy": "Chọn {name}",
"title": "Sổ đăng ký proxy",
"description": "Lưu trữ các proxy có thể tái sử dụng và theo dõi việc gán chúng.",
"importLegacy": "Nhập dữ liệu cũ",
@@ -8094,6 +9714,11 @@
"modalEditTitle": "Chỉnh sửa proxy",
"labelName": "Tên",
"labelType": "Loại",
+ "labelFamily": "Họ IP",
+ "familyAuto": "Tự động (dual-stack)",
+ "familyIpv4": "Chỉ IPv4",
+ "familyIpv6": "Chỉ IPv6",
+ "familyHint": "Họ địa chỉ IP đầu ra. Tự động giữ ngăn xếp kép; IPv4/IPv6 sẽ cố định proxy này vào họ tương ứng (ngăn rò rỉ IPv4 khi dùng proxy chỉ hỗ trợ IPv6).",
"labelHost": "Máy chủ",
"labelPort": "Cổng",
"labelUsername": "Tên đăng nhập",
@@ -8142,6 +9767,13 @@
"testSuccess": "✓ {ip}",
"testLatency": "{latency}ms",
"testFailure": "✗ {error}",
+ "repair": "Sửa chữa",
+ "relayAuthMissing": "thiếu thông tin xác thực",
+ "relayRepairTooltip": "Khôi phục thông tin xác thực relay tại chỗ. Nếu token không thể khôi phục (ví dụ: sau khi xoay vòng STORAGE_ENCRYPTION_KEY), hãy triển khai lại relay.",
+ "relayRepairRedeployRequired": "Không thể khôi phục thông tin xác thực relay — hãy triển khai lại relay để ghi một token mới.",
+ "relayRepairFailed": "Sửa chữa relay thất bại",
+ "relayRepairError": "sửa chữa thất bại",
+ "relayProbeSummary": "Dò tìm relay: {alive}/{tested} đang hoạt động",
"bulkImport": "Nhập proxy hàng loạt",
"bulkImportTitle": "Nhập proxy hàng loạt",
"bulkImportDescription": "Dán hồ sơ proxy, mỗi hồ sơ trên một dòng. Các định dạng được hỗ trợ: phân tách bằng dấu gạch đứng (NAME|HOST|PORT|…), dạng viết tắt (ip:port, ip:port:user:pass, user:pass@ip:port, user:pass:ip:port, protocol://ip:port, protocol://user:pass@ip:port), hoặc chế độ tiêu đề giao thức (giao thức đơn thuần ở dòng đầu tiên sẽ áp dụng cho các dòng dạng viết tắt tiếp theo). Các proxy hiện có (cùng host + port) sẽ được cập nhật.",
@@ -8158,49 +9790,37 @@
"bulkImportPreview": "Xem trước",
"clearAssignment": "(xóa gán)",
"bulkProxyAssignment": "Gán proxy hàng loạt",
- "labelFamily": "IP family",
- "familyAuto": "Auto (dual-stack)",
- "familyIpv4": "IPv4 only",
- "familyIpv6": "IPv6 only",
- "familyHint": "Egress address family. Auto keeps dual-stack; IPv4/IPv6 pin this proxy to that family (prevents a v4 leak under an IPv6-only proxy).",
- "repair": "Repair",
- "relayAuthMissing": "auth missing",
- "relayRepairTooltip": "Recover relay auth in place. If the token is unrecoverable (e.g. after a STORAGE_ENCRYPTION_KEY rotation), redeploy the relay instead.",
- "relayRepairRedeployRequired": "Relay auth is unrecoverable — redeploy the relay to write a fresh token.",
- "relayRepairFailed": "Relay repair failed",
- "relayRepairError": "repair failed",
- "relayProbeSummary": "Relay probes: {alive}/{tested} alive",
- "testAll": "Test All",
- "errorTestFailed": "Failed to test proxies",
- "batchSelectedCount": "{count} selected",
- "batchDeleteSelected": "Delete {count} selected",
- "batchActivateSelected": "Enable {count} selected",
+ "testAll": "Kiểm tra tất cả",
+ "errorTestFailed": "Không thể kiểm tra proxy",
+ "batchSelectedCount": "Đã chọn {count}",
+ "batchDeleteSelected": "Xóa {count} mục đã chọn",
+ "batchActivateSelected": "Kích hoạt {count} mục đã chọn",
"testPassed": "✓ OK",
- "close": "Close",
- "managePool": "Manage Pool",
- "poolTitle": "Proxy Pool & Rotation",
- "poolDescription": "Attach multiple proxies to a scope and rotate egress IPs across them. A scope with a single proxy behaves exactly like a plain assignment.",
- "poolScopeIdLabel": "Scope ID",
- "poolScopeIdPlaceholder": "provider id / connection id / combo id",
- "poolScopeIdRequired": "A scope ID is required for provider, account, or combo scope.",
- "poolLoad": "Load Pool",
- "poolLoadFailed": "Failed to load the proxy pool",
- "poolStrategyLabel": "Rotation strategy",
- "poolStrategyHint": "round-robin cycles members in order; random picks uniformly; sticky holds one member for a window; latency-optimized picks the fastest based on logs.",
- "poolStrategyFailed": "Failed to update the rotation strategy",
+ "close": "Đóng",
+ "managePool": "Quản lý nhóm proxy",
+ "poolTitle": "Nhóm proxy và xoay vòng",
+ "poolDescription": "Gắn nhiều proxy vào một phạm vi và xoay vòng IP đầu ra qua các proxy đó. Một phạm vi chỉ có một proxy sẽ hoạt động giống hệt như một mục gán thông thường.",
+ "poolScopeIdLabel": "ID phạm vi",
+ "poolScopeIdPlaceholder": "ID nhà cung cấp / ID kết nối / ID combo",
+ "poolScopeIdRequired": "Yêu cầu ID phạm vi cho phạm vi nhà cung cấp, tài khoản hoặc combo.",
+ "poolLoad": "Tải nhóm proxy",
+ "poolLoadFailed": "Không thể tải nhóm proxy",
+ "poolStrategyLabel": "Chiến lược xoay vòng",
+ "poolStrategyHint": "round-robin luân phiên các thành viên theo thứ tự; random chọn ngẫu nhiên đồng đều; sticky giữ một thành viên trong một khoảng thời gian; latency-optimized chọn thành viên nhanh nhất dựa trên nhật ký.",
+ "poolStrategyFailed": "Không thể cập nhật chiến lược xoay vòng",
"strategyRoundRobin": "Round-robin",
- "strategyRandom": "Random",
+ "strategyRandom": "Ngẫu nhiên",
"strategySticky": "Sticky",
- "strategyLatency": "Latency-optimized",
- "poolMembersLabel": "Pool members ({count})",
- "poolNoMembers": "No proxies in this pool yet.",
- "poolRemove": "Remove",
- "poolRemoveFailed": "Failed to remove the proxy from the pool",
- "poolAddLabel": "Add a proxy",
- "poolAddMember": "Add",
- "poolAddFailed": "Failed to add the proxy to the pool",
- "poolSelectProxy": "Select a proxy…",
- "poolSaveFailed": "Failed to save pool assignment"
+ "strategyLatency": "Tối ưu độ trễ (Latency-optimized)",
+ "poolMembersLabel": "Thành viên nhóm proxy ({count})",
+ "poolNoMembers": "Chưa có proxy nào trong nhóm này.",
+ "poolRemove": "Gỡ bỏ",
+ "poolRemoveFailed": "Không thể gỡ proxy khỏi nhóm",
+ "poolAddLabel": "Thêm proxy",
+ "poolAddMember": "Thêm",
+ "poolAddFailed": "Không thể thêm proxy vào nhóm",
+ "poolSelectProxy": "Chọn một proxy…",
+ "poolSaveFailed": "Không thể lưu mục gán nhóm proxy"
},
"playground": {
"title": "Khu thử nghiệm mô hình",
@@ -8233,14 +9853,17 @@
"endpointOptions": {
"chat": "Chat completions",
"responses": "Phản hồi",
+ "completions": "Hoàn thành văn bản",
"images": "Tạo hình ảnh",
"embeddings": "Embeddings",
"speech": "Chuyển văn bản thành giọng nói",
"transcription": "Chuyển âm thanh thành văn bản",
"video": "Tạo video",
"music": "Tạo nhạc",
+ "moderations": "Kiểm duyệt",
"rerank": "Xếp hạng lại",
- "search": "Tìm kiếm web"
+ "search": "Tìm kiếm web",
+ "webFetch": "Lấy nội dung web"
},
"conversationalChat": "Trò chuyện đa lượt",
"clearChat": "Xóa cuộc trò chuyện",
@@ -8262,6 +9885,10 @@
"topP": "Top-p",
"presencePenalty": "Mức phạt hiện diện",
"frequencyPenalty": "Mức phạt tần suất",
+ "reasoningLabel": "Suy luận",
+ "thinking": "Thinking",
+ "effort": "Nỗ lực",
+ "effortDefault": "Mặc định",
"seedPlaceholder": "Ngẫu nhiên (để trống)",
"presetsLabel": "Cài đặt sẵn",
"loadPreset": "Tải cài đặt sẵn",
@@ -8275,11 +9902,25 @@
"improvingPrompt": "Đang cải thiện…",
"improvePromptTitle": "Cải thiện prompt của bạn bằng AI",
"setModelFirst": "Hãy thiết lập một mô hình trước",
+ "setModelInConfigFirst": "Hãy thiết lập mô hình trong bảng Cấu hình trước.",
+ "improvePromptFailed": "Không thể cải thiện prompt.",
+ "improvePromptAria": "Cải thiện prompt bằng AI",
+ "confirmImprovePrompt": "Xác nhận cải thiện prompt",
+ "improvePromptDescription": "Prompt hệ thống hiện tại sẽ được gửi tới để tạo phiên bản cải thiện.",
"improveQuotaWarning": "Hành động này sẽ tiêu thụ hạn mức từ mô hình được định cấu hình trong mục Cấu hình.",
"improveConfirm": "Cải thiện",
"exportCode": "Xuất mã",
"exportCodeTitle": "Xuất mã",
+ "exportShort": "Xuất",
"noStateToExport": "Không có trạng thái khu thử nghiệm nào để xuất.",
+ "closeExportModal": "Đóng cửa sổ xuất",
+ "close": "Đóng",
+ "exportRealKeyWarning": "Cảnh báo bảo mật: đã chặn việc xuất mã — phát hiện khóa API thực trong kết quả đầu ra. Vui lòng đặt lại khóa API của bạn rồi thử lại.",
+ "placeholderHintPrefix": "Thay thế",
+ "placeholderHintSuffix": "bằng khóa API thực tế của bạn hoặc thiết lập khóa này dưới dạng biến môi trường.",
+ "copyLangCode": "Sao chép mã {language}",
+ "loadingPresets": "Đang tải…",
+ "loadPresetPlaceholder": "Tải cài đặt sẵn…",
"copyCode": "Sao chép",
"copiedCode": "Đã sao chép!",
"addModel": "+ Thêm mô hình",
@@ -8287,56 +9928,104 @@
"cancelAll": "Hủy tất cả",
"maxColumnsReached": "Tối đa {max} cột",
"modelPlaceholderCompare": "Mô hình (Cmd+K)…",
+ "noModel": "Chưa chọn mô hình",
+ "statusLabel": "Trạng thái: {status}",
+ "status": {
+ "idle": "sẵn sàng",
+ "streaming": "đang truyền",
+ "done": "hoàn tất",
+ "error": "lỗi"
+ },
+ "cancelStream": "Hủy luồng",
+ "removeColumn": "Xóa cột",
+ "removeModelColumn": "Xóa cột của {model}",
+ "readyToRun": "Sẵn sàng chạy.",
+ "errorLabel": "Lỗi",
+ "unknownError": "Đã xảy ra lỗi không xác định.",
+ "waitingForResponse": "Đang chờ phản hồi…",
"ttft": "TTFT",
"tps": "TPS",
"metricsDisclaimer": "ước tính phía máy khách",
"tokensLabel": "Token",
"costLabel": "Chi phí",
"costEstimated": "(ước tính)",
+ "ttftTitle": "Thời gian nhận token đầu tiên (ước tính phía máy khách)",
+ "tpsTitle": "Số token mỗi giây (ước tính phía máy khách)",
+ "tokenCountsTitle": "Token prompt ↑ / Token hoàn thành ↓",
+ "estimatedCostTitle": "Chi phí ước tính (không đảm bảo chính xác)",
"toolsLabel": "Công cụ",
+ "toolsCount": "Công cụ ({count})",
"addTool": "Thêm công cụ",
+ "editTool": "Sửa công cụ {name}",
+ "removeTool": "Xóa công cụ {name}",
"toolNamePlaceholder": "Tên hàm",
+ "toolNameRequiredPlaceholder": "Tên hàm *",
"toolDescPlaceholder": "Mô tả (tùy chọn)",
+ "toolParamsLabel": "Tham số (lược đồ JSON)",
+ "toolParamsJsonSchema": "Lược đồ JSON cho tham số",
"toolParamsInvalid": "Các tham số phải là JSON hợp lệ",
"structuredOutputLabel": "Đầu ra cấu trúc",
"enableJsonMode": "Bật chế độ JSON",
"disableJsonMode": "Tắt chế độ JSON",
"invalidJson": "JSON không hợp lệ",
+ "jsonMode": "Chế độ JSON",
+ "jsonModeDescription": "Buộc sử dụng response_format: json_schema",
+ "schemaName": "Tên lược đồ",
+ "jsonSchema": "Lược đồ JSON",
+ "jsonSchemaEditor": "Trình sửa lược đồ JSON",
+ "schemaValidated": "Lược đồ hợp lệ",
+ "validateSchema": "Kiểm tra lược đồ",
+ "seed": "Seed",
+ "stopSequences": "Chuỗi dừng",
+ "stopSequencesPlaceholder": "Ví dụ: \"\\n\\n\" hoặc \"END\"",
+ "autoProvider": "Tự động",
+ "httpError": "Lỗi {status}",
+ "requestCancelled": "Yêu cầu đã bị hủy",
+ "networkError": "Lỗi mạng",
+ "regenerateLastResponse": "Tạo lại phản hồi gần nhất",
+ "regenerate": "Tạo lại",
+ "startConversation": "Bắt đầu trò chuyện — nhập tin nhắn bên dưới",
+ "role": {
+ "system": "hệ thống",
+ "user": "bạn",
+ "assistant": "trợ lý"
+ },
+ "generating": "Đang tạo…",
+ "typeMessageWithShortcut": "Nhập tin nhắn... (Enter để gửi, Shift+Enter để xuống dòng)",
+ "stop": "Dừng",
+ "noResponseBody": "Phản hồi không có nội dung",
+ "comparePromptPlaceholder": "Nhập prompt tại đây…",
+ "userPrompt": "Prompt của người dùng",
+ "cancelAllStreams": "Hủy tất cả luồng",
+ "runAllColumns": "Chạy tất cả cột",
+ "newColumnModelName": "Tên mô hình cho cột mới",
+ "addColumn": "Thêm cột",
+ "addModelColumn": "Thêm cột mô hình",
+ "columnCount": "{count}/{max} cột",
+ "addModelToCompare": "Thêm một cột mô hình để so sánh",
+ "modelsSimultaneously": "Tối đa {max} mô hình cùng lúc",
"running": "Đang chạy…",
"runLabel": "Chạy",
"enterToolResult": "Nhập kết quả công cụ…",
- "reasoningLabel": "Reasoning",
- "thinking": "Thinking",
- "effort": "Effort",
- "effortDefault": "Default",
- "exportShort": "Export",
- "closeExportModal": "Close export modal",
- "close": "Close",
- "exportRealKeyWarning": "Security warning: export blocked — a real API key was detected in the output. Please reset your API key and try again.",
- "placeholderHintPrefix": "Replace",
- "placeholderHintSuffix": "with your actual API key, or set it as an environment variable.",
- "copyLangCode": "Copy {language} code",
- "loadingPresets": "Loading…",
- "loadPresetPlaceholder": "Load preset…",
"build": {
- "step1Label": "What to test?",
- "step2Label": "Configure",
- "step3Label": "Run",
- "step1Title": "What do you want to test?",
- "step1Subtitle": "Choose the capability you want to explore in this session.",
- "step2Title": "Configure",
- "step2Subtitle": "Set up the tools or JSON schema that will be used in the request.",
- "step3Title": "Run",
- "modeToolsTitle": "Tools",
- "modeToolsDesc": "Test function calling — define tools and see how the model invokes them.",
+ "step1Label": "Kiểm thử gì?",
+ "step2Label": "Cấu hình",
+ "step3Label": "Chạy",
+ "step1Title": "Bạn muốn kiểm thử điều gì?",
+ "step1Subtitle": "Chọn khả năng bạn muốn khám phá trong phiên này.",
+ "step2Title": "Cấu hình",
+ "step2Subtitle": "Thiết lập các công cụ hoặc lược đồ JSON sẽ được sử dụng trong yêu cầu.",
+ "step3Title": "Chạy",
+ "modeToolsTitle": "Công cụ",
+ "modeToolsDesc": "Kiểm thử gọi hàm — định nghĩa các công cụ và xem cách mô hình gọi chúng.",
"modeJsonTitle": "JSON",
- "modeJsonDesc": "Test structured output — constrain the response to a JSON schema.",
- "modeBothTitle": "Tools + JSON",
- "modeBothDesc": "Combine function calling and structured output in a single request.",
- "backButton": "Back",
- "nextButton": "Next",
- "runButton": "Run",
- "promptPlaceholder": "Enter your message… (Enter to send, Shift+Enter for newline)"
+ "modeJsonDesc": "Kiểm thử đầu ra cấu trúc — giới hạn phản hồi theo một lược đồ JSON.",
+ "modeBothTitle": "Công cụ + JSON",
+ "modeBothDesc": "Kết hợp gọi hàm và đầu ra cấu trúc trong một yêu cầu duy nhất.",
+ "backButton": "Quay lại",
+ "nextButton": "Tiếp theo",
+ "runButton": "Chạy",
+ "promptPlaceholder": "Nhập tin nhắn của bạn… (Enter để gửi, Shift+Enter để xuống dòng)"
}
},
"miniPlayground": {
@@ -8371,10 +10060,42 @@
"duration": "Thời lượng",
"question": "Câu hỏi",
"audioFile": "Tệp âm thanh",
+ "fileTooLarge25Mb": "Tệp quá lớn. Kích thước tối đa là 25 MB.",
+ "selectAudioFirst": "Hãy chọn tệp âm thanh trước.",
+ "speechToText": "Chuyển giọng nói thành văn bản",
+ "chooseFile": "Chọn tệp",
+ "audioFormats25Mb": "MP3, WAV, M4A, OGG hoặc FLAC · Tối đa 25 MB",
+ "musicSample": "Một bản nhạc ambient nhẹ nhàng với piano ấm và tiếng dây êm dịu",
+ "noAudioUrl": "Phản hồi không chứa URL âm thanh: {response}",
+ "music": "Âm nhạc",
+ "embeddingSample": "OmniRoute định tuyến yêu cầu AI qua nhiều nhà cung cấp.",
+ "embedding": "Vector nhúng",
+ "imageSample": "Một thành phố tương lai lúc hoàng hôn với ánh sáng điện ảnh",
+ "image": "Hình ảnh",
+ "ttsSample": "Xin chào từ OmniRoute. Đây là bài kiểm tra chuyển văn bản thành giọng nói.",
+ "textToSpeech": "Chuyển văn bản thành giọng nói",
+ "videoSample": "Một chiếc máy bay giấy bay qua thành phố tương lai",
+ "video": "Video",
+ "webFetch": "Tải nội dung web",
+ "webSearchSample": "OmniRoute là gì?",
+ "webSearch": "Tìm kiếm web",
+ "documentUrl": "URL tài liệu",
+ "browserAudioUnsupported": "Trình duyệt của bạn không hỗ trợ âm thanh.",
+ "browserVideoUnsupported": "Trình duyệt của bạn không hỗ trợ video.",
+ "searchResultFallback": "Kết quả {number}",
"noKeysFound": "Không tìm thấy khóa API nào. Hãy thêm một khóa trong phần Khóa.",
"exampleLabel": "Ví dụ",
"latency": "{ms}ms",
- "statsLine": "{ms}ms · {tokensIn} token vào / {tokensOut} token ra"
+ "statsLine": "{ms}ms · {tokensIn} token vào / {tokensOut} token ra",
+ "defaultKey": "(mặc định)",
+ "clear": "Xóa cuộc trò chuyện",
+ "emptyConversation": "Gửi tin nhắn để bắt đầu cuộc trò chuyện",
+ "sendHint": "Shift+Enter để xuống dòng · Enter để gửi",
+ "you": "Bạn",
+ "assistant": "Trợ lý",
+ "errorLabel": "Lỗi",
+ "requestFailed": "Yêu cầu thất bại",
+ "stop": "Dừng"
},
"requestLogger": {
"recording": "Đang ghi",
@@ -8442,8 +10163,8 @@
"noLogs": "Chưa có nhật ký nào. Hãy thực hiện một số cuộc gọi API để xem chúng ở đây.",
"noMatchingLogs": "Không có nhật ký nào khớp với các bộ lọc hiện tại.",
"callLogsInfo": "Nhật ký cuộc gọi cũng được lưu dưới dạng tệp JSON tại {dataDir} và được luân chuyển dựa trên {retentionDays} và {maxEntries}.",
- "loadMore": "Load More",
- "loadingMore": "Loading more..."
+ "loadMore": "Tải thêm",
+ "loadingMore": "Đang tải thêm..."
},
"proxyLogger": {
"filterAll": "Tất cả",
@@ -8481,7 +10202,7 @@
"noProxyLogs": "Chưa có nhật ký proxy. Cấu hình proxy và thực hiện các cuộc gọi API để xem chúng ở đây.",
"noMatchingLogs": "Không có nhật ký nào khớp với bộ lọc hiện tại.",
"tlsFingerprint": "Dấu vân tay TLS Chrome 124",
- "colPublicIp": "Public IP"
+ "colPublicIp": "IP công cộng"
},
"endpointOptions": {
"speech": "Giọng nói",
@@ -8569,6 +10290,7 @@
"moreSuffix": "+{count} nữa"
},
"quotaShare": {
+ "weightPercent": "Trọng số %",
"title": "Chia sẻ hạn ngạch",
"description": "Chia sẻ hạn ngạch của nhà cung cấp giữa các khóa API với giới hạn %",
"newPool": "Nhóm mới",
@@ -8591,6 +10313,9 @@
"capLabel": "giới hạn {value}",
"notTrackedYet": "(chưa được theo dõi)",
"policy": "Chính sách",
+ "apiKeyColumn": "Khóa API",
+ "weightColumn": "Trọng số",
+ "fairShareShort": "công bằng",
"policyHard": "cứng",
"policySoft": "mềm",
"policyBurst": "bùng phát",
@@ -8622,85 +10347,85 @@
"policyLabel": "Chính sách:",
"resetIn": "đặt lại sau",
"quotaTotal": "tổng cộng",
- "kpiAvgUtilization": "Avg utilization",
- "kpiBorrowingNow": "Borrowing now",
- "conceptTitle": "How Quota Share works",
- "conceptIntro": "Quota Share divides a provider's quota among multiple API keys using work-conserving fair-share: each key receives a proportional slice but can borrow from the free balance without exceeding the global cap.",
- "conceptFairShare": "Fair-share: each key receives quota proportional to its configured weight",
- "conceptBorrowing": "Borrowing: keys can consume free balance from others without breaching the cap",
- "conceptGlobalCap": "Hard global cap: the provider's absolute limit is never exceeded",
- "conceptWindows": "Windows: 5h, hourly, daily, weekly, monthly — each tracked independently",
- "conceptKeyHowTitle": "Enabling a key for quota",
- "conceptKeyHowDesc": "Create the key normally in API Keys — it appears in the wizard's key step automatically. Tick Exclusive there to make it quota-only. No separate enable step.",
- "conceptExclusiveTitle": "What \"Exclusive\" does",
- "conceptExclusiveDesc": "An exclusive key only sees/uses the pool's quotaShared-* models — every other model is hidden from its /v1/models and blocked.",
- "burnRateTitle": "Burn rate",
- "burnRateExhaustsIn": "Exhausts in",
- "dimensionResetIn": "Resets in",
- "realConsumedColumn": "Consumed",
- "deficitColumn": "Deficit",
- "borrowingIndicator": "borrowing",
- "migratedFromLocalStorageNotice": "Pools successfully migrated from localStorage.",
- "policyCapAbsoluteLabel": "Absolute cap",
- "policyCapAbsolutePlaceholder": "Numeric limit (optional)",
- "multiDimensionLabel": "Multi-dimension",
- "stackedBarTitle": "Slices by API key",
- "usedSuffix": "used {percent}%",
- "wizardTitle": "New quota pool",
- "editPoolTitle": "Edit pool",
- "saveChanges": "Save changes",
- "wizardStep1Label": "Account",
- "wizardStep2Label": "Limit",
- "wizardStep3Label": "Keys",
- "wizardStep1Title": "Choose provider connection",
- "wizardStep1Subtitle": "Select the provider account this pool will share quota for, set a name and default policy.",
- "wizardStep2Title": "Configure quota dimensions",
- "wizardStep2Subtitle": "Define the quota plan dimensions (unit, window, limit) for the chosen connection. Leave unchanged to keep current settings.",
- "wizardStep3Title": "Allocate API keys",
- "wizardStep3Subtitle": "Assign API keys to this pool with weight % allocations and optional caps.",
- "wizardPoolNameLabel": "Pool name",
- "wizardPoolNamePlaceholder": "My quota pool",
- "wizardNext": "Next",
- "wizardBack": "Back",
- "wizardCreatePool": "Create pool",
- "wizardDimensionsEditedNotice": "Dimensions edited — will be saved as a manual override when the pool is created.",
- "wizardExclusiveLabel": "Exclusive quota",
- "wizardExclusiveHint": "When enabled, these API keys will only be allowed to use this pool's virtual models (allowedQuotas reconciliation is applied on save).",
- "wizardPreviewLabel": "Virtual model names preview",
- "wizardConnectionsLabel": "Provider connections",
- "wizardPrimaryBadge": "primary",
- "wizardAdditionalConnectionsNote": "Additional connections use their catalog default limits (editable later).",
- "wizardSingleProviderNote": "A pool uses a single provider",
- "wizardPreviewMoreModels": "+{count} more",
- "accountQuotaTitle": "Account quota",
+ "kpiAvgUtilization": "Mức sử dụng trung bình",
+ "kpiBorrowingNow": "Đang mượn",
+ "conceptTitle": "Cách hoạt động của Quota Share",
+ "conceptIntro": "Quota Share phân chia hạn ngạch của nhà cung cấp giữa nhiều khóa API bằng cơ chế chia sẻ công bằng không để lãng phí hạn ngạch: mỗi khóa API nhận được một phần theo tỷ lệ nhưng có thể mượn từ phần hạn ngạch còn trống mà không vượt quá giới hạn chung.",
+ "conceptFairShare": "Chia sẻ công bằng (Fair-share): mỗi khóa API nhận được hạn ngạch tỷ lệ thuận với trọng số đã cấu hình",
+ "conceptBorrowing": "Mượn (Borrowing): các khóa API có thể sử dụng phần hạn ngạch còn trống của các khóa khác mà không vượt quá giới hạn",
+ "conceptGlobalCap": "Giới hạn toàn cục nghiêm ngặt: giới hạn tuyệt đối của nhà cung cấp không bao giờ bị vượt quá",
+ "conceptWindows": "Chu kỳ: 5 giờ, hàng giờ, hàng ngày, hàng tuần, hàng tháng — mỗi chu kỳ được theo dõi độc lập",
+ "conceptKeyHowTitle": "Bật hạn mức cho một khóa",
+ "conceptKeyHowDesc": "Tạo khóa như bình thường trong mục Khóa API — khóa sẽ tự động xuất hiện trong bước khóa của trình hướng dẫn. Đánh dấu chọn Độc quyền tại đó để khóa chỉ dùng cho hạn mức. Không cần bước kích hoạt riêng biệt.",
+ "conceptExclusiveTitle": "«Độc quyền» có tác dụng gì",
+ "conceptExclusiveDesc": "Một khóa độc quyền chỉ nhìn thấy và sử dụng các mô hình quotaShared-* của nhóm hạn mức — mọi mô hình khác sẽ bị ẩn khỏi /v1/models của khóa đó và bị chặn.",
+ "burnRateTitle": "Tốc độ tiêu thụ",
+ "burnRateExhaustsIn": "Sẽ cạn kiệt trong",
+ "dimensionResetIn": "Đặt lại sau",
+ "realConsumedColumn": "Đã tiêu thụ",
+ "deficitColumn": "Thâm hụt",
+ "borrowingIndicator": "đang mượn",
+ "migratedFromLocalStorageNotice": "Đã di chuyển thành công các nhóm hạn mức từ localStorage.",
+ "policyCapAbsoluteLabel": "Giới hạn tuyệt đối",
+ "policyCapAbsolutePlaceholder": "Giới hạn số (tùy chọn)",
+ "multiDimensionLabel": "Đa chiều",
+ "stackedBarTitle": "Phân chia theo khóa API",
+ "usedSuffix": "đã dùng {percent}%",
+ "wizardTitle": "Nhóm hạn mức mới",
+ "editPoolTitle": "Chỉnh sửa nhóm hạn mức",
+ "saveChanges": "Lưu thay đổi",
+ "wizardStep1Label": "Tài khoản",
+ "wizardStep2Label": "Giới hạn",
+ "wizardStep3Label": "Các khóa",
+ "wizardStep1Title": "Chọn kết nối nhà cung cấp",
+ "wizardStep1Subtitle": "Chọn tài khoản nhà cung cấp mà nhóm hạn mức này sẽ chia sẻ hạn mức, đặt tên và chính sách mặc định.",
+ "wizardStep2Title": "Cấu hình các chiều hạn mức",
+ "wizardStep2Subtitle": "Xác định các chiều của kế hoạch hạn mức (đơn vị, chu kỳ, giới hạn) cho kết nối đã chọn. Để nguyên để giữ các cài đặt hiện tại.",
+ "wizardStep3Title": "Phân bổ khóa API",
+ "wizardStep3Subtitle": "Gán các khóa API vào nhóm hạn mức này với tỷ lệ phân bổ theo trọng số (%) và các giới hạn tùy chọn.",
+ "wizardPoolNameLabel": "Tên nhóm hạn mức",
+ "wizardPoolNamePlaceholder": "Nhóm hạn mức của tôi",
+ "wizardNext": "Tiếp theo",
+ "wizardBack": "Quay lại",
+ "wizardCreatePool": "Tạo nhóm hạn mức",
+ "wizardDimensionsEditedNotice": "Các chiều đã được chỉnh sửa — sẽ được lưu dưới dạng ghi đè thủ công khi nhóm hạn mức được tạo.",
+ "wizardExclusiveLabel": "Hạn mức độc quyền",
+ "wizardExclusiveHint": "Khi được bật, các khóa API này sẽ chỉ được phép sử dụng các mô hình ảo của nhóm hạn mức này (đối soát allowedQuotas sẽ được áp dụng khi lưu).",
+ "wizardPreviewLabel": "Xem trước tên mô hình ảo",
+ "wizardConnectionsLabel": "Kết nối nhà cung cấp",
+ "wizardPrimaryBadge": "chính",
+ "wizardAdditionalConnectionsNote": "Các kết nối bổ sung sử dụng các giới hạn mặc định trong danh mục của chúng (có thể chỉnh sửa sau).",
+ "wizardSingleProviderNote": "Một nhóm hạn mức chỉ sử dụng một nhà cung cấp duy nhất",
+ "wizardPreviewMoreModels": "+{count} khác",
+ "accountQuotaTitle": "Hạn mức tài khoản",
"accountQuotaNone": "—",
- "logTitle": "Usage log",
- "logEmpty": "No usage yet",
- "groupLabel": "Group",
- "newGroup": "New group",
- "renameGroup": "Rename group",
- "groupSelectHint": "Filter pools by group",
- "groupAllocationNote": "Allocations apply to all pools in this group via the shared quota layer.",
- "groupNamePrompt": "Enter group name",
- "wizardGroupLabel": "Group",
- "allGroups": "All groups",
- "endpointsTitle": "Available endpoints",
- "endpointsHint": "Call these virtual models with any allocated key — routing + quota are handled per group.",
- "previewForKey": "Preview for key",
- "previewKeyNone": "(all endpoints)",
+ "logTitle": "Nhật ký sử dụng",
+ "logEmpty": "Chưa có lượt sử dụng nào",
+ "groupLabel": "Nhóm",
+ "newGroup": "Nhóm mới",
+ "renameGroup": "Đổi tên nhóm",
+ "groupSelectHint": "Lọc nhóm hạn mức theo nhóm",
+ "groupAllocationNote": "Các phân bổ được áp dụng cho tất cả nhóm hạn mức trong nhóm này thông qua lớp hạn mức dùng chung.",
+ "groupNamePrompt": "Nhập tên nhóm",
+ "wizardGroupLabel": "Nhóm",
+ "allGroups": "Tất cả các nhóm",
+ "endpointsTitle": "Các endpoint khả dụng",
+ "endpointsHint": "Gọi các mô hình ảo này bằng bất kỳ khóa API nào được phân bổ — việc định tuyến và hạn mức được xử lý theo từng nhóm.",
+ "previewForKey": "Xem trước cho khóa",
+ "previewKeyNone": "(tất cả các endpoint)",
"endpointsBaseUrl": "Base URL",
- "endpointsCollapse": "Collapse",
- "endpointsExpand": "Expand",
- "endpointsAnthropicNote": "Anthropic-native",
+ "endpointsCollapse": "Thu gọn",
+ "endpointsExpand": "Mở rộng",
+ "endpointsAnthropicNote": "Chuẩn Anthropic",
"endpointsResponsesNote": "OpenAI Responses — codex/github",
- "endpointsWsNote": "WebSocket — codex only",
- "betaText": "Quota Share is functional but bugs are expected. Found one? Please report it.",
- "betaReportLink": "Report an issue",
- "deleteGroup": "Delete group",
- "deleteGroupConfirm": "Delete this group? Its pools must be reassigned or removed first.",
- "deleteGroupHasPools": "This group still has pools — reassign or delete them first.",
- "ungroupedTitle": "Ungrouped",
- "ungroupedHint": "These pools are not assigned to a known group. Edit a pool to move it into a real group."
+ "endpointsWsNote": "WebSocket — chỉ dành cho codex",
+ "betaText": "Quota Share đang hoạt động nhưng có thể vẫn còn lỗi. Bạn tìm thấy lỗi? Vui lòng báo cáo.",
+ "betaReportLink": "Báo cáo sự cố",
+ "deleteGroup": "Xóa nhóm",
+ "deleteGroupConfirm": "Xóa nhóm này? Các pool của nhóm phải được gán lại hoặc loại bỏ trước.",
+ "deleteGroupHasPools": "Nhóm này vẫn còn các pool — hãy gán lại hoặc xóa chúng trước.",
+ "ungroupedTitle": "Chưa phân nhóm",
+ "ungroupedHint": "Các pool này chưa được gán cho một nhóm mà hệ thống nhận diện. Hãy chỉnh sửa một pool để chuyển pool đó vào một nhóm hợp lệ."
},
"plugins": {
"title": "Plugin",
@@ -8736,18 +10461,1500 @@
"status": "Trạng thái",
"enabled": "Đã bật",
"disabled": "Đã tắt",
- "hooks": "Hooks",
- "installedTab": "Installed",
+ "installedTab": "Đã cài đặt",
"marketplaceTab": "Marketplace",
- "marketplaceUrlLabel": "Custom Marketplace URL",
- "marketplaceUrlPlaceholder": "Leave empty for official Omniroute registry",
- "saveMarketplaceUrl": "Save & Reload",
- "marketplaceUrlSaved": "Marketplace URL updated",
- "marketplaceEmpty": "No plugins found in marketplace.",
- "marketplaceInstallComingSoon": "Marketplace installs are coming soon.",
- "verified": "Verified",
- "install": "Install",
- "installedFromMarketplace": "Plugin {name} installed!"
+ "marketplaceUrlLabel": "URL Marketplace tùy chỉnh",
+ "marketplaceUrlPlaceholder": "Để trống để sử dụng kho plugin chính thức của OmniRoute",
+ "saveMarketplaceUrl": "Lưu & Tải lại",
+ "marketplaceUrlSaved": "Đã cập nhật URL Marketplace",
+ "marketplaceEmpty": "Không tìm thấy plugin nào trong marketplace.",
+ "marketplaceInstallComingSoon": "Chức năng cài đặt từ Marketplace sẽ sớm ra mắt.",
+ "verified": "Đã xác minh",
+ "install": "Cài đặt",
+ "installedFromMarketplace": "Đã cài đặt plugin {name}!",
+ "hooks": "Hooks"
+ },
+ "quotaPlans": {
+ "title": "Gói dịch vụ & Hạn ngạch",
+ "description": "Cấu hình các gói hạn ngạch theo từng nhà cung cấp — các chỉ số (%, yêu cầu, token, $) và khung thời gian",
+ "providerLabel": "Nhà cung cấp / Kết nối",
+ "detectedPlanLabel": "Gói được phát hiện",
+ "manualPlanLabel": "Ghi đè thủ công",
+ "unconfiguredLabel": "Chưa cấu hình — yêu cầu cấu hình thủ công",
+ "dimensionLabel": "Các chỉ số",
+ "addDimension": "Thêm chỉ số",
+ "removeDimension": "Loại bỏ",
+ "limitLabel": "Giới hạn",
+ "unitOptions": {
+ "percent": "%",
+ "requests": "yêu cầu",
+ "tokens": "tokens",
+ "usd": "USD ($)"
+ },
+ "windowOptions": {
+ "5h": "5 giờ",
+ "hourly": "Hàng giờ",
+ "daily": "Hàng ngày",
+ "weekly": "Hàng tuần",
+ "monthly": "Hàng tháng"
+ },
+ "useCatalogButton": "Sử dụng danh mục",
+ "saveOverrideButton": "Lưu ghi đè",
+ "revertToCatalogButton": "Khôi phục về danh mục",
+ "unknownProviderNotice": "Chọn một nhà cung cấp ở bên trái để cấu hình kế hoạch hạn mức của họ.",
+ "catalogTitle": "Danh mục đã biết",
+ "catalogDescription": "Các kế hoạch được phát hiện tự động cho các nhà cung cấp sau:"
+ },
+ "activity": {
+ "title": "Hoạt động",
+ "description": "Bảng tin các sự kiện gần đây",
+ "emptyTitle": "Chưa có hoạt động nào",
+ "emptyDescription": "Khi bạn thêm nhà cung cấp, tạo combo hoặc xoay vòng khóa, các sự kiện sẽ xuất hiện ở đây.",
+ "todayHeader": "Hôm nay",
+ "yesterdayHeader": "Hôm qua",
+ "filterAll": "Tất cả",
+ "filterProviders": "Nhà cung cấp",
+ "filterCombos": "Combos",
+ "filterApiKeys": "Khóa API",
+ "filterSettings": "Cài đặt",
+ "filterQuota": "Hạn mức",
+ "filterAuth": "Xác thực",
+ "filterSystem": "Hệ thống",
+ "filterAria": "Lọc theo loại sự kiện",
+ "refresh": "Làm mới",
+ "refreshAria": "Làm mới bảng tin hoạt động",
+ "loading": "Đang tải",
+ "loadingActivity": "Đang tải hoạt động…",
+ "fetchFailed": "Không thể tải hoạt động",
+ "relative": {
+ "justNow": "vừa xong",
+ "minutesAgo": "{n} phút trước",
+ "hoursAgo": "{n} giờ trước",
+ "yesterday": "hôm qua",
+ "daysAgo": "{n} ngày trước"
+ },
+ "eventVerb": {
+ "providerAdded": "{actor} đã thêm nhà cung cấp {target}",
+ "providerRemoved": "{actor} đã xóa nhà cung cấp {target}",
+ "providerTested": "{actor} đã kiểm tra nhà cung cấp {target}",
+ "comboCreated": "{actor} đã tạo combo {target}",
+ "comboUpdated": "{actor} đã cập nhật combo {target}",
+ "comboDeleted": "{actor} đã xóa combo {target}",
+ "apiKeyCreated": "{actor} đã tạo khóa API {target}",
+ "apiKeyRevoked": "{actor} đã thu hồi khóa API {target}",
+ "apiKeyRotated": "{actor} đã xoay vòng khóa API {target}",
+ "budgetThreshold": "Đã đạt ngưỡng ngân sách cho {target}",
+ "settingUpdated": "{actor} đã cập nhật cài đặt {target}",
+ "authLogin": "{actor} đã đăng nhập",
+ "authLogout": "{actor} đã đăng xuất",
+ "cloudAgentSession": "Đã bắt đầu phiên tác nhân đám mây cho {target}",
+ "mcpToolRegistered": "Công cụ MCP {target} đã được đăng ký",
+ "webhookCreated": "{actor} đã tạo webhook {target}",
+ "webhookDeleted": "{actor} đã xóa webhook {target}",
+ "quotaPoolCreated": "{actor} đã tạo nhóm hạn mức {target}",
+ "quotaPoolUpdated": "{actor} đã cập nhật nhóm hạn mức {target}",
+ "quotaPoolDeleted": "{actor} đã xóa nhóm hạn mức {target}",
+ "quotaPlanUpdated": "{actor} đã cập nhật kế hoạch hạn mức {target}",
+ "quotaStoreDriverChanged": "Đã thay đổi trình điều khiển QuotaStore",
+ "updateApplied": "Đã áp dụng bản cập nhật {target}",
+ "deployCompleted": "Triển khai hoàn tất",
+ "skillInstalled": "{actor} đã cài đặt kỹ năng {target}",
+ "skillRemoved": "{actor} đã xóa kỹ năng {target}",
+ "providerCredentialsCreated": "{actor} đã tạo thông tin xác thực cho {target}",
+ "providerCredentialsApplied": "Đã áp dụng thông tin xác thực cho {target}",
+ "providerCredentialsUpdated": "{actor} đã cập nhật thông tin xác thực cho {target}",
+ "providerCredentialsRevoked": "{actor} đã thu hồi thông tin xác thực cho {target}",
+ "providerCredentialsBatchRevoked": "{actor} đã thu hồi hàng loạt thông tin xác thực",
+ "providerCredentialsBatchUpdated": "{actor} đã cập nhật hàng loạt thông tin xác thực",
+ "providerCredentialsBulkCreated": "{actor} đã tạo hàng loạt thông tin xác thực",
+ "providerCredentialsBulkImported": "{actor} đã nhập hàng loạt thông tin xác thực",
+ "providerCredentialsImported": "{actor} đã nhập thông tin xác thực",
+ "providerSsrfBlocked": "Nỗ lực SSRF bị chặn đối với {target}",
+ "authLoginSuccess": "{actor} đã đăng nhập",
+ "authLoginError": "Lỗi đăng nhập của {actor}",
+ "authLoginFailed": "Đăng nhập thất bại cho {actor}",
+ "authLoginLocked": "{actor} bị khóa tài khoản do thử quá nhiều lần",
+ "authLoginMisconfigured": "Cấu hình xác thực không hợp lệ",
+ "authLoginSetupRequired": "Cần thiết lập xác thực",
+ "authLogoutSuccess": "{actor} đã đăng xuất",
+ "syncTokenCreated": "{actor} đã tạo token đồng bộ",
+ "syncTokenRevoked": "{actor} đã thu hồi token đồng bộ",
+ "settingsUpdate": "{actor} đã cập nhật cài đặt",
+ "settingsUpdateFailed": "Cập nhật cài đặt thất bại",
+ "serviceRevealApiKey": "{actor} đã hiển thị khóa API của {target}",
+ "genericEvent": "{actor} {target}"
+ }
+ },
+ "agentBridge": {
+ "title": "AgentBridge",
+ "subtitle": "Sử dụng các agent của IDE với các mô hình OmniRoute — không cần cấu hình",
+ "riskBannerTitle": "Tự chịu rủi ro khi sử dụng",
+ "riskBannerBody": "AgentBridge chặn bắt lưu lượng HTTPS từ các agent IDE. Khi kích hoạt, bạn chịu trách nhiệm tuân thủ điều khoản dịch vụ của từng agent. Tuyệt đối không sử dụng trên các thiết bị hoặc mạng cấm kiểm tra TLS.",
+ "riskBannerDismiss": "Bỏ qua",
+ "serverCardTitle": "Máy chủ AgentBridge",
+ "statusRunning": "Đang chạy",
+ "statusStopped": "Đã dừng",
+ "statusActive": "Hoạt động",
+ "statusDnsOff": "Tắt DNS",
+ "statusSetupRequired": "Cần thiết lập",
+ "statusInvestigating": "Đang điều tra",
+ "serverPort": "Cổng",
+ "serverConns": "Kết nối",
+ "serverIntercepted": "Đã chặn bắt",
+ "serverLastStarted": "Lần khởi động cuối",
+ "startServer": "Bắt đầu",
+ "stopServer": "Dừng",
+ "restartServer": "Khởi động lại",
+ "trustCert": "Tin cậy chứng chỉ",
+ "downloadCert": "Tải xuống chứng chỉ",
+ "certManualTitle": "Không thể tự động cài đặt chứng chỉ (ví dụ: bên trong vùng chứa). AgentBridge vẫn có thể chạy — hãy tin cậy CA theo cách thủ công:",
+ "regenerateCert": "Tạo lại chứng chỉ",
+ "starting": "Đang bắt đầu…",
+ "stopping": "Đang dừng…",
+ "restarting": "Đang khởi động lại…",
+ "trusting": "Đang thiết lập tin cậy…",
+ "regenerating": "Đang tạo lại…",
+ "upstreamCaLabel": "Chứng chỉ CA thượng nguồn (doanh nghiệp)",
+ "upstreamCaPlaceholder": "/etc/ssl/certs/corp-ca.pem",
+ "upstreamCaTest": "Kiểm tra TLS",
+ "upstreamCaTestOk": "Kiểm tra TLS thành công",
+ "upstreamCaTestError": "Kiểm tra TLS thất bại — vui lòng kiểm tra đường dẫn và tệp CA",
+ "bypassSectionTitle": "Danh sách bỏ qua",
+ "bypassSectionDesc": "Các máy chủ khớp với những mẫu này sẽ được chuyển tiếp trực tiếp qua tunnel (không giải mã TLS). Các giá trị mặc định bao gồm banks.gov và SSO doanh nghiệp.",
+ "bypassDefaultsLabel": "Mẫu bỏ qua mặc định (chỉ đọc)",
+ "bypassUserLabel": "Mẫu bỏ qua tùy chỉnh (mỗi dòng một mẫu, glob hoặc regex)",
+ "saveBypassList": "Lưu danh sách bỏ qua",
+ "agentListTitle": "Agent của IDE",
+ "filterAll": "Tất cả",
+ "filterActive": "Hoạt động",
+ "filterSetupRequired": "Yêu cầu thiết lập",
+ "filterInvestigating": "Đang điều tra",
+ "searchAgents": "Tìm kiếm agent…",
+ "noAgentsMatch": "Không có agent nào khớp với bộ lọc hiện tại",
+ "agentHosts": "Các máy chủ bị chặn",
+ "certTrusted": "Chứng chỉ đã được tin cậy",
+ "certNotTrusted": "Chứng chỉ chưa được tin cậy",
+ "investigatingNotice": "Agent này đang được xem xét. Các máy chủ và phạm vi API vẫn đang được xác nhận. Việc thiết lập sẽ khả dụng sau khi API thượng nguồn được lập tài liệu.",
+ "modelMappingsLabel": "Ánh xạ mô hình",
+ "sourceModel": "Mô hình nguồn (agent gốc)",
+ "targetModel": "Mô hình mục tiêu (OmniRoute)",
+ "noMappings": "Chưa cấu hình ánh xạ mô hình. Hãy chạy trình hướng dẫn thiết lập để tự động phát hiện mô hình.",
+ "selectModel": "Chọn…",
+ "saveMappings": "Lưu ánh xạ",
+ "setupWizard": "Trình hướng dẫn thiết lập",
+ "startDns": "Khởi động DNS",
+ "stopDns": "Dừng DNS",
+ "toggling": "Đang chuyển đổi…",
+ "viewTraffic": "Xem lưu lượng",
+ "emptyNoProvidersTitle": "Chưa có nhà cung cấp nào được cấu hình",
+ "emptyNoProvidersBody": "Để sử dụng AgentBridge, trước tiên hãy kết nối ít nhất một nhà cung cấp. Đây sẽ là đích để định tuyến các yêu cầu từ IDE đến.",
+ "emptyGoToProviders": "Đi tới Nhà cung cấp",
+ "wizardTitle": "Trình hướng dẫn thiết lập",
+ "wizardSubtitle": "Thiết lập 3 bước",
+ "wizardStep1Label": "Xác minh",
+ "wizardStep2Label": "DNS",
+ "wizardStep3Label": "Ánh xạ",
+ "wizardStep1Desc": "Xác nhận rằng máy chủ đang chạy và chứng chỉ đã được cài đặt.",
+ "wizardStep2Desc": "Các mục sau đây sẽ được thêm vào /etc/hosts để chuyển hướng lưu lượng truy cập qua AgentBridge:",
+ "wizardStep3Desc": "Bây giờ bạn có thể cấu hình ánh xạ mô hình trong thẻ tác nhân. Khởi động lại IDE để áp dụng các thay đổi.",
+ "wizardStep3Success": "Agent đã được cấu hình!",
+ "wizardServerCheck": "Máy chủ AgentBridge",
+ "wizardRunning": "đang chạy",
+ "wizardNotRunning": "không chạy",
+ "wizardCertCheck": "Chứng chỉ",
+ "wizardTrusted": "được tin cậy",
+ "wizardNotTrusted": "chưa được tin cậy — hãy dùng nút Tin cậy chứng chỉ",
+ "wizardTutorialTitle": "Hướng dẫn thiết lập:",
+ "wizardEnableDns": "Thêm các mục /etc/hosts",
+ "wizardDnsAlreadyEnabled": "DNS đã được bật cho agent này",
+ "enablingDns": "Đang bật…",
+ "modelSelectorTitle": "Chọn mô hình mục tiêu",
+ "modelSelectorSearch": "Tìm kiếm mô hình…",
+ "noModelsFound": "Không tìm thấy mô hình nào",
+ "quickLinks": "Liên kết nhanh",
+ "quickLinkProviders": "Cấu hình nhà cung cấp",
+ "quickLinkInspector": "Xem lưu lượng trong Traffic Inspector",
+ "unknownError": "Lỗi không xác định",
+ "maintenanceTitle": "Bảo trì & Chẩn đoán",
+ "maintenanceSubtitle": "Tự kiểm tra quy trình thu thập, hoàn tác trạng thái hệ thống còn sót lại và di chuyển thiết lập của bạn giữa các máy.",
+ "orphanedStateWarning": "Phiên làm việc trước đó đã để lại trạng thái hệ thống (giả mạo DNS, CA hoặc proxy hệ thống). Hãy chạy Sửa chữa để dọn dẹp.",
+ "diagnose": "Chẩn đoán",
+ "diagnosing": "Đang chẩn đoán…",
+ "diagnoseHealthy": "Quy trình thu thập hoạt động tốt.",
+ "diagnoseUnhealthy": "Quy trình thu thập gặp sự cố:",
+ "repair": "Sửa chữa",
+ "repairing": "Đang sửa chữa…",
+ "repairDone": "Đã sửa chữa: {items}",
+ "repairNothing": "Không có gì cần sửa chữa — trạng thái hệ thống sạch sẽ.",
+ "removeCa": "Gỡ bỏ CA",
+ "removeCaConfirm": "Gỡ bỏ CA?",
+ "removeCaDone": "CA gốc MITM đã được gỡ khỏi kho tin cậy của hệ điều hành.",
+ "removing": "Đang gỡ bỏ…",
+ "confirm": "Xác nhận",
+ "exportConfig": "Xuất cấu hình",
+ "exporting": "Đang xuất…",
+ "importConfig": "Nhập cấu hình",
+ "importing": "Đang nhập…",
+ "importInvalidJson": "Tệp được chọn không phải là JSON hợp lệ.",
+ "importDone": "Đã nhập {bypass} mục bỏ qua · {hosts} máy chủ · {agents} tác nhân",
+ "save": "Lưu",
+ "saving": "Đang lưu…",
+ "cancel": "Hủy",
+ "back": "Quay lại",
+ "next": "Tiếp tục",
+ "done": "Hoàn tất",
+ "loading": "Đang tải…",
+ "riskNoticeTitle": "Xác nhận rủi ro",
+ "riskNoticeBody": "AgentBridge sẽ chặn lưu lượng HTTPS từ tác nhân này bằng cách chuyển hướng các máy chủ API của tác nhân đó qua DNS. Chỉ bật nếu bạn chấp nhận trách nhiệm tuân thủ điều khoản dịch vụ của tác nhân và mọi chính sách mạng hiện hành.",
+ "pageMoved": {
+ "goNow": "Đi ngay",
+ "message": "MITM Proxy hiện nằm trong AgentBridge.",
+ "title": "Trang này đã được di chuyển"
+ }
+ },
+ "providerStats": {
+ "unknownError": "Lỗi không xác định",
+ "loading": "Đang tải thống kê nhà cung cấp...",
+ "loadFailed": "Không thể tải thống kê nhà cung cấp: {error}",
+ "retry": "Thử lại",
+ "updated": "Cập nhật lúc {time}",
+ "refresh": "Làm mới",
+ "totalRequests": "Tổng số yêu cầu",
+ "avgLatency": "Độ trễ trung bình",
+ "successRate": "Tỷ lệ thành công",
+ "activeProviders": "Nhà cung cấp đang hoạt động",
+ "providerBreakdown": "Phân bổ theo nhà cung cấp",
+ "providerCount": "{count} nhà cung cấp",
+ "provider": "Nhà cung cấp",
+ "requests": "Yêu cầu",
+ "success": "Thành công",
+ "rate": "Tỷ lệ",
+ "tokensIn": "Token đầu vào",
+ "tokensOut": "Token đầu ra",
+ "ttftAfterTool": "TTFT sau công cụ",
+ "gapAfterTool": "Khoảng chờ sau công cụ",
+ "model": "Mô hình",
+ "noProviderData": "Chưa ghi nhận dữ liệu nhà cung cấp.",
+ "comboMetrics": "Chỉ số combo",
+ "comboMetricsDescription": "Độ trễ và thông lượng theo từng combo từ luồng dữ liệu",
+ "avgTtft": "TTFT trung bình",
+ "avgTotal": "Tổng thời gian trung bình",
+ "requestTelemetry": "Phân tích từ xa yêu cầu",
+ "requestTelemetryDescription": "Phân tích quy trình 7 giai đoạn trong 5 phút qua"
+ },
+ "relay": {
+ "title": "Relay Proxy không máy chủ",
+ "description": "Tạo các endpoint API công khai chuyển tiếp tới OmniRoute kèm giới hạn tốc độ và kiểm soát truy cập",
+ "created": "Đã tạo token relay",
+ "createFailed": "Không thể tạo token",
+ "toggleFailed": "Không thể thay đổi trạng thái token",
+ "deleteConfirm": "Xóa token relay này? Thao tác này không thể hoàn tác.",
+ "deleted": "Đã xóa token",
+ "deleteFailed": "Không thể xóa token",
+ "cancel": "Hủy",
+ "newToken": "Token relay mới",
+ "createTitle": "Tạo token relay",
+ "nameRequired": "Tên *",
+ "tokenDescription": "Mô tả",
+ "descriptionPlaceholder": "Dùng cho các hàm không máy chủ của tôi",
+ "maxPerMinute": "Số yêu cầu tối đa/phút",
+ "maxPerDay": "Số yêu cầu tối đa/ngày",
+ "createButton": "Tạo token",
+ "createdTitle": "Đã tạo token — Hãy sao chép ngay!",
+ "tokenFor": "Token cho {name} :",
+ "shownOnce": "Token này sẽ không được hiển thị lại. Hãy lưu trữ an toàn.",
+ "dismiss": "Đóng",
+ "usage": "Cách sử dụng",
+ "usageDescription": "Gửi yêu cầu tới endpoint relay:",
+ "tokenCount": "Token relay ({count})",
+ "loading": "Đang tải...",
+ "empty": "Chưa cấu hình token relay nào. Hãy tạo một token để bắt đầu.",
+ "disable": "Tắt",
+ "enable": "Bật",
+ "delete": "Xóa"
+ },
+ "trafficInspector": {
+ "title": "Traffic Inspector",
+ "subtitle": "Giám sát các cuộc gọi LLM và gỡ lỗi lưu lượng HTTPS của bất kỳ ứng dụng nào",
+ "captureModesTitle": "Chế độ thu thập",
+ "agentBridgeMode": "AgentBridge",
+ "agentBridgeModeDesc": "Thu thập lưu lượng truy cập từ tất cả tác nhân IDE đã kết nối",
+ "customHostsMode": "Máy chủ tùy chỉnh",
+ "customHostsModeDesc": "Thêm các máy chủ cụ thể để đánh chặn",
+ "httpProxyMode": "HTTP Proxy",
+ "httpProxyModeDesc": "Sử dụng biến môi trường HTTP_PROXY",
+ "systemWideMode": "Toàn hệ thống",
+ "systemWideModeDesc": "Đánh chặn tất cả lưu lượng hệ thống (nâng cao)",
+ "tproxyMode": "Giải mã TPROXY",
+ "tproxyModeUnavailable": "Giải mã TPROXY yêu cầu Linux + root + tiện ích bổ sung gốc",
+ "filterBarTitle": "Bộ lọc",
+ "profileLlmOnly": "Chỉ LLM",
+ "profileCustom": "Tùy chỉnh",
+ "profileAll": "Tất cả",
+ "filterHost": "Lọc máy chủ…",
+ "filterAgent": "Tác nhân",
+ "filterStatus": "Trạng thái",
+ "pauseBtn": "Tạm dừng",
+ "resumeBtn": "Tiếp tục",
+ "clearBtn": "Xóa",
+ "exportHar": "Xuất tệp .har",
+ "recordSession": "REC",
+ "stopSession": "Dừng",
+ "liveBadge": "trực tiếp",
+ "offlineBadge": "ngoại tuyến",
+ "noRequests": "Chưa có yêu cầu nào được thu thập.",
+ "noRequestsDesc": "Hãy đảm bảo AgentBridge đang chạy hoặc bật một chế độ thu thập khác.",
+ "selectRequest": "Chọn một yêu cầu để kiểm tra.",
+ "tabConversation": "Cuộc trò chuyện",
+ "tabHeaders": "Headers",
+ "tabRequest": "Yêu cầu",
+ "tabResponse": "Phản hồi",
+ "tabTiming": "Thời gian",
+ "tabLlm": "LLM",
+ "tabStats": "Thống kê",
+ "requestHeaders": "Header yêu cầu",
+ "responseHeaders": "Header phản hồi",
+ "rawEvents": "Sự kiện thô",
+ "mergedView": "Chế độ xem hợp nhất",
+ "noBody": "Không có phần thân.",
+ "streaming": "đang truyền trực tuyến…",
+ "manageHosts": "Quản lý máy chủ",
+ "copySnippet": "Sao chép đoạn mã proxy",
+ "addHost": "Thêm",
+ "hostPlaceholder": "api.openai.com",
+ "noHostsYet": "Chưa có máy chủ tùy chỉnh nào được thêm.",
+ "noSessionsYet": "Chưa có phiên nào",
+ "allTraffic": "Tất cả lưu lượng (không có phiên)",
+ "sessionsDropdown": "Phiên",
+ "annotationPlaceholder": "Thêm ghi chú…",
+ "contextFingerprint": "Dấu vân tay ngữ cảnh",
+ "llmProvider": "Nhà cung cấp được phát hiện",
+ "llmApiKind": "Loại API",
+ "llmModel": "Mô hình",
+ "llmMessages": "Tin nhắn",
+ "llmStream": "Luồng",
+ "llmMappedTo": "Ánh xạ tới",
+ "llmCostEstimate": "Ước tính chi phí",
+ "systemProxyExitWarning": "Proxy toàn hệ thống vẫn đang hoạt động — bạn vẫn muốn rời khỏi trang?",
+ "customHostsTitle": "Máy chủ tùy chỉnh",
+ "loading": "Đang tải…",
+ "copied": "Đã sao chép!",
+ "copy": "Sao chép",
+ "httpProxyTitle": "Đoạn mã HTTP Proxy — cổng {port}",
+ "notRecording": "Không ghi lại",
+ "anyStatus": "Mọi trạng thái",
+ "liveOnly": "Trực tiếp",
+ "viewingRecordedSession": "Đang xem phiên đã ghi",
+ "backToLive": "Trở lại chế độ trực tiếp",
+ "untitledSession": "Phiên không có tiêu đề",
+ "contextHistory": "Lịch sử ngữ cảnh",
+ "modelResponse": "Phản hồi của mô hình",
+ "conversationNoMessages": "Không tìm thấy tin nhắn nào trong yêu cầu này.",
+ "conversationNotAvailable": "Dữ liệu hội thoại không khả dụng. Đây có thể không phải là yêu cầu LLM hoặc phần thân không thể được phân tích cú pháp.",
+ "loadingCharts": "Đang tải biểu đồ…",
+ "statsErrors": "Lỗi",
+ "statsLatency": "Độ trễ (50 yêu cầu gần nhất)",
+ "statsNoData": "Chưa có yêu cầu nào. Hãy bắt đầu ghi phiên để thu thập dữ liệu cho thống kê.",
+ "statsStatusDistribution": "Phân bố trạng thái",
+ "statsSuccessful": "Thành công",
+ "statsTotalRequests": "Tổng số yêu cầu",
+ "timingProxyOverhead": "Chi phí xử lý của proxy",
+ "timingUpstreamResponse": "Phản hồi từ máy chủ thượng nguồn",
+ "timingNoData": "Không có dữ liệu thời gian.",
+ "timingTotalLatency": "Tổng độ trễ",
+ "timingTimestamp": "Dấu thời gian",
+ "timingMethod": "Phương thức",
+ "timingStatus": "Trạng thái",
+ "timingRequestSize": "Kích thước yêu cầu",
+ "timingResponseSize": "Kích thước phản hồi",
+ "pausedNewBadge": "{count} mới",
+ "clearContextFilter": "xóa",
+ "invalidHostname": "Tên máy chủ không hợp lệ",
+ "invalidHost": "Máy chủ không hợp lệ",
+ "addHostFailed": "Không thể thêm máy chủ",
+ "networkError": "Lỗi mạng",
+ "close": "Đóng",
+ "removeHost": "Xóa {host}",
+ "requestDetails": "Chi tiết yêu cầu",
+ "filterByContext": "Lọc theo ngữ cảnh này",
+ "filteringContext": "Đang lọc: ngữ cảnh {context}",
+ "clear": "xóa",
+ "trafficProfile": "Hồ sơ lưu lượng",
+ "roleSystem": "Hệ thống",
+ "roleUser": "Người dùng",
+ "roleAssistant": "Trợ lý",
+ "roleTool": "Công cụ",
+ "expand": "Mở rộng",
+ "collapse": "Thu gọn",
+ "systemPromptHidden": "Prompt hệ thống đang ẩn — nhấp để mở rộng",
+ "sessionName": "Phiên {id}",
+ "sessions": "Các phiên",
+ "requestCountShort": "{count} yêu cầu",
+ "deleteSession": "Xóa phiên",
+ "saving": "Đang lưu…",
+ "sensitiveHeaders": "Header nhạy cảm",
+ "show": "Hiển thị",
+ "hide": "Ẩn",
+ "name": "Tên",
+ "value": "Giá trị",
+ "noSseEvents": "Không có sự kiện SSE",
+ "noRequestBody": "Không có phần thân yêu cầu.",
+ "noResponseBody": "Không có phần thân phản hồi.",
+ "formatted": "Đã định dạng",
+ "raw": "Dữ liệu thô"
+ },
+ "cliCommon": {
+ "concept": {
+ "code": {
+ "title": "Công cụ viết mã CLI",
+ "phrase": "Các công cụ viết mã mà bạn trỏ tới OmniRoute",
+ "flow": "Bạn → CLI Code → OmniRoute → Nhà cung cấp",
+ "seeOther": "See →"
+ },
+ "agent": {
+ "title": "Các tác nhân CLI",
+ "phrase": "Các tác nhân CLI tự trị đa dụng mà bạn trỏ tới OmniRoute",
+ "flow": "Bạn → CLI Agent → OmniRoute → Nhà cung cấp",
+ "seeOther": "See →"
+ },
+ "acp": {
+ "title": "Các tác nhân ACP",
+ "phrase": "Các CLI mà OmniRoute khởi chạy làm phần phụ trợ thực thi (luồng ngược)",
+ "flow": "Ứng dụng khách → OmniRoute → khởi chạy CLI (stdio/ACP) → phản hồi",
+ "seeOther": "See →"
+ }
+ },
+ "comparison": {
+ "title": "Hiểu 3 loại CLI trong OmniRoute",
+ "thisPage": "Trang này",
+ "open": "Mở →",
+ "code": {
+ "title": "Công cụ viết mã",
+ "desc": "Trỏ tới Omni",
+ "flow": "bạn → CLI → Omni → nhà cung cấp",
+ "examples": "Ví dụ: claude, codex"
+ },
+ "agent": {
+ "title": "Tác nhân tự trị đa dụng",
+ "desc": "Trỏ tới Omni",
+ "flow": "bạn → tác nhân → Omni",
+ "examples": "Ví dụ: hermes, goose"
+ },
+ "acp": {
+ "title": "CLI được Omni sử dụng làm phần phụ trợ",
+ "desc": "Thực thi ngược",
+ "flow": "Omni → khởi chạy CLI → phản hồi",
+ "examples": "Ví dụ: claude, codex (ACP)"
+ }
+ },
+ "card": {
+ "detected": "Đã phát hiện",
+ "notDetected": "Chưa phát hiện",
+ "configured": "Đã cấu hình",
+ "notConfigured": "Chưa cấu hình",
+ "configure": "Cấu hình →",
+ "howToInstall": "Cách cài đặt →",
+ "versionNotFound": "không tìm thấy",
+ "manualConfig": "Cấu hình thủ công",
+ "installGuide": "Hướng dẫn cài đặt",
+ "endpointLabel": "Endpoint",
+ "baseUrlFull": "URL cơ sở đầy đủ",
+ "baseUrlPartial": "URL cơ sở một phần",
+ "refreshDetection": "Làm mới phát hiện",
+ "alsoAcp": "cũng là ACP",
+ "connectProviderHint": "Kết nối một nhà cung cấp trong mục Nhà cung cấp"
+ },
+ "detail": {
+ "back": "Quay lại",
+ "apply": "Lưu",
+ "reset": "Xóa",
+ "manualConfig": "Cấu hình thủ công",
+ "vendor": "Nhà cung cấp",
+ "category": "Loại",
+ "detectionStatus": "Phát hiện",
+ "configStatus": "Cấu hình",
+ "baseUrlLabel": "URL cơ sở",
+ "apiKeyLabel": "Khóa API",
+ "modelMappingLabel": "Ánh xạ mô hình",
+ "noActiveProviders": "Không có nhà cung cấp nào đang hoạt động.",
+ "noActiveProvidersDesc": "Đi tới mục Nhà cung cấp để kết nối ít nhất 1 nhà cung cấp trước khi cấu hình CLI.",
+ "openProviders": "Mở nhà cung cấp →"
+ }
+ },
+ "cliCode": {
+ "pageTitle": "CLI Code's",
+ "pageSubtitle": "Các công cụ lập trình trỏ đến OmniRoute",
+ "searchPlaceholder": "Tìm kiếm CLI…",
+ "filterDetectionLabel": "Phát hiện",
+ "filterBaseUrlLabel": "URL cơ sở",
+ "detectionAll": "Tất cả",
+ "detectionInstalled": "Đã cài đặt",
+ "detectionNotFound": "Không tìm thấy",
+ "baseUrlAll": "Tất cả",
+ "baseUrlFull": "Đầy đủ",
+ "baseUrlPartial": "Một phần"
+ },
+ "cliAgents": {
+ "pageTitle": "CLI Agents",
+ "pageSubtitle": "Các tác nhân CLI tự trị đa mục đích",
+ "refreshDetection": "Làm mới phát hiện",
+ "searchPlaceholder": "Tìm kiếm tác nhân…",
+ "detectionFilterLabel": "Phát hiện",
+ "detectionAll": "Tất cả",
+ "detectionInstalled": "Đã cài đặt",
+ "detectionNotInstalled": "Chưa cài đặt",
+ "visibleCount": "Hiển thị {count}",
+ "emptyState": "Không tìm thấy tác nhân CLI nào với các bộ lọc hiện tại."
+ },
+ "acpAgents": {
+ "pageTitle": "ACP Agents",
+ "pageSubtitle": "Các CLI mà OmniRoute khởi chạy làm phụ trợ thực thi",
+ "scanning": "Đang phát hiện tác nhân…",
+ "refresh": "Làm mới",
+ "setupGuideTitle": "Hướng dẫn thiết lập",
+ "setupGuideDetectCliTitle": "Phát hiện CLI",
+ "setupGuideDetectCliDesc": "Các tác nhân được xác định bằng cách chạy lệnh `--version` trong PATH.",
+ "setupGuideCustomAgentTitle": "Thêm tác nhân tùy chỉnh",
+ "setupGuideCustomAgentDesc": "Điền vào biểu mẫu bên dưới để đăng ký một tác nhân CLI tùy chỉnh.",
+ "setupGuideCommandMissingTitle": "Không tìm thấy lệnh",
+ "setupGuideCommandMissingDesc": "Kiểm tra xem tệp thực thi (binary) đã có trong PATH chưa.",
+ "fingerprintSettingsHint": "Cấu hình định tuyến và dấu vân tay trong",
+ "settingsRoutingLink": "Cài đặt → Định tuyến",
+ "installed": "Đã cài đặt",
+ "notFound": "Không tìm thấy",
+ "builtIn": "Tích hợp sẵn",
+ "custom": "Tùy chỉnh",
+ "agentUseCaseHint": "Có thể khởi chạy qua ACP.",
+ "remove": "Gỡ bỏ",
+ "addCustomAgent": "Thêm tác nhân tùy chỉnh",
+ "addCustomAgentDesc": "Đăng ký một tác nhân CLI tùy chỉnh để được khởi chạy qua ACP.",
+ "addAgent": "Thêm tác nhân",
+ "agentName": "Tên",
+ "agentNamePlaceholder": "ví dụ: Tác nhân của tôi",
+ "binaryName": "Tệp thực thi",
+ "binaryNamePlaceholder": "ví dụ: myagent",
+ "versionCommand": "Lệnh kiểm tra phiên bản",
+ "versionCommandPlaceholder": "ví dụ: myagent --version",
+ "spawnArgs": "Đối số khởi chạy",
+ "spawnArgsPlaceholder": "ví dụ: --quiet, --json",
+ "cliCodeRedirectCta": "Mở CLI Code"
+ },
+ "agentSkills": {
+ "catalog": {
+ "omni-auth": {
+ "name": "Xác thực",
+ "description": "Quản lý xác thực bằng khóa API và token phiên. Bắt đầu tại đây để xác thực yêu cầu bằng token Bearer, lấy cookie phiên và cấu hình yêu cầu đăng nhập cho API OmniRoute."
+ },
+ "omni-providers": {
+ "name": "Nhà cung cấp",
+ "description": "Quản lý kết nối nhà cung cấp, khóa API, luồng OAuth và kiểm tra kết nối qua REST API. Liệt kê, thêm, cập nhật, xóa và kiểm tra hơn 160 tích hợp AI như OpenAI, Anthropic và Gemini."
+ },
+ "omni-models": {
+ "name": "Mô hình",
+ "description": "Truy vấn các mô hình AI khả dụng trên mọi nhà cung cấp đã cấu hình. Liệt kê mô hình, phân giải bí danh và duyệt toàn bộ danh mục gồm các biến thể riêng của từng nhà cung cấp."
+ },
+ "omni-combos-routing": {
+ "name": "Combo & định tuyến",
+ "description": "Tạo và quản lý combo định tuyến với 14 chiến lược như ưu tiên, trọng số, round-robin và Auto-combo. Cấu hình chuỗi dự phòng, kiểm tra kết quả định tuyến và lấy số liệu combo."
+ },
+ "omni-api-keys": {
+ "name": "Khóa API",
+ "description": "Tạo, liệt kê, xoay vòng và thu hồi khóa API OmniRoute. Kiểm soát phạm vi, giới hạn chi tiêu và thời hạn theo khóa. Khóa bảo vệ quyền truy cập các endpoint proxy và quản lý."
+ },
+ "omni-usage-logs": {
+ "name": "Sử dụng & nhật ký",
+ "description": "Truy cập nhật ký lệnh gọi chi tiết và phân tích sử dụng. Lọc theo nhà cung cấp, mô hình, thời gian, trạng thái và chi phí; xuất nhật ký và tổng hợp token trên mọi kết nối."
+ },
+ "omni-budget": {
+ "name": "Ngân sách & giới hạn tần suất",
+ "description": "Cấu hình giới hạn chi tiêu, hạn ngạch token và chính sách giới hạn tần suất theo khóa API hoặc toàn cục. Xem mức tiêu thụ và áp dụng kiểm soát chi phí giữa các nhà cung cấp."
+ },
+ "omni-settings": {
+ "name": "Cài đặt",
+ "description": "Đọc và cập nhật cài đặt ứng dụng toàn cục: system prompt, ngân sách thinking, bộ lọc IP, quy tắc payload, mặc định combo và cấu hình bắt buộc đăng nhập."
+ },
+ "omni-proxies": {
+ "name": "Cấu hình proxy",
+ "description": "Cấu hình proxy HTTP/HTTPS/SOCKS cho yêu cầu tới nhà cung cấp thượng nguồn. Đặt quy tắc theo nhà cung cấp hoặc toàn cục, kiểm tra kết nối và quản lý xoay vòng proxy."
+ },
+ "omni-cache": {
+ "name": "Cache",
+ "description": "Quản lý cache phản hồi LLM. Xem số liệu cache, xóa mục, cấu hình chính sách TTL và điều khiển ngưỡng cache theo độ tương đồng ngữ nghĩa."
+ },
+ "omni-compression": {
+ "name": "Nén",
+ "description": "Cấu hình RTK cho đầu ra lệnh, Caveman cho văn xuôi và các chế độ nén xếp chồng. Quản lý gói ngôn ngữ, quy tắc tùy chỉnh và kiểm tra nén prompt giúp giảm 60–90% token."
+ },
+ "omni-context-rtk": {
+ "name": "Context & RTK",
+ "description": "Cấu hình bộ lọc RTK, quy tắc kỹ thuật context và cài đặt chuyển tiếp context. Kiểm tra nén bằng mẫu prompt thật và quản lý pipeline biến đổi context."
+ },
+ "omni-resilience": {
+ "name": "Khả năng phục hồi & giám sát",
+ "description": "Giám sát tình trạng nhà cung cấp, trạng thái circuit breaker, độ trễ p50/p95/p99 và cảnh báo ngân sách. Xem cooldown kết nối và khóa mô hình theo thời gian thực."
+ },
+ "omni-cli-tools": {
+ "name": "Công cụ CLI",
+ "description": "Quản lý các tích hợp công cụ CLI được cung cấp qua API. Liệt kê, cấu hình và gọi plugin CLI mở rộng bề mặt tự động hóa của OmniRoute."
+ },
+ "omni-tunnels": {
+ "name": "Tunnel",
+ "description": "Tạo và quản lý tunnel bảo mật như ngrok, Cloudflare Tunnel và tunnel tùy chỉnh để đưa OmniRoute lên internet hoặc chia sẻ quyền truy cập với agent từ xa và pipeline CI."
+ },
+ "omni-sync-cloud": {
+ "name": "Đồng bộ đám mây",
+ "description": "Đồng bộ cấu hình OmniRoute, kết nối nhà cung cấp và cài đặt tới/từ lưu trữ đám mây. Quản lý xác thực cloud worker và đích sao lưu từ xa."
+ },
+ "omni-db-backups": {
+ "name": "Cơ sở dữ liệu & sao lưu",
+ "description": "Kích hoạt sao lưu hệ thống, khôi phục từ tập tin sao lưu và quản lý vòng đời SQLite. Hỗ trợ xuất, nhập và chiến lược snapshot gia tăng."
+ },
+ "omni-webhooks": {
+ "name": "Webhook",
+ "description": "Đăng ký, liệt kê, kiểm tra và xóa endpoint webhook. Cấu hình đăng ký sự kiện như request.completed, provider.error, budget.exceeded và quản lý thử gửi lại."
+ },
+ "omni-mcp": {
+ "name": "Máy chủ MCP",
+ "description": "Kết nối máy chủ MCP OmniRoute với 37 công cụ và 3 transport SSE/stdio/HTTP. Bao phủ định tuyến, cache, nén, bộ nhớ, skill, nhà cung cấp và kiểm toán trong 16 phạm vi quyền."
+ },
+ "omni-agents-a2a": {
+ "name": "Agent & giao thức A2A",
+ "description": "Tương tác với OmniRoute qua giao thức agent-to-agent JSON-RPC 2.0. Có 6 skill A2A: smart-routing, quota-management, provider-discovery, cost-analysis, health-report và list-capabilities."
+ },
+ "omni-version-manager": {
+ "name": "Trình quản lý phiên bản",
+ "description": "Cài đặt, khởi động, dừng, khởi động lại và cập nhật dịch vụ nhúng như 9Router và CLIProxyAPI. Giám sát trạng thái, lấy nhật ký và cấu hình tự khởi động cho endpoint cục bộ."
+ },
+ "omni-inference": {
+ "name": "Suy luận (tương thích OpenAI)",
+ "description": "Các endpoint suy luận cốt lõi tương thích OpenAI: chat completions, embedding, hình ảnh, âm thanh TTS/STT, kiểm duyệt, rerank và Responses API. Đây là bề mặt tích hợp chính cho agent AI."
+ },
+ "cli-serve": {
+ "name": "CLI: Phục vụ",
+ "description": "Khởi động, dừng và khởi động lại máy chủ OmniRoute từ CLI. Quản lý daemon, cổng, tự phục hồi, tích hợp khay hệ thống và lối tắt mở bảng điều khiển."
+ },
+ "cli-health": {
+ "name": "CLI: Tình trạng",
+ "description": "Kiểm tra tình trạng máy chủ, thành phần và số liệu trực tiếp từ CLI. Chạy `health`, `health components` và `health watch` để xem circuit breaker và trạng thái nhà cung cấp theo thời gian thực."
+ },
+ "cli-providers": {
+ "name": "CLI: Nhà cung cấp",
+ "description": "Quản lý kết nối nhà cung cấp từ CLI: liệt kê nhà cung cấp khả dụng/đã cấu hình, thêm, kiểm tra, test-all, xác thực, xoay khóa API và xem số liệu theo nhà cung cấp."
+ },
+ "cli-keys": {
+ "name": "CLI: Khóa API",
+ "description": "Tạo, liệt kê, xoay vòng và thu hồi khóa API OmniRoute từ CLI. Quản lý luồng OAuth để xác thực nhà cung cấp, đồng thời xem phạm vi và thời hạn khóa."
+ },
+ "cli-models": {
+ "name": "CLI: Mô hình",
+ "description": "Truy vấn mô hình AI khả dụng, liệt kê bí danh và duyệt toàn bộ danh mục từ CLI. Lọc theo nhà cung cấp, tìm theo khả năng và phân giải biến thể tên mô hình."
+ },
+ "cli-chat": {
+ "name": "CLI: Trò chuyện",
+ "description": "Gửi chat completions, truyền phản hồi và mở phiên REPL tương tác từ CLI. Hỗ trợ mọi nhà cung cấp OmniRoute, định tuyến combo và cấu hình system prompt."
+ },
+ "cli-routing": {
+ "name": "CLI: Định tuyến & combo",
+ "description": "Tạo, liệt kê, cập nhật và xóa combo định tuyến từ CLI. Kiểm tra chiến lược, xem số liệu combo và cấu hình chuỗi dự phòng theo cách tương tác."
+ },
+ "cli-resilience": {
+ "name": "CLI: Phục hồi & hạn ngạch",
+ "description": "Xem và quản lý circuit breaker, cooldown kết nối, giới hạn hạn ngạch và mức backoff từ CLI. Đặt lại nhà cung cấp bị kẹt và cấu hình ngưỡng phục hồi."
+ },
+ "cli-compression": {
+ "name": "CLI: Nén",
+ "description": "Cấu hình và kiểm tra nén prompt từ CLI. Quản lý bộ lọc RTK, quy tắc Caveman, chế độ nén xếp chồng và xem trước đầu ra nén bằng prompt thật."
+ },
+ "cli-contexts": {
+ "name": "CLI: Context & phiên",
+ "description": "Quản lý cấu hình kỹ thuật context, bộ lọc RTK và phiên hội thoại từ CLI. Áp dụng cài đặt chuyển tiếp context và xem các pipeline context đang hoạt động."
+ },
+ "cli-cost-usage": {
+ "name": "CLI: Chi phí & sử dụng",
+ "description": "Xem phân tích chi phí, mức dùng token và nhật ký lệnh gọi từ CLI. Lọc theo nhà cung cấp, mô hình hoặc ngày; xuất báo cáo và xem chi tiêu theo kết nối."
+ },
+ "cli-mcp": {
+ "name": "CLI: MCP",
+ "description": "Xem trạng thái máy chủ MCP, liệt kê công cụ và phạm vi đã đăng ký, chạy lệnh gọi công cụ và quản lý nhật ký kiểm toán MCP từ CLI."
+ },
+ "cli-a2a": {
+ "name": "CLI: Giao thức A2A",
+ "description": "Tương tác với máy chủ A2A OmniRoute từ CLI. Gửi tác vụ, xem lịch sử thực thi skill và kiểm tra tương tác giao thức agent-to-agent JSON-RPC 2.0."
+ },
+ "cli-tunnel": {
+ "name": "CLI: Tunnel",
+ "description": "Khởi động và dừng kết nối tunnel như ngrok, Cloudflare và tùy chỉnh từ CLI. Xem URL tunnel hoạt động, cấu hình xác thực và kiểm tra khả năng truy cập bên ngoài."
+ },
+ "cli-backup-sync": {
+ "name": "CLI: Sao lưu & đồng bộ",
+ "description": "Sao lưu và khôi phục dữ liệu OmniRoute từ CLI. Tạo snapshot gia tăng, đồng bộ lên đám mây, quản lý lịch sao lưu và khôi phục từ tập tin lưu trữ."
+ },
+ "cli-policy-audit": {
+ "name": "CLI: Chính sách & kiểm toán",
+ "description": "Xem nhật ký kiểm toán, quản lý chính sách truy cập, dữ liệu telemetry và lịch sử yêu cầu từ CLI. Lọc theo loại sự kiện, người dùng hoặc thời gian cho quy trình tuân thủ."
+ },
+ "cli-batches": {
+ "name": "CLI: Lô & tập tin",
+ "description": "Gửi và giám sát tác vụ suy luận theo lô từ CLI. Tải lên, quản lý tập tin xử lý lô, lấy kết quả và tích hợp pipeline lô với CI/CD."
+ },
+ "cli-eval": {
+ "name": "CLI: Đánh giá",
+ "description": "Tạo và chạy bộ đánh giá, theo dõi benchmark trực tiếp, xem scorecard, so sánh hiệu năng mô hình và tích hợp lượt đánh giá với quy trình CI từ CLI."
+ },
+ "cli-plugins-skills": {
+ "name": "CLI: Plugin, skill & bộ nhớ",
+ "description": "Quản lý Omni Skills (liệt kê, cài đặt, kiểm tra, xóa), plugin (tạo, cấu hình) và bộ nhớ bền vững (tìm kiếm, thêm, xóa) từ CLI."
+ },
+ "cli-setup": {
+ "name": "CLI: Thiết lập & cấu hình",
+ "description": "Chạy thiết lập ban đầu, cấu hình CLI toàn cục, quản lý biến môi trường, kiểm tra cập nhật và cấu hình tự khởi động bằng các lệnh setup/config."
+ },
+ "cli-skill-collector": {
+ "name": "CLI: Trình thu thập skill cho agent",
+ "description": "Phát hiện các công cụ CLI lập trình đã cài (Claude Code, Codex, Cursor, Copilot, Cline và nhiều công cụ khác), tìm skill agent phù hợp trên GitHub và cài chúng vào các công cụ đã phát hiện qua API tích hợp của OmniRoute."
+ },
+ "config-codex-cli": {
+ "name": "Cấu hình: Codex CLI",
+ "description": "Quy trình từng bước để agent cấu hình OpenAI Codex CLI trên Linux, macOS hoặc Windows dùng OmniRoute làm backend tương thích OpenAI. Tự phát hiện hệ điều hành/shell, ghi config.toml cùng 7 hồ sơ, đặt biến môi trường và xác minh."
+ },
+ "omni-github-skills": {
+ "name": "Khám phá skill trên GitHub",
+ "description": "Tìm kiếm, chấm điểm, quét và nhập skill agent từ kho GitHub có SKILL.md, CLAUDE.md, .cursorrules hoặc tập tin tương tự. Khám phá hơn 160 danh mục, đánh giá độ liên quan, kiểm tra mã độc/secret viết cứng và cài vào Hermes, Claude Code, Gemini CLI hoặc OpenCode."
+ }
+ },
+ "pageTitle": "Kỹ năng tác nhân",
+ "pageSubtitle": "Dạy tác nhân của bạn cách vận hành OmniRoute — 23 mảng API + 21 nhóm CLI",
+ "conceptCard": {
+ "agent": {
+ "title": "Kỹ năng tác nhân — Gửi đi",
+ "description": "Kỹ năng tác nhân là các tài liệu SKILL.md mà máy có thể đọc được, để các tác nhân AI bên ngoài (Claude Code, Cursor, Copilot…) lấy từ GitHub và học cách vận hành OmniRoute thông qua REST hoặc CLI. Chúng được tác nhân đọc, chứ không được OmniRoute thực thi.",
+ "crossLinkLabel": "Hiểu sự khác biệt →"
+ },
+ "omni": {
+ "title": "Kỹ năng Omni — Nhận vào",
+ "description": "Kỹ năng Omni là các công cụ sandbox mà OmniRoute chèn vào ngữ cảnh của mô hình trong mỗi yêu cầu. Chúng được OmniRoute thực thi, chứ không phải do tác nhân đọc.",
+ "crossLinkLabel": "Hiểu sự khác biệt →"
+ },
+ "comparison": {
+ "colAgent": "Kỹ năng tác nhân",
+ "colOmni": "Kỹ năng Omni",
+ "whatIs": {
+ "label": "Nó là gì",
+ "agent": "Tệp SKILL.md mà máy có thể đọc được, hướng dẫn một tác nhân bên ngoài vận hành OmniRoute",
+ "omni": "Các công cụ có thể thực thi mà OmniRoute chèn dưới dạng công cụ vào các yêu cầu LLM"
+ },
+ "direction": {
+ "label": "Hướng",
+ "agent": "Hướng ra (Outbound) — agent bên ngoài học cách điều khiển OmniRoute",
+ "omni": "Hướng vào (Inbound) — OmniRoute cung cấp công cụ cho các mô hình đi qua nó"
+ },
+ "executor": {
+ "label": "Bộ thực thi",
+ "agent": "Agent bên ngoài (đọc Markdown → thực hiện hành động qua API/CLI)",
+ "omni": "Bản thân OmniRoute (chặn tool_calls, hộp cát Docker)"
+ },
+ "storage": {
+ "label": "Nơi lưu trữ",
+ "agent": "Danh mục động (/api/agent-skills) → SKILL.md trên GitHub",
+ "omni": "SQLite (SkillsMP / skills.sh / cục bộ)"
+ },
+ "tagline": {
+ "label": "Khẩu hiệu",
+ "agent": "Dạy agent của bạn cách sử dụng OmniRoute",
+ "omni": "Cung cấp các công cụ thực thi cho các mô hình của OmniRoute"
+ }
+ }
+ },
+ "filters": {
+ "category": "Danh mục",
+ "area": "Phạm vi",
+ "searchPlaceholder": "Tìm kiếm kỹ năng…"
+ },
+ "categoryApi": "API",
+ "categoryCli": "CLI",
+ "categoryConfig": "Cấu hình",
+ "filterAll": "Tất cả",
+ "coverageLabel": "Độ bao phủ",
+ "mcpUrl": "MCP URL",
+ "a2aLink": "A2A",
+ "mcpPrompt": "Thêm endpoint MCP này vào agent để cung cấp 37 công cụ OmniRoute.",
+ "a2aPrompt": "Đăng ký Agent Card này với bộ điều phối để bật khả năng ủy quyền tác vụ A2A.",
+ "refresh": "Làm mới",
+ "copyUrl": "Sao chép URL",
+ "viewOnGithub": "Xem trên GitHub",
+ "previewLoading": "Đang tải tài liệu kỹ năng…",
+ "previewError": "Không thể tải tài liệu kỹ năng.",
+ "previewEmpty": "Chọn một kỹ năng để xem trước tài liệu của nó.",
+ "generateButton": "Tạo các kỹ năng còn thiếu",
+ "coverageBar": {
+ "complete": "Hoàn thành",
+ "partial": "Một phần"
+ },
+ "noSkillsFound": "Không tìm thấy kỹ năng nào khớp với bộ lọc của bạn.",
+ "regenerateConfirm": "Thao tác này sẽ tạo lại tất cả các tệp SKILL.md còn thiếu. Tiếp tục chứ?",
+ "regenerateRunning": "Đang tạo lại các kỹ năng…",
+ "regenerateSuccess": "Đã tạo lại các kỹ năng thành công.",
+ "regenerateError": "Không thể tạo lại các kỹ năng."
+ },
+ "freeProviderRankingsPage": {
+ "title": "Bảng xếp hạng nhà cung cấp miễn phí",
+ "subtitle": "Các nhà cung cấp miễn phí tốt nhất được xếp hạng theo điểm ELO của mô hình từ bảng xếp hạng Arena AI",
+ "loading": "Đang tải bảng xếp hạng...",
+ "errorLoading": "Không thể tải bảng xếp hạng",
+ "emptyState": "Chưa có bảng xếp hạng nào. Dữ liệu Arena ELO được đồng bộ khi khởi động (mỗi ngày) — hãy quay lại sau hoặc kích hoạt đồng bộ hóa thủ công. Vô hiệu hóa Đồng bộ hóa Arena ELO trong Cờ tính năng hoặc đặt ARENA_ELO_SYNC_ENABLED=false để không tham gia.",
+ "bestModel": "Tốt nhất",
+ "allCategories": "Tất cả danh mục",
+ "categoryDefault": "Mặc định",
+ "categoryCoding": "Lập trình",
+ "categoryReview": "Rà soát",
+ "categoryDocumentation": "Tài liệu",
+ "categoryDebugging": "Gỡ lỗi",
+ "colRank": "Thứ hạng",
+ "colProvider": "Nhà cung cấp",
+ "colTopModel": "Mô hình hàng đầu",
+ "colScore": "Điểm số",
+ "colAvgScore": "Điểm trung bình",
+ "colModels": "Mô hình",
+ "colType": "Loại",
+ "filterConfiguredOnly": "Chỉ nhà cung cấp đã cấu hình",
+ "filterAvailableOnly": "Chỉ nhà cung cấp khả dụng",
+ "filterAvailableOnlyHelp": "Ẩn các nhà cung cấp mà mọi kết nối đều bị giới hạn tốc độ hoặc hết hạn ngạch.",
+ "configuredOnly": "Chỉ đã cấu hình",
+ "configuredOnlyHint": "Chỉ hiển thị các nhà cung cấp có kết nối đang hoạt động",
+ "noConfiguredProviders": "Không tìm thấy nhà cung cấp nào được cấu hình. Vui lòng thêm kết nối nhà cung cấp trước.",
+ "colConfigured": "Trạng thái",
+ "typeAll": "Tất cả loại",
+ "typeNoauth": "Không cần đăng ký",
+ "typeOauth": "Đăng nhập OAuth",
+ "typeApikey": "Khóa API",
+ "sortTypeFirst": "Dễ thiết lập trước",
+ "sortTypeFirstHelp": "Nhóm theo mức độ thiết lập (Không cần đăng ký → Đăng nhập OAuth → Khóa API), đồng thời giữ thứ tự chất lượng trong từng nhóm",
+ "typeLegend": "Không cần đăng ký = không cần thiết lập · Đăng nhập OAuth = đăng nhập bằng tài khoản của bạn · Khóa API = dùng khóa riêng hoặc gói miễn phí của nhà cung cấp"
+ },
+ "discovery": {
+ "title": "Khám phá nhà cung cấp",
+ "subtitle": "Quét các nhà cung cấp để tìm phương thức truy cập miễn phí hoặc không giới hạn và xem xét kết quả. Chỉ tham gia khi tự nguyện, chỉ hoạt động cục bộ.",
+ "scanLabel": "Nhà cung cấp cần quét",
+ "scanPlaceholder": "ví dụ: huggingchat",
+ "scan": "Quét",
+ "scanning": "Đang quét…",
+ "scanQueued": "Quá trình quét hoàn tất cho {provider}.",
+ "scanFailed": "Quét thất bại.",
+ "loadFailed": "Không thể tải kết quả khám phá.",
+ "localOnlyNote": "Công cụ này chỉ chạy cục bộ (loopback). Các lượt quét chạy từ máy này và không bao giờ có thể truy cập từ xa.",
+ "verify": "Xác minh",
+ "verifyFailed": "Không thể xác minh kết quả tìm thấy.",
+ "delete": "Xóa",
+ "deleteFailed": "Không thể xóa kết quả tìm thấy.",
+ "deleteTitle": "Xóa kết quả khám phá",
+ "deleteConfirm": "Xóa kết quả khám phá cho {provider}? Hành động này không thể hoàn tác.",
+ "emptyTitle": "Chưa có kết quả khám phá nào",
+ "emptyDescription": "Chạy quét ở trên để tìm kiếm các phương thức truy cập miễn phí của một nhà cung cấp.",
+ "risk": "Rủi ro",
+ "method": "Phương thức",
+ "auth": "Xác thực",
+ "feasibility": "Tính khả thi",
+ "models": "Mô hình"
+ },
+ "noAuthProvider": {
+ "title": "Không cần xác thực",
+ "description": "Nhà cung cấp này có thể dùng ngay — không cần đăng ký hoặc khóa API.",
+ "accountDescription": "Có thể dùng ngay — không cần đăng ký. Thêm tài khoản để luân phiên khi chạm giới hạn tốc độ.",
+ "addAccount": "Thêm tài khoản",
+ "accountName": "Tài khoản {provider} {number}",
+ "accounts": "Tài khoản ({count})",
+ "adding": "Đang thêm...",
+ "autoGeneratedAccount": "Đang dùng tài khoản được tạo tự động. Chọn “{addLabel}” để luân phiên khi chạm giới hạn tốc độ.",
+ "configureProxy": "Cấu hình proxy",
+ "proxyConfigured": "Đã cấu hình proxy: {host}",
+ "removeAccount": "Xóa tài khoản",
+ "proxyForAccount": "Proxy cho tài khoản {number}",
+ "saved": "Đã lưu",
+ "custom": "Tùy chỉnh",
+ "noSavedProxies": "Chưa có proxy đã lưu — hãy thêm trong Cài đặt → Proxy",
+ "directConnection": "Kết nối trực tiếp (không dùng proxy)",
+ "host": "Máy chủ",
+ "port": "Cổng",
+ "usernameOptional": "Tên người dùng (tùy chọn)",
+ "passwordOptional": "Mật khẩu (tùy chọn)",
+ "cancel": "Hủy",
+ "saving": "Đang lưu...",
+ "save": "Lưu",
+ "createConnectionFailed": "Không thể tạo kết nối",
+ "updateConnectionFailed": "Không thể cập nhật kết nối",
+ "fetchProxiesFailed": "Không thể tải danh sách proxy",
+ "noSavedProxiesError": "Không tìm thấy proxy đã lưu. Hãy thêm proxy trong Cài đặt → Proxy trước.",
+ "updateProviderFailed": "Không thể cập nhật nhà cung cấp",
+ "providerEnabled": "Đã bật {provider}",
+ "providerDisabled": "Đã tắt {provider}"
+ },
+ "gamification": {
+ "leaderboardScopes": {
+ "allTime": "Toàn thời gian",
+ "weekly": "Hàng tuần",
+ "monthly": "Hàng tháng",
+ "tokensShared": "Token đã chia sẻ"
+ },
+ "leaderboardLoadFailed": "Không thể tải bảng xếp hạng (HTTP {status})",
+ "scope": "Phạm vi",
+ "tokensShared": "token đã chia sẻ",
+ "points": "điểm",
+ "rank": "Thứ hạng",
+ "name": "Tên",
+ "score": "Điểm số",
+ "leaderboardEmpty": "Chưa có dữ liệu trong phạm vi này. Hãy bắt đầu sử dụng OmniRoute để xuất hiện trên bảng xếp hạng!",
+ "profileLoadFailed": "Không thể tải dữ liệu hồ sơ",
+ "levelTitles": {
+ "beginner": "Người mới",
+ "explorer": "Nhà khám phá",
+ "expert": "Chuyên gia",
+ "master": "Bậc thầy",
+ "legend": "Huyền thoại"
+ },
+ "tiers": {
+ "bronze": "Đồng",
+ "silver": "Bạc",
+ "gold": "Vàng",
+ "platinum": "Bạch kim",
+ "diamond": "Kim cương"
+ },
+ "tierLabel": "Hạng: {tier}",
+ "dayStreak": "Chuỗi {count} ngày",
+ "levelProgress": "Cấp {current} → {next}",
+ "totalXpEarned": "Đã kiếm tổng cộng {count} XP",
+ "maintainStreak": "Hãy sử dụng OmniRoute mỗi ngày để duy trì chuỗi hoạt động!",
+ "badgesTitle": "Huy hiệu ({earned}/{total})",
+ "noBadges": "Chưa có huy hiệu nào.",
+ "hiddenBadge": "Thành tích ẩn",
+ "earnedDate": "Đạt được ngày {date}",
+ "earnedOn": "Đạt được vào ngày {date}",
+ "category": "Danh mục",
+ "rarities": {
+ "common": "Phổ biến",
+ "uncommon": "Ít gặp",
+ "rare": "Hiếm",
+ "epic": "Sử thi",
+ "legendary": "Huyền thoại"
+ },
+ "categories": {
+ "usage": "Sử dụng",
+ "sharing": "Chia sẻ",
+ "contribution": "Đóng góp",
+ "streak": "Chuỗi hoạt động",
+ "rare": "Thành tích hiếm"
+ },
+ "badges": {
+ "first-token": {
+ "name": "Token đầu tiên",
+ "description": "Đã thực hiện yêu cầu API đầu tiên",
+ "criteria": "Hoàn thành yêu cầu API đầu tiên thông qua OmniRoute."
+ },
+ "token-consumer": {
+ "name": "Người dùng token",
+ "description": "Đã thực hiện 1.000 yêu cầu API",
+ "criteria": "Hoàn thành 1.000 yêu cầu API thông qua OmniRoute."
+ },
+ "token-machine": {
+ "name": "Cỗ máy token",
+ "description": "Đã thực hiện 10.000 yêu cầu API",
+ "criteria": "Hoàn thành 10.000 yêu cầu API thông qua OmniRoute."
+ },
+ "token-whale": {
+ "name": "Cá voi token",
+ "description": "Đã thực hiện 100.000 yêu cầu API",
+ "criteria": "Hoàn thành 100.000 yêu cầu API thông qua OmniRoute."
+ },
+ "generous": {
+ "name": "Hào phóng",
+ "description": "Đã chia sẻ 1.000 token với người khác",
+ "criteria": "Chia sẻ tổng cộng 1.000 token với người dùng khác."
+ },
+ "philanthropist": {
+ "name": "Nhà thiện nguyện",
+ "description": "Đã chia sẻ 10.000 token với người khác",
+ "criteria": "Chia sẻ tổng cộng 10.000 token với người dùng khác."
+ },
+ "token-santa": {
+ "name": "Ông già Noel token",
+ "description": "Đã chia sẻ 100.000 token với người khác",
+ "criteria": "Chia sẻ tổng cộng 100.000 token với người dùng khác."
+ },
+ "community-hero": {
+ "name": "Anh hùng cộng đồng",
+ "description": "Đã chia sẻ 1.000.000 token với người khác",
+ "criteria": "Chia sẻ tổng cộng 1.000.000 token với người dùng khác."
+ },
+ "explorer": {
+ "name": "Nhà khám phá",
+ "description": "Đã sử dụng 5 nhà cung cấp khác nhau",
+ "criteria": "Sử dụng ít nhất 5 nhà cung cấp AI khác nhau."
+ },
+ "polyglot": {
+ "name": "Nhà đa ngôn ngữ",
+ "description": "Đã sử dụng 10 mô hình khác nhau",
+ "criteria": "Sử dụng ít nhất 10 mô hình AI khác nhau."
+ },
+ "architect": {
+ "name": "Kiến trúc sư",
+ "description": "Đã tạo 3 tuyến combo",
+ "criteria": "Tạo 3 tuyến combo."
+ },
+ "speedster": {
+ "name": "Tia chớp",
+ "description": "Duy trì độ trễ trung bình dưới 500 ms trong 100 yêu cầu",
+ "criteria": "Giữ độ trễ trung bình dưới 500 ms trong 100 yêu cầu."
+ },
+ "resilient": {
+ "name": "Bền bỉ",
+ "description": "Duy trì thời gian hoạt động 100% trong 7 ngày",
+ "criteria": "Duy trì thời gian hoạt động 100% trong 7 ngày liên tiếp."
+ },
+ "daily-user": {
+ "name": "Người dùng hằng ngày",
+ "description": "Hoạt động trong 3 ngày liên tiếp",
+ "criteria": "Sử dụng OmniRoute trong 3 ngày liên tiếp."
+ },
+ "weekly-warrior": {
+ "name": "Chiến binh tuần",
+ "description": "Hoạt động trong 7 ngày liên tiếp",
+ "criteria": "Sử dụng OmniRoute trong 7 ngày liên tiếp."
+ },
+ "monthly-master": {
+ "name": "Bậc thầy tháng",
+ "description": "Hoạt động trong 30 ngày liên tiếp",
+ "criteria": "Sử dụng OmniRoute trong 30 ngày liên tiếp."
+ },
+ "unstoppable": {
+ "name": "Không thể ngăn cản",
+ "description": "Hoạt động trong 365 ngày liên tiếp",
+ "criteria": "Sử dụng OmniRoute trong 365 ngày liên tiếp."
+ },
+ "early-adopter": {
+ "name": "Người tiên phong",
+ "description": "Tham gia trong tháng đầu tiên của tính năng trò chơi hóa",
+ "criteria": "Tham gia trong tháng đầu tiên kể từ khi tính năng trò chơi hóa ra mắt."
+ },
+ "bug-hunter": {
+ "name": "Thợ săn lỗi",
+ "description": "Đã báo cáo 5 lỗi",
+ "criteria": "Báo cáo 5 lỗi hợp lệ."
+ },
+ "contributor": {
+ "name": "Người đóng góp",
+ "description": "Đã có 1 pull request được hợp nhất",
+ "criteria": "Có 1 pull request được hợp nhất vào OmniRoute."
+ },
+ "community-leader": {
+ "name": "Lãnh đạo cộng đồng",
+ "description": "Đã lọt vào top 10 của một bảng xếp hạng",
+ "criteria": "Lọt vào top 10 của bất kỳ bảng xếp hạng nào."
+ },
+ "secret-badge": {
+ "name": "???",
+ "description": "Một thành tích ẩn đang chờ bạn...",
+ "criteria": "Hoàn thành thành tích ẩn để mở huy hiệu này."
+ }
+ }
+ },
+ "featureFlags": {
+ "title": "Cờ tính năng",
+ "activeCount": "{count} đang hoạt động",
+ "inactiveCount": "{count} không hoạt động",
+ "dbOverrideCount": "{count} ghi đè DB",
+ "searchPlaceholder": "Tìm kiếm cờ...",
+ "categories": {
+ "all": "Tất cả",
+ "security": "Bảo mật",
+ "network": "Mạng",
+ "policies": "Chính sách",
+ "runtime": "Runtime",
+ "cli": "CLI",
+ "health": "Tình trạng",
+ "requiresRestart": "Cần khởi động lại"
+ },
+ "categoryLabel": "Danh mục: {category}",
+ "caution": "Thận trọng",
+ "danger": "Nguy hiểm",
+ "requiresRestart": "Cần khởi động lại",
+ "source": "Nguồn",
+ "resetFlag": "Đặt lại {label} về mặc định",
+ "reset": "Đặt lại",
+ "loadFailed": "Không thể tải cờ tính năng",
+ "updateFailedHttp": "Không thể cập nhật cờ: HTTP {status}",
+ "updateFailed": "Không thể cập nhật cờ",
+ "restartFailedHttp": "Khởi động lại thất bại: HTTP {status}",
+ "restartFailed": "Khởi động lại thất bại",
+ "resetOverridesFailedHttp": "Không thể đặt lại ghi đè: HTTP {status}",
+ "resetOverridesFailed": "Không thể đặt lại ghi đè",
+ "restartRequiredCount": "{count} thay đổi cần khởi động lại máy chủ để có hiệu lực.",
+ "restartRequiredDescription": "Các cờ này chỉ được áp dụng sau khi tiến trình tải lại. Có thể khởi động lại ngay hoặc tiếp tục chỉnh sửa — các cờ đang chờ vẫn được giữ cho đến khi bạn xác nhận.",
+ "restartServer": "Khởi động lại máy chủ",
+ "cancel": "Hủy",
+ "restarting": "Đang khởi động lại…",
+ "confirmRestart": "Xác nhận khởi động lại",
+ "restartViewDescription": "Các cờ này chỉ có hiệu lực sau khi máy chủ khởi động lại. Bạn có thể bật/tắt như các cờ khác — thay đổi được lưu ngay nhưng giá trị mới chỉ được đọc khi tiến trình khởi động. Dùng nút Khởi động lại máy chủ phía trên để áp dụng.",
+ "retry": "Thử lại",
+ "noSearchResults": "Không có cờ nào phù hợp với tìm kiếm",
+ "resetAllOverrides": "Đặt lại tất cả ghi đè",
+ "confirmResetOverrides": "Đặt lại toàn bộ {count} ghi đè DB?",
+ "resetting": "Đang đặt lại...",
+ "confirmReset": "Xác nhận đặt lại",
+ "enumValues": {
+ "off": "Tắt",
+ "warn": "Cảnh báo",
+ "block": "Chặn",
+ "redact": "Che dữ liệu",
+ "disabled": "Vô hiệu hóa",
+ "dual": "Song song",
+ "alias": "Bí danh",
+ "canonical": "Chính thức"
+ },
+ "definitions": {
+ "REQUIRE_API_KEY": {
+ "description": "Yêu cầu khóa API cho mọi yêu cầu gửi đến."
+ },
+ "INPUT_SANITIZER_ENABLED": {
+ "description": "Làm sạch dữ liệu đầu vào cho mọi yêu cầu."
+ },
+ "INJECTION_GUARD_MODE": {
+ "description": "Đặt chế độ bảo vệ khỏi prompt injection."
+ },
+ "PII_REDACTION_ENABLED": {
+ "description": "Che thông tin nhận dạng cá nhân trong yêu cầu."
+ },
+ "PII_RESPONSE_SANITIZATION": {
+ "description": "Làm sạch thông tin nhận dạng cá nhân trong phản hồi của nhà cung cấp."
+ },
+ "PII_RESPONSE_SANITIZATION_MODE": {
+ "description": "Chọn cách xử lý PII trong phản hồi: che dữ liệu sẽ thay thế PII, cảnh báo chỉ ghi nhật ký, chặn sẽ từ chối phản hồi và tắt sẽ vô hiệu hóa việc làm sạch."
+ },
+ "OUTBOUND_SSRF_GUARD_ENABLED": {
+ "description": "Chặn yêu cầu gửi ra các dải IP riêng tư hoặc nội bộ."
+ },
+ "ALLOW_API_KEY_REVEAL": {
+ "description": "Cho phép người dùng bảng điều khiển đã xác thực xem khóa API đã lưu thay vì chỉ thấy giá trị được che."
+ },
+ "ENABLE_TLS_FINGERPRINT": {
+ "description": "Bật chế độ ẩn dấu vân tay TLS."
+ },
+ "ONEPROXY_ENABLED": {
+ "description": "Bật chuyển tiếp yêu cầu qua 1proxy."
+ },
+ "PROXY_AUTO_SELECT_ENABLED": {
+ "description": "Khi kết nối chưa được gán proxy, tự động chọn proxy hoạt động đầu tiên trong danh sách. Mặc định tắt vì nếu bật, một proxy trong danh sách có thể trở thành đường dự phòng toàn cục cho mọi lưu lượng (#3332)."
+ },
+ "OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK": {
+ "description": "Cho phép luồng OAuth và xác thực nhà cung cấp bỏ qua proxy đã ghim khi kiểm tra khả năng kết nối proxy thất bại. Mặc định tắt vì có thể làm thay đổi IP đầu ra của tài khoản."
+ },
+ "MITM_DISABLE_TLS_VERIFY": {
+ "description": "Tắt xác minh chứng chỉ TLS cho proxy MITM."
+ },
+ "OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS": {
+ "description": "Cho phép URL nhà cung cấp trỏ đến mạng riêng tư hoặc nội bộ."
+ },
+ "OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS": {
+ "description": "Cho phép thêm và xác thực nhà cung cấp trên localhost, LAN và các dải IP riêng. Tính năng này cần cho mô hình tương thích OpenAI chạy cục bộ và được bật mặc định. Các endpoint metadata đám mây như 169.254.169.254 vẫn luôn bị chặn."
+ },
+ "ENABLE_CC_COMPATIBLE_PROVIDER": {
+ "description": "Bật chế độ nhà cung cấp tương thích với Claude Code."
+ },
+ "TOOL_POLICY_MODE": {
+ "description": "Đặt chế độ thực thi chính sách sử dụng công cụ."
+ },
+ "RATE_LIMIT_AUTO_ENABLE": {
+ "description": "Tự động bật giới hạn tốc độ dựa trên kiểu sử dụng."
+ },
+ "ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE": {
+ "description": "Cho phép nhiều kết nối trên mỗi node tương thích."
+ },
+ "RESPONSES_PASSTHROUGH_DROP_COMMENTARY": {
+ "description": "Loại các mục đầu ra thuộc giai đoạn commentary nội bộ khỏi luồng chuyển tiếp Responses API trước khi gửi tới ứng dụng khách. Tắt cờ này để nhận nguyên dữ liệu commentary từ thượng nguồn."
+ },
+ "OMNIROUTE_MCP_ENFORCE_SCOPES": {
+ "description": "Thực thi giới hạn phạm vi khi truy cập công cụ MCP."
+ },
+ "OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS": {
+ "description": "Nén mô tả công cụ MCP để giảm lượng token sử dụng."
+ },
+ "OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS": {
+ "description": "Bật xử lý tác vụ nền trong runtime."
+ },
+ "OMNIROUTE_DISABLE_BACKGROUND_SERVICES": {
+ "description": "Tắt mọi dịch vụ nền, bao gồm làm mới hạn ngạch và đồng bộ hóa."
+ },
+ "OMNIROUTE_RTK_TRUST_PROJECT_FILTERS": {
+ "description": "Tin cậy các bộ lọc RTK cấp dự án mà không cần xác thực."
+ },
+ "OMNIROUTE_ENABLE_LIVE_WS": {
+ "description": "Khởi chạy máy chủ WebSocket thời gian thực của bảng điều khiển trên cổng loopback 20132 khi import. Đặt cờ thành 0 hoặc false để tắt. Muốn mở trong LAN còn phải đặt LIVE_WS_HOST=0.0.0.0 và LIVE_WS_ALLOWED_ORIGINS."
+ },
+ "OMNIROUTE_CODEX_WS_ENABLED": {
+ "description": "Cho phép Codex dùng giao thức Responses qua WebSocket. Khi tắt, Codex sẽ dự phòng bằng HTTP Responses."
+ },
+ "OMNIROUTE_EMERGENCY_FALLBACK": {
+ "description": "Định tuyến yêu cầu đã cạn ngân sách đến nhà cung cấp và mô hình miễn phí dự phòng khẩn cấp."
+ },
+ "STREAM_RECOVERY_ENABLED": {
+ "description": "Tự động thử lại sớm các luồng SSE thượng nguồn bị cắt trước khi byte phản hồi nào được gửi tới ứng dụng khách."
+ },
+ "STREAM_RECOVERY_MIDSTREAM_ENABLED": {
+ "description": "Cho phép khôi phục luồng bằng cách yêu cầu lại và ghép phản hồi sau khi dữ liệu đã bắt đầu được gửi tới ứng dụng khách."
+ },
+ "MODEL_CATALOG_INCLUDE_NAMES": {
+ "description": "Thêm trường tên dễ đọc vào phản hồi /v1/models. Tắt với các ứng dụng khách chỉ chấp nhận ID mô hình."
+ },
+ "MODELS_CATALOG_PREFIX_MODE": {
+ "description": "Điều khiển tiền tố ID mô hình trong /v1/models: song song xuất cả bí danh và tiền tố chính thức, bí danh chỉ xuất tiền tố ngắn, còn chính thức chỉ xuất ID nhà cung cấp đầy đủ."
+ },
+ "ARENA_ELO_SYNC_ENABLED": {
+ "description": "Định kỳ đồng bộ điểm ELO từ bảng xếp hạng Arena AI để xếp hạng năng lực mô hình."
+ },
+ "CLI_COMPAT_ALL": {
+ "description": "Bật chế độ tương thích cho mọi ứng dụng khách CLI."
+ },
+ "MODEL_ALIAS_COMPAT_ENABLED": {
+ "description": "Bật lớp tương thích bí danh mô hình."
+ },
+ "PRICING_SYNC_ENABLED": {
+ "description": "Tự động đồng bộ dữ liệu giá. Biến môi trường PRICING_SYNC_ENABLED cũng phải được đặt thành true."
+ },
+ "OMNIROUTE_AUTO_SYNC_CODEX_PROFILES": {
+ "description": "Sau khi đồng bộ mô hình nhà cung cấp, tạo lại các hồ sơ ~/.codex/*.config.toml từ danh mục trực tiếp. Không bao giờ thay đổi cấu hình Codex đang hoạt động hoặc cấu hình mặc định. Tính năng này mặc định tắt."
+ },
+ "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES": {
+ "description": "Sau khi đồng bộ mô hình nhà cung cấp, tạo lại các hồ sơ Claude Code tại ~/.claude/profiles//settings.json từ danh mục trực tiếp. Không bao giờ thay đổi cấu hình Claude đang hoạt động hoặc cấu hình mặc định. Tính năng này mặc định tắt."
+ },
+ "OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK": {
+ "description": "Tắt endpoint kiểm tra tình trạng của phiên bản cục bộ."
+ },
+ "OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK": {
+ "description": "Tắt kiểm tra tình trạng xác thực token."
+ },
+ "SKILLS_SANDBOX_NETWORK_ENABLED": {
+ "description": "Cho phép môi trường sandbox của kỹ năng truy cập mạng."
+ }
+ }
+ },
+ "comboControl": {
+ "title": "Trung tâm điều khiển combo",
+ "unavailable": "Trung tâm điều khiển combo không khả dụng",
+ "backToCombos": "Quay lại Combo",
+ "loadFailed": "Không thể tải trung tâm điều khiển combo",
+ "state": {
+ "healthy": "Khỏe mạnh",
+ "warning": "Cần chú ý",
+ "critical": "Nghiêm trọng",
+ "idle": "Chưa hoạt động"
+ },
+ "active": "Đang hoạt động",
+ "disabled": "Đã tắt",
+ "description": "Màn hình chỉ đọc tập trung về hành vi định tuyến, tình trạng, hạn ngạch, chỉ số runtime và các quyết định gần đây của .",
+ "refresh": "Làm mới",
+ "editInCombos": "Chỉnh sửa trong Combo",
+ "requests": "Yêu cầu",
+ "success": "Thành công",
+ "latency": "Độ trễ",
+ "quota": "Hạn ngạch",
+ "rangeWindow": "khung {range}",
+ "runtimeHealthBlend": "kết hợp runtime/tình trạng",
+ "averageResponseTime": "thời gian phản hồi trung bình",
+ "worstQuota": "Hạn ngạch thấp nhất",
+ "providerAccountTelemetry": "dữ liệu đo nhà cung cấp/tài khoản",
+ "overview": "Tổng quan",
+ "overviewDescription": "Chiến lược, trạng thái runtime và liên kết điều khiển của combo này.",
+ "strategy": "Chiến lược",
+ "targets": "Đích",
+ "providers": "Nhà cung cấp",
+ "targetCounts": "{configured} đã cấu hình · {resolved} đã phân giải",
+ "healthReasons": "Lý do tình trạng",
+ "healthReason": {
+ "noRecentTraffic": "Không có lưu lượng combo gần đây",
+ "lowSuccessRate": "Tỷ lệ thành công thấp",
+ "successBelowTarget": "Tỷ lệ thành công dưới mục tiêu",
+ "highFallbackRate": "Tỷ lệ dự phòng cao",
+ "elevatedFallbackRate": "Tỷ lệ dự phòng đang tăng",
+ "quotaExhausted": "Ít nhất một hạn ngạch đã cạn",
+ "quotaNearlyExhausted": "Hạn ngạch gần cạn",
+ "quotaGettingLow": "Hạn ngạch đang thấp",
+ "trafficHighlySkewed": "Phân phối lưu lượng bị lệch nhiều",
+ "comboHealthy": "Combo đang khỏe mạnh"
+ },
+ "configuredTargets": "Đích đã cấu hình",
+ "configuredTargetsDescription": "Các bước combo đã lưu, được bổ sung dữ liệu tình trạng tương ứng khi có.",
+ "noConfiguredTargets": "Chưa cấu hình đích nào.",
+ "runtimeConfig": "Cấu hình runtime",
+ "runtimeConfigDescription": "Các cài đặt nâng cao đã chọn cho combo này.",
+ "noRuntimeConfig": "Không có cấu hình runtime tùy chỉnh.",
+ "resolvedTargets": "Đích runtime đã phân giải",
+ "resolvedTargetsDescription": "Các đích được làm phẳng sau khi phân giải combo lồng nhau, kèm chỉ số cấp đích.",
+ "noResolvedTargetHealth": "Chưa có dữ liệu tình trạng đích đã phân giải.",
+ "quotaDistribution": "Hạn ngạch và phân phối",
+ "noQuotaSnapshots": "Không có ảnh chụp hạn ngạch trong khung combo này.",
+ "usageSkew": "Độ lệch sử dụng",
+ "recentDecisions": "Quyết định định tuyến gần đây",
+ "recentDecisionsDescription": "Nhật ký lệnh gọi gần đây được lọc theo tên combo này. Mở mục Phân tích để xem giải thích đầy đủ.",
+ "noRecentLogs": "Không tìm thấy nhật ký lệnh gọi combo gần đây.",
+ "quickLinks": "Liên kết nhanh",
+ "comboHealth": "Tình trạng combo",
+ "callLogs": "Nhật ký lệnh gọi",
+ "costs": "Chi phí",
+ "playground": "Khu thử nghiệm",
+ "nestedCombo": "Combo lồng nhau",
+ "modelTarget": "Đích mô hình",
+ "weight": "trọng số {value}%",
+ "comboReference": "Tham chiếu combo",
+ "accountShort": "tài khoản {id}",
+ "keyShort": "khóa {id}",
+ "stepShort": "bước {id}",
+ "dynamic": "động",
+ "unknown": "không xác định",
+ "unknownProvider": "nhà cung cấp không xác định",
+ "unknownModel": "mô hình không xác định",
+ "resolvedTargetMetrics": "{requests} yêu cầu · {success} thành công · {latency} · hạn ngạch {quota}"
+ },
+ "usageLimits": {
+ "usdUsageQuota": "Hạn ngạch sử dụng USD",
+ "usdUsageQuotaDescription": "Chặn khóa này bằng lỗi API 400 khi chi tiêu USD cục bộ đạt hạn ngạch ngày hoặc tuần đã cấu hình.",
+ "dailyQuotaUsd": "Hạn ngạch ngày (USD)",
+ "weeklyQuotaUsd": "Hạn ngạch tuần (USD)",
+ "quotaWindowDescription": "Hạn ngạch tuần dùng mốc đặt lại Claude đã cache khi có; nếu không sẽ dùng khung trượt 7 ngày. Hạn ngạch ngày dùng ngày theo lịch Fortaleza.",
+ "apiKeyUsdQuota": "Hạn ngạch USD của khóa API",
+ "apiKeyUsdQuotaDescription": "Khi bật, @@om-usage trả về hạn ngạch ngày, hạn ngạch tuần, chi tiêu ngày và chi tiêu tuần bằng USD. Hạn ngạch tuần dùng mốc đặt lại Claude đã cache khi có.",
+ "enabled": "Đã bật",
+ "disabled": "Đã tắt",
+ "dailySpend": "Chi tiêu ngày",
+ "weeklySpend": "Chi tiêu tuần",
+ "dailyQuota": "Hạn ngạch ngày",
+ "weeklyQuota": "Hạn ngạch tuần",
+ "fallbackRollingDays": "dự phòng: khung trượt {days} ngày",
+ "fallbackRollingDaysShort": "Dự phòng khung trượt {days} ngày",
+ "resetDueNow": "đến hạn đặt lại ngay",
+ "resetsInHours": "đặt lại sau {count} giờ",
+ "resetsInDays": "đặt lại sau {count} ngày",
+ "saveFailed": "Không thể lưu giới hạn sử dụng",
+ "saving": "Đang lưu...",
+ "saveQuota": "Lưu hạn ngạch",
+ "loadUsdCostsFailed": "Không thể tải chi phí USD",
+ "usdCost": "Chi phí USD",
+ "close": "Đóng",
+ "loadingUsdCosts": "Đang tải chi phí USD",
+ "used": "Đã dùng",
+ "quotaUsed": "Hạn ngạch đã dùng",
+ "estimatedFullQuota": "Ước tính 100%",
+ "rows": "Dòng",
+ "window": "Khung thời gian",
+ "unknown": "không xác định",
+ "fromRecordedReset": "Theo mốc đặt lại {quota} đã ghi nhận",
+ "fromObservedReset": "Theo mốc đặt lại {quota} đã quan sát",
+ "fromReset": "Theo mốc đặt lại {quota}",
+ "quotaEstimator": "Ước tính hạn ngạch",
+ "noApiKeyUsage": "Không có mức sử dụng khóa API trong khung nhà cung cấp này.",
+ "requestTokenCounts": "{requests} yêu cầu · {tokens} token",
+ "noUsdLimit": "Không có giới hạn USD"
+ },
+ "freeBudget": {
+ "title": "Ngân sách token miễn phí",
+ "remaining": "Còn {remaining} · {percent}% trên tổng {total}",
+ "steadyMonth": "Ổn định / tháng",
+ "firstMonth": "Tháng đầu (+ tín dụng)",
+ "usedThisMonth": "Đã dùng tháng này",
+ "segmentHint": "Mỗi đoạn là một nhóm miễn phí · đã khử trùng lặp theo nhóm, đếm đúng thực tế (không thổi phồng trần giới hạn tốc độ).",
+ "boost": "Mở khóa thêm khoảng {tokens}/tháng bằng một lần nạp $10 vào OpenRouter (50 → 1000 yêu cầu/ngày)",
+ "uncapped": "Miễn phí vĩnh viễn, không công bố giới hạn (bị giới hạn tốc độ) — quyền truy cập thực, không tính vào tổng nổi bật:",
+ "tosRestricted": "Có {count} mô hình bị đánh dấu hạn chế theo ToS — bạn tự quyết định",
+ "provider": "Nhà cung cấp",
+ "model": "Mô hình",
+ "modelName": "Tên mô hình",
+ "type": "Loại",
+ "tokensMonth": "Token/tháng",
+ "credit": "{tokens} tín dụng",
+ "hideTosRestricted": "Ẩn mục bị hạn chế theo ToS",
+ "sort": "Sắp xếp",
+ "freeType": {
+ "daily": "hằng ngày",
+ "monthly": "hằng tháng",
+ "creditMonthly": "tín dụng/tháng",
+ "uncapped": "không giới hạn công bố",
+ "signupCredit": "tín dụng đăng ký",
+ "keyless": "không cần khóa",
+ "discontinued": "đã ngừng"
+ },
+ "tosTitle": {
+ "avoid": "Bị hạn chế theo ToS — hãy xem lại điều khoản",
+ "caution": "Thận trọng — điều khoản sử dụng cá nhân/proxy",
+ "ok": "Nhìn chung cho phép"
+ }
+ },
+ "providerHealthAutopilot": {
+ "title": "Tự động điều phối tình trạng nhà cung cấp",
+ "description": "Phát hiện nhà cung cấp không ổn định, tài khoản đang cooldown, lỗi cũ và các cách sửa thủ công an toàn.",
+ "loadFailed": "Không thể tải báo cáo tự động điều phối",
+ "actionApplied": "Đã áp dụng: {action}.",
+ "actionFailed": "Thao tác tự động điều phối thất bại",
+ "refresh": "Làm mới",
+ "status": "Trạng thái",
+ "issues": "Vấn đề",
+ "actions": "Thao tác",
+ "connections": "Kết nối",
+ "state": {
+ "healthy": "khỏe mạnh",
+ "warning": "cảnh báo",
+ "critical": "nghiêm trọng",
+ "loading": "đang tải"
+ },
+ "loadingRecommendations": "Đang tải khuyến nghị cho nhà cung cấp...",
+ "noRecommendations": "Hiện không có khuyến nghị nào về tình trạng nhà cung cấp.",
+ "providerMetrics": "điểm {score}% · hoạt động {active}/{total} · cooldown {cooldown} · khóa mô hình {lockouts}",
+ "providerState": {
+ "healthy": "khỏe mạnh",
+ "degraded": "suy giảm",
+ "down": "ngừng hoạt động"
+ },
+ "severity": {
+ "info": "thông tin",
+ "warning": "cảnh báo",
+ "critical": "nghiêm trọng"
+ },
+ "remainingSeconds": "còn {seconds} giây",
+ "errorCode": "mã {code}",
+ "applying": "Đang áp dụng...",
+ "issue": {
+ "circuitOpenTitle": "Bộ ngắt mạch của nhà cung cấp đang mở",
+ "circuitOpenRecommendation": "Xác minh upstream đã phục hồi, sau đó đặt lại bộ ngắt mạch hoặc chờ hết cửa sổ thử lại.",
+ "circuitRecoveryTitle": "Nhà cung cấp đang thăm dò khả năng phục hồi",
+ "circuitRecoveryRecommendation": "Chờ lượt thăm dò tiếp theo hoàn tất hoặc đặt lại bộ ngắt sau khi xác minh thủ công.",
+ "terminalTitle": "{label} đang ở trạng thái tài khoản cuối",
+ "terminalRecommendation": "Kiểm tra thanh toán, xác thực lại hoặc thay thông tin đăng nhập trước khi kích hoạt lại.",
+ "cooldownTitle": "{label} đang cooldown tạm thời",
+ "cooldownRecommendation": "Chờ cửa sổ thử lại của upstream hoặc xóa cooldown sau khi xác minh đã phục hồi.",
+ "staleErrorTitle": "{label} còn trạng thái lỗi cũ",
+ "staleErrorRecommendation": "Xóa các trường lỗi cũ để kết nối đủ điều kiện định tuyến bình thường trở lại.",
+ "inactiveTitle": "{label} đã bị tắt",
+ "inactiveRecommendation": "Chỉ kích hoạt lại nếu kết nối không bị tắt có chủ ý.",
+ "modelLockoutTitle": "{model} bị khóa trên một kết nối",
+ "modelLockoutRecommendation": "Giải quyết trạng thái kết nối hoặc xác nhận hạn ngạch/khả dụng của mô hình đã phục hồi trước khi xóa khóa.",
+ "quotaTitle": "Trình giám sát hạn ngạch báo trạng thái {status}",
+ "quotaRecommendation": "Kiểm tra mức sử dụng hạn ngạch và chuyển lưu lượng sang kết nối khỏe mạnh khác nếu cần."
+ },
+ "action": {
+ "resetProviderBreaker": "Đặt lại bộ ngắt mạch nhà cung cấp",
+ "clearConnectionCooldown": "Xóa cooldown của kết nối",
+ "disableConnection": "Tắt kết nối này",
+ "clearStaleError": "Xóa trạng thái lỗi cũ",
+ "reactivateConnection": "Kích hoạt lại kết nối",
+ "clearModelLockout": "Xóa khóa mô hình"
+ }
+ },
+ "changelogPage": {
+ "newsTab": "Tin tức",
+ "changelogTab": "Lịch sử thay đổi",
+ "loading": "Đang tải lịch sử thay đổi...",
+ "announcementsLoadFailed": "Không thể tải thông báo. Vui lòng thử lại sau.",
+ "noAnnouncements": "Hiện chưa có thông báo mới.",
+ "learnMore": "Tìm hiểu thêm",
+ "changelogLoadFailed": "Không thể tải lịch sử thay đổi. Vui lòng thử lại sau.",
+ "retry": "Thử lại",
+ "viewFullHistory": "Xem toàn bộ lịch sử trên GitHub"
},
"reasoningRouting": {
"title": "Reasoning routing policies",
@@ -8826,632 +12033,45 @@
"set": "Set fixed value"
}
},
- "freeProviderRankingsPage": {
- "typeAll": "Tất cả loại",
- "typeNoauth": "Không cần đăng ký",
- "typeOauth": "Đăng nhập OAuth",
- "typeApikey": "Khóa API",
- "sortTypeFirst": "Dễ thiết lập trước",
- "sortTypeFirstHelp": "Nhóm theo mức độ thiết lập (Không cần đăng ký → Đăng nhập OAuth → Khóa API), đồng thời giữ thứ tự chất lượng trong từng nhóm",
- "typeLegend": "Không cần đăng ký = không cần thiết lập · Đăng nhập OAuth = đăng nhập bằng tài khoản của bạn · Khóa API = dùng khóa riêng hoặc gói miễn phí của nhà cung cấp",
- "title": "Free Provider Rankings",
- "subtitle": "Best free providers ranked by model ELO scores from Arena AI leaderboards",
- "loading": "Loading rankings...",
- "errorLoading": "Failed to load rankings",
- "emptyState": "No rankings available yet. Arena ELO data syncs on startup (daily) — check back shortly, or trigger a manual sync. Disable Arena ELO Sync in Feature Flags or set ARENA_ELO_SYNC_ENABLED=false to opt out.",
- "bestModel": "Best",
- "allCategories": "All Categories",
- "categoryDefault": "Default",
- "categoryCoding": "Coding",
- "categoryReview": "Review",
- "categoryDocumentation": "Documentation",
- "categoryDebugging": "Debugging",
- "colRank": "Rank",
- "colProvider": "Provider",
- "colTopModel": "Top Model",
- "colScore": "Score",
- "colAvgScore": "Avg Score",
- "colModels": "Models",
- "colType": "Type",
- "filterConfiguredOnly": "Configured only",
- "filterAvailableOnly": "Available only",
- "filterAvailableOnlyHelp": "Hide providers whose connections are all rate-limited or out of quota.",
- "configuredOnly": "Configured Only",
- "configuredOnlyHint": "Show only providers with active connections",
- "noConfiguredProviders": "No configured providers found. Add a provider connection first.",
- "colConfigured": "Status"
- },
- "disabled": "Disabled",
- "featureFlagOmnirouteEmergencyFallbackDescription": "Route budget-exhausted requests to the emergency free fallback provider/model.",
- "featureFlagArenaEloSyncEnabledDescription": "Enable periodic Arena AI leaderboard ELO sync for model intelligence rankings.",
- "quotaPlans": {
- "title": "Plans & Quotas",
- "description": "Configure quota plans per provider — dimensions (%, requests, tokens, $) and time windows",
- "providerLabel": "Provider / Connection",
- "detectedPlanLabel": "Detected plan",
- "manualPlanLabel": "Manual override",
- "unconfiguredLabel": "Not configured — manual required",
- "dimensionLabel": "Dimensions",
- "addDimension": "Add dimension",
- "removeDimension": "Remove",
- "limitLabel": "Limit",
- "unitOptions": {
- "percent": "%",
- "requests": "requests",
- "tokens": "tokens",
- "usd": "USD ($)"
- },
- "windowOptions": {
- "5h": "5 hours",
- "hourly": "Hourly",
- "daily": "Daily",
- "weekly": "Weekly",
- "monthly": "Monthly"
- },
- "useCatalogButton": "Use catalog",
- "saveOverrideButton": "Save override",
- "revertToCatalogButton": "Revert to catalog",
- "unknownProviderNotice": "Select a provider on the left to configure its quota plan.",
- "catalogTitle": "Known catalog",
- "catalogDescription": "Automatically detected plans for the following providers:"
- },
- "activity": {
- "title": "Activity",
- "description": "Recent events feed",
- "emptyTitle": "No activity yet",
- "emptyDescription": "When you add providers, create combos, or rotate keys, events will appear here.",
- "todayHeader": "Today",
- "yesterdayHeader": "Yesterday",
- "filterAll": "All",
- "filterProviders": "Providers",
- "filterCombos": "Combos",
- "filterApiKeys": "API Keys",
- "filterSettings": "Settings",
- "filterQuota": "Quota",
- "filterAuth": "Auth",
- "filterSystem": "System",
- "relative": {
- "justNow": "just now",
- "minutesAgo": "{n} min ago",
- "hoursAgo": "{n} h ago",
- "yesterday": "yesterday",
- "daysAgo": "{n} days ago"
- },
- "eventVerb": {
- "providerAdded": "{actor} added provider {target}",
- "providerRemoved": "{actor} removed provider {target}",
- "providerTested": "{actor} tested provider {target}",
- "comboCreated": "{actor} created combo {target}",
- "comboUpdated": "{actor} updated combo {target}",
- "comboDeleted": "{actor} removed combo {target}",
- "apiKeyCreated": "{actor} created API key {target}",
- "apiKeyRevoked": "{actor} revoked API key {target}",
- "apiKeyRotated": "{actor} rotated API key {target}",
- "budgetThreshold": "Budget threshold reached for {target}",
- "settingUpdated": "{actor} updated setting {target}",
- "authLogin": "{actor} signed in",
- "authLogout": "{actor} signed out",
- "cloudAgentSession": "Cloud agent session started for {target}",
- "mcpToolRegistered": "MCP tool {target} registered",
- "webhookCreated": "{actor} created webhook {target}",
- "webhookDeleted": "{actor} removed webhook {target}",
- "quotaPoolCreated": "{actor} created quota pool {target}",
- "quotaPoolUpdated": "{actor} updated quota pool {target}",
- "quotaPoolDeleted": "{actor} removed quota pool {target}",
- "quotaPlanUpdated": "{actor} updated quota plan {target}",
- "quotaStoreDriverChanged": "QuotaStore driver changed",
- "updateApplied": "Update {target} applied",
- "deployCompleted": "Deploy completed",
- "skillInstalled": "{actor} installed skill {target}",
- "skillRemoved": "{actor} removed skill {target}",
- "providerCredentialsCreated": "{actor} created credentials for {target}",
- "providerCredentialsApplied": "Credentials for {target} applied",
- "providerCredentialsUpdated": "{actor} updated credentials for {target}",
- "providerCredentialsRevoked": "{actor} revoked credentials for {target}",
- "providerCredentialsBatchRevoked": "{actor} batch-revoked credentials",
- "providerCredentialsBatchUpdated": "{actor} batch-updated credentials",
- "providerCredentialsBulkCreated": "{actor} bulk-created credentials",
- "providerCredentialsBulkImported": "{actor} bulk-imported credentials",
- "providerCredentialsImported": "{actor} imported credentials",
- "providerSsrfBlocked": "SSRF attempt blocked for {target}",
- "authLoginSuccess": "{actor} signed in",
- "authLoginError": "Login error for {actor}",
- "authLoginFailed": "Login failed for {actor}",
- "authLoginLocked": "{actor} locked out after too many attempts",
- "authLoginMisconfigured": "Auth configuration invalid",
- "authLoginSetupRequired": "Auth setup required",
- "authLogoutSuccess": "{actor} signed out",
- "syncTokenCreated": "{actor} created sync token",
- "syncTokenRevoked": "{actor} revoked sync token",
- "settingsUpdate": "{actor} updated settings",
- "settingsUpdateFailed": "Settings update failed",
- "serviceRevealApiKey": "{actor} revealed API key for {target}",
- "genericEvent": "{actor} {target}"
- }
- },
- "agentBridge": {
- "title": "AgentBridge",
- "subtitle": "Use IDE agents with OmniRoute models — no config required",
- "riskBannerTitle": "Use at your own risk",
- "riskBannerBody": "AgentBridge intercepts HTTPS traffic from IDE agents. By activating it you accept responsibility for compliance with the terms of service of each agent. Never use on devices or networks where TLS inspection is prohibited.",
- "riskBannerDismiss": "Dismiss",
- "serverCardTitle": "AgentBridge Server",
- "statusRunning": "Running",
- "statusStopped": "Stopped",
- "statusActive": "Active",
- "statusDnsOff": "DNS off",
- "statusSetupRequired": "Setup required",
- "statusInvestigating": "Investigating",
- "serverPort": "Port",
- "serverConns": "Connections",
- "serverIntercepted": "Intercepted",
- "serverLastStarted": "Last started",
- "startServer": "Start",
- "stopServer": "Stop",
- "restartServer": "Restart",
- "trustCert": "Trust Cert",
- "downloadCert": "Download Cert",
- "certManualTitle": "Certificate couldn't be installed automatically (e.g. inside a container). The bridge can still run — trust the CA manually:",
- "regenerateCert": "Regenerate Cert",
- "starting": "Starting…",
- "stopping": "Stopping…",
- "restarting": "Restarting…",
- "trusting": "Trusting…",
- "regenerating": "Regenerating…",
- "upstreamCaLabel": "Upstream CA Certificate (corporate)",
- "upstreamCaPlaceholder": "/etc/ssl/certs/corp-ca.pem",
- "upstreamCaTest": "Test TLS",
- "upstreamCaTestOk": "TLS test passed",
- "upstreamCaTestError": "TLS test failed — check the path and CA file",
- "bypassSectionTitle": "Bypass List",
- "bypassSectionDesc": "Hosts matching these patterns are tunneled directly (no TLS decryption). Defaults include banks.gov, and corporate SSO.",
- "bypassDefaultsLabel": "Default bypass patterns (read-only)",
- "bypassUserLabel": "Custom bypass patterns (one per line, glob or regex)",
- "saveBypassList": "Save bypass list",
- "agentListTitle": "IDE Agents",
- "filterAll": "All",
- "filterActive": "Active",
- "filterSetupRequired": "Setup required",
- "filterInvestigating": "Investigating",
- "searchAgents": "Search agents…",
- "noAgentsMatch": "No agents match the current filter",
- "agentHosts": "Intercepted hosts",
- "certTrusted": "Certificate trusted",
- "certNotTrusted": "Certificate not trusted",
- "investigatingNotice": "This agent is under investigation. Hosts and API surface are still being confirmed. Setup will be available once the upstream API is documented.",
- "modelMappingsLabel": "Model mappings",
- "sourceModel": "Source model (agent native)",
- "targetModel": "Target model (OmniRoute)",
- "noMappings": "No model mappings configured. Run setup wizard to auto-detect models.",
- "selectModel": "Select…",
- "saveMappings": "Save mappings",
- "setupWizard": "Setup wizard",
- "startDns": "Start DNS",
- "stopDns": "Stop DNS",
- "toggling": "Toggling…",
- "viewTraffic": "View traffic",
- "emptyNoProvidersTitle": "No providers configured yet",
- "emptyNoProvidersBody": "To use AgentBridge, first connect at least one provider. It will be the destination where IDE requests are routed.",
- "emptyGoToProviders": "Go to Providers",
- "wizardTitle": "Setup wizard",
- "wizardSubtitle": "3-step setup",
- "wizardStep1Label": "Verify",
- "wizardStep2Label": "DNS",
- "wizardStep3Label": "Mappings",
- "wizardStep1Desc": "Confirm the server is running and the certificate is installed.",
- "wizardStep2Desc": "The following entries will be added to /etc/hosts to redirect traffic through AgentBridge:",
- "wizardStep3Desc": "You can now configure model mappings in the agent card. Restart the IDE to apply changes.",
- "wizardStep3Success": "Agent is configured!",
- "wizardServerCheck": "AgentBridge server",
- "wizardRunning": "running",
- "wizardNotRunning": "not running",
- "wizardCertCheck": "Certificate",
- "wizardTrusted": "trusted",
- "wizardNotTrusted": "not yet trusted — use Trust Cert button",
- "wizardTutorialTitle": "Setup instructions:",
- "wizardEnableDns": "Add /etc/hosts entries",
- "wizardDnsAlreadyEnabled": "DNS already enabled for this agent",
- "enablingDns": "Enabling…",
- "modelSelectorTitle": "Select target model",
- "modelSelectorSearch": "Search models…",
- "noModelsFound": "No models found",
- "quickLinks": "Quick links",
- "quickLinkProviders": "Configure providers",
- "quickLinkInspector": "View traffic in Traffic Inspector",
- "maintenanceTitle": "Maintenance & Diagnostics",
- "maintenanceSubtitle": "Self-test the capture pipeline, undo leftover system state, and move your setup between machines.",
- "orphanedStateWarning": "A previous session left system state behind (DNS spoof, CA, or system proxy). Run Repair to clean it up.",
- "diagnose": "Diagnose",
- "diagnosing": "Diagnosing…",
- "diagnoseHealthy": "Capture pipeline is healthy.",
- "diagnoseUnhealthy": "Capture pipeline has problems:",
- "repair": "Repair",
- "repairing": "Repairing…",
- "repairDone": "Repaired: {items}",
- "repairNothing": "Nothing to repair — system state is clean.",
- "removeCa": "Remove CA",
- "removeCaConfirm": "Remove CA?",
- "removeCaDone": "MITM root CA removed from the OS trust store.",
- "removing": "Removing…",
- "confirm": "Confirm",
- "exportConfig": "Export config",
- "exporting": "Exporting…",
- "importConfig": "Import config",
- "importing": "Importing…",
- "importInvalidJson": "The selected file is not valid JSON.",
- "importDone": "Imported {bypass} bypass · {hosts} hosts · {agents} agents",
- "save": "Save",
- "saving": "Saving…",
- "cancel": "Cancel",
- "back": "Back",
- "next": "Next",
- "done": "Done",
- "loading": "Loading…",
- "riskNoticeTitle": "Risk acknowledgment",
- "riskNoticeBody": "AgentBridge will intercept HTTPS traffic from this agent by redirecting its API hosts via DNS. Only enable if you accept responsibility for compliance with the agent's terms of service and any applicable network policies.",
- "pageMoved": {
- "goNow": "Go now",
- "message": "The MITM Proxy now lives under AgentBridge.",
- "title": "This page has moved"
- }
- },
- "trafficInspector": {
- "title": "Traffic Inspector",
- "subtitle": "Monitor LLM calls and debug any application's HTTPS traffic",
- "captureModesTitle": "Capture Modes",
- "agentBridgeMode": "AgentBridge",
- "agentBridgeModeDesc": "Capture traffic from all connected IDE agents",
- "customHostsMode": "Custom Hosts",
- "customHostsModeDesc": "Add specific hosts to intercept",
- "httpProxyMode": "HTTP Proxy",
- "httpProxyModeDesc": "Use HTTP_PROXY environment variable",
- "systemWideMode": "System-wide",
- "systemWideModeDesc": "Intercept all system traffic (advanced)",
- "tproxyMode": "TPROXY Decrypt",
- "tproxyModeUnavailable": "TPROXY decrypt requires Linux + root + the native addon",
- "filterBarTitle": "Filters",
- "profileLlmOnly": "LLM only",
- "profileCustom": "Custom",
- "profileAll": "All",
- "filterHost": "Filter host…",
- "filterAgent": "Agent",
- "filterStatus": "Status",
- "pauseBtn": "Pause",
- "resumeBtn": "Resume",
- "clearBtn": "Clear",
- "exportHar": "Export .har",
- "recordSession": "REC",
- "stopSession": "Stop",
- "liveBadge": "live",
- "offlineBadge": "offline",
- "noRequests": "No requests captured yet.",
- "noRequestsDesc": "Make sure AgentBridge is running or enable another capture mode.",
- "selectRequest": "Select a request to inspect it.",
- "tabConversation": "Conversation",
- "tabHeaders": "Headers",
- "tabRequest": "Request",
- "tabResponse": "Response",
- "tabTiming": "Timing",
- "tabLlm": "LLM",
- "tabStats": "Stats",
- "requestHeaders": "Request Headers",
- "responseHeaders": "Response Headers",
- "rawEvents": "Raw events",
- "mergedView": "Merged view",
- "noBody": "No body.",
- "streaming": "streaming…",
- "manageHosts": "Manage hosts",
- "copySnippet": "Copy proxy snippet",
- "addHost": "Add",
- "hostPlaceholder": "api.openai.com",
- "noHostsYet": "No custom hosts added yet.",
- "noSessionsYet": "No sessions yet",
- "allTraffic": "All traffic (no session)",
- "sessionsDropdown": "Sessions",
- "annotationPlaceholder": "Add a note…",
- "contextFingerprint": "Context fingerprint",
- "llmProvider": "Detected provider",
- "llmApiKind": "API kind",
- "llmModel": "Model",
- "llmMessages": "Messages",
- "llmStream": "Stream",
- "llmMappedTo": "Mapped to",
- "llmCostEstimate": "Cost estimate",
- "systemProxyExitWarning": "System-wide proxy still active — leave page anyway?",
- "customHostsTitle": "Custom Hosts",
- "loading": "Loading…",
- "copied": "Copied!",
- "copy": "Copy",
- "httpProxyTitle": "HTTP Proxy Snippet — port {port}",
- "notRecording": "Not recording",
- "anyStatus": "Any status",
- "liveOnly": "Live",
- "viewingRecordedSession": "Viewing recorded session",
- "backToLive": "Back to live",
- "untitledSession": "Untitled session",
- "contextHistory": "Context History",
- "modelResponse": "Model Response",
- "conversationNoMessages": "No messages found in this request.",
- "conversationNotAvailable": "Conversation data not available. This may not be an LLM request or the body could not be parsed.",
- "loadingCharts": "Loading charts…",
- "statsErrors": "Errors",
- "statsLatency": "Latency (last 50 requests)",
- "statsNoData": "No requests yet. Start a session recording to capture data for stats.",
- "statsStatusDistribution": "Status distribution",
- "statsSuccessful": "Successful",
- "statsTotalRequests": "Total requests",
- "timingProxyOverhead": "Proxy overhead",
- "timingUpstreamResponse": "Upstream response",
- "timingNoData": "No timing data available.",
- "timingTotalLatency": "Total latency",
- "timingTimestamp": "Timestamp",
- "timingMethod": "Method",
- "timingStatus": "Status",
- "timingRequestSize": "Request size",
- "timingResponseSize": "Response size",
- "pausedNewBadge": "{count} new",
- "clearContextFilter": "clear"
- },
- "cliCommon": {
- "concept": {
- "code": {
- "title": "CLI Code",
- "phrase": "Code tools you point at OmniRoute",
- "flow": "You → CLI Code → OmniRoute → Provider",
- "seeOther": "See →"
- },
- "agent": {
- "title": "CLI Agents",
- "phrase": "Broad-purpose autonomous CLI agents you point at OmniRoute",
- "flow": "You → CLI Agent → OmniRoute → Provider",
- "seeOther": "See →"
- },
- "acp": {
- "title": "ACP Agents",
- "phrase": "CLIs that OmniRoute spawns as execution backend (reverse flow)",
- "flow": "Client → OmniRoute → spawn CLI (stdio/ACP) → response",
- "seeOther": "See →"
- }
- },
- "comparison": {
- "title": "Understand the 3 CLI types in OmniRoute",
- "thisPage": "This page",
- "open": "Open →",
- "code": {
- "title": "Code tool",
- "desc": "Points to Omni",
- "flow": "you → CLI → Omni → provider",
- "examples": "e.g.: claude, codex"
- },
- "agent": {
- "title": "Broad autonomous agent",
- "desc": "Points to Omni",
- "flow": "you → agent → Omni",
- "examples": "e.g.: hermes, goose"
- },
- "acp": {
- "title": "CLI used as backend by Omni",
- "desc": "Reverse execution",
- "flow": "Omni → spawn CLI → resp",
- "examples": "e.g.: claude, codex (ACP)"
- }
- },
- "card": {
- "detected": "Detected",
- "notDetected": "Not detected",
- "configured": "Configured",
- "notConfigured": "Not configured",
- "configure": "Configure →",
- "howToInstall": "How to install →",
- "manualConfig": "Manual config",
- "installGuide": "Install guide",
- "endpointLabel": "Endpoint",
- "baseUrlFull": "Full Base URL",
- "baseUrlPartial": "Partial Base URL",
- "refreshDetection": "Refresh detection",
- "alsoAcp": "also ACP",
- "connectProviderHint": "Connect a provider in Providers"
- },
- "detail": {
- "back": "Back",
- "apply": "Save",
- "reset": "Clear",
- "manualConfig": "Manual configuration",
- "vendor": "Vendor",
- "category": "Type",
- "detectionStatus": "Detection",
- "configStatus": "Configuration",
- "baseUrlLabel": "Base URL",
- "apiKeyLabel": "API Key",
- "modelMappingLabel": "Model mapping",
- "noActiveProviders": "No active providers.",
- "noActiveProvidersDesc": "Go to Providers to connect at least 1 provider before configuring the CLI.",
- "openProviders": "Open Providers →"
- }
- },
- "cliCode": {
- "pageTitle": "CLI Code's",
- "pageSubtitle": "Code tools pointing to OmniRoute",
- "searchPlaceholder": "Search CLI…",
- "filterDetectionLabel": "Detection",
- "filterBaseUrlLabel": "Base URL",
- "detectionAll": "All",
- "detectionInstalled": "Installed",
- "detectionNotFound": "Not found",
- "baseUrlAll": "All",
- "baseUrlFull": "Full",
- "baseUrlPartial": "Partial"
- },
- "cliAgents": {
- "pageTitle": "CLI Agents",
- "pageSubtitle": "Broad-purpose autonomous CLI agents",
- "refreshDetection": "Refresh detection",
- "searchPlaceholder": "Search agent…",
- "detectionFilterLabel": "Detection",
- "detectionAll": "All",
- "detectionInstalled": "Installed",
- "detectionNotInstalled": "Not installed",
- "visibleCount": "{count} visible",
- "emptyState": "No CLI agents found with the current filters."
- },
- "acpAgents": {
- "pageTitle": "ACP Agents",
- "pageSubtitle": "CLIs that OmniRoute spawns as execution backend",
- "scanning": "Detecting agents…",
- "refresh": "Refresh",
- "setupGuideTitle": "Setup guide",
- "setupGuideDetectCliTitle": "Detect CLI",
- "setupGuideDetectCliDesc": "Agents are identified by running the --version command in PATH.",
- "setupGuideCustomAgentTitle": "Add custom agent",
- "setupGuideCustomAgentDesc": "Fill in the form below to register a custom CLI agent.",
- "setupGuideCommandMissingTitle": "Command not found",
- "setupGuideCommandMissingDesc": "Check that the binary is in PATH.",
- "fingerprintSettingsHint": "Configure routing and fingerprints in",
- "settingsRoutingLink": "Settings → Routing",
- "installed": "Installed",
- "notFound": "Not found",
- "builtIn": "Built-in",
- "custom": "Custom",
- "agentUseCaseHint": "Available for spawn via ACP.",
- "remove": "Remove",
- "addCustomAgent": "Add custom agent",
- "addCustomAgentDesc": "Register a custom CLI agent to be spawned via ACP.",
- "addAgent": "Add agent",
- "agentName": "Name",
- "agentNamePlaceholder": "e.g.: My Agent",
- "binaryName": "Binary",
- "binaryNamePlaceholder": "e.g.: myagent",
- "versionCommand": "Version command",
- "versionCommandPlaceholder": "e.g.: myagent --version",
- "spawnArgs": "Spawn arguments",
- "spawnArgsPlaceholder": "e.g.: --quiet, --json",
- "cliCodeRedirectCta": "Open CLI Code"
- },
- "agentSkills": {
- "pageTitle": "Agent Skills",
- "pageSubtitle": "Teach your agent to operate OmniRoute — 22 API areas + 20 CLI families",
- "conceptCard": {
- "agent": {
- "title": "Agent Skills — Outbound",
- "description": "Agent Skills are machine-readable SKILL.md documents that external AI agents (Claude Code, Cursor, Copilot…) fetch from GitHub to learn how to operate OmniRoute via REST or CLI. They are read by the agent, not executed by OmniRoute.",
- "crossLinkLabel": "Understand the difference →"
- },
- "omni": {
- "title": "Omni Skills — Inbound",
- "description": "Omni Skills are sandbox tools that OmniRoute injects into the model's context on every request. They are executed by OmniRoute, not read by the agent.",
- "crossLinkLabel": "Understand the difference →"
- },
- "comparison": {
- "colAgent": "Agent Skills",
- "colOmni": "Omni Skills",
- "whatIs": {
- "label": "What it is",
- "agent": "Machine-readable SKILL.md that teaches an external agent to operate OmniRoute",
- "omni": "Executable tools that OmniRoute injects as tools into LLM requests"
- },
- "direction": {
- "label": "Direction",
- "agent": "Outbound — external agent learns to control OmniRoute",
- "omni": "Inbound — OmniRoute gives tools to models passing through it"
- },
- "executor": {
- "label": "Executor",
- "agent": "External agent (reads markdown → acts via API/CLI)",
- "omni": "OmniRoute itself (intercepts tool_calls, Docker sandbox)"
- },
- "storage": {
- "label": "Storage",
- "agent": "Dynamic catalog (/api/agent-skills) → SKILL.md on GitHub",
- "omni": "SQLite (SkillsMP / skills.sh / local)"
- },
- "tagline": {
- "label": "Tagline",
- "agent": "Teach your agent to use OmniRoute",
- "omni": "Give executable tools to OmniRoute models"
- }
- }
- },
- "filters": {
- "category": "Category",
- "area": "Area",
- "searchPlaceholder": "Search skills…"
- },
- "categoryApi": "API",
- "categoryCli": "CLI",
- "categoryConfig": "Config",
- "filterAll": "All",
- "coverageLabel": "Coverage",
- "mcpUrl": "MCP URL",
- "a2aLink": "A2A",
- "copyUrl": "Copy URL",
- "viewOnGithub": "View on GitHub",
- "previewLoading": "Loading skill documentation…",
- "previewError": "Failed to load skill documentation.",
- "previewEmpty": "Select a skill to preview its documentation.",
- "generateButton": "Generate missing skills",
- "coverageBar": {
- "complete": "Complete",
- "partial": "Partial"
- },
- "noSkillsFound": "No skills found matching your filters.",
- "regenerateConfirm": "This will regenerate all missing SKILL.md files. Continue?",
- "regenerateRunning": "Regenerating skills…",
- "regenerateSuccess": "Skills regenerated successfully.",
- "regenerateError": "Failed to regenerate skills."
- },
- "discovery": {
- "title": "Provider Discovery",
- "subtitle": "Scan providers for free/unlimited access methods and review findings. Opt-in, local-only.",
- "scanLabel": "Provider to scan",
- "scanPlaceholder": "e.g. huggingchat",
- "scan": "Scan",
- "scanning": "Scanning…",
- "scanQueued": "Scan complete for {provider}.",
- "scanFailed": "Scan failed.",
- "loadFailed": "Failed to load discovery results.",
- "localOnlyNote": "This tool is local-only (loopback). Scans run from this machine and are never reachable remotely.",
- "verify": "Verify",
- "verifyFailed": "Failed to verify finding.",
- "delete": "Delete",
- "deleteFailed": "Failed to delete finding.",
- "deleteTitle": "Delete discovery result",
- "deleteConfirm": "Delete the discovery finding for {provider}? This cannot be undone.",
- "emptyTitle": "No discovery results yet",
- "emptyDescription": "Run a scan above to look for free access methods on a provider.",
- "risk": "Risk",
- "method": "Method",
- "auth": "Auth",
- "feasibility": "Feasibility",
- "models": "Models"
- },
"chaosConfig": {
- "pageTitle": "Chaos Mode",
- "pageSubtitle": "Run multiple AI models in parallel or collaboratively on the same task",
- "enableChaos": "Enable Chaos Mode",
- "enableChaosDesc": "Allow API keys with chaos mode enabled to use this feature",
- "mode": "Default Mode",
+ "pageTitle": "Chế độ Chaos",
+ "pageSubtitle": "Chạy đồng thời hoặc cộng tác nhiều mô hình AI trên cùng một tác vụ",
+ "enableChaos": "Bật Chế độ Chaos",
+ "enableChaosDesc": "Cho phép các khóa API đã bật Chế độ Chaos sử dụng tính năng này",
+ "mode": "Chế độ mặc định",
"modeParallel": "Parallel",
- "modeCollaborative": "Collaborative",
- "modeParallelDesc": "All models run simultaneously — fastest results",
- "modeCollaborativeDesc": "Models chain outputs — each sees the previous result",
- "timeout": "Timeout (ms)",
- "timeoutDesc": "Max time per model call (5000-600000ms)",
- "systemPrompt": "System Prompt (optional)",
- "systemPromptDesc": "Custom instructions for all chaos mode model instances",
- "providerOverrides": "Provider Overrides",
- "providerOverridesDesc": "Select specific models per provider for chaos mode",
- "providerId": "Provider",
- "modelId": "Model",
- "addProvider": "Add Provider",
- "removeProvider": "Remove",
- "saveConfig": "Save Configuration",
- "configSaved": "Chaos configuration saved successfully",
- "configError": "Failed to save chaos configuration",
- "configReset": "Reset to Defaults",
- "keyPermission": "Chaos Mode Access",
- "keyPermissionDesc": "Allow this API key to use Chaos Mode (multi-model parallel execution)",
- "testButton": "Test Chaos Mode",
- "testTask": "Write a short poem about artificial intelligence",
- "loadingProviderModels": "Loading providers..."
+ "modeCollaborative": "Cộng tác",
+ "modeParallelDesc": "Tất cả các mô hình chạy đồng thời — cho kết quả nhanh nhất",
+ "modeCollaborativeDesc": "Các mô hình liên kết chuỗi đầu ra — mỗi mô hình thấy kết quả của mô hình trước đó",
+ "timeout": "Thời gian chờ (ms)",
+ "timeoutDesc": "Thời gian tối đa cho mỗi cuộc gọi mô hình (5000-600000ms)",
+ "systemPrompt": "Prompt hệ thống (tùy chọn)",
+ "systemPromptDesc": "Hướng dẫn tùy chỉnh cho tất cả các thực thể mô hình trong Chế độ Chaos",
+ "providerOverrides": "Ghi đè nhà cung cấp",
+ "providerOverridesDesc": "Chọn các mô hình cụ thể cho từng nhà cung cấp trong chế độ chaos",
+ "providerId": "Nhà cung cấp",
+ "modelId": "Mô hình",
+ "addProvider": "Thêm nhà cung cấp",
+ "removeProvider": "Xóa bỏ",
+ "saveConfig": "Lưu cấu hình",
+ "configSaved": "Đã lưu cấu hình chaos thành công",
+ "configError": "Không thể lưu cấu hình chaos",
+ "configReset": "Đặt lại về mặc định",
+ "keyPermission": "Quyền truy cập Chế độ Chaos",
+ "keyPermissionDesc": "Cho phép khóa API này sử dụng Chế độ Chaos (thực thi song song nhiều mô hình)",
+ "testButton": "Thử nghiệm Chế độ Chaos",
+ "testTask": "Viết một bài thơ ngắn về trí tuệ nhân tạo",
+ "loadingProviderModels": "Đang tải các nhà cung cấp...",
+ "systemPromptPlaceholder": "Không bắt buộc: ghi đè prompt hệ thống mặc định của chế độ Chaos...",
+ "enabled": "Đã bật",
+ "disabled": "Đã tắt",
+ "maxTokens": "Số token tối đa",
+ "maxTokensDesc": "Số token tối đa trong mỗi phản hồi của mô hình. Giá trị cao hơn sẽ tốn nhiều chi phí và thời gian hơn.",
+ "providerIdPlaceholder": "ID nhà cung cấp (nhập hoặc chọn)",
+ "modelIdPlaceholder": "ID mô hình (không bắt buộc)",
+ "on": "BẬT",
+ "off": "TẮT",
+ "availableProviders": "Nhà cung cấp khả dụng ({count})",
+ "noProviderOverrides": "Không có ghi đè — mọi nhà cung cấp đang hoạt động sẽ tham gia bằng mô hình mặc định"
}
}
diff --git a/src/shared/components/Breadcrumbs.tsx b/src/shared/components/Breadcrumbs.tsx
index e22b2a030f..6df3201e75 100644
--- a/src/shared/components/Breadcrumbs.tsx
+++ b/src/shared/components/Breadcrumbs.tsx
@@ -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 (
{
children?: React.ReactNode;
- variant?: keyof typeof variants;
+ variant?: ButtonVariant;
size?: keyof typeof sizes;
icon?: string;
iconRight?: string;
diff --git a/src/shared/components/CloudSyncStatus.tsx b/src/shared/components/CloudSyncStatus.tsx
index aae7fa1b09..7196d35c37 100644
--- a/src/shared/components/CloudSyncStatus.tsx
+++ b/src/shared/components/CloudSyncStatus.tsx
@@ -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 (
{config.icon}
@@ -96,7 +102,7 @@ export default function CloudSyncStatus({ collapsed = false }) {
- {config.label}
+ {label}
)}
diff --git a/src/shared/components/ColumnToggle.tsx b/src/shared/components/ColumnToggle.tsx
index 071a19cd4b..f02d73596e 100644
--- a/src/shared/components/ColumnToggle.tsx
+++ b/src/shared/components/ColumnToggle.tsx
@@ -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 }) {
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 }) {
}}
>
⚙️
- Columns
+ {t("columns")}
{open && (
diff --git a/src/shared/components/ConsoleLogViewer.tsx b/src/shared/components/ConsoleLogViewer.tsx
index a7104f4deb..9ddcbb347e 100644
--- a/src/shared/components/ConsoleLogViewer.tsx
+++ b/src/shared/components/ConsoleLogViewer.tsx
@@ -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
= {
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([]);
@@ -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() {
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)]"
>
{t("allLevels")}
@@ -166,17 +170,17 @@ export default function ConsoleLogViewer() {
{/* Search */}
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 */}
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() {
{autoScroll ? "vertical_align_bottom" : "lock"}
- Auto-scroll
+ {tv("autoScroll")}
{/* Refresh */}
@@ -201,13 +205,13 @@ export default function ConsoleLogViewer() {
{/* Status */}
- {filteredLogs.length} entries
+ {tv("entryCount", { count: filteredLogs.length })}
•
- Last 1h
+ {tv("lastHour")}
{lastUpdated && (
<>
•
- Updated {lastUpdated.toLocaleTimeString()}
+ {tv("updatedAt", { time: lastUpdated.toLocaleTimeString(locale) })}
>
)}
@@ -221,9 +225,7 @@ export default function ConsoleLogViewer() {
>
error
{error}
-
- — Make sure the application is writing logs to file (APP_LOG_TO_FILE=true)
-
+ — {tv("fileLoggingRequired")}
)}
@@ -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() {
- OmniRoute — Application Console
+
+ OmniRoute — {tv("applicationConsole")}
+
{/* Log entries */}
@@ -252,9 +256,7 @@ export default function ConsoleLogViewer() {
terminal
{t("noLogEntries")}
-
- Ensure APP_LOG_TO_FILE=true is set in your .env file
-
+
{tv("emptyFileLoggingHint")}
) : (
filteredLogs.map((entry, idx) => {
@@ -316,7 +318,7 @@ export default function ConsoleLogViewer() {
progress_activity
- Loading logs...
+ {t("loadingLogs")}
)}
diff --git a/src/shared/components/EmptyState.tsx b/src/shared/components/EmptyState.tsx
index c1b74ced95..41815b5139 100644
--- a/src/shared/components/EmptyState.tsx
+++ b/src/shared/components/EmptyState.tsx
@@ -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 (
- {icon}
+ {usesMaterialSymbol ? (
+
+ {icon}
+
+ ) : (
+ icon
+ )}
{
@@ -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")}
{(filter.options || []).map((opt) => (
- Clear
+ {t("clear")}
)}
diff --git a/src/shared/components/Header.tsx b/src/shared/components/Header.tsx
index 94341613fc..e3f32772f8 100644
--- a/src/shared/components/Header.tsx
+++ b/src/shared/components/Header.tsx
@@ -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")}
>
search
- Quick nav
+ {t("quickNavigation")}
{isMac ? "⌘K" : "Ctrl+K"}
@@ -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")}
>
search
@@ -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")}
>
logout
diff --git a/src/shared/components/Input.tsx b/src/shared/components/Input.tsx
index ef54299dae..9a2c1f78a6 100644
--- a/src/shared/components/Input.tsx
+++ b/src/shared/components/Input.tsx
@@ -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, "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({
keyboard_capslock
- Caps Lock is on
+ {t("capsLockOn")}
)}
{error && (
diff --git a/src/shared/components/Modal.tsx b/src/shared/components/Modal.tsx
index 5e385ebf8d..54c2e46e1c 100644
--- a/src/shared/components/Modal.tsx
+++ b/src/shared/components/Modal.tsx
@@ -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;
+ 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 && (
@@ -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 (
- {cancelText}
+ {resolvedCancelText}
-
- {confirmText}
+
+ {resolvedConfirmText}
>
}
diff --git a/src/shared/components/NoAuthAccountCard.tsx b/src/shared/components/NoAuthAccountCard.tsx
index 3eef733f5a..7d579d5d3c 100644
--- a/src/shared/components/NoAuthAccountCard.tsx
+++ b/src/shared/components/NoAuthAccountCard.tsx
@@ -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([]);
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({
lock_open
-
No authentication required
-
{description}
+
{t("title")}
+
{resolvedDescription}
@@ -354,7 +362,7 @@ export default function NoAuthAccountCard({
- Accounts ({loading ? "..." : allAccountIds.length})
+ {t("accounts", { count: loading ? "..." : allAccountIds.length })}
{!loading && allAccountIds.length > 0 && (
@@ -365,14 +373,14 @@ export default function NoAuthAccountCard({
/>
)}
- {adding ? "Adding..." : addLabel}
+ {adding ? t("adding") : resolvedAddLabel}
{!loading && allAccountIds.length === 0 && (
- Using auto-generated account. Click "{addLabel}" for rate-limit rotation.
+ {t("autoGeneratedAccount", { addLabel: resolvedAddLabel })}
)}
@@ -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"}
>
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")}
>
delete
@@ -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"
>
- Proxy for Account {allAccountIds.indexOf(proxyAccountId) + 1}
+ {t("proxyForAccount", {
+ number: allAccountIds.indexOf(proxyAccountId) + 1,
+ })}
{/* #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")}
- Custom
+ {t("custom")}
@@ -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"
>
- {savedProxies.length === 0
- ? "No saved proxies — add one in Settings → Proxy"
- : "Direct (no proxy)"}
+ {savedProxies.length === 0 ? t("noSavedProxies") : t("directConnection")}
{savedProxies.map((p) => (
@@ -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"
/>
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"
/>
@@ -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"
/>
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")}
- {savingProxy ? "Saving..." : "Save"}
+ {savingProxy ? t("saving") : t("save")}
diff --git a/src/shared/components/NoAuthProviderCard.tsx b/src/shared/components/NoAuthProviderCard.tsx
index 8d27f1a71b..77f05aaf65 100644
--- a/src/shared/components/NoAuthProviderCard.tsx
+++ b/src/shared/components/NoAuthProviderCard.tsx
@@ -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 (
@@ -25,10 +28,8 @@ export default function NoAuthProviderCard({
lock_open
-
No authentication required
-
- This provider is ready to use immediately — no signup or API key needed.
-
+
{t("title")}
+
{t("description")}
diff --git a/src/shared/components/NotificationToast.tsx b/src/shared/components/NotificationToast.tsx
index d6fec929a5..7629beb2b4 100644
--- a/src/shared/components/NotificationToast.tsx
+++ b/src/shared/components/NotificationToast.tsx
@@ -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",
diff --git a/src/shared/components/ProviderIcon.tsx b/src/shared/components/ProviderIcon.tsx
index ba38db525b..0a5650dbf1 100644
--- a/src/shared/components/ProviderIcon.tsx
+++ b/src/shared/components/ProviderIcon.tsx
@@ -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
= {
},
};
+const PROVIDER_ICON_ALIASES: Record = {
+ "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 (
-
- {/* eslint-disable-next-line @next/next/no-img-element -- external SVG from thesvg.org, not a static/known asset */}
- setFailedAssets((current) => ({ ...current, [theSvgKey]: true }))}
- />
-
- );
- }
-
- // 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 (
+ {/* eslint-disable-next-line @next/next/no-img-element -- external SVG from thesvg.org, not a static/known asset */}
+ setFailedAssets((current) => ({ ...current, [theSvgKey]: true }))}
+ />
+
+ );
+ }
+
// Tier 6: Generic AI icon
return (
diff --git a/src/shared/components/Select.tsx b/src/shared/components/Select.tsx
index d3714b8265..e69a336983 100644
--- a/src/shared/components/Select.tsx
+++ b/src/shared/components/Select.tsx
@@ -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")) && (
- {placeholder}
+ {placeholder ?? t("selectOption")}
)}
{!children &&
diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx
index 34dafb0998..ed2188055e 100644
--- a/src/shared/components/Sidebar.tsx
+++ b/src/shared/components/Sidebar.tsx
@@ -537,7 +537,7 @@ export default function Sidebar({
)}
{
- 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;
diff --git a/src/shared/components/analytics/charts.tsx b/src/shared/components/analytics/charts.tsx
index a97ae5d1d2..f569179983 100644
--- a/src/shared/components/analytics/charts.tsx
+++ b/src/shared/components/analytics/charts.tsx
@@ -133,7 +133,13 @@ export function CompactStatGrid({ sections }: { sections: CompactStatSection[] }
export function ActivityHeatmap({ activityMap }) {
const t = useTranslations("analytics");
+ const locale = useLocale();
const scrollRef = useRef(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")}
- {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,
+ })}
@@ -249,11 +250,11 @@ export function ActivityHeatmap({ activityMap }) {
- Mon
+ {weekdayLabels[0]}
- Wed
+ {weekdayLabels[1]}
- Fri
+ {weekdayLabels[2]}
@@ -262,7 +263,11 @@ export function ActivityHeatmap({ activityMap }) {
{week.map((day, di) => (
))}
@@ -273,13 +278,13 @@ export function ActivityHeatmap({ activityMap }) {
-
Less
+
{t("activityLess")}
-
More
+
{t("activityMore")}
);
@@ -334,7 +339,7 @@ export function ApiKeyTable({ byApiKey }) {
return (
- API Key Breakdown
+ {t("chartApiKeyBreakdown")}
{t("chartNoData")}
@@ -345,7 +350,7 @@ export function ApiKeyTable({ byApiKey }) {
- API Key Breakdown
+ {t("chartApiKeyBreakdown")}
toggleSort("apiKeyName")}
>
- API Key
+ {t("chartApiKey")}{" "}
+
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")}
{data ? (
<>
@@ -493,12 +500,12 @@ export function MostActiveDay7d({ activityMap }) {
{data.weekday}
- {data.label} · {fmt(data.tokens)} tokens
+ {t("datedTokenCount", { date: data.label, tokens: fmt(data.tokens) })}
>
) : (
- No data in the last 7 days
+ {t("noDataLast7Days")}
)}
@@ -561,7 +568,7 @@ export function WeeklySquares7d({ activityMap }) {
style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}
>
byServiceTier || [], [byServiceTier]);
const totalRequests = Number(summary?.totalRequests || 0);
const totalCost = Number(summary?.totalCost || 0);
@@ -807,7 +815,10 @@ export function ServiceTierBreakdown({ byServiceTier, summary }) {
{tierLabel}
- {fmtFull(tier.requests)} requests · {fmt(tier.totalTokens)} tokens
+ {tAnalytics("requestTokenSummary", {
+ requests: fmtFull(tier.requests),
+ tokens: fmt(tier.totalTokens),
+ })}
{usageSavingsText}
diff --git a/src/shared/components/analytics/rechartsDonuts.tsx b/src/shared/components/analytics/rechartsDonuts.tsx
index 70b7f27a28..9a463ccf56 100644
--- a/src/shared/components/analytics/rechartsDonuts.tsx
+++ b/src/shared/components/analytics/rechartsDonuts.tsx
@@ -103,14 +103,14 @@ export function AccountDonut({ byAccount }) {
return (
- By Account
+ {t("chartByAccount")}
{t("chartNoData")}
);
}
- return
;
+ return
;
}
// ── 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 (
- By API Key
+ {t("chartByApiKey")}
{t("chartNoData")}
@@ -143,7 +143,7 @@ export function ApiKeyDonut({ byApiKey }) {
return (
`${seg.fullName}-${i}`}
getLegendTitle={(seg) => seg.fullName}
diff --git a/src/shared/components/cli/CliToolCard.tsx b/src/shared/components/cli/CliToolCard.tsx
index 5a35d18d5d..4b027558c1 100644
--- a/src/shared/components/cli/CliToolCard.tsx
+++ b/src/shared/components/cli/CliToolCard.tsx
@@ -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 = (
{/* Icon / image */}
- {tool.image ? (
+ {imageSrc ? (
- {tool.description}
+
+ {tTools(`toolDescriptions.${tool.id}`)}
+
chevron_right
@@ -124,10 +131,10 @@ export default function CliToolCard({
{showInstallChips && (
<>
- 📋 Manual config
+ 📋 {t("card.manualConfig")}
- ⬇ Install
+ ⬇ {t("card.installGuide")}
>
)}
diff --git a/src/shared/components/compression/CompressionPipelineEditor.tsx b/src/shared/components/compression/CompressionPipelineEditor.tsx
index c295f63ea3..281d4f7d8a 100644
--- a/src/shared/components/compression/CompressionPipelineEditor.tsx
+++ b/src/shared/components/compression/CompressionPipelineEditor.tsx
@@ -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) => 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: {
>
props.onPatch({ engine: event.target.value })}
className={SELECT_CLASS}
@@ -95,7 +93,7 @@ function SortableRow(props: {
))}
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")}
);
}
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 (
-
Pipeline
+ {t("pipeline")}
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")}
diff --git a/src/shared/components/compression/EngineConfigPage.tsx b/src/shared/components/compression/EngineConfigPage.tsx
index 9d81672d3a..4cca345006 100644
--- a/src/shared/components/compression/EngineConfigPage.tsx
+++ b/src/shared/components/compression/EngineConfigPage.tsx
@@ -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 = {
+ 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 (
- {label}
+ {translateLabel(label)}
{text}
@@ -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(null);
const [configState, setConfigState] = useState>({});
@@ -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(null);
const [previewError, setPreviewError] = useState(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 (
- Loading…
+
+ {t("loading")}
+
);
}
if (!engine) {
return (
- {loadError ?? `Engine "${engineId}" not found.`}
+ {loadError ?? t("engineNotFound", { engine: engineId })}
);
}
- 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}
)}
- {engine.name}
+ {engineName}
{subtitle && {subtitle}
}
@@ -283,17 +317,17 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
{/* ── Panel pointer (on/off + level live there now) ── */}
{/* ── Config form ── */}
-
Configuration
+
{t("configuration")}
{visibleConfigSchema.length > 0 ? (
) : (
-
No additional configuration.
+
{t("noAdditionalConfiguration")}
)}
{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")}
) : (
- This layer is configured by the global settings; there is no per-engine override to
- save here yet.
+ {t("globalSettingsOnly")}
)}
{saveError &&
{saveError}
}
@@ -324,12 +357,12 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
{/* ── Live preview ── */}
-
Preview
+
{t("preview")}
setPreviewText(e.target.value)}
- aria-label="Preview input"
+ aria-label={t("previewInput")}
/>
- {previewLoading ? "Processing..." : "Preview"}
+ {previewLoading ? t("processing") : t("preview")}
{previewError && {previewError}
}
@@ -345,19 +378,22 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
- Original tokens: {preview.originalTokens}
+ {t("originalTokens")}:{" "}
+ {preview.originalTokens}
- Compressed tokens: {preview.compressedTokens}
+ {t("compressedTokens")}:{" "}
+ {preview.compressedTokens}
- Savings: {preview.savingsPct.toFixed(1)}%
+ {t("savings")}:{" "}
+ {preview.savingsPct.toFixed(1)}%
- Original
+ {t("original")}
{preview.original ?? ""}
@@ -365,7 +401,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
- Compressed
+ {t("compressed")}
{preview.compressed ?? ""}
@@ -375,10 +411,15 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
{preview.diff && preview.diff.length > 0 && (
- Diff
+ {t("diff")}
- {preview.diff.map(renderDiffSegment)}
+ {preview.diff.map((segment, index) =>
+ renderDiffSegment(segment, index, (label) => {
+ const key = `diffLabels.${label}`;
+ return t.has(key) ? t(key) : label;
+ })
+ )}
)}
@@ -388,20 +429,23 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
{/* ── Analytics strip ── */}
-
Last 7 days
+
{t("last7Days")}
{analytics && analytics.runs === 0 ? (
-
No data yet
+
{t("noDataYet")}
) : analytics ? (
-
-
+
+
) : (
-
No data yet
+
{t("noDataYet")}
)}
diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts
index d8933f3e70..a1dd3f7a08 100644
--- a/src/shared/constants/cliTools.ts
+++ b/src/shared/constants/cliTools.ts
@@ -9,7 +9,7 @@ export const CLI_TOOLS: Record
= {
claude: {
id: "claude",
name: "Claude Code",
- icon: "terminal",
+ image: "/providers/claude.svg",
color: "#D97757",
description: "Anthropic Claude Code CLI — ANTHROPIC_BASE_URL points to OmniRoute",
docsUrl: "https://docs.anthropic.com/en/docs/claude-code/overview",
@@ -71,6 +71,7 @@ export const CLI_TOOLS: Record = {
codex: {
id: "codex",
name: "OpenAI Codex CLI",
+ image: "/providers/codex.svg",
color: "#10A37F",
description: "OpenAI Codex CLI — OpenAI-compatible base URL targets OmniRoute",
docsUrl: "https://github.com/openai/codex",
@@ -98,7 +99,7 @@ export const CLI_TOOLS: Record = {
openclaw: {
id: "openclaw",
name: "Open Claw",
- image: "/providers/openclaw.png",
+ image: "/providers/openclaw.svg",
color: "#FF6B35",
description: "Open Claw — open-source multi-backend agent CLI (OSS, P. Steinberger)",
docsUrl: "/docs?section=cli-tools&tool=openclaw",
@@ -112,7 +113,7 @@ export const CLI_TOOLS: Record = {
cursor: {
id: "cursor",
name: "Cursor",
- image: "/providers/cursor.png",
+ image: "/providers/cursor.svg",
color: "#000000",
// Cursor App routes via its own cloud server — local base URL not supported.
// Use cursor-cli entry for headless/agent CLI mode with custom endpoint.
@@ -144,6 +145,7 @@ export const CLI_TOOLS: Record = {
cline: {
id: "cline",
name: "Cline",
+ image: "/providers/cline.svg",
color: "#00D1B2",
description: "Cline — open-source VS Code coding agent with OpenAI-compatible base URL",
docsUrl: "https://docs.cline.bot/",
@@ -171,7 +173,7 @@ export const CLI_TOOLS: Record = {
continue: {
id: "continue",
name: "Continue",
- image: "/providers/continue.png",
+ image: "/providers/continue.svg",
color: "#7C3AED",
description: "Continue — open-source AI coding assistant with full provider config",
docsUrl: "https://docs.continue.dev/",
@@ -242,7 +244,7 @@ export const CLI_TOOLS: Record = {
copilot: {
id: "copilot",
name: "GitHub Copilot",
- image: "/providers/copilot.png",
+ image: "/providers/copilot.svg",
color: "#1F6FEB",
// D-nota: copilot suporta COPILOT_PROVIDER_BASE_URL desde v1.0.19+
description: "GitHub Copilot Chat — VS Code extension with COPILOT_PROVIDER_BASE_URL support",
@@ -402,7 +404,7 @@ export const CLI_TOOLS: Record = {
qwen: {
id: "qwen",
name: "Qwen Code",
- icon: "psychology",
+ image: "/providers/qwen.svg",
color: "#10B981",
description: "Qwen Code CLI — current V4 OpenAI-compatible model provider via OmniRoute",
docsUrl: "https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/",
@@ -512,7 +514,7 @@ aider --openai-api-base "{{baseUrl}}" --model "{{model}}"`,
"cursor-cli": {
id: "cursor-cli",
name: "Cursor Agent CLI",
- icon: "terminal",
+ image: "/providers/cursor.svg",
color: "#000000",
description: "Cursor Agent CLI — headless agent mode with custom provider endpoint",
docsUrl: "https://docs.cursor.com/advanced/api",
@@ -536,7 +538,7 @@ aider --openai-api-base "{{baseUrl}}" --model "{{model}}"`,
roo: {
id: "roo",
name: "Roo Code",
- icon: "terminal",
+ image: "/providers/roocode.svg",
color: "#7C3AED",
description: "Roo Code AI Assistant — VS Code extension with OpenAI-compatible custom base URL",
docsUrl: "https://docs.roocode.com/",
@@ -579,7 +581,7 @@ aider --openai-api-base "{{baseUrl}}" --model "{{model}}"`,
"deepseek-tui": {
id: "deepseek-tui",
name: "DeepSeek TUI",
- icon: "terminal",
+ image: "/providers/deepseek.svg",
color: "#4F46E5",
description: "DeepSeek TUI — Rust-based coding agent CLI with OPENAI_BASE_URL support",
docsUrl: "https://github.com/hunterbown/deepseek-tui",
diff --git a/src/shared/constants/sidebarVisibility/sections.ts b/src/shared/constants/sidebarVisibility/sections.ts
index 3ec67d3f75..3667842a05 100644
--- a/src/shared/constants/sidebarVisibility/sections.ts
+++ b/src/shared/constants/sidebarVisibility/sections.ts
@@ -59,6 +59,7 @@ const OMNI_PROXY_ITEMS: readonly SidebarItemDefinition[] = [
href: "/dashboard/combos/live",
i18nKey: "combosLive",
labelFallback: "Combo Studio",
+ subtitleKey: "combosLiveSubtitle",
subtitleFallback: "Live routing cascade",
icon: "account_tree",
},
@@ -90,6 +91,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
href: "/dashboard/context/settings",
i18nKey: "contextSettings",
labelFallback: "Compression Settings",
+ subtitleKey: "contextSettingsSubtitle",
subtitleFallback: "Global defaults",
icon: "settings",
},
@@ -119,6 +121,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
href: "/dashboard/context/headroom",
i18nKey: "contextHeadroom",
labelFallback: "Headroom",
+ subtitleKey: "contextHeadroomSubtitle",
subtitleFallback: "Tabular compaction",
icon: "table_rows",
},
@@ -127,6 +130,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
href: "/dashboard/context/session-dedup",
i18nKey: "contextSessionDedup",
labelFallback: "Session Dedup",
+ subtitleKey: "contextSessionDedupSubtitle",
subtitleFallback: "Cross-turn dedup",
icon: "content_copy",
},
@@ -135,6 +139,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
href: "/dashboard/context/ccr",
i18nKey: "contextCcr",
labelFallback: "CCR",
+ subtitleKey: "contextCcrSubtitle",
subtitleFallback: "Retrieve markers",
icon: "archive",
},
@@ -143,6 +148,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
href: "/dashboard/context/llmlingua",
i18nKey: "contextLlmlingua",
labelFallback: "LLMLingua",
+ subtitleKey: "contextLlmlinguaSubtitle",
subtitleFallback: "Semantic pruning",
icon: "psychology",
},
@@ -151,6 +157,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
href: "/dashboard/context/lite",
i18nKey: "contextLite",
labelFallback: "Lite",
+ subtitleKey: "contextLiteSubtitle",
subtitleFallback: "Fast whitespace cleanup",
icon: "compress",
},
@@ -159,6 +166,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
href: "/dashboard/context/aggressive",
i18nKey: "contextAggressive",
labelFallback: "Aggressive",
+ subtitleKey: "contextAggressiveSubtitle",
subtitleFallback: "Summary + aging",
icon: "speed",
},
@@ -167,6 +175,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
href: "/dashboard/context/ultra",
i18nKey: "contextUltra",
labelFallback: "Ultra",
+ subtitleKey: "contextUltraSubtitle",
subtitleFallback: "Heuristic pruning",
icon: "bolt",
},
@@ -175,6 +184,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
href: "/dashboard/context/omniglyph",
i18nKey: "contextOmniglyph",
labelFallback: "OmniGlyph",
+ subtitleKey: "contextOmniglyphSubtitle",
subtitleFallback: "Context-as-image",
icon: "grain",
},
@@ -183,6 +193,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
href: "/dashboard/compression/studio",
i18nKey: "compressionStudio",
labelFallback: "Compression Studio",
+ subtitleKey: "compressionStudioSubtitle",
subtitleFallback: "Live engine cascade",
icon: "monitoring",
},
@@ -520,6 +531,7 @@ const AGENTIC_FEATURES_ITEMS: readonly SidebarSectionChild[] = [
href: "/dashboard/chaos",
i18nKey: "chaosConfig",
labelFallback: "Chaos Mode",
+ subtitleKey: "chaosConfigSubtitle",
subtitleFallback: "Multi-model parallel execution",
icon: "blender",
},
diff --git a/tests/unit/cli-catalog-display-contract.test.ts b/tests/unit/cli-catalog-display-contract.test.ts
new file mode 100644
index 0000000000..70d520b99d
--- /dev/null
+++ b/tests/unit/cli-catalog-display-contract.test.ts
@@ -0,0 +1,35 @@
+import assert from "node:assert/strict";
+import { existsSync } from "node:fs";
+import test from "node:test";
+
+import en from "../../src/i18n/messages/en.json" with { type: "json" };
+import vi from "../../src/i18n/messages/vi.json" with { type: "json" };
+import { CLI_TOOLS } from "../../src/shared/constants/cliTools";
+
+test("every CLI catalog image points to a bundled public asset", () => {
+ const missing = Object.values(CLI_TOOLS).flatMap((tool) =>
+ [tool.image, tool.imageLight, tool.imageDark]
+ .filter((asset): asset is string => Boolean(asset))
+ .filter((asset) => !existsSync(`public${asset}`))
+ .map((asset) => `${tool.id}: ${asset}`)
+ );
+
+ assert.deepEqual(missing, []);
+});
+
+test("every visible CLI catalog entry has English and Vietnamese descriptions", () => {
+ const visibleToolIds = Object.values(CLI_TOOLS)
+ .filter((tool) => tool.baseUrlSupport !== "none")
+ .map((tool) => tool.id);
+ const englishDescriptions = en.cliTools.toolDescriptions as Record;
+ const vietnameseDescriptions = vi.cliTools.toolDescriptions as Record;
+
+ assert.deepEqual(
+ visibleToolIds.filter((id) => !englishDescriptions[id]?.trim()),
+ []
+ );
+ assert.deepEqual(
+ visibleToolIds.filter((id) => !vietnameseDescriptions[id]?.trim()),
+ []
+ );
+});
diff --git a/tests/unit/dashboard-localization-contract.test.ts b/tests/unit/dashboard-localization-contract.test.ts
new file mode 100644
index 0000000000..de8ebf36b8
--- /dev/null
+++ b/tests/unit/dashboard-localization-contract.test.ts
@@ -0,0 +1,199 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+function readSource(path: string): string {
+ return readFileSync(path, "utf8");
+}
+
+test("shared provider playground uses localized visible copy", () => {
+ const source = readSource(
+ "src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx"
+ );
+ for (const rawText of [
+ "Send a message to start the conversation",
+ "Shift+Enter for newline",
+ ">Clear<",
+ 'title="Stop"',
+ ]) {
+ assert.equal(source.includes(rawText), false, `raw playground copy: ${rawText}`);
+ }
+});
+
+test("CLI guide fallback checks key existence before translating", () => {
+ const source = readSource(
+ "src/app/(dashboard)/dashboard/cli-code/components/DefaultToolCard.tsx"
+ );
+ assert.match(source, /if \(!t\.has\(key\)\) return fallback;/);
+});
+
+test("known provider PNGs resolve locally before the external CDN", () => {
+ const source = readSource("src/shared/components/ProviderIcon.tsx");
+ assert.match(source, /"poe-web": "poe"/);
+ assert.match(source, /"opencode-go": "opencode"/);
+ assert.match(source, /"opencode-zen": "opencode"/);
+ assert.ok(source.indexOf("if (hasPng && !pngFailed)") < source.indexOf("if (!theSvgFailed)"));
+});
+
+test("provider onboarding renders provider logos instead of treating catalog ids as glyphs", () => {
+ const source = readSource(
+ "src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx"
+ );
+ assert.match(source, / {
+ const source = readSource("src/shared/components/EmptyState.tsx");
+ assert.match(source, /usesMaterialSymbol/);
+ assert.match(source, /className="material-symbols-outlined"/);
+});
+
+test("compression engine pages localize API-driven labels and normalize icon ids", () => {
+ const source = readSource("src/shared/components/compression/EngineConfigPage.tsx");
+ assert.match(source, /useTranslations\("compressionEngineConfig"\)/);
+ assert.match(source, /brain: "psychology"/);
+ assert.equal(source.includes(">Last 7 days<"), false);
+ assert.equal(source.includes("Turn this layer on/off"), false);
+});
+
+test("budget management does not expose deferred English-only states", () => {
+ const source = readSource("src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx");
+ for (const rawText of [
+ ">Budget<",
+ ">Templates:<",
+ ">Projection<",
+ ">Cost breakdown (30d)<",
+ ">Limits<",
+ ">Daily<",
+ "No keys selected",
+ "Failed to apply template",
+ ]) {
+ assert.equal(source.includes(rawText), false, `raw budget copy: ${rawText}`);
+ }
+});
+
+test("feature-flag descriptions are localized without changing flag values", () => {
+ const card = readSource("src/app/(dashboard)/dashboard/settings/components/FeatureFlagCard.tsx");
+ const messages = JSON.parse(readSource("src/i18n/messages/vi.json"));
+ assert.match(card, /enumValues\.\$\{val\}/);
+ assert.ok(messages.featureFlags.definitions.REQUIRE_API_KEY.description.includes("khóa API"));
+ assert.ok(
+ messages.featureFlags.definitions.OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES.description.includes(
+ "Claude Code"
+ )
+ );
+});
+
+test("analytics charts localize calendar, account, and diversity labels", () => {
+ const sources = [
+ readSource("src/shared/components/analytics/charts.tsx"),
+ readSource("src/shared/components/analytics/rechartsDonuts.tsx"),
+ readSource("src/app/(dashboard)/dashboard/analytics/components/DiversityScoreCard.tsx"),
+ ].join("\n");
+ for (const rawText of [
+ ">Less<",
+ ">More<",
+ ">Most Active Day<",
+ ">By Account<",
+ ">By API Key<",
+ '"Healthy Distribution"',
+ ]) {
+ assert.equal(sources.includes(rawText), false, `raw analytics copy: ${rawText}`);
+ }
+});
+
+test("no-auth provider controls contain no raw English headings", () => {
+ const sources = [
+ readSource("src/shared/components/NoAuthAccountCard.tsx"),
+ readSource("src/shared/components/NoAuthProviderCard.tsx"),
+ ].join("\n");
+ assert.equal(sources.includes(">No authentication required<"), false);
+ assert.equal(sources.includes(">Configure proxy<"), false);
+ assert.equal(sources.includes(">Remove account<"), false);
+});
+
+test("Vietnamese navigation preserves engine and product names", () => {
+ const messages = JSON.parse(readSource("src/i18n/messages/vi.json"));
+ assert.equal(messages.sidebar.contextLite, "Lite");
+ assert.equal(messages.sidebar.contextAggressive, "Aggressive");
+ assert.equal(messages.sidebar.contextHeadroom, "Headroom");
+ assert.equal(messages.sidebar.contextSessionDedup, "Session Dedup");
+ assert.equal(messages.sidebar.compressionStudio, "Compression Studio");
+ assert.equal(messages.sidebar.cliCode, "CLI Code");
+ assert.equal(messages.sidebar.trafficInspector, "Traffic Inspector");
+});
+
+test("CLI cards use packaged brand icons whenever an asset exists", () => {
+ const catalog = readSource("src/shared/constants/cliTools.ts");
+ for (const image of [
+ "/providers/claude.svg",
+ "/providers/codex.svg",
+ "/providers/cline.svg",
+ "/providers/qwen.svg",
+ "/providers/cursor.svg",
+ "/providers/roocode.svg",
+ "/providers/deepseek.svg",
+ ]) {
+ assert.ok(catalog.includes(`image: "${image}"`), `missing CLI icon mapping: ${image}`);
+ }
+
+ const card = readSource("src/shared/components/cli/CliToolCard.tsx");
+ assert.match(card, /tool\.imageDark \|\| tool\.imageLight/);
+ assert.match(card, /tool\.imageLight \|\| tool\.imageDark/);
+});
+
+test("changelog and settings breadcrumbs use localized labels", () => {
+ const changelog = [
+ readSource("src/app/(dashboard)/dashboard/changelog/page.tsx"),
+ readSource("src/app/(dashboard)/dashboard/changelog/components/NewsViewer.tsx"),
+ readSource("src/app/(dashboard)/dashboard/changelog/components/ChangelogViewer.tsx"),
+ ].join("\n");
+ for (const rawText of [
+ 'label: "News"',
+ 'label: "Changelog"',
+ "No new announcements at this time.",
+ "Could not load the changelog.",
+ "View Full History on GitHub",
+ ]) {
+ assert.equal(changelog.includes(rawText), false, `raw changelog copy: ${rawText}`);
+ }
+
+ const breadcrumbs = readSource("src/shared/components/Breadcrumbs.tsx");
+ assert.match(breadcrumbs, /general: "general"/);
+ assert.match(breadcrumbs, /"feature-flags": "featureFlags"/);
+});
+
+test("production audit regressions stay localized and provider icons stay bounded", () => {
+ const costs = readSource("src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx");
+ assert.match(costs, /formatWeekdayLabel\(row\.day, locale\)/);
+ assert.equal(costs.includes("} tokens`"), false);
+
+ const storage = [
+ readSource("src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx"),
+ readSource("src/app/(dashboard)/dashboard/settings/components/DatabaseBackupRetentionCard.tsx"),
+ ].join("\n");
+ for (const rawText of [
+ ">Database Statistics<",
+ "Automatic SQLite backups are stored",
+ ">Keep latest backups<",
+ ">Save retention<",
+ ]) {
+ assert.equal(storage.includes(rawText), false, `raw storage copy: ${rawText}`);
+ }
+
+ const endpoint = readSource("src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx");
+ for (const rawText of [
+ 'label: "Context Sources"',
+ ">Active Endpoints<",
+ ">Running<",
+ ">Tunnels<",
+ ">Not configured<",
+ ]) {
+ assert.equal(endpoint.includes(rawText), false, `raw endpoint copy: ${rawText}`);
+ }
+
+ const security = readSource("src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx");
+ assert.match(security, / {
const source = readSource("src/app/(dashboard)/dashboard/analytics/page.tsx");
assert.ok(source.includes('role="tablist"'));
- assert.ok(source.includes('aria-label="Analytics sections"'));
- for (const label of [
- "Overview",
- "Evals",
- "Search",
- "Utilization",
- "Combo Health",
- "Route Trace",
+ assert.ok(source.includes('aria-label={t("sectionsAria")}'));
+ for (const [labelKey, label] of [
+ ["overview", "Overview"],
+ ["evals", "Evals"],
+ ["search", "Search"],
+ ["utilization", "Utilization"],
+ ["comboHealth", "Combo Health"],
+ ["routeTrace", "Route Trace"],
]) {
+ assert.ok(source.includes('labelKey: "' + labelKey + '"'));
assert.ok(source.includes('label: "' + label + '"'));
}
for (const tabId of [
@@ -36,13 +37,16 @@ test("analytics page exposes the restored analytics tab shell", () => {
test("endpoint page keeps APIs, MCP, and A2A as in-page tabs", () => {
const source = readSource("src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx");
- assert.ok(source.includes('type EndpointTab = "apis" | "mcp" | "a2a"'));
- for (const label of ["APIs", "MCP", "A2A"]) {
- assert.ok(source.includes('label: "' + label + '"'));
+ assert.ok(source.includes('type EndpointTab = "apis" | "mcp" | "a2a" | "context-sources"'));
+ for (const labelKey of ["tabApis", "tabMcp", "tabA2a", "tabContextSources"]) {
+ assert.ok(source.includes('labelKey: "' + labelKey + '"'));
}
+ assert.ok(source.includes("label: t(tab.labelKey)"));
+ assert.ok(source.includes('aria-label={t("endpointSections")}'));
assert.ok(source.includes('useState("apis")'));
assert.ok(source.includes('activeEndpointTab === "mcp" ? : null'));
assert.ok(source.includes('activeEndpointTab === "a2a" ? : null'));
+ assert.ok(source.includes('activeEndpointTab === "context-sources"'));
});
test("endpoint page exposes context-sources tab with Notion and Obsidian source cards", () => {
@@ -53,7 +57,7 @@ test("endpoint page exposes context-sources tab with Notion and Obsidian source
// Verify context-sources tab label is in ENDPOINT_TABS
assert.ok(
- source.includes('{ value: "context-sources", label: "Context Sources", icon: "database" }')
+ source.includes('{ value: "context-sources", labelKey: "tabContextSources", icon: "database" }')
);
// Verify both source card components are imported
diff --git a/tests/unit/gamification-display-contract.test.ts b/tests/unit/gamification-display-contract.test.ts
new file mode 100644
index 0000000000..f5012fff89
--- /dev/null
+++ b/tests/unit/gamification-display-contract.test.ts
@@ -0,0 +1,51 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+import en from "../../src/i18n/messages/en.json" with { type: "json" };
+import vi from "../../src/i18n/messages/vi.json" with { type: "json" };
+import { BUILTIN_BADGES } from "../../src/lib/gamification/badges";
+
+const profileSource = readFileSync("src/app/(dashboard)/dashboard/profile/page.tsx", "utf8");
+const tokensSource = readFileSync("src/app/(dashboard)/dashboard/tokens/page.tsx", "utf8");
+const topListSource = readFileSync(
+ "src/app/(dashboard)/dashboard/costs/components/TopListCard.tsx",
+ "utf8"
+);
+const englishBadges = en.gamification.badges as Record>;
+const vietnameseBadges = vi.gamification.badges as Record>;
+
+test("every built-in badge has complete English and Vietnamese display copy", () => {
+ for (const badge of BUILTIN_BADGES) {
+ for (const field of ["name", "description", "criteria"] as const) {
+ const englishValue = englishBadges[badge.id]?.[field];
+ const vietnameseValue = vietnameseBadges[badge.id]?.[field];
+ assert.ok(englishValue?.trim(), `en missing gamification.badges.${badge.id}.${field}`);
+ assert.ok(vietnameseValue?.trim(), `vi missing gamification.badges.${badge.id}.${field}`);
+ }
+ }
+});
+
+test("profile renders mapped icons and localized badge criteria", () => {
+ assert.match(profileSource, /function BadgeIcon/);
+ assert.match(profileSource, /translateBadge\(selectedBadge, "criteria"\)/);
+ assert.doesNotMatch(profileSource, /\{selectedBadge\.criteria\}<\/p>/);
+});
+
+test("token page no longer contains known raw English controls", () => {
+ for (const rawText of [
+ "Send Tokens",
+ "Create Invite",
+ "Connect Server",
+ "No servers connected",
+ "Last sync:",
+ "Disconnect",
+ ]) {
+ assert.equal(tokensSource.includes(`>${rawText}<`), false, `raw token copy: ${rawText}`);
+ }
+});
+
+test("cost list component receives its localized legacy-free label", () => {
+ assert.match(topListSource, /legacyFreeLabel: string;/);
+ assert.match(topListSource, /\{legacyFreeLabel\}/);
+});
diff --git a/tests/unit/i18n-vi-completeness.test.ts b/tests/unit/i18n-vi-completeness.test.ts
index aa938a533f..837bf15046 100644
--- a/tests/unit/i18n-vi-completeness.test.ts
+++ b/tests/unit/i18n-vi-completeness.test.ts
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
import test from "node:test";
import { parse } from "@formatjs/icu-messageformat-parser";
@@ -81,3 +82,17 @@ test("Vietnamese locale introduces no ICU parse regression", () => {
});
assert.deepEqual(regressions, []);
});
+
+test("no-auth provider controls keep locale translators unambiguous", () => {
+ const source = readFileSync(
+ new URL(
+ "../../src/app/(dashboard)/dashboard/providers/[id]/components/NoAuthProviderControls.tsx",
+ import.meta.url
+ ),
+ "utf8"
+ );
+
+ assert.equal(source.match(/import \{ useTranslations \} from "next-intl";/g)?.length, 1);
+ assert.match(source, /const noAuthT = useTranslations\("noAuthProvider"\);/);
+ assert.match(source, /const t = useTranslations\("providers"\);/);
+});
diff --git a/tests/unit/ui/CompressionHub-patch-only.test.tsx b/tests/unit/ui/CompressionHub-patch-only.test.tsx
index eaa0d2bf68..cfd3fd847d 100644
--- a/tests/unit/ui/CompressionHub-patch-only.test.tsx
+++ b/tests/unit/ui/CompressionHub-patch-only.test.tsx
@@ -17,6 +17,8 @@ import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { NextIntlClientProvider } from "next-intl";
+import messages from "../../../src/i18n/messages/en.json";
// ── Mocks ─────────────────────────────────────────────────────────────────────
@@ -43,9 +45,7 @@ import CompressionHub from "../../../src/app/(dashboard)/dashboard/context/combo
// ── Helpers ───────────────────────────────────────────────────────────────────
function getLastPutBody(): Record | null {
- const putCall = [...fetchCalls].reverse().find(
- (c) => c.init?.method === "PUT"
- );
+ const putCall = [...fetchCalls].reverse().find((c) => c.init?.method === "PUT");
if (!putCall) return null;
return JSON.parse(putCall.init.body as string);
}
@@ -95,13 +95,19 @@ describe("CompressionHub — PUT sends patch only, not full settings", () => {
});
afterEach(() => {
- act(() => { root.unmount(); });
+ act(() => {
+ root.unmount();
+ });
document.body.removeChild(container);
});
it("sends only the changed field when activeComboId is updated", async () => {
await act(async () => {
- root.render( );
+ root.render(
+
+
+
+ );
});
// Find the combo selector and change it to "c1"
@@ -131,7 +137,11 @@ describe("CompressionHub — PUT sends patch only, not full settings", () => {
it("sends only the toggle field when contextEditing is toggled", async () => {
await act(async () => {
- root.render( );
+ root.render(
+
+
+
+ );
});
// Find the context editing toggle button
diff --git a/tests/unit/ui/compression-pipeline-editor.test.tsx b/tests/unit/ui/compression-pipeline-editor.test.tsx
index 7324100dd8..6e5d134d75 100644
--- a/tests/unit/ui/compression-pipeline-editor.test.tsx
+++ b/tests/unit/ui/compression-pipeline-editor.test.tsx
@@ -7,10 +7,11 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { NextIntlClientProvider } from "next-intl";
+import messages from "../../../src/i18n/messages/en.json";
-const { CompressionPipelineEditor } = await import(
- "../../../src/shared/components/compression/CompressionPipelineEditor"
-);
+const { CompressionPipelineEditor } =
+ await import("../../../src/shared/components/compression/CompressionPipelineEditor");
const TABLE = {
rtk: ["standard", "aggressive"],
@@ -34,11 +35,13 @@ afterEach(() => {
function render(steps: { engine: string; intensity?: string }[], onChange: (s: unknown) => void) {
act(() => {
root.render(
- }
- />
+
+ }
+ />
+
);
});
}
@@ -63,7 +66,9 @@ describe("CompressionPipelineEditor (T06)", () => {
render([{ engine: "rtk", intensity: "standard" }], (s) => {
received = s as typeof received;
});
- const addBtn = container.querySelector('[data-testid="pipeline-add-step"]') as HTMLButtonElement;
+ const addBtn = container.querySelector(
+ '[data-testid="pipeline-add-step"]'
+ ) as HTMLButtonElement;
act(() => addBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(received).not.toBeNull();
expect(received!.length).toBe(2);
diff --git a/tests/unit/ui/compressionCockpit.test.tsx b/tests/unit/ui/compressionCockpit.test.tsx
index 0179f63159..fdbf5ca99f 100644
--- a/tests/unit/ui/compressionCockpit.test.tsx
+++ b/tests/unit/ui/compressionCockpit.test.tsx
@@ -2,6 +2,8 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
+import { NextIntlClientProvider } from "next-intl";
+import messages from "../../../src/i18n/messages/en.json";
import type { CompressionRunModel } from "@/app/(dashboard)/dashboard/compression/studio/compressionFlowModel";
// ── Polyfill ResizeObserver (required by ReactFlow) ───────────────────────
@@ -41,7 +43,14 @@ function mount(ui: React.ReactElement): HTMLElement {
containers.push(container);
const root = createRoot(container);
act(() => {
- root.render(ui);
+ root.render(
+
+ {ui}
+
+ );
});
return container;
}
diff --git a/tests/unit/ui/compressionHub-active-selector.test.tsx b/tests/unit/ui/compressionHub-active-selector.test.tsx
index b5705c9336..c189ded9e0 100644
--- a/tests/unit/ui/compressionHub-active-selector.test.tsx
+++ b/tests/unit/ui/compressionHub-active-selector.test.tsx
@@ -2,6 +2,8 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { NextIntlClientProvider } from "next-intl";
+import messages from "../../../src/i18n/messages/en.json";
const containers: HTMLElement[] = [];
const roots: Array<{ unmount: () => void }> = [];
@@ -13,13 +15,19 @@ function mount(ui: React.ReactElement): HTMLElement {
const root = createRoot(container);
roots.push(root);
act(() => {
- root.render(ui);
+ root.render(
+
+ {ui}
+
+ );
});
return container;
}
beforeEach(() => {
- (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ (
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
+ ).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(async () => {
@@ -89,9 +97,8 @@ function setSelectValue(select: HTMLSelectElement, value: string) {
describe("CompressionHub — active-profile selector", () => {
async function render() {
- const { default: CompressionHub } = await import(
- "../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub"
- );
+ const { default: CompressionHub } =
+ await import("../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub");
let container!: HTMLElement;
await act(async () => {
container = mount( );
@@ -103,7 +110,9 @@ describe("CompressionHub — active-profile selector", () => {
it("renders the active-profile select with Default + each named combo", async () => {
setupFetchMock();
const container = await render();
- const select = container.querySelector('[data-testid="active-profile-select"]') as HTMLSelectElement | null;
+ const select = container.querySelector(
+ '[data-testid="active-profile-select"]'
+ ) as HTMLSelectElement | null;
expect(select).toBeTruthy();
expect(container.textContent).toContain("Default (from panel)");
expect(container.textContent).toContain("RTK only");
@@ -112,7 +121,9 @@ describe("CompressionHub — active-profile selector", () => {
it("changing the select to a combo PUTs activeComboId === that id", async () => {
const { puts } = setupFetchMock();
const container = await render();
- const select = container.querySelector('[data-testid="active-profile-select"]') as HTMLSelectElement;
+ const select = container.querySelector(
+ '[data-testid="active-profile-select"]'
+ ) as HTMLSelectElement;
await act(async () => {
setSelectValue(select, "c1");
});
@@ -128,7 +139,9 @@ describe("CompressionHub — active-profile selector", () => {
const preview = () => container.querySelector('[data-testid="active-profile-preview"]');
expect(preview()).toBeTruthy();
expect(preview()!.textContent).toContain("Default");
- const select = container.querySelector('[data-testid="active-profile-select"]') as HTMLSelectElement;
+ const select = container.querySelector(
+ '[data-testid="active-profile-select"]'
+ ) as HTMLSelectElement;
await act(async () => {
setSelectValue(select, "c1");
});
diff --git a/tests/unit/ui/compressionHub-context-editing.test.tsx b/tests/unit/ui/compressionHub-context-editing.test.tsx
index 6abeb667b6..97702d68ae 100644
--- a/tests/unit/ui/compressionHub-context-editing.test.tsx
+++ b/tests/unit/ui/compressionHub-context-editing.test.tsx
@@ -2,6 +2,8 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { NextIntlClientProvider } from "next-intl";
+import messages from "../../../src/i18n/messages/en.json";
// ── Helpers ───────────────────────────────────────────────────────────────
@@ -15,7 +17,11 @@ function mountInContainer(ui: React.ReactElement): HTMLElement {
const root = createRoot(container);
roots.push(root);
act(() => {
- root.render(ui);
+ root.render(
+
+ {ui}
+
+ );
});
return container;
}
@@ -139,9 +145,6 @@ describe("CompressionHub — Context Editing", () => {
});
await flush();
- // CompressionHub deliberately does NOT use useTranslations (see the
- // hydration note at the top of CompressionHub.tsx) — its strings are
- // literal English text, exactly like EngineConfigPage.
const text = container.textContent ?? "";
expect(text).toContain("Provider-delegated compression");
expect(text).toContain("Context Editing (Claude)");
diff --git a/tests/unit/ui/compressionHub.test.tsx b/tests/unit/ui/compressionHub.test.tsx
index 27d380d3f0..20674e153b 100644
--- a/tests/unit/ui/compressionHub.test.tsx
+++ b/tests/unit/ui/compressionHub.test.tsx
@@ -2,6 +2,8 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { NextIntlClientProvider } from "next-intl";
+import messages from "../../../src/i18n/messages/en.json";
// ── Helpers ───────────────────────────────────────────────────────────────
@@ -15,7 +17,11 @@ function mountInContainer(ui: React.ReactElement): HTMLElement {
const root = createRoot(container);
roots.push(root);
act(() => {
- root.render(ui);
+ root.render(
+
+ {ui}
+
+ );
});
return container;
}
@@ -119,58 +125,62 @@ describe("CompressionHub", () => {
// that the master toggle/mode selector/reorder buttons no longer render, is
// covered by compressionHub-active-selector.test.tsx.
- it("INVARIANT #1: no per-layer control issues a PUT/POST to /api/context/combos/default", { timeout: 20000 }, async () => {
- const comboWrites: { method: string }[] = [];
- const json = (body: unknown, status = 200) =>
- new Response(JSON.stringify(body), {
- status,
- headers: { "Content-Type": "application/json" },
- });
- vi.spyOn(globalThis, "fetch").mockImplementation(
- async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = input.toString();
- if (url.includes("/api/context/combos/default")) {
- if (init?.method === "PUT" || init?.method === "POST") {
- comboWrites.push({ method: init.method });
+ it(
+ "INVARIANT #1: no per-layer control issues a PUT/POST to /api/context/combos/default",
+ { timeout: 20000 },
+ async () => {
+ const comboWrites: { method: string }[] = [];
+ const json = (body: unknown, status = 200) =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+ vi.spyOn(globalThis, "fetch").mockImplementation(
+ async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = input.toString();
+ if (url.includes("/api/context/combos/default")) {
+ if (init?.method === "PUT" || init?.method === "POST") {
+ comboWrites.push({ method: init.method });
+ }
+ return json({ id: "default", name: "Default", pipeline: [{ engine: "rtk" }] });
}
- return json({ id: "default", name: "Default", pipeline: [{ engine: "rtk" }] });
+ if (url.includes("/api/settings/compression")) {
+ return json({ enabled: true, defaultMode: "stacked" });
+ }
+ if (url.includes("/api/compression/engines")) {
+ return json(enginePayload());
+ }
+ if (url.includes("/api/context/combos") || url.includes("/api/combos")) {
+ return json({ combos: [] });
+ }
+ if (url.includes("/api/compression/language-packs")) {
+ return json({ packs: [] });
+ }
+ return json({}, 404);
}
- if (url.includes("/api/settings/compression")) {
- return json({ enabled: true, defaultMode: "stacked" });
- }
- if (url.includes("/api/compression/engines")) {
- return json(enginePayload());
- }
- if (url.includes("/api/context/combos") || url.includes("/api/combos")) {
- return json({ combos: [] });
- }
- if (url.includes("/api/compression/language-packs")) {
- return json({ packs: [] });
- }
- return json({}, 404);
- }
- );
+ );
- const { default: CompressionHub } =
- await import("../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub");
+ const { default: CompressionHub } =
+ await import("../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub");
- let container!: HTMLElement;
- await act(async () => {
- container = mountInContainer( );
- });
- await flush();
-
- // Click every on/off switch in the Hub (master + any layer controls that remain).
- const switches = Array.from(container.querySelectorAll('[role="switch"]'));
- for (const sw of switches) {
+ let container!: HTMLElement;
await act(async () => {
- (sw as HTMLElement).click();
+ container = mountInContainer( );
});
await flush();
- }
- expect(comboWrites).toHaveLength(0);
- });
+ // Click every on/off switch in the Hub (master + any layer controls that remain).
+ const switches = Array.from(container.querySelectorAll('[role="switch"]'));
+ for (const sw of switches) {
+ await act(async () => {
+ (sw as HTMLElement).click();
+ });
+ await flush();
+ }
+
+ expect(comboWrites).toHaveLength(0);
+ }
+ );
});
describe("CompressionCombosPageClient", () => {
diff --git a/tests/unit/ui/compressionPanel.test.tsx b/tests/unit/ui/compressionPanel.test.tsx
index ba0a761241..03fedb0994 100644
--- a/tests/unit/ui/compressionPanel.test.tsx
+++ b/tests/unit/ui/compressionPanel.test.tsx
@@ -2,16 +2,14 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
-import {
- ENGINE_IDS,
- engineMeta,
-} from "../../../open-sse/services/compression/engineCatalog.ts";
+import { ENGINE_IDS } from "../../../open-sse/services/compression/engineCatalog.ts";
// i18n does not resolve to a real locale in vitest/jsdom, so mock next-intl to echo
-// the key. This test therefore asserts ONLY on i18n-independent strings: catalog
-// labels/descriptions, engine ids, data-testid hooks, and the PUT request body.
+// the key. This test therefore asserts on translation keys, engine ids,
+// data-testid hooks, and the PUT request body.
vi.mock("next-intl", () => ({
- useTranslations: () => (key: string) => key,
+ useTranslations: () => (key: string, values?: Record) =>
+ values ? `${key} ${Object.values(values).join(" ")}` : key,
useLocale: () => "en",
}));
@@ -125,9 +123,8 @@ function setupFetchMock(): { puts: CapturedPut[] } {
describe("CompressionPanel", () => {
it("renders a row for every engine id in the catalog", async () => {
setupFetchMock();
- const { default: CompressionPanel } = await import(
- "../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
- );
+ const { default: CompressionPanel } =
+ await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel");
let container!: HTMLElement;
await act(async () => {
@@ -138,16 +135,14 @@ describe("CompressionPanel", () => {
for (const id of ENGINE_IDS) {
const row = container.querySelector(`[data-testid="engine-row-${id}"]`);
expect(row, `expected a row for engine "${id}"`).toBeTruthy();
- // Catalog label/description are hardcoded English (i18n-independent).
- expect(container.textContent).toContain(engineMeta(id).label);
+ expect(container.textContent).toContain(`compressionEngine.${id}.label`);
}
});
it("shows the rtk level 'standard' as selected", async () => {
setupFetchMock();
- const { default: CompressionPanel } = await import(
- "../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
- );
+ const { default: CompressionPanel } =
+ await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel");
let container!: HTMLElement;
await act(async () => {
@@ -164,9 +159,8 @@ describe("CompressionPanel", () => {
it("toggling caveman PUTs engines.caveman.enabled === true", async () => {
const { puts } = setupFetchMock();
- const { default: CompressionPanel } = await import(
- "../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
- );
+ const { default: CompressionPanel } =
+ await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel");
let container!: HTMLElement;
await act(async () => {
@@ -202,9 +196,8 @@ describe("CompressionPanel", () => {
it("derived-pipeline preview reflects the enabled engines", async () => {
setupFetchMock();
- const { default: CompressionPanel } = await import(
- "../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
- );
+ const { default: CompressionPanel } =
+ await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel");
let container!: HTMLElement;
await act(async () => {
diff --git a/tests/unit/ui/compressionStylesPanel.test.tsx b/tests/unit/ui/compressionStylesPanel.test.tsx
index 0c4f5904b1..0aa8855268 100644
--- a/tests/unit/ui/compressionStylesPanel.test.tsx
+++ b/tests/unit/ui/compressionStylesPanel.test.tsx
@@ -2,10 +2,7 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
-import {
- OUTPUT_STYLE_IDS,
- outputStyleMeta,
-} from "../../../open-sse/services/compression/outputStyles/catalog.ts";
+import { OUTPUT_STYLE_IDS } from "../../../open-sse/services/compression/outputStyles/catalog.ts";
// Locale is mutable per-test so we can exercise the locale gate (terse-cjk → zh only).
const intl = vi.hoisted(() => ({ locale: "en" }));
@@ -28,8 +25,9 @@ function mount(ui: React.ReactElement): HTMLElement {
}
beforeEach(() => {
- (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
- true;
+ (
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
+ ).IS_REACT_ACT_ENVIRONMENT = true;
intl.locale = "en";
});
@@ -65,7 +63,8 @@ function setupFetchMock() {
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
const method = (init?.method ?? "GET").toUpperCase();
- if (url.includes("/api/settings/compression/mcp-accessibility")) return json({ enabled: true });
+ if (url.includes("/api/settings/compression/mcp-accessibility"))
+ return json({ enabled: true });
if (url.includes("/api/settings/compression")) {
if (method === "PUT") {
const body = JSON.parse(String(init?.body ?? "{}"));
@@ -84,9 +83,8 @@ describe("CompressionPanel output styles", () => {
it("renders one row per catalog style", async () => {
setupFetchMock();
intl.locale = "zh-CN"; // a locale that matches every gated style, so all rows render
- const { default: CompressionPanel } = await import(
- "../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
- );
+ const { default: CompressionPanel } =
+ await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel");
let container!: HTMLElement;
await act(async () => {
container = mount( );
@@ -95,16 +93,15 @@ describe("CompressionPanel output styles", () => {
for (const id of OUTPUT_STYLE_IDS) {
const row = container.querySelector(`[data-testid="output-style-row-${id}"]`);
expect(row, `expected a row for style "${id}"`).toBeTruthy();
- expect(container.textContent).toContain(outputStyleMeta(id).label);
+ expect(row?.textContent).toContain(`compressionOutputStyle.${id}.label`);
}
});
it("locale-gates terse-cjk: hidden under a non-zh locale", async () => {
setupFetchMock();
intl.locale = "en";
- const { default: CompressionPanel } = await import(
- "../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
- );
+ const { default: CompressionPanel } =
+ await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel");
let container!: HTMLElement;
await act(async () => {
container = mount( );
@@ -120,9 +117,8 @@ describe("CompressionPanel output styles", () => {
it("locale-gates terse-cjk: offered under a zh locale (zh-CN base matches)", async () => {
setupFetchMock();
intl.locale = "zh-CN";
- const { default: CompressionPanel } = await import(
- "../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
- );
+ const { default: CompressionPanel } =
+ await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel");
let container!: HTMLElement;
await act(async () => {
container = mount( );
@@ -133,9 +129,8 @@ describe("CompressionPanel output styles", () => {
it("toggling a style PUTs an outputStyles selection", async () => {
const { puts } = setupFetchMock();
- const { default: CompressionPanel } = await import(
- "../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"
- );
+ const { default: CompressionPanel } =
+ await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel");
let container!: HTMLElement;
await act(async () => {
container = mount( );
diff --git a/tests/unit/ui/compressionStylesTile.test.tsx b/tests/unit/ui/compressionStylesTile.test.tsx
index d552c1e76d..675d175986 100644
--- a/tests/unit/ui/compressionStylesTile.test.tsx
+++ b/tests/unit/ui/compressionStylesTile.test.tsx
@@ -3,7 +3,10 @@ import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
-vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
+vi.mock("next-intl", () => ({
+ useTranslations: () => (key: string) => key,
+ useLocale: () => "en",
+}));
const containers: HTMLElement[] = [];
const roots: Array<{ unmount: () => void }> = [];
@@ -19,8 +22,9 @@ function mount(ui: React.ReactElement): HTMLElement {
}
beforeEach(() => {
- (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
- true;
+ (
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
+ ).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(async () => {
@@ -53,9 +57,8 @@ describe("CompressionStylesTile", () => {
{ status: 200, headers: { "Content-Type": "application/json" } }
)
);
- const { default: CompressionStylesTile } = await import(
- "../../../src/app/(dashboard)/dashboard/context/CompressionStylesTile"
- );
+ const { default: CompressionStylesTile } =
+ await import("../../../src/app/(dashboard)/dashboard/context/CompressionStylesTile");
let container!: HTMLElement;
await act(async () => {
container = mount( );