feat(i18n): replace hardcoded strings with translation keys in HomePageClient

Replace hardcoded English text in quick start links and getting started
steps with proper i18n translation keys. Use `t.rich()` for step
descriptions containing inline links to support rich text interpolation.
This commit is contained in:
diegosouzapw
2026-02-26 03:05:30 -03:00
parent 187aba0514
commit 107b9e8cd2
27 changed files with 1497 additions and 869 deletions

View File

@@ -14,6 +14,7 @@ import { useNotificationStore } from "@/store/notificationStore";
export default function HomePageClient({ machineId }) {
const t = useTranslations("home");
const tc = useTranslations("common");
const ts = useTranslations("sidebar");
const [providerConnections, setProviderConnections] = useState([]);
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(true);
@@ -108,11 +109,11 @@ export default function HomePageClient({ machineId }) {
const quickStartLinks = [
{ label: t("documentation"), href: "/docs", icon: "menu_book" },
{ label: tc("provider") + "s", href: "/dashboard/providers", icon: "dns" },
{ label: "Combos", href: "/dashboard/combos", icon: "layers" },
{ label: "Analytics", href: "/dashboard/analytics", icon: "analytics" },
{ label: ts("providers"), href: "/dashboard/providers", icon: "dns" },
{ label: ts("combos"), href: "/dashboard/combos", icon: "layers" },
{ label: ts("analytics"), href: "/dashboard/analytics", icon: "analytics" },
{ label: t("healthMonitor"), href: "/dashboard/health", icon: "health_and_safety" },
{ label: "CLI Tools", href: "/dashboard/cli-tools", icon: "terminal" },
{ label: ts("cliTools"), href: "/dashboard/cli-tools", icon: "terminal" },
{
label: t("reportIssue"),
href: "https://github.com/diegosouzapw/OmniRoute/issues",
@@ -159,11 +160,13 @@ export default function HomePageClient({ machineId }) {
<div>
<span className="font-semibold">{t("step1Title")}</span>
<p className="text-text-muted mt-0.5">
Go to{" "}
<Link href="/dashboard/endpoint" className="text-primary hover:underline">
Endpoint
</Link>{" "}
Registered Keys. Generate one key per environment.
{t.rich("step1Desc", {
endpoint: (chunks) => (
<Link href="/dashboard/endpoint" className="text-primary hover:underline">
{chunks}
</Link>
),
})}
</p>
</div>
</li>
@@ -174,11 +177,13 @@ export default function HomePageClient({ machineId }) {
<div>
<span className="font-semibold">{t("step2Title")}</span>
<p className="text-text-muted mt-0.5">
Add accounts in{" "}
<Link href="/dashboard/providers" className="text-primary hover:underline">
Providers
</Link>
. Supports OAuth, API Key, and free tiers.
{t.rich("step2Desc", {
providers: (chunks) => (
<Link href="/dashboard/providers" className="text-primary hover:underline">
{chunks}
</Link>
),
})}
</p>
</div>
</li>
@@ -188,13 +193,7 @@ export default function HomePageClient({ machineId }) {
</div>
<div>
<span className="font-semibold">{t("step3Title")}</span>
<p className="text-text-muted mt-0.5">
Set base URL to{" "}
<code className="px-1.5 py-0.5 rounded bg-surface text-xs font-mono">
{currentEndpoint}
</code>{" "}
in your IDE or API client.
</p>
<p className="text-text-muted mt-0.5">{t("step3Desc", { url: currentEndpoint })}</p>
</div>
</li>
<li className="rounded-lg border border-border bg-bg-subtle p-4 flex gap-3">
@@ -204,15 +203,18 @@ export default function HomePageClient({ machineId }) {
<div>
<span className="font-semibold">{t("step4Title")}</span>
<p className="text-text-muted mt-0.5">
Track tokens, cost and errors in{" "}
<Link href="/dashboard/usage" className="text-primary hover:underline">
Request Logs
</Link>{" "}
and{" "}
<Link href="/dashboard/analytics" className="text-primary hover:underline">
Analytics
</Link>
.
{t.rich("step4Desc", {
logs: (chunks) => (
<Link href="/dashboard/usage" className="text-primary hover:underline">
{chunks}
</Link>
),
analytics: (chunks) => (
<Link href="/dashboard/analytics" className="text-primary hover:underline">
{chunks}
</Link>
),
})}
</p>
</div>
</li>
@@ -243,20 +245,22 @@ export default function HomePageClient({ machineId }) {
<div>
<h2 className="text-lg font-semibold">{t("providersOverview")}</h2>
<p className="text-sm text-text-muted">
{providerStats.filter((item) => item.total > 0).length} configured of{" "}
{providerStats.length} available providers
{t("configuredOf", {
configured: providerStats.filter((item) => item.total > 0).length,
total: providerStats.length,
})}
</p>
</div>
<div className="flex items-center gap-4">
<div className="hidden sm:flex items-center gap-3 text-[11px] text-text-muted">
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-green-500" /> Free
<span className="size-2 rounded-full bg-green-500" /> {tc("free")}
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-blue-500" /> OAuth
<span className="size-2 rounded-full bg-blue-500" /> {t("oauthLabel")}
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-amber-500" /> API Key
<span className="size-2 rounded-full bg-amber-500" /> {t("apiKeyLabel")}
</span>
</div>
<Link
@@ -264,7 +268,7 @@ export default function HomePageClient({ machineId }) {
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
>
<span className="material-symbols-outlined text-[14px]">settings</span>
Manage
{tc("manage")}
</Link>
</div>
</div>
@@ -299,15 +303,16 @@ HomePageClient.propTypes = {
function ProviderOverviewCard({ item, metrics, onClick }) {
const [imgError, setImgError] = useState(false);
const t = useTranslations("home");
const tc = useTranslations("common");
const statusVariant =
item.errors > 0 ? "text-red-500" : item.connected > 0 ? "text-green-500" : "text-text-muted";
const authTypeConfig = {
free: { color: "bg-green-500", label: "Free" },
oauth: { color: "bg-blue-500", label: "OAuth" },
apikey: { color: "bg-amber-500", label: "API Key" },
free: { color: "bg-green-500", label: tc("free") },
oauth: { color: "bg-blue-500", label: t("oauthLabel") },
apikey: { color: "bg-amber-500", label: t("apiKeyLabel") },
};
const authInfo = authTypeConfig[item.authType] || authTypeConfig.apikey;
@@ -352,13 +357,13 @@ function ProviderOverviewCard({ item, metrics, onClick }) {
<p className={`text-xs ${statusVariant}`}>
{item.total === 0
? tc("notConfigured")
: `${item.connected} active · ${item.errors} error`}
: t("activeError", { active: item.connected, errors: item.errors })}
</p>
{metrics && metrics.totalRequests > 0 && (
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-text-muted">
<span className="text-emerald-500">{metrics.totalSuccesses}</span>/
{metrics.totalRequests} reqs
{t("requestsShort", { count: metrics.totalRequests })}
</span>
<span className="text-[10px] text-text-muted">{metrics.successRate}%</span>
<span className="text-[10px] text-text-muted">~{metrics.avgLatencyMs}ms</span>
@@ -368,7 +373,7 @@ function ProviderOverviewCard({ item, metrics, onClick }) {
<div className="text-right shrink-0">
<p className="text-xs font-medium text-text-main">{item.modelCount}</p>
<p className="text-[10px] text-text-muted">models</p>
<p className="text-[10px] text-text-muted">{tc("models")}</p>
</div>
</div>
</button>
@@ -406,6 +411,7 @@ function ProviderModelsModal({ provider, models, onClose }) {
const router = useRouter();
const t = useTranslations("home");
const tc = useTranslations("common");
const ts = useTranslations("sidebar");
const navigateTo = (path) => {
onClose();
@@ -415,20 +421,29 @@ function ProviderModelsModal({ provider, models, onClose }) {
const handleCopy = (text) => {
navigator.clipboard.writeText(text);
setCopiedModel(text);
notify.success(`Copied: ${text}`);
notify.success(t("copiedModel", { model: text }));
setTimeout(() => setCopiedModel(null), 2000);
};
return (
<Modal isOpen={true} title={`${provider.provider.name} — Models`} onClose={onClose}>
<Modal
isOpen={true}
title={t("providerModelsTitle", { provider: provider.provider.name })}
onClose={onClose}
>
<div className="flex flex-col gap-3">
{/* Summary */}
<div className="flex items-center gap-2 text-sm text-text-muted">
<span className="material-symbols-outlined text-[16px]">token</span>
{models.length} model{models.length !== 1 ? "s" : ""} available
{models.length === 1
? t("modelAvailable", { count: models.length })
: t("modelsAvailable", { count: models.length })}
{provider.total > 0 && (
<span className="ml-auto text-xs text-green-500">
{provider.connected} connection{provider.connected !== 1 ? "s" : ""} active
{" "}
{provider.connected === 1
? t("connectionsActive", { count: provider.connected })
: t("connectionsActivePlural", { count: provider.connected })}
</span>
)}
</div>
@@ -440,13 +455,7 @@ function ProviderModelsModal({ provider, models, onClose }) {
</span>
<p className="text-sm text-text-muted">{t("noModelsAvailable")}</p>
<p className="text-xs text-text-muted mt-1">
Configure a connection first in{" "}
<button
onClick={() => navigateTo("/dashboard/providers")}
className="text-primary hover:underline cursor-pointer"
>
Providers
</button>
{t("configureFirst", { providers: ts("providers") })}
</p>
</div>
) : (
@@ -459,7 +468,9 @@ function ProviderModelsModal({ provider, models, onClose }) {
<div className="min-w-0 flex-1">
<p className="font-mono text-sm text-text-main truncate">{m.fullModel}</p>
{m.alias !== m.model && (
<p className="text-[10px] text-text-muted">alias: {m.alias}</p>
<p className="text-[10px] text-text-muted">
{t("aliasLabel")}: {m.alias}
</p>
)}
</div>
<button
@@ -489,7 +500,7 @@ function ProviderModelsModal({ provider, models, onClose }) {
{t("configureProvider")}
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
Close
{tc("close")}
</Button>
</div>
</div>

View File

@@ -50,11 +50,11 @@ export default function AuditLogPage() {
setHasMore(data.length > PAGE_SIZE);
setEntries(data.slice(0, PAGE_SIZE));
} catch (err: any) {
setError(err.message || "Failed to fetch audit log");
setError(err.message || t("failedFetchAuditLog"));
} finally {
setLoading(false);
}
}, [actionFilter, actorFilter, offset]);
}, [actionFilter, actorFilter, offset, t]);
useEffect(() => {
fetchEntries();
@@ -96,7 +96,7 @@ export default function AuditLogPage() {
<button
onClick={fetchEntries}
disabled={loading}
aria-label="Refresh audit log"
aria-label={t("refreshAuditLogAria")}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] transition-colors disabled:opacity-50"
>
{loading ? tc("loading") : tc("refresh")}
@@ -107,7 +107,7 @@ export default function AuditLogPage() {
<div
className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]"
role="search"
aria-label="Filter audit log entries"
aria-label={t("filterEntriesAria")}
>
<input
type="text"
@@ -115,7 +115,7 @@ export default function AuditLogPage() {
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label="Filter by action type"
aria-label={t("filterByActionTypeAria")}
className="flex-1 min-w-[180px] 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)]"
/>
<input
@@ -124,7 +124,7 @@ export default function AuditLogPage() {
value={actorFilter}
onChange={(e) => setActorFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label="Filter by actor"
aria-label={t("filterByActorAria")}
className="flex-1 min-w-[180px] 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)]"
/>
<button
@@ -147,7 +147,7 @@ export default function AuditLogPage() {
{/* Table */}
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)]">
<table className="w-full text-sm" role="table" aria-label="Audit log entries">
<table className="w-full text-sm" role="table" aria-label={t("tableAria")}>
<thead>
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
@@ -195,13 +195,13 @@ export default function AuditLogPage() {
</td>
<td className="px-4 py-3 text-[var(--color-text-main)]">{entry.actor}</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[200px] truncate">
{entry.target || "—"}
{entry.target || t("notAvailable")}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[300px] truncate font-mono text-xs">
{entry.details ? JSON.stringify(entry.details) : "—"}
{entry.details ? JSON.stringify(entry.details) : t("notAvailable")}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] font-mono text-xs whitespace-nowrap">
{entry.ip_address || "—"}
{entry.ip_address || t("notAvailable")}
</td>
</tr>
))

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { Card, Button, Badge, Modal, Input, ModelSelectModal } from "@/shared/components";
import Image from "next/image";
import { useTranslations } from "next-intl";
export default function AntigravityToolCard({
tool,
@@ -14,6 +15,7 @@ export default function AntigravityToolCard({
hasActiveProviders,
cloudEnabled,
}) {
const t = useTranslations("cliTools");
const [status, setStatus] = useState(null);
const [loading, setLoading] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
@@ -104,12 +106,12 @@ export default function AntigravityToolCard({
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "MITM started" });
setMessage({ type: "success", text: t("mitmStarted") });
setShowPasswordModal(false);
setSudoPassword("");
fetchStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to start" });
setMessage({ type: "error", text: data.error || t("failedStart") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -130,12 +132,12 @@ export default function AntigravityToolCard({
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "MITM stopped" });
setMessage({ type: "success", text: t("mitmStopped") });
setShowPasswordModal(false);
setSudoPassword("");
fetchStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to stop" });
setMessage({ type: "error", text: data.error || t("failedStop") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -146,7 +148,7 @@ export default function AntigravityToolCard({
const handleConfirmPassword = () => {
if (!sudoPassword.trim()) {
setMessage({ type: "error", text: "Sudo password is required" });
setMessage({ type: "error", text: t("sudoPasswordRequiredError") });
return;
}
if (status?.running) {
@@ -190,10 +192,10 @@ export default function AntigravityToolCard({
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to save mappings");
throw new Error(data.error || t("failedSaveMappings"));
}
setMessage({ type: "success", text: "Mappings saved!" });
setMessage({ type: "success", text: t("mappingsSaved") });
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
@@ -225,15 +227,15 @@ export default function AntigravityToolCard({
<h3 className="font-medium text-sm">{tool.name}</h3>
{isRunning ? (
<Badge variant="success" size="sm">
Active
{t("active")}
</Badge>
) : (
<Badge variant="default" size="sm">
Inactive
{t("inactive")}
</Badge>
)}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">{t("toolDescriptions.antigravity")}</p>
</div>
</div>
<span
@@ -254,7 +256,7 @@ export default function AntigravityToolCard({
className="px-4 py-2 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 font-medium text-sm flex items-center gap-2 hover:bg-red-500/20 transition-colors disabled:opacity-50"
>
<span className="material-symbols-outlined text-[18px]">stop_circle</span>
Stop MITM
{t("stopMitm")}
</button>
) : (
<button
@@ -263,7 +265,7 @@ export default function AntigravityToolCard({
className="px-4 py-2 rounded-lg bg-primary/10 border border-primary/30 text-primary font-medium text-sm flex items-center gap-2 hover:bg-primary/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-[18px]">play_circle</span>
Start MITM
{t("startMitm")}
</button>
)}
</div>
@@ -280,7 +282,7 @@ export default function AntigravityToolCard({
<>
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
API Key
{t("apiKey")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -299,9 +301,7 @@ export default function AntigravityToolCard({
</select>
) : (
<span className="flex-1 text-xs text-text-muted px-2 py-1.5">
{cloudEnabled
? "No API keys - Create one in Keys page"
: "sk_omniroute (default)"}
{cloudEnabled ? t("noApiKeysCreateOne") : t("defaultOmnirouteKey")}
</span>
)}
</div>
@@ -318,7 +318,7 @@ export default function AntigravityToolCard({
type="text"
value={modelMappings[model.alias] || ""}
onChange={(e) => handleModelMappingChange(model.alias, e.target.value)}
placeholder="provider/model-id"
placeholder={t("modelPlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
@@ -326,13 +326,13 @@ export default function AntigravityToolCard({
disabled={!hasActiveProviders}
className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select
{t("select")}
</button>
{modelMappings[model.alias] && (
<button
onClick={() => handleModelMappingChange(model.alias, "")}
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
title={t("clear")}
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
@@ -348,7 +348,7 @@ export default function AntigravityToolCard({
disabled={loading || Object.keys(modelMappings).length === 0}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
Save Mappings
{t("saveMappings")}
</Button>
</div>
</>
@@ -358,19 +358,19 @@ export default function AntigravityToolCard({
{!isRunning && (
<div className="flex flex-col gap-1.5 px-1">
<p className="text-xs text-text-muted">
<span className="font-medium text-text-main">How it works:</span> Intercepts
Antigravity traffic via DNS redirect, letting you reroute models through OmniRoute.
<span className="font-medium text-text-main">{t("howItWorks")}</span>{" "}
{t("antigravityHowWorksDesc")}
</p>
<div className="flex flex-col gap-0.5 text-[11px] text-text-muted">
<span>1. Generates SSL cert & adds to system keychain</span>
<span>{t("antigravityStep1")}</span>
<span>
2. Redirects{" "}
{t("antigravityStep2Prefix")}{" "}
<code className="text-[10px] bg-surface px-1 rounded">
daily-cloudcode-pa.googleapis.com
</code>{" "}
localhost
{t("antigravityStep2Suffix")}
</span>
<span>3. Maps Antigravity models to any provider via OmniRoute</span>
<span>{t("antigravityStep3")}</span>
</div>
</div>
)}
@@ -385,20 +385,18 @@ export default function AntigravityToolCard({
setSudoPassword("");
setMessage(null);
}}
title="Sudo Password Required"
title={t("sudoPasswordRequiredTitle")}
size="sm"
>
<div className="flex flex-col gap-4">
<div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<span className="material-symbols-outlined text-yellow-500 text-[20px]">warning</span>
<p className="text-xs text-text-muted">
Required for SSL certificate and DNS configuration
</p>
<p className="text-xs text-text-muted">{t("sudoPasswordHint")}</p>
</div>
<Input
type="password"
placeholder="Enter sudo password"
placeholder={t("enterSudoPassword")}
value={sudoPassword}
onChange={(e) => setSudoPassword(e.target.value)}
onKeyDown={(e) => {
@@ -428,10 +426,10 @@ export default function AntigravityToolCard({
}}
disabled={loading}
>
Cancel
{t("cancel")}
</Button>
<Button variant="primary" size="sm" onClick={handleConfirmPassword} loading={loading}>
Confirm
{t("confirm")}
</Button>
</div>
</div>
@@ -444,7 +442,7 @@ export default function AntigravityToolCard({
onSelect={handleModelSelect}
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
activeProviders={activeProviders}
title={`Select model for ${currentEditingAlias}`}
title={t("selectModelForAlias", { alias: currentEditingAlias || "" })}
/>
</Card>
);

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -21,6 +22,7 @@ export default function ClaudeToolCard({
batchStatus,
lastConfiguredAt,
}) {
const t = useTranslations("cliTools");
const [claudeStatus, setClaudeStatus] = useState(null);
const [checkingClaude, setCheckingClaude] = useState(false);
const [applying, setApplying] = useState(false);
@@ -151,14 +153,14 @@ export default function ClaudeToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
setMessage({ type: "success", text: t("settingsApplied") });
setClaudeStatus((prev) => ({
...prev,
hasBackup: true,
settings: { ...prev?.settings, env },
}));
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
setMessage({ type: "error", text: data.error || t("failedApplySettings") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -174,13 +176,13 @@ export default function ClaudeToolCard({
const res = await fetch("/api/cli-tools/claude-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setMessage({ type: "success", text: t("settingsReset") });
tool.defaultModels.forEach((model) =>
onModelMappingChange(model.alias, model.defaultValue || "")
);
setSelectedApiKey("");
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
setMessage({ type: "error", text: data.error || t("failedResetSettings") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -242,11 +244,11 @@ export default function ClaudeToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Backup restored!" });
setMessage({ type: "success", text: t("backupRestored") });
checkClaudeStatus();
fetchBackups();
} else {
setMessage({ type: "error", text: data.error || "Failed to restore" });
setMessage({ type: "error", text: data.error || t("failedRestore") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -281,7 +283,7 @@ export default function ClaudeToolCard({
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">{t("toolDescriptions.claude")}</p>
</div>
</div>
<span
@@ -296,7 +298,7 @@ export default function ClaudeToolCard({
{checkingClaude && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Claude CLI...</span>
<span>{t("checkingCli", { tool: "Claude" })}</span>
</div>
)}
@@ -307,13 +309,16 @@ export default function ClaudeToolCard({
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">
{claudeStatus.installed
? "Claude CLI not runnable"
: "Claude CLI not installed"}
? t("cliNotRunnable", { tool: "Claude" })
: t("cliNotInstalled", { tool: "Claude" })}
</p>
<p className="text-sm text-text-muted">
{claudeStatus.installed
? `Claude CLI was found but failed runtime healthcheck${claudeStatus.reason ? ` (${claudeStatus.reason})` : ""}.`
: "Please install Claude CLI to use this feature."}
? t("cliFoundFailedHealthcheck", {
tool: "Claude",
reason: claudeStatus.reason ? ` (${claudeStatus.reason})` : "",
})
: t("installCliPrompt", { tool: "Claude" })}
</p>
</div>
<Button
@@ -324,23 +329,23 @@ export default function ClaudeToolCard({
<span className="material-symbols-outlined text-[18px] mr-1">
{showInstallGuide ? "expand_less" : "help"}
</span>
{showInstallGuide ? "Hide" : "How to Install"}
{showInstallGuide ? t("hide") : t("howToInstall")}
</Button>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<h4 className="font-medium mb-3">{t("installationGuide")}</h4>
<div className="space-y-3 text-sm">
<div>
<p className="text-text-muted mb-1">macOS / Linux / Windows:</p>
<p className="text-text-muted mb-1">{t("platforms")}</p>
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">
npm install -g @anthropic-ai/claude-code
</code>
</div>
<p className="text-text-muted">
After installation, run{" "}
<code className="px-1 bg-black/5 dark:bg-white/5 rounded">claude</code> to
verify.
{t("afterInstallationRun")}{" "}
<code className="px-1 bg-black/5 dark:bg-white/5 rounded">claude</code>{" "}
{t("toVerify")}
</p>
</div>
</div>
@@ -355,7 +360,7 @@ export default function ClaudeToolCard({
{claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL && (
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Current
{t("current")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -369,7 +374,7 @@ export default function ClaudeToolCard({
{/* Base URL */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Base URL
{t("baseUrl")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -378,14 +383,14 @@ export default function ClaudeToolCard({
type="text"
value={getDisplayUrl()}
onChange={(e) => setCustomBaseUrl(e.target.value)}
placeholder="https://.../v1"
placeholder={t("baseUrlPlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
{customBaseUrl && customBaseUrl !== baseUrl && (
<button
onClick={() => setCustomBaseUrl("")}
className="p-1 text-text-muted hover:text-primary rounded transition-colors"
title="Reset to default"
title={t("resetToDefault")}
>
<span className="material-symbols-outlined text-[14px]">restart_alt</span>
</button>
@@ -395,7 +400,7 @@ export default function ClaudeToolCard({
{/* API Key */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
API Key
{t("apiKey")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -414,9 +419,7 @@ export default function ClaudeToolCard({
</select>
) : (
<span className="flex-1 text-xs text-text-muted px-2 py-1.5">
{cloudEnabled
? "No API keys - Create one in Keys page"
: "sk_omniroute (default)"}
{cloudEnabled ? t("noApiKeysCreateOne") : t("defaultOmnirouteKey")}
</span>
)}
</div>
@@ -434,7 +437,7 @@ export default function ClaudeToolCard({
type="text"
value={modelMappings[model.alias] || ""}
onChange={(e) => onModelMappingChange(model.alias, e.target.value)}
placeholder="provider/model-id"
placeholder={t("providerModelPlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
@@ -442,13 +445,13 @@ export default function ClaudeToolCard({
disabled={!hasActiveProviders}
className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select Model
{t("selectModel")}
</button>
{modelMappings[model.alias] && (
<button
onClick={() => onModelMappingChange(model.alias, "")}
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
title={t("clear")}
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
@@ -476,7 +479,8 @@ export default function ClaudeToolCard({
disabled={!hasActiveProviders}
loading={applying}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
{t("apply")}
</Button>
<Button
variant="outline"
@@ -485,11 +489,12 @@ export default function ClaudeToolCard({
disabled={!claudeStatus?.hasOmniRoute}
loading={restoring}
>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>
{t("reset")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>
Manual Config
{t("manualConfig")}
</Button>
<div className="flex-1" />
<Button
@@ -500,7 +505,8 @@ export default function ClaudeToolCard({
if (!showBackups) fetchBackups();
}}
>
<span className="material-symbols-outlined text-[14px] mr-1">history</span>Backups
<span className="material-symbols-outlined text-[14px] mr-1">history</span>
{t("backups")}
{backups.length > 0 && ` (${backups.length})`}
</Button>
</div>
@@ -510,12 +516,10 @@ export default function ClaudeToolCard({
<div className="mt-2 p-3 bg-surface border border-border rounded-lg">
<h4 className="text-xs font-semibold text-text-main mb-2 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">history</span>
Config Backups
{t("configBackups")}
</h4>
{backups.length === 0 ? (
<p className="text-xs text-text-muted">
No backups yet. Backups are created automatically before each Apply or Reset.
</p>
<p className="text-xs text-text-muted">{t("noBackupsYet")}</p>
) : (
<div className="space-y-1.5">
{backups.map((b) => (
@@ -537,7 +541,7 @@ export default function ClaudeToolCard({
disabled={restoringBackup === b.id}
className="px-2 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
>
{restoringBackup === b.id ? "..." : "Restore"}
{restoringBackup === b.id ? "..." : t("restore")}
</button>
</div>
))}
@@ -557,13 +561,13 @@ export default function ClaudeToolCard({
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
activeProviders={activeProviders}
modelAliases={modelAliases}
title={`Select model for ${currentEditingAlias}`}
title={t("selectModelForAlias", { alias: currentEditingAlias || "" })}
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Claude CLI - Manual Configuration"
title={t("claudeManualConfiguration")}
configs={getManualConfigs()}
/>
</Card>

View File

@@ -1,4 +1,5 @@
"use client";
import { useLocale, useTranslations } from "next-intl";
/**
* Shared status badge for CLI tool cards.
@@ -7,28 +8,31 @@
* Optionally shows last-configured relative timestamp.
*/
function formatRelativeTime(isoDate: string): string {
function formatRelativeTime(
isoDate: string,
t: (key: string, values?: Record<string, unknown>) => string
): string {
const now = Date.now();
const then = new Date(isoDate).getTime();
const diffMs = now - then;
if (diffMs < 0) return "just now";
if (diffMs < 0) return t("justNow");
const seconds = Math.floor(diffMs / 1000);
if (seconds < 60) return "just now";
if (seconds < 60) return t("justNow");
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
if (minutes < 60) return t("minutesAgoShort", { count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
if (hours < 24) return t("hoursAgoShort", { count: hours });
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
if (days < 30) return t("daysAgoShort", { count: days });
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
if (months < 12) return t("monthsAgoShort", { count: months });
return `${Math.floor(months / 12)}y ago`;
return t("yearsAgoShort", { count: Math.floor(months / 12) });
}
export default function CliStatusBadge({
@@ -36,6 +40,8 @@ export default function CliStatusBadge({
batchStatus,
lastConfiguredAt = null,
}) {
const t = useTranslations("cliTools");
const locale = useLocale();
// Determine badge from effectiveConfigStatus or batchStatus
const status = effectiveConfigStatus || batchStatus?.configStatus || null;
@@ -43,27 +49,27 @@ export default function CliStatusBadge({
configured: {
dotClass: "bg-green-500",
badgeClass: "bg-green-500/10 text-green-600 dark:text-green-400",
text: "Configured",
text: t("configured"),
},
not_configured: {
dotClass: "bg-yellow-500",
badgeClass: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
text: "Not configured",
text: t("notConfigured"),
},
not_installed: {
dotClass: "bg-zinc-400 dark:bg-zinc-500",
badgeClass: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400",
text: "Not installed",
text: t("notInstalled"),
},
other: {
dotClass: "bg-blue-500",
badgeClass: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
text: "Custom",
text: t("custom"),
},
unknown: {
dotClass: "bg-zinc-400 dark:bg-zinc-500",
badgeClass: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400",
text: "Unknown",
text: t("unknown"),
},
};
@@ -82,15 +88,15 @@ export default function CliStatusBadge({
{lastConfiguredAt ? (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] text-text-muted"
title={`Last saved: ${new Date(lastConfiguredAt).toLocaleString()}`}
title={t("lastSavedAt", { date: new Date(lastConfiguredAt).toLocaleString(locale) })}
>
<span className="material-symbols-outlined text-[12px]">schedule</span>
{formatRelativeTime(lastConfiguredAt)}
{formatRelativeTime(lastConfiguredAt, t)}
</span>
) : status && status !== "not_installed" ? (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] text-text-muted">
<span className="material-symbols-outlined text-[12px]">schedule</span>
Never
{t("never")}
</span>
) : null}
</>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -19,6 +20,7 @@ export default function ClineToolCard({
batchStatus,
lastConfiguredAt,
}) {
const t = useTranslations("cliTools");
const [clineStatus, setClineStatus] = useState(null);
const [checkingCline, setCheckingCline] = useState(false);
const [applying, setApplying] = useState(false);
@@ -109,12 +111,12 @@ export default function ClineToolCard({
body: JSON.stringify({ tool: "cline", backupId }),
});
if (res.ok) {
setMessage({ type: "success", text: "Backup restored! Reloading status..." });
setMessage({ type: "success", text: t("backupRestoredReloading") });
await checkClineStatus();
await fetchBackups();
} else {
const data = await res.json();
setMessage({ type: "error", text: data.error || "Failed to restore backup" });
setMessage({ type: "error", text: data.error || t("failedRestoreBackup") });
}
} catch (e) {
setMessage({ type: "error", text: e.message });
@@ -161,11 +163,11 @@ export default function ClineToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: data.message || "Applied!" });
setMessage({ type: "success", text: data.message || t("applied") });
await checkClineStatus();
await fetchBackups();
} else {
setMessage({ type: "error", text: data.error || "Failed" });
setMessage({ type: "error", text: data.error || t("failed") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -181,13 +183,13 @@ export default function ClineToolCard({
const res = await fetch("/api/cli-tools/cline-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: data.message || "Reset!" });
setMessage({ type: "success", text: data.message || t("resetDone") });
setSelectedModel("");
hasInitializedModel.current = false;
await checkClineStatus();
await fetchBackups();
} else {
setMessage({ type: "error", text: data.error || "Failed" });
setMessage({ type: "error", text: data.error || t("failed") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -240,7 +242,7 @@ export default function ClineToolCard({
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">{t("toolDescriptions.cline")}</p>
</div>
</div>
<span
@@ -257,7 +259,7 @@ export default function ClineToolCard({
<span className="material-symbols-outlined animate-spin text-base">
progress_activity
</span>
<span>Checking Cline CLI...</span>
<span>{t("checkingCli", { tool: "Cline" })}</span>
</div>
)}
@@ -273,14 +275,14 @@ export default function ClineToolCard({
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">
{cliReady
? "Cline CLI detected and ready"
? t("cliDetectedReady", { tool: "Cline" })
: clineStatus.installed
? "Cline CLI installed but not runnable"
: "Cline CLI not detected"}
? t("cliNotRunnable", { tool: "Cline" })
: t("cliNotDetected", { tool: "Cline" })}
</p>
{clineStatus.commandPath && (
<p className="text-xs text-text-muted">
Binary:{" "}
{t("binary")}:{" "}
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
{clineStatus.commandPath}
</code>
@@ -288,7 +290,7 @@ export default function ClineToolCard({
)}
{clineStatus.globalStatePath && (
<p className="text-xs text-text-muted">
Config:{" "}
{t("configPathShort")}:{" "}
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
{clineStatus.globalStatePath}
</code>
@@ -307,10 +309,10 @@ export default function ClineToolCard({
</span>
<div className="flex flex-col gap-1">
<p className="text-sm text-green-700 dark:text-green-300">
OmniRoute is configured as OpenAI-compatible provider
{t("omnirouteConfiguredOpenAiCompatible")}
</p>
<p className="text-xs text-text-muted">
Provider: <strong>openai</strong> Model:{" "}
{t("provider")}: <strong>openai</strong> {t("model")}:{" "}
<strong>{clineStatus.settings?.openAiModelId || "—"}</strong>
</p>
</div>
@@ -319,13 +321,13 @@ export default function ClineToolCard({
{/* Model selection */}
<div className="flex flex-col gap-2">
<label className="text-sm text-text-muted">Model</label>
<label className="text-sm text-text-muted">{t("model")}</label>
<div className="flex items-center gap-2">
<input
type="text"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
placeholder="provider/model-id"
placeholder={t("providerModelPlaceholder")}
className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<Button
@@ -334,7 +336,7 @@ export default function ClineToolCard({
onClick={() => setModalOpen(true)}
disabled={!hasActiveProviders}
>
Select
{t("select")}
</Button>
<Button
variant="ghost"
@@ -348,7 +350,7 @@ export default function ClineToolCard({
{/* API Key selection */}
<div className="flex flex-col gap-2">
<label className="text-sm text-text-muted">API Key</label>
<label className="text-sm text-text-muted">{t("apiKey")}</label>
{apiKeys && apiKeys.length > 0 ? (
<select
value={selectedApiKey}
@@ -363,7 +365,7 @@ export default function ClineToolCard({
</select>
) : (
<p className="text-sm text-text-muted">
{cloudEnabled ? "No API keys available" : "Using default: sk_omniroute"}
{cloudEnabled ? t("noApiKeysAvailable") : t("usingDefaultOmniroute")}
</p>
)}
</div>
@@ -378,14 +380,14 @@ export default function ClineToolCard({
loading={applying}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
{configStatus === "configured" ? "Update Config" : "Apply Config"}
{configStatus === "configured" ? t("updateConfig") : t("applyConfig")}
</Button>
{configStatus === "configured" && (
<Button variant="outline" size="sm" onClick={handleReset} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">
restart_alt
</span>
Reset
{t("reset")}
</Button>
)}
</div>
@@ -414,7 +416,7 @@ export default function ClineToolCard({
chevron_right
</span>
<span className="material-symbols-outlined text-[16px]">backup</span>
Backups {backups.length > 0 && `(${backups.length})`}
{t("backups")} {backups.length > 0 && `(${backups.length})`}
</button>
{showBackups && backups.length > 0 && (
<div className="mt-2 flex flex-col gap-1.5 pl-6">
@@ -435,14 +437,14 @@ export default function ClineToolCard({
onClick={() => handleRestoreBackup(b.id)}
loading={restoringBackup === b.id}
>
Restore
{t("restore")}
</Button>
</div>
))}
</div>
)}
{showBackups && backups.length === 0 && (
<p className="mt-2 pl-6 text-xs text-text-muted">No backups available.</p>
<p className="mt-2 pl-6 text-xs text-text-muted">{t("noBackupsAvailable")}</p>
)}
</div>
</>
@@ -458,13 +460,13 @@ export default function ClineToolCard({
onSelect={handleSelectModel}
selectedModel={selectedModel}
activeProviders={activeProviders}
title="Select Model for Cline"
title={t("selectModelForTool", { tool: "Cline" })}
/>
{showManualConfigModal && (
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Cline Manual Configuration"
title={t("clineManualConfiguration")}
{...({
onApply: handleManualConfig,
currentConfig: {

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
export default function CodexToolCard({
tool,
@@ -16,6 +17,7 @@ export default function CodexToolCard({
batchStatus,
lastConfiguredAt,
}) {
const t = useTranslations("cliTools");
const [codexStatus, setCodexStatus] = useState(null);
const [checkingCodex, setCheckingCodex] = useState(false);
const [applying, setApplying] = useState(false);
@@ -132,10 +134,10 @@ export default function CodexToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
setMessage({ type: "success", text: t("settingsApplied") });
checkCodexStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
setMessage({ type: "error", text: data.error || t("failedApplySettings") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -151,11 +153,11 @@ export default function CodexToolCard({
const res = await fetch("/api/cli-tools/codex-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setMessage({ type: "success", text: t("settingsReset") });
setSelectedModel("");
checkCodexStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
setMessage({ type: "error", text: data.error || t("failedResetSettings") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -192,11 +194,11 @@ export default function CodexToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: `Profile "${newProfileName}" saved!` });
setMessage({ type: "success", text: t("profileSaved", { name: newProfileName }) });
setNewProfileName("");
fetchProfiles();
} else {
setMessage({ type: "error", text: data.error || "Failed to save profile" });
setMessage({ type: "error", text: data.error || t("failedSaveProfile") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -216,11 +218,11 @@ export default function CodexToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: data.message || "Profile activated!" });
setMessage({ type: "success", text: data.message || t("profileActivated") });
checkCodexStatus();
fetchBackups();
} else {
setMessage({ type: "error", text: data.error || "Failed to activate profile" });
setMessage({ type: "error", text: data.error || t("failedActivateProfile") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -264,11 +266,11 @@ export default function CodexToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Backup restored!" });
setMessage({ type: "success", text: t("backupRestored") });
checkCodexStatus();
fetchBackups();
} else {
setMessage({ type: "error", text: data.error || "Failed to restore" });
setMessage({ type: "error", text: data.error || t("failedRestore") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -341,7 +343,7 @@ wire_api = "responses"
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">{t("toolDescriptions.codex")}</p>
</div>
</div>
<span
@@ -356,7 +358,7 @@ wire_api = "responses"
{checkingCodex && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Codex CLI...</span>
<span>{t("checkingCli", { tool: "Codex" })}</span>
</div>
)}
@@ -366,12 +368,17 @@ wire_api = "responses"
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">
{codexStatus.installed ? "Codex CLI not runnable" : "Codex CLI not installed"}
{codexStatus.installed
? t("cliNotRunnable", { tool: "Codex" })
: t("cliNotInstalled", { tool: "Codex" })}
</p>
<p className="text-sm text-text-muted">
{codexStatus.installed
? `Codex CLI was found but failed runtime healthcheck${codexStatus.reason ? ` (${codexStatus.reason})` : ""}.`
: "Please install Codex CLI to use auto-apply feature."}
? t("cliFoundFailedHealthcheck", {
tool: "Codex",
reason: codexStatus.reason ? ` (${codexStatus.reason})` : "",
})
: t("installCodexPrompt")}
</p>
</div>
<Button
@@ -382,35 +389,35 @@ wire_api = "responses"
<span className="material-symbols-outlined text-[18px] mr-1">
{showInstallGuide ? "expand_less" : "help"}
</span>
{showInstallGuide ? "Hide" : "How to Install"}
{showInstallGuide ? t("hide") : t("howToInstall")}
</Button>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<h4 className="font-medium mb-3">{t("installationGuide")}</h4>
<div className="space-y-3 text-sm">
<div>
<p className="text-text-muted mb-1">macOS / Linux / Windows:</p>
<p className="text-text-muted mb-1">{t("platforms")}</p>
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">
npm install -g @openai/codex
</code>
</div>
<p className="text-text-muted">
After installation, run{" "}
<code className="px-1 bg-black/5 dark:bg-white/5 rounded">codex</code> to
verify.
{t("afterInstallationRun")}{" "}
<code className="px-1 bg-black/5 dark:bg-white/5 rounded">codex</code>{" "}
{t("toVerify")}
</p>
<div className="pt-2 border-t border-border">
<p className="text-text-muted text-xs">
Codex uses{" "}
{t("codexAuthNotePrefix")}{" "}
<code className="px-1 bg-black/5 dark:bg-white/5 rounded">
~/.codex/auth.json
</code>{" "}
with{" "}
{t("codexAuthNoteMiddle")}{" "}
<code className="px-1 bg-black/5 dark:bg-white/5 rounded">
OPENAI_API_KEY
</code>
. Click &quot;Apply&quot; to auto-configure.
. {t("codexAuthNoteSuffix")}
</p>
</div>
</div>
@@ -430,7 +437,7 @@ wire_api = "responses"
return currentBaseUrl ? (
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Current
{t("current")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -445,7 +452,7 @@ wire_api = "responses"
{/* Base URL */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Base URL
{t("baseUrl")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -454,14 +461,14 @@ wire_api = "responses"
type="text"
value={getDisplayUrl()}
onChange={(e) => setCustomBaseUrl(e.target.value)}
placeholder="https://.../v1"
placeholder={t("baseUrlPlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
{customBaseUrl && customBaseUrl !== `${baseUrl}/v1` && (
<button
onClick={() => setCustomBaseUrl("")}
className="p-1 text-text-muted hover:text-primary rounded transition-colors"
title="Reset to default"
title={t("resetToDefault")}
>
<span className="material-symbols-outlined text-[14px]">restart_alt</span>
</button>
@@ -471,7 +478,7 @@ wire_api = "responses"
{/* API Key */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
API Key
{t("apiKey")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -490,9 +497,7 @@ wire_api = "responses"
</select>
) : (
<span className="flex-1 text-xs text-text-muted px-2 py-1.5">
{cloudEnabled
? "No API keys - Create one in Keys page"
: "sk_omniroute (default)"}
{cloudEnabled ? t("noApiKeysCreateOne") : t("defaultOmnirouteKey")}
</span>
)}
</div>
@@ -500,7 +505,7 @@ wire_api = "responses"
{/* Model */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Model
{t("model")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -509,7 +514,7 @@ wire_api = "responses"
type="text"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
placeholder="provider/model-id"
placeholder={t("providerModelPlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
@@ -517,13 +522,13 @@ wire_api = "responses"
disabled={!activeProviders?.length}
className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select Model
{t("selectModel")}
</button>
{selectedModel && (
<button
onClick={() => setSelectedModel("")}
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
title={t("clear")}
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
@@ -550,7 +555,8 @@ wire_api = "responses"
disabled={!selectedApiKey || !selectedModel}
loading={applying}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
{t("apply")}
</Button>
<Button
variant="outline"
@@ -559,11 +565,12 @@ wire_api = "responses"
disabled={!codexStatus.hasOmniRoute}
loading={restoring}
>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>
{t("reset")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>
Manual Config
{t("manualConfig")}
</Button>
<div className="flex-1" />
<Button
@@ -577,7 +584,7 @@ wire_api = "responses"
<span className="material-symbols-outlined text-[14px] mr-1">
manage_accounts
</span>
Profiles
{t("profiles")}
</Button>
<Button
variant="ghost"
@@ -587,7 +594,8 @@ wire_api = "responses"
if (!showBackups) fetchBackups();
}}
>
<span className="material-symbols-outlined text-[14px] mr-1">history</span>Backups
<span className="material-symbols-outlined text-[14px] mr-1">history</span>
{t("backups")}
{backups.length > 0 && ` (${backups.length})`}
</Button>
</div>
@@ -597,12 +605,10 @@ wire_api = "responses"
<div className="mt-2 p-3 bg-surface border border-border rounded-lg">
<h4 className="text-xs font-semibold text-text-main mb-2 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">manage_accounts</span>
Saved Profiles
{t("savedProfiles")}
</h4>
{profiles.length === 0 ? (
<p className="text-xs text-text-muted">
No profiles saved yet. Save current config as a profile below.
</p>
<p className="text-xs text-text-muted">{t("noProfilesYet")}</p>
) : (
<div className="space-y-1.5 mb-3">
{profiles.map((p) => (
@@ -625,12 +631,12 @@ wire_api = "responses"
disabled={activatingProfile === p.id}
className="px-2 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
>
{activatingProfile === p.id ? "..." : "Activate"}
{activatingProfile === p.id ? "..." : t("activate")}
</button>
<button
onClick={() => handleDeleteProfile(p.id)}
className="p-0.5 text-text-muted hover:text-red-500 transition-colors"
title="Delete profile"
title={t("deleteProfile")}
>
<span className="material-symbols-outlined text-[14px]">delete</span>
</button>
@@ -643,7 +649,7 @@ wire_api = "responses"
type="text"
value={newProfileName}
onChange={(e) => setNewProfileName(e.target.value)}
placeholder="Profile name (e.g. Personal Account)"
placeholder={t("profileNamePlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
onKeyDown={(e) => e.key === "Enter" && handleSaveProfile()}
/>
@@ -654,8 +660,8 @@ wire_api = "responses"
disabled={!newProfileName.trim()}
loading={savingProfile}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Save
Current
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
{t("saveCurrent")}
</Button>
</div>
</div>
@@ -666,12 +672,10 @@ wire_api = "responses"
<div className="mt-2 p-3 bg-surface border border-border rounded-lg">
<h4 className="text-xs font-semibold text-text-main mb-2 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">history</span>
Config Backups
{t("configBackups")}
</h4>
{backups.length === 0 ? (
<p className="text-xs text-text-muted">
No backups yet. Backups are created automatically before each Apply or Reset.
</p>
<p className="text-xs text-text-muted">{t("noBackupsYet")}</p>
) : (
<div className="space-y-1.5">
{backups.map((b) => (
@@ -693,7 +697,7 @@ wire_api = "responses"
disabled={restoringBackup === b.id}
className="px-2 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
>
{restoringBackup === b.id ? "..." : "Restore"}
{restoringBackup === b.id ? "..." : t("restore")}
</button>
</div>
))}
@@ -713,13 +717,13 @@ wire_api = "responses"
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Codex"
title={t("selectModelForTool", { tool: "Codex" })}
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Codex CLI - Manual Configuration"
title={t("codexManualConfiguration")}
configs={getManualConfigs()}
/>
</Card>

View File

@@ -3,6 +3,7 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { Card, Button, ModelSelectModal } from "@/shared/components";
import Image from "next/image";
import { useTranslations } from "next-intl";
export default function DefaultToolCard({
toolId,
@@ -15,6 +16,17 @@ export default function DefaultToolCard({
cloudEnabled = false,
batchStatus,
}) {
const t = useTranslations("cliTools");
const translateOrFallback = useCallback(
(key, fallback, values) => {
try {
return t(key, values);
} catch {
return fallback;
}
},
[t]
);
const [copiedField, setCopiedField] = useState(null);
const [showModelModal, setShowModelModal] = useState(false);
const [modelValue, setModelValue] = useState("");
@@ -65,7 +77,7 @@ export default function DefaultToolCard({
fetch(`/api/cli-tools/runtime/${toolId}`)
.then((res) => res.json())
.then((data) => setRuntimeStatus(data))
.catch((error) => setRuntimeStatus({ error: error?.message || "runtime_check_failed" }));
.catch((error) => setRuntimeStatus({ error: error?.message || t("runtimeCheckFailed") }));
}, [isExpanded, runtimeStatus, toolId]);
const replaceVars = (text) => {
@@ -74,7 +86,7 @@ export default function DefaultToolCard({
? selectedApiKey
: !cloudEnabled
? "sk_omniroute"
: "your-api-key";
: t("yourApiKeyPlaceholder");
const normalizedBaseUrl = baseUrl || "http://localhost:20128";
const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1")
@@ -84,7 +96,7 @@ export default function DefaultToolCard({
return text
.replace(/\{\{baseUrl\}\}/g, baseUrlWithV1)
.replace(/\{\{apiKey\}\}/g, keyToUse)
.replace(/\{\{model\}\}/g, modelValue || "provider/model-id");
.replace(/\{\{model\}\}/g, modelValue || t("modelPlaceholder"));
};
const handleCopy = async (text, field) => {
@@ -128,9 +140,9 @@ export default function DefaultToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: data.message || "Configuration saved!" });
setMessage({ type: "success", text: data.message || t("configurationSaved") });
} else {
setMessage({ type: "error", text: data.error || "Failed to save" });
setMessage({ type: "error", text: data.error || t("failedToSave") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -169,7 +181,7 @@ export default function DefaultToolCard({
</>
) : (
<span className="text-sm text-text-muted">
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_omniroute"}
{cloudEnabled ? t("noApiKeysCreateOne") : "sk_omniroute"}
</span>
)}
</div>
@@ -183,7 +195,7 @@ export default function DefaultToolCard({
type="text"
value={modelValue}
onChange={(e) => handleModelChange(e.target.value)}
placeholder="provider/model-id"
placeholder={t("modelPlaceholder")}
className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
@@ -195,7 +207,7 @@ export default function DefaultToolCard({
: "opacity-50 cursor-not-allowed border-border"
}`}
>
Select Model
{t("selectModel")}
</button>
{modelValue && (
<>
@@ -210,7 +222,7 @@ export default function DefaultToolCard({
<button
onClick={() => handleModelChange("")}
className="p-2 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
title={t("clear")}
>
<span className="material-symbols-outlined text-lg">close</span>
</button>
@@ -251,7 +263,9 @@ export default function DefaultToolCard({
return (
<div key={index} className={`flex items-start gap-3 p-3 rounded-lg border ${bgClass}`}>
<span className={`material-symbols-outlined text-lg ${iconClass}`}>{icon}</span>
<p className={`text-sm ${textClass}`}>{note.text}</p>
<p className={`text-sm ${textClass}`}>
{translateOrFallback(`guides.${toolId}.notes.${index}`, note.text)}
</p>
</div>
);
})}
@@ -265,7 +279,7 @@ export default function DefaultToolCard({
};
const renderGuideSteps = () => {
if (!tool.guideSteps) return <p className="text-text-muted text-sm">Coming soon...</p>;
if (!tool.guideSteps) return <p className="text-text-muted text-sm">{t("comingSoon")}</p>;
return (
<div className="flex flex-col gap-4">
@@ -274,7 +288,7 @@ export default function DefaultToolCard({
<span className="material-symbols-outlined animate-spin text-base">
progress_activity
</span>
<span>Checking runtime...</span>
<span>{t("checkingRuntime")}</span>
</div>
)}
{!checkingRuntime && runtimeStatus && !runtimeStatus.error && (
@@ -289,16 +303,18 @@ export default function DefaultToolCard({
<div className="flex flex-col gap-1">
<p className="text-sm text-blue-700 dark:text-blue-300">
{runtimeStatus.reason === "not_required"
? "Guide-only integration: no local binary required"
? t("guideOnlyIntegration")
: runtimeStatus.installed && runtimeStatus.runnable
? "CLI runtime detected and healthy"
? t("cliRuntimeDetected")
: runtimeStatus.installed
? `CLI found but not runnable${runtimeStatus.reason ? ` (${runtimeStatus.reason})` : ""}`
: "CLI runtime not detected"}
? t("cliFoundNotRunnable", {
reason: runtimeStatus.reason ? `: ${runtimeStatus.reason}` : "",
})
: t("cliRuntimeNotDetected")}
</p>
{runtimeStatus.commandPath && (
<p className="text-xs text-text-muted">
Binary:{" "}
{t("binary")}:{" "}
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
{runtimeStatus.commandPath}
</code>
@@ -306,7 +322,7 @@ export default function DefaultToolCard({
)}
{runtimeStatus.configPath && (
<p className="text-xs text-text-muted">
Config path:{" "}
{t("configPath")}:{" "}
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
{runtimeStatus.configPath}
</code>
@@ -319,7 +335,7 @@ export default function DefaultToolCard({
<div className="flex items-start gap-3 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<span className="material-symbols-outlined text-red-500 text-lg">error</span>
<p className="text-sm text-red-600 dark:text-red-400">
Failed to check runtime status.
{t("failedCheckRuntimeStatus")}
</p>
</div>
)}
@@ -334,8 +350,14 @@ export default function DefaultToolCard({
{item.step}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-text">{item.title}</p>
{item.desc && <p className="text-sm text-text-muted mt-0.5">{item.desc}</p>}
<p className="font-medium text-text">
{translateOrFallback(`guides.${toolId}.steps.${item.step}.title`, item.title)}
</p>
{item.desc && (
<p className="text-sm text-text-muted mt-0.5">
{translateOrFallback(`guides.${toolId}.steps.${item.step}.desc`, item.desc)}
</p>
)}
{item.type === "apiKeySelector" && renderApiKeySelector()}
{item.type === "modelSelector" && renderModelSelector()}
{item.value && (
@@ -372,7 +394,7 @@ export default function DefaultToolCard({
<span className="material-symbols-outlined text-sm">
{copiedField === "codeblock" ? "check" : "content_copy"}
</span>
{copiedField === "codeblock" ? "Copied!" : "Copy"}
{copiedField === "codeblock" ? t("copied") : t("copy")}
</button>
</div>
<pre className="p-4 bg-bg-secondary rounded-lg border border-border overflow-x-auto">
@@ -405,8 +427,8 @@ export default function DefaultToolCard({
disabled={!modelValue}
loading={saving}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Save
Config
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
{t("saveConfig")}
</Button>
)}
{tool.codeBlock && (
@@ -418,7 +440,7 @@ export default function DefaultToolCard({
<span className="material-symbols-outlined text-[14px] mr-1">
{copiedField === "codeblock" ? "check" : "content_copy"}
</span>
{copiedField === "codeblock" ? "Copied!" : "Copy Config"}
{copiedField === "codeblock" ? t("copied") : t("copyConfig")}
</Button>
)}
{modelValue && (
@@ -426,7 +448,7 @@ export default function DefaultToolCard({
<span className="material-symbols-outlined text-[14px] text-green-500">
check_circle
</span>
Selection saved
{t("selectionSaved")}
</span>
)}
</div>
@@ -496,7 +518,7 @@ export default function DefaultToolCard({
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400">
<span className="size-1.5 rounded-full bg-blue-500" />
Guide
{t("guide")}
</span>
);
}
@@ -504,7 +526,7 @@ export default function DefaultToolCard({
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-green-500/10 text-green-600 dark:text-green-400">
<span className="size-1.5 rounded-full bg-green-500" />
Detected
{t("detected")}
</span>
);
}
@@ -512,7 +534,7 @@ export default function DefaultToolCard({
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-zinc-500/10 text-zinc-500 dark:text-zinc-400">
<span className="size-1.5 rounded-full bg-zinc-400 dark:bg-zinc-500" />
Not installed
{t("notInstalled")}
</span>
);
}
@@ -520,14 +542,16 @@ export default function DefaultToolCard({
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400">
<span className="size-1.5 rounded-full bg-yellow-500" />
Not ready
{t("notReady")}
</span>
);
}
return null;
})()}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">
{translateOrFallback(`toolDescriptions.${toolId}`, tool.description)}
</p>
</div>
</div>
<span
@@ -545,7 +569,7 @@ export default function DefaultToolCard({
onSelect={handleSelectModel}
selectedModel={modelValue}
activeProviders={activeProviders}
title="Select Model"
title={t("selectModel")}
/>
</Card>
);

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -19,6 +20,7 @@ export default function DroidToolCard({
batchStatus,
lastConfiguredAt,
}) {
const t = useTranslations("cliTools");
const [droidStatus, setDroidStatus] = useState(null);
const [checkingDroid, setCheckingDroid] = useState(false);
const [applying, setApplying] = useState(false);
@@ -137,10 +139,10 @@ export default function DroidToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
setMessage({ type: "success", text: t("settingsApplied") });
checkDroidStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
setMessage({ type: "error", text: data.error || t("failedApplySettings") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -156,12 +158,12 @@ export default function DroidToolCard({
const res = await fetch("/api/cli-tools/droid-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setMessage({ type: "success", text: t("settingsReset") });
setSelectedModel("");
setSelectedApiKey("");
checkDroidStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
setMessage({ type: "error", text: data.error || t("failedResetSettings") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -197,11 +199,11 @@ export default function DroidToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Backup restored!" });
setMessage({ type: "success", text: t("backupRestored") });
checkDroidStatus();
fetchBackups();
} else {
setMessage({ type: "error", text: data.error || "Failed to restore" });
setMessage({ type: "error", text: data.error || t("failedRestore") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -274,7 +276,7 @@ export default function DroidToolCard({
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">{t("toolDescriptions.droid")}</p>
</div>
</div>
<span
@@ -289,7 +291,7 @@ export default function DroidToolCard({
{checkingDroid && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Factory Droid CLI...</span>
<span>{t("checkingCli", { tool: "Factory Droid" })}</span>
</div>
)}
@@ -299,13 +301,16 @@ export default function DroidToolCard({
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">
{droidStatus.installed
? "Factory Droid CLI not runnable"
: "Factory Droid CLI not installed"}
? t("cliNotRunnable", { tool: "Factory Droid" })
: t("cliNotInstalled", { tool: "Factory Droid" })}
</p>
<p className="text-sm text-text-muted">
{droidStatus.installed
? `Factory Droid CLI was found but failed runtime healthcheck${droidStatus.reason ? ` (${droidStatus.reason})` : ""}.`
: "Please install Factory Droid CLI to use this feature."}
? t("cliFoundFailedHealthcheck", {
tool: "Factory Droid",
reason: droidStatus.reason ? ` (${droidStatus.reason})` : "",
})
: t("installCliPrompt", { tool: "Factory Droid" })}
</p>
</div>
</div>
@@ -319,7 +324,7 @@ export default function DroidToolCard({
?.baseUrl && (
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Current
{t("current")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -336,7 +341,7 @@ export default function DroidToolCard({
{/* Base URL */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Base URL
{t("baseUrl")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -345,14 +350,14 @@ export default function DroidToolCard({
type="text"
value={getDisplayUrl()}
onChange={(e) => setCustomBaseUrl(e.target.value)}
placeholder="https://.../v1"
placeholder={t("baseUrlPlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
{customBaseUrl && customBaseUrl !== baseUrl && (
<button
onClick={() => setCustomBaseUrl("")}
className="p-1 text-text-muted hover:text-primary rounded transition-colors"
title="Reset to default"
title={t("resetToDefault")}
>
<span className="material-symbols-outlined text-[14px]">restart_alt</span>
</button>
@@ -362,7 +367,7 @@ export default function DroidToolCard({
{/* API Key */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
API Key
{t("apiKey")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -381,9 +386,7 @@ export default function DroidToolCard({
</select>
) : (
<span className="flex-1 text-xs text-text-muted px-2 py-1.5">
{cloudEnabled
? "No API keys - Create one in Keys page"
: "sk_omniroute (default)"}
{cloudEnabled ? t("noApiKeysCreateOne") : t("defaultOmnirouteKey")}
</span>
)}
</div>
@@ -391,7 +394,7 @@ export default function DroidToolCard({
{/* Model */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Model
{t("model")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -400,7 +403,7 @@ export default function DroidToolCard({
type="text"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
placeholder="provider/model-id"
placeholder={t("providerModelPlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
@@ -408,13 +411,13 @@ export default function DroidToolCard({
disabled={!hasActiveProviders}
className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select Model
{t("selectModel")}
</button>
{selectedModel && (
<button
onClick={() => setSelectedModel("")}
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
title={t("clear")}
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
@@ -441,7 +444,8 @@ export default function DroidToolCard({
disabled={!selectedModel}
loading={applying}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
{t("apply")}
</Button>
<Button
variant="outline"
@@ -450,11 +454,12 @@ export default function DroidToolCard({
disabled={!droidStatus?.hasOmniRoute}
loading={restoring}
>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>
{t("reset")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>
Manual Config
{t("manualConfig")}
</Button>
<div className="flex-1" />
<Button
@@ -465,7 +470,8 @@ export default function DroidToolCard({
if (!showBackups) fetchBackups();
}}
>
<span className="material-symbols-outlined text-[14px] mr-1">history</span>Backups
<span className="material-symbols-outlined text-[14px] mr-1">history</span>
{t("backups")}
{backups.length > 0 && ` (${backups.length})`}
</Button>
</div>
@@ -474,12 +480,10 @@ export default function DroidToolCard({
<div className="mt-2 p-3 bg-surface border border-border rounded-lg">
<h4 className="text-xs font-semibold text-text-main mb-2 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">history</span>
Config Backups
{t("configBackups")}
</h4>
{backups.length === 0 ? (
<p className="text-xs text-text-muted">
No backups yet. Backups are created automatically before each Apply or Reset.
</p>
<p className="text-xs text-text-muted">{t("noBackupsYet")}</p>
) : (
<div className="space-y-1.5">
{backups.map((b) => (
@@ -501,7 +505,7 @@ export default function DroidToolCard({
disabled={restoringBackup === b.id}
className="px-2 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
>
{restoringBackup === b.id ? "..." : "Restore"}
{restoringBackup === b.id ? "..." : t("restore")}
</button>
</div>
))}
@@ -521,13 +525,13 @@ export default function DroidToolCard({
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Factory Droid"
title={t("selectModelForTool", { tool: "Factory Droid" })}
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Factory Droid - Manual Configuration"
title={t("droidManualConfiguration")}
configs={getManualConfigs()}
/>
</Card>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -19,6 +20,7 @@ export default function KiloToolCard({
batchStatus,
lastConfiguredAt,
}) {
const t = useTranslations("cliTools");
const [kiloStatus, setKiloStatus] = useState(null);
const [checkingKilo, setCheckingKilo] = useState(false);
const [applying, setApplying] = useState(false);
@@ -95,12 +97,12 @@ export default function KiloToolCard({
body: JSON.stringify({ tool: "kilo", backupId }),
});
if (res.ok) {
setMessage({ type: "success", text: "Backup restored! Reloading status..." });
setMessage({ type: "success", text: t("backupRestoredReloading") });
await checkKiloStatus();
await fetchBackups();
} else {
const data = await res.json();
setMessage({ type: "error", text: data.error || "Failed to restore backup" });
setMessage({ type: "error", text: data.error || t("failedRestoreBackup") });
}
} catch (e) {
setMessage({ type: "error", text: e.message });
@@ -147,11 +149,11 @@ export default function KiloToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: data.message || "Applied!" });
setMessage({ type: "success", text: data.message || t("applied") });
await checkKiloStatus();
await fetchBackups();
} else {
setMessage({ type: "error", text: data.error || "Failed" });
setMessage({ type: "error", text: data.error || t("failed") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -167,13 +169,13 @@ export default function KiloToolCard({
const res = await fetch("/api/cli-tools/kilo-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: data.message || "Reset!" });
setMessage({ type: "success", text: data.message || t("resetDone") });
setSelectedModel("");
hasInitializedModel.current = false;
await checkKiloStatus();
await fetchBackups();
} else {
setMessage({ type: "error", text: data.error || "Failed" });
setMessage({ type: "error", text: data.error || t("failed") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -226,7 +228,7 @@ export default function KiloToolCard({
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">{t("toolDescriptions.kilo")}</p>
</div>
</div>
<span
@@ -243,7 +245,7 @@ export default function KiloToolCard({
<span className="material-symbols-outlined animate-spin text-base">
progress_activity
</span>
<span>Checking Kilo Code CLI...</span>
<span>{t("checkingCli", { tool: "Kilo Code" })}</span>
</div>
)}
@@ -259,14 +261,14 @@ export default function KiloToolCard({
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">
{cliReady
? "Kilo Code CLI detected and ready"
? t("cliDetectedReady", { tool: "Kilo Code" })
: kiloStatus.installed
? "Kilo Code CLI installed but not runnable"
: "Kilo Code CLI not detected"}
? t("cliNotRunnable", { tool: "Kilo Code" })
: t("cliNotDetected", { tool: "Kilo Code" })}
</p>
{kiloStatus.commandPath && (
<p className="text-xs text-text-muted">
Binary:{" "}
{t("binary")}:{" "}
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
{kiloStatus.commandPath}
</code>
@@ -274,7 +276,7 @@ export default function KiloToolCard({
)}
{kiloStatus.authPath && (
<p className="text-xs text-text-muted">
Auth:{" "}
{t("auth")}:{" "}
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
{kiloStatus.authPath}
</code>
@@ -293,10 +295,11 @@ export default function KiloToolCard({
</span>
<div className="flex flex-col gap-1">
<p className="text-sm text-green-700 dark:text-green-300">
OmniRoute is configured as OpenAI-compatible provider
{t("omnirouteConfiguredOpenAiCompatible")}
</p>
<p className="text-xs text-text-muted">
Providers: <strong>{kiloStatus.settings?.auth?.join(", ") || "—"}</strong>
{t("providers")}:{" "}
<strong>{kiloStatus.settings?.auth?.join(", ") || "—"}</strong>
</p>
</div>
</div>
@@ -304,13 +307,13 @@ export default function KiloToolCard({
{/* Model selection */}
<div className="flex flex-col gap-2">
<label className="text-sm text-text-muted">Model</label>
<label className="text-sm text-text-muted">{t("model")}</label>
<div className="flex items-center gap-2">
<input
type="text"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
placeholder="provider/model-id"
placeholder={t("providerModelPlaceholder")}
className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<Button
@@ -319,7 +322,7 @@ export default function KiloToolCard({
onClick={() => setModalOpen(true)}
disabled={!hasActiveProviders}
>
Select
{t("select")}
</Button>
<Button
variant="ghost"
@@ -333,7 +336,7 @@ export default function KiloToolCard({
{/* API Key selection */}
<div className="flex flex-col gap-2">
<label className="text-sm text-text-muted">API Key</label>
<label className="text-sm text-text-muted">{t("apiKey")}</label>
{apiKeys && apiKeys.length > 0 ? (
<select
value={selectedApiKey}
@@ -348,7 +351,7 @@ export default function KiloToolCard({
</select>
) : (
<p className="text-sm text-text-muted">
{cloudEnabled ? "No API keys available" : "Using default: sk_omniroute"}
{cloudEnabled ? t("noApiKeysAvailable") : t("usingDefaultOmniroute")}
</p>
)}
</div>
@@ -363,14 +366,14 @@ export default function KiloToolCard({
loading={applying}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
{configStatus === "configured" ? "Update Config" : "Apply Config"}
{configStatus === "configured" ? t("updateConfig") : t("applyConfig")}
</Button>
{configStatus === "configured" && (
<Button variant="outline" size="sm" onClick={handleReset} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">
restart_alt
</span>
Reset
{t("reset")}
</Button>
)}
</div>
@@ -399,7 +402,7 @@ export default function KiloToolCard({
chevron_right
</span>
<span className="material-symbols-outlined text-[16px]">backup</span>
Backups {backups.length > 0 && `(${backups.length})`}
{t("backups")} {backups.length > 0 && `(${backups.length})`}
</button>
{showBackups && backups.length > 0 && (
<div className="mt-2 flex flex-col gap-1.5 pl-6">
@@ -420,14 +423,14 @@ export default function KiloToolCard({
onClick={() => handleRestoreBackup(b.id)}
loading={restoringBackup === b.id}
>
Restore
{t("restore")}
</Button>
</div>
))}
</div>
)}
{showBackups && backups.length === 0 && (
<p className="mt-2 pl-6 text-xs text-text-muted">No backups available.</p>
<p className="mt-2 pl-6 text-xs text-text-muted">{t("noBackupsAvailable")}</p>
)}
</div>
</>
@@ -443,13 +446,13 @@ export default function KiloToolCard({
onSelect={handleSelectModel}
selectedModel={selectedModel}
activeProviders={activeProviders}
title="Select Model for Kilo Code"
title={t("selectModelForTool", { tool: "Kilo Code" })}
/>
{showManualConfigModal && (
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Kilo Code Manual Configuration"
title={t("kiloManualConfiguration")}
{...({
onApply: handleManualConfig,
currentConfig: {

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -19,6 +20,7 @@ export default function OpenClawToolCard({
batchStatus,
lastConfiguredAt,
}) {
const t = useTranslations("cliTools");
const [openclawStatus, setOpenclawStatus] = useState(null);
const [checkingOpenclaw, setCheckingOpenclaw] = useState(false);
const [applying, setApplying] = useState(false);
@@ -138,10 +140,10 @@ export default function OpenClawToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
setMessage({ type: "success", text: t("settingsApplied") });
checkOpenclawStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
setMessage({ type: "error", text: data.error || t("failedApplySettings") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -157,12 +159,12 @@ export default function OpenClawToolCard({
const res = await fetch("/api/cli-tools/openclaw-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setMessage({ type: "success", text: t("settingsReset") });
setSelectedModel("");
setSelectedApiKey("");
checkOpenclawStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
setMessage({ type: "error", text: data.error || t("failedResetSettings") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -198,11 +200,11 @@ export default function OpenClawToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Backup restored!" });
setMessage({ type: "success", text: t("backupRestored") });
checkOpenclawStatus();
fetchBackups();
} else {
setMessage({ type: "error", text: data.error || "Failed to restore" });
setMessage({ type: "error", text: data.error || t("failedRestore") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -278,7 +280,7 @@ export default function OpenClawToolCard({
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">{t("toolDescriptions.openclaw")}</p>
</div>
</div>
<span
@@ -293,7 +295,7 @@ export default function OpenClawToolCard({
{checkingOpenclaw && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Open Claw CLI...</span>
<span>{t("checkingCli", { tool: "Open Claw" })}</span>
</div>
)}
@@ -303,13 +305,16 @@ export default function OpenClawToolCard({
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">
{openclawStatus.installed
? "Open Claw CLI not runnable"
: "Open Claw CLI not installed"}
? t("cliNotRunnable", { tool: "Open Claw" })
: t("cliNotInstalled", { tool: "Open Claw" })}
</p>
<p className="text-sm text-text-muted">
{openclawStatus.installed
? `Open Claw CLI was found but failed runtime healthcheck${openclawStatus.reason ? ` (${openclawStatus.reason})` : ""}.`
: "Please install Open Claw CLI to use this feature."}
? t("cliFoundFailedHealthcheck", {
tool: "Open Claw",
reason: openclawStatus.reason ? ` (${openclawStatus.reason})` : "",
})
: t("installCliPrompt", { tool: "Open Claw" })}
</p>
</div>
</div>
@@ -322,7 +327,7 @@ export default function OpenClawToolCard({
{openclawStatus?.settings?.models?.providers?.["omniroute"]?.baseUrl && (
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Current
{t("current")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -336,7 +341,7 @@ export default function OpenClawToolCard({
{/* Base URL */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Base URL
{t("baseUrl")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -345,14 +350,14 @@ export default function OpenClawToolCard({
type="text"
value={getDisplayUrl()}
onChange={(e) => setCustomBaseUrl(e.target.value)}
placeholder="https://.../v1"
placeholder={t("baseUrlPlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
{customBaseUrl && customBaseUrl !== baseUrl && (
<button
onClick={() => setCustomBaseUrl("")}
className="p-1 text-text-muted hover:text-primary rounded transition-colors"
title="Reset to default"
title={t("resetToDefault")}
>
<span className="material-symbols-outlined text-[14px]">restart_alt</span>
</button>
@@ -362,7 +367,7 @@ export default function OpenClawToolCard({
{/* API Key */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
API Key
{t("apiKey")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -381,9 +386,7 @@ export default function OpenClawToolCard({
</select>
) : (
<span className="flex-1 text-xs text-text-muted px-2 py-1.5">
{cloudEnabled
? "No API keys - Create one in Keys page"
: "sk_omniroute (default)"}
{cloudEnabled ? t("noApiKeysCreateOne") : t("defaultOmnirouteKey")}
</span>
)}
</div>
@@ -391,7 +394,7 @@ export default function OpenClawToolCard({
{/* Model */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Model
{t("model")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -400,7 +403,7 @@ export default function OpenClawToolCard({
type="text"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
placeholder="provider/model-id"
placeholder={t("providerModelPlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
@@ -408,13 +411,13 @@ export default function OpenClawToolCard({
disabled={!hasActiveProviders}
className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select Model
{t("selectModel")}
</button>
{selectedModel && (
<button
onClick={() => setSelectedModel("")}
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
title={t("clear")}
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
@@ -441,7 +444,8 @@ export default function OpenClawToolCard({
disabled={!selectedModel}
loading={applying}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
{t("apply")}
</Button>
<Button
variant="outline"
@@ -450,11 +454,12 @@ export default function OpenClawToolCard({
disabled={!openclawStatus?.hasOmniRoute}
loading={restoring}
>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>
{t("reset")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>
Manual Config
{t("manualConfig")}
</Button>
<div className="flex-1" />
<Button
@@ -465,7 +470,8 @@ export default function OpenClawToolCard({
if (!showBackups) fetchBackups();
}}
>
<span className="material-symbols-outlined text-[14px] mr-1">history</span>Backups
<span className="material-symbols-outlined text-[14px] mr-1">history</span>
{t("backups")}
{backups.length > 0 && ` (${backups.length})`}
</Button>
</div>
@@ -474,12 +480,10 @@ export default function OpenClawToolCard({
<div className="mt-2 p-3 bg-surface border border-border rounded-lg">
<h4 className="text-xs font-semibold text-text-main mb-2 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">history</span>
Config Backups
{t("configBackups")}
</h4>
{backups.length === 0 ? (
<p className="text-xs text-text-muted">
No backups yet. Backups are created automatically before each Apply or Reset.
</p>
<p className="text-xs text-text-muted">{t("noBackupsYet")}</p>
) : (
<div className="space-y-1.5">
{backups.map((b) => (
@@ -501,7 +505,7 @@ export default function OpenClawToolCard({
disabled={restoringBackup === b.id}
className="px-2 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
>
{restoringBackup === b.id ? "..." : "Restore"}
{restoringBackup === b.id ? "..." : t("restore")}
</button>
</div>
))}
@@ -521,13 +525,13 @@ export default function OpenClawToolCard({
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Open Claw"
title={t("selectModelForTool", { tool: "Open Claw" })}
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Open Claw - Manual Configuration"
title={t("openClawManualConfiguration")}
configs={getManualConfigs()}
/>
</Card>

View File

@@ -109,9 +109,9 @@ export default function APIPageClient({ machineId }) {
return { ok: res.ok, status: res.status, data };
} catch (error) {
if (error?.name === "AbortError") {
return { ok: false, status: 408, data: { error: "Cloud request timeout" } };
return { ok: false, status: 408, data: { error: t("cloudRequestTimeout") } };
}
return { ok: false, status: 500, data: { error: error.message || "Cloud request failed" } };
return { ok: false, status: 500, data: { error: error.message || t("cloudRequestFailed") } };
} finally {
clearTimeout(timeoutId);
}
@@ -190,13 +190,13 @@ export default function APIPageClient({ machineId }) {
setModalSuccess(false);
if (data.verified) {
setCloudStatus({ type: "success", message: "Cloud Proxy connected and verified!" });
setCloudStatus({ type: "success", message: t("cloudConnectedVerified") });
} else {
setCloudStatus({
type: "warning",
message: data.verifyError
? `Connected — verification pending: ${data.verifyError}`
: "Connected — verification pending",
? t("connectedVerificationPendingWithError", { error: data.verifyError })
: t("connectedVerificationPending"),
});
}
@@ -208,16 +208,15 @@ export default function APIPageClient({ machineId }) {
await loadCloudSettings();
} else {
// Sync failed — provide a helpful error message
let errorMessage = data.error || "Failed to enable cloud";
let errorMessage = data.error || t("failedEnable");
if (status === 502 || status === 408) {
errorMessage =
"Could not reach cloud worker. Make sure the cloud service is running (npm run dev in /cloud).";
errorMessage = t("cloudWorkerUnreachable");
}
setCloudStatus({ type: "error", message: errorMessage });
setShowCloudModal(false);
}
} catch (error) {
setCloudStatus({ type: "error", message: error.message || "Connection failed" });
setCloudStatus({ type: "error", message: error.message || t("connectionFailed") });
setShowCloudModal(false);
} finally {
setCloudSyncing(false);
@@ -240,16 +239,16 @@ export default function APIPageClient({ machineId }) {
if (ok) {
setCloudEnabled(false);
setCloudStatus({ type: "success", message: "Cloud disabled successfully" });
setCloudStatus({ type: "success", message: t("cloudDisabledSuccess") });
setShowDisableModal(false);
dispatchCloudChange();
await loadCloudSettings();
} else {
setCloudStatus({ type: "error", message: data.error || "Failed to disable cloud" });
setCloudStatus({ type: "error", message: data.error || t("failedDisable") });
}
} catch (error) {
console.log("Error disabling cloud:", error);
setCloudStatus({ type: "error", message: "Failed to disable cloud" });
setCloudStatus({ type: "error", message: t("failedDisable") });
} finally {
setCloudSyncing(false);
setSyncStep("");
@@ -263,12 +262,12 @@ export default function APIPageClient({ machineId }) {
try {
const { ok, data } = await postCloudAction("sync");
if (ok) {
setCloudStatus({ type: "success", message: "Synced successfully" });
setCloudStatus({ type: "success", message: t("syncedSuccess") });
} else {
setCloudStatus({ type: "error", message: data.error });
setCloudStatus({ type: "error", message: data.error || t("syncFailed") });
}
} catch (error) {
setCloudStatus({ type: "error", message: error.message });
setCloudStatus({ type: "error", message: error.message || t("syncFailed") });
} finally {
setCloudSyncing(false);
}
@@ -296,24 +295,6 @@ export default function APIPageClient({ machineId }) {
// Use new format endpoint (machineId embedded in key)
const currentEndpoint = cloudEnabled ? cloudEndpointNew : baseUrl;
const cloudBenefits = [
{ icon: "public", title: "Access Anywhere", desc: "No port forwarding needed" },
{ icon: "group", title: "Share Endpoint", desc: "Easy team collaboration" },
{ icon: "schedule", title: "Always Online", desc: "24/7 availability" },
{ icon: "speed", title: "Global Edge", desc: "Fast worldwide access" },
];
const quickStartLinks = [
{ label: "Documentation", href: "/docs" },
{ label: "OpenAI API compatibility", href: "/docs#api-reference" },
{ label: "Cherry/Codex compatibility", href: "/docs#client-compatibility" },
{
label: "Report issue",
href: "https://github.com/diegosouzapw/OmniRoute/issues",
external: true,
},
];
return (
<div className="flex flex-col gap-8">
{/* Endpoint Card */}
@@ -407,10 +388,9 @@ export default function APIPageClient({ machineId }) {
<div>
<h2 className="text-lg font-semibold">{t("available")}</h2>
<p className="text-sm text-text-muted">
{Object.values(endpointData).reduce((acc, models) => acc + models.length, 0)} models
across{" "}
{
[
{t("modelsAcrossEndpoints", {
models: Object.values(endpointData).reduce((acc, models) => acc + models.length, 0),
endpoints: [
endpointData.chat,
endpointData.embeddings,
endpointData.images,
@@ -418,9 +398,8 @@ export default function APIPageClient({ machineId }) {
endpointData.audioTranscription,
endpointData.audioSpeech,
endpointData.moderation,
].filter((a) => a.length > 0).length
}{" "}
endpoints
].filter((a) => a.length > 0).length,
})}
</p>
</div>
</div>
@@ -550,70 +529,6 @@ export default function APIPageClient({ machineId }) {
</div>
</Card>
{/* Cloud Proxy Card - Hidden */}
{false && (
<Card className={cloudEnabled ? "bg-primary/5" : ""}>
<div className="flex flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className={`p-2 rounded-lg ${cloudEnabled ? "bg-primary text-white" : "bg-sidebar text-text-muted"}`}
>
<span className="material-symbols-outlined text-xl">cloud</span>
</div>
<div>
<h2 className="text-lg font-semibold">Cloud Proxy</h2>
<p className="text-xs text-text-muted">
{cloudEnabled ? "Connected & Ready" : "Access your API from anywhere"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{cloudEnabled ? (
<Button
size="sm"
variant="secondary"
icon="cloud_off"
onClick={() => handleCloudToggle(false)}
disabled={cloudSyncing}
className="bg-red-500/10! text-red-500! hover:bg-red-500/20! border-red-500/30!"
>
Disable
</Button>
) : (
<Button
variant="primary"
icon="cloud_upload"
onClick={() => handleCloudToggle(true)}
disabled={cloudSyncing}
className="bg-linear-to-r from-primary to-blue-500 hover:from-primary-hover hover:to-blue-600 px-6"
>
Enable Cloud
</Button>
)}
</div>
</div>
{/* Benefits Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{cloudBenefits.map((benefit) => (
<div
key={benefit.title}
className="flex flex-col items-center text-center p-3 rounded-lg bg-sidebar/50"
>
<span className="material-symbols-outlined text-xl text-primary mb-1">
{benefit.icon}
</span>
<p className="text-xs font-semibold">{benefit.title}</p>
<p className="text-xs text-text-muted">{benefit.desc}</p>
</div>
))}
</div>
</div>
</Card>
)}
{/* Cloud Enable Modal */}
<Modal
isOpen={showCloudModal}
@@ -634,13 +549,12 @@ export default function APIPageClient({ machineId }) {
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-medium mb-1">Note</p>
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-medium mb-1">
{tc("note")}
</p>
<ul className="text-sm text-yellow-700 dark:text-yellow-300 space-y-1">
<li>
Cloud will keep your auth session for 1 day. If not used, it will be automatically
deleted.
</li>
<li> Cloud is currently unstable with Claude Code OAuth in some cases.</li>
<li> {t("cloudSessionNote")}</li>
<li> {t("cloudUnstableNote")}</li>
</ul>
</div>
@@ -719,10 +633,10 @@ export default function APIPageClient({ machineId }) {
warning
</span>
<div>
<p className="text-sm text-red-800 dark:text-red-200 font-medium mb-1">Warning</p>
<p className="text-sm text-red-700 dark:text-red-300">
All auth sessions will be deleted from cloud.
<p className="text-sm text-red-800 dark:text-red-200 font-medium mb-1">
{tc("warning")}
</p>
<p className="text-sm text-red-700 dark:text-red-300">{t("disableWarning")}</p>
</div>
</div>
</div>
@@ -794,6 +708,8 @@ APIPageClient.propTypes = {
// -- Sub-component: Provider Models Modal ------------------------------------------
function ProviderModelsModal({ provider, models, copy, copied, onClose }) {
const t = useTranslations("endpoint");
const tc = useTranslations("common");
// Get provider alias for matching models
// Filter out parent models (models with parent field set) to avoid showing duplicates
const providerAlias = provider.provider.alias || provider.id;
@@ -826,13 +742,13 @@ function ProviderModelsModal({ provider, models, copy, copied, onClose }) {
<code className="text-sm font-mono flex-1 truncate">{m.id}</code>
{m.custom && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary">
custom
{t("custom")}
</span>
)}
<button
onClick={() => copy(m.id, copyKey)}
className="p-1 hover:bg-sidebar rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy model ID"
title={tc("copy")}
>
<span className="material-symbols-outlined text-sm">
{copied === copyKey ? "check" : "content_copy"}
@@ -847,17 +763,19 @@ function ProviderModelsModal({ provider, models, copy, copied, onClose }) {
};
return (
<Modal isOpen onClose={onClose} title={`${provider.provider.name} — Models`}>
<Modal
isOpen
onClose={onClose}
title={t("providerModelsTitle", { provider: provider.provider.name })}
>
<div className="max-h-[60vh] overflow-y-auto">
{providerModels.length === 0 ? (
<p className="text-sm text-text-muted py-4 text-center">
No models available for this provider.
</p>
<p className="text-sm text-text-muted py-4 text-center">{t("noModelsForProvider")}</p>
) : (
<>
{renderModelGroup("Chat", "chat", chatModels)}
{renderModelGroup("Embedding", "data_array", embeddingModels)}
{renderModelGroup("Image", "image", imageModels)}
{renderModelGroup(t("chat"), "chat", chatModels)}
{renderModelGroup(t("embedding"), "data_array", embeddingModels)}
{renderModelGroup(t("image"), "image", imageModels)}
</>
)}
</div>
@@ -889,6 +807,7 @@ function EndpointSection({
copied,
baseUrl,
}) {
const t = useTranslations("endpoint");
const grouped = useMemo(() => {
const map = {};
for (const m of models) {
@@ -920,7 +839,7 @@ function EndpointSection({
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">{title}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-surface text-text-muted font-medium">
{models.length} {models.length === 1 ? "model" : "models"}
{t("modelsCount", { count: models.length })}
</span>
</div>
<p className="text-xs text-text-muted mt-0.5">{description}</p>

View File

@@ -32,14 +32,16 @@ function formatBytes(bytes) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const CB_COLORS = {
CLOSED: { bg: "bg-green-500/10", text: "text-green-500", label: "Healthy" },
OPEN: { bg: "bg-red-500/10", text: "text-red-500", label: "Open" },
HALF_OPEN: { bg: "bg-amber-500/10", text: "text-amber-500", label: "Half-Open" },
const CB_STYLES = {
CLOSED: { bg: "bg-green-500/10", text: "text-green-500", labelKey: "healthy" },
OPEN: { bg: "bg-red-500/10", text: "text-red-500", labelKey: "down" },
HALF_OPEN: { bg: "bg-amber-500/10", text: "text-amber-500", labelKey: "recovering" },
};
export default function HealthPage() {
const t = useTranslations("health");
const tc = useTranslations("common");
const tp = useTranslations("providers");
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [lastRefresh, setLastRefresh] = useState(null);
@@ -101,7 +103,8 @@ export default function HealthPage() {
}
};
const fmtMs = (ms) => (ms != null ? `${Math.round(ms)}ms` : "—");
const fmtMs = (ms) =>
ms != null ? t("millisecondsShort", { value: Math.round(ms) }) : t("notAvailable");
if (!data && !error) {
return (
@@ -146,7 +149,7 @@ export default function HealthPage() {
<div className="flex items-center gap-3">
{lastRefresh && (
<span className="text-xs text-text-muted">
Updated {lastRefresh.toLocaleTimeString()}
{t("updatedAt", { time: lastRefresh.toLocaleTimeString() })}
</span>
)}
<button
@@ -155,7 +158,7 @@ export default function HealthPage() {
fetchExtras();
}}
className="p-2 rounded-lg bg-surface hover:bg-surface/80 text-text-muted hover:text-text-main transition-colors"
title="Refresh"
title={tc("refresh")}
>
<span className="material-symbols-outlined text-[18px]">refresh</span>
</button>
@@ -204,7 +207,9 @@ export default function HealthPage() {
<span className="text-sm text-text-muted">{t("version")}</span>
</div>
<p className="text-xl font-semibold text-text-main">v{system.version}</p>
<p className="text-xs text-text-muted mt-1">Node {system.nodeVersion}</p>
<p className="text-xs text-text-muted mt-1">
{t("nodeVersion", { version: system.nodeVersion })}
</p>
</Card>
<Card className="p-4">
@@ -228,11 +233,13 @@ export default function HealthPage() {
<div className="flex items-center justify-center size-8 rounded-lg bg-amber-500/10 text-amber-500">
<span className="material-symbols-outlined text-[18px]">dns</span>
</div>
<span className="text-sm text-text-muted">Providers</span>
<span className="text-sm text-text-muted">{t("providers")}</span>
</div>
<p className="text-xl font-semibold text-text-main">{cbEntries.length}</p>
<p className="text-xs text-text-muted mt-1">
{cbEntries.filter(([, v]: [string, any]) => v.state === "CLOSED").length} healthy
{t("healthyCount", {
count: cbEntries.filter(([, v]: [string, any]) => v.state === "CLOSED").length,
})}
</p>
</Card>
</div>
@@ -248,15 +255,15 @@ export default function HealthPage() {
{telemetry ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">p50</span>
<span className="text-text-muted">{t("latencyP50")}</span>
<span className="font-mono">{fmtMs(telemetry.p50)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">p95</span>
<span className="text-text-muted">{t("latencyP95")}</span>
<span className="font-mono">{fmtMs(telemetry.p95)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">p99</span>
<span className="text-text-muted">{t("latencyP99")}</span>
<span className="font-mono">{fmtMs(telemetry.p99)}</span>
</div>
<div className="flex justify-between border-t border-border pt-2 mt-2">
@@ -308,19 +315,23 @@ export default function HealthPage() {
{signatureCache ? (
<div className="grid grid-cols-2 gap-2">
{[
{ label: "Defaults", value: signatureCache.defaultCount, color: "text-text-muted" },
{
label: "Tool",
label: t("signatureDefaults"),
value: signatureCache.defaultCount,
color: "text-text-muted",
},
{
label: t("signatureTool"),
value: `${signatureCache.tool.entries}/${signatureCache.tool.patterns}`,
color: "text-blue-400",
},
{
label: "Family",
label: t("signatureFamily"),
value: `${signatureCache.family.entries}/${signatureCache.family.patterns}`,
color: "text-purple-400",
},
{
label: "Session",
label: t("signatureSession"),
value: `${signatureCache.session.entries}/${signatureCache.session.patterns}`,
color: "text-cyan-400",
},
@@ -341,7 +352,7 @@ export default function HealthPage() {
</div>
{/* Provider Health */}
<Card className="p-5" role="region" aria-label="Provider health status">
<Card className="p-5" role="region" aria-label={t("providerHealthStatusAria")}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-primary">
@@ -359,7 +370,7 @@ export default function HealthPage() {
? "bg-surface/50 text-text-muted cursor-wait"
: "bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300 border border-red-500/20"
}`}
title="Reset all circuit breakers to healthy state"
title={t("resetAllTitle")}
>
{resetting ? (
<>
@@ -406,7 +417,7 @@ export default function HealthPage() {
{t("issuesLabel")}
</p>
{unhealthy.map(([provider, cb]: [string, any]) => {
const style = CB_COLORS[cb.state] || CB_COLORS.OPEN;
const style = CB_STYLES[cb.state] || CB_STYLES.OPEN;
const providerInfo = AI_PROVIDERS[provider];
const displayName = providerInfo?.name || provider;
return (
@@ -431,14 +442,17 @@ export default function HealthPage() {
<span
className={`text-xs font-semibold px-1.5 py-0.5 rounded ${style.bg} ${style.text}`}
>
{style.label}
{t(style.labelKey)}
</span>
</div>
<div className="text-xs text-text-muted mt-0.5">
{cb.failures} failure{cb.failures !== 1 ? "s" : ""}
{cb.failures === 1
? t("failures", { count: cb.failures })
: t("failuresPlural", { count: cb.failures })}
{cb.lastFailure && (
<span className="ml-2">
· Last: {new Date(cb.lastFailure).toLocaleTimeString()}
· {t("lastFailure")}:{" "}
{new Date(cb.lastFailure).toLocaleTimeString()}
</span>
)}
</div>
@@ -502,13 +516,13 @@ export default function HealthPage() {
if (providerId.startsWith("openai-compatible-")) {
const customName = providerId.replace("openai-compatible-", "");
displayName = `OpenAI Compatible`;
displayName = tp("openaiCompatibleName");
providerInfo = { color: "#10A37F", textIcon: "OC" };
if (customName.length > 12) displayName += ` (${customName.slice(0, 8)}…)`;
else if (customName) displayName += ` (${customName})`;
} else if (providerId.startsWith("anthropic-compatible-")) {
const customName = providerId.replace("anthropic-compatible-", "");
displayName = `Anthropic Compatible`;
displayName = tp("anthropicCompatibleName");
providerInfo = { color: "#D97757", textIcon: "AC" };
if (customName.length > 12) displayName += ` (${customName.slice(0, 8)}…)`;
else if (customName) displayName += ` (${customName})`;
@@ -544,7 +558,9 @@ export default function HealthPage() {
{t("rateLimitStatus")}
</h2>
<span className="text-xs text-text-muted">
{entries.length} active limiter{entries.length !== 1 ? "s" : ""}
{entries.length === 1
? t("activeLimiters", { count: entries.length })
: t("activeLimitersPlural", { count: entries.length })}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
@@ -598,19 +614,19 @@ export default function HealthPage() {
: "bg-green-500/10 text-green-400"
}`}
>
{isQueued ? "Queued" : isActive ? "Active" : "OK"}
{isQueued ? t("queued") : isActive ? tc("active") : t("ok")}
</span>
</div>
<div className="flex items-center gap-3 text-[11px] text-text-muted">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">schedule</span>
{status.queued || 0} queued
{t("queuedCount", { count: status.queued || 0 })}
</span>
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">
play_arrow
</span>
{status.running || 0} running
{t("runningCount", { count: status.running || 0 })}
</span>
</div>
</div>
@@ -643,7 +659,7 @@ export default function HealthPage() {
</div>
{lockout.until && (
<span className="text-xs text-red-400">
Until {new Date(lockout.until).toLocaleTimeString()}
{t("until", { time: new Date(lockout.until).toLocaleTimeString() })}
</span>
)}
</div>

View File

@@ -47,11 +47,11 @@ export default function AuditLogTab() {
setHasMore(data.length > PAGE_SIZE);
setEntries(data.slice(0, PAGE_SIZE));
} catch (err: any) {
setError(err.message || "Failed to fetch audit log");
setError(err.message || t("failedFetchAuditLog"));
} finally {
setLoading(false);
}
}, [actionFilter, actorFilter, offset]);
}, [actionFilter, actorFilter, offset, t]);
useEffect(() => {
fetchEntries();
@@ -93,7 +93,7 @@ export default function AuditLogTab() {
<button
onClick={fetchEntries}
disabled={loading}
aria-label="Refresh audit log"
aria-label={t("refreshAuditLogAria")}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] transition-colors disabled:opacity-50"
>
{loading ? t("loading") : t("refresh")}
@@ -104,7 +104,7 @@ export default function AuditLogTab() {
<div
className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]"
role="search"
aria-label="Filter audit log entries"
aria-label={t("filterEntriesAria")}
>
<input
type="text"
@@ -112,7 +112,7 @@ export default function AuditLogTab() {
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label="Filter by action type"
aria-label={t("filterByActionTypeAria")}
className="flex-1 min-w-[180px] 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)]"
/>
<input
@@ -121,7 +121,7 @@ export default function AuditLogTab() {
value={actorFilter}
onChange={(e) => setActorFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label="Filter by actor"
aria-label={t("filterByActorAria")}
className="flex-1 min-w-[180px] 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)]"
/>
<button
@@ -144,7 +144,7 @@ export default function AuditLogTab() {
{/* Table */}
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)]">
<table className="w-full text-sm" role="table" aria-label="Audit log entries">
<table className="w-full text-sm" role="table" aria-label={t("tableAria")}>
<thead>
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
@@ -162,7 +162,9 @@ export default function AuditLogTab() {
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
{t("details")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">IP</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
{t("ipAddress")}
</th>
</tr>
</thead>
<tbody>
@@ -190,13 +192,13 @@ export default function AuditLogTab() {
</td>
<td className="px-4 py-3 text-[var(--color-text-main)]">{entry.actor}</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[200px] truncate">
{entry.target || "—"}
{entry.target || t("notAvailable")}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[300px] truncate font-mono text-xs">
{entry.details ? JSON.stringify(entry.details) : "—"}
{entry.details ? JSON.stringify(entry.details) : t("notAvailable")}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] font-mono text-xs whitespace-nowrap">
{entry.ip_address || "—"}
{entry.ip_address || t("notAvailable")}
</td>
</tr>
))
@@ -208,7 +210,7 @@ export default function AuditLogTab() {
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-xs text-[var(--color-text-muted)]">
Showing {entries.length} entries (offset {offset})
{t("showing", { count: entries.length, offset })}
</p>
<div className="flex gap-2">
<button

View File

@@ -237,10 +237,7 @@ export default function OnboardingWizard() {
{/* Welcome */}
{currentStep.id === "welcome" && (
<div className="text-center space-y-4">
<p className="text-text-muted">
<strong className="text-text-main">OmniRoute</strong>{" "}
{t("welcomeDesc").replace("OmniRoute is your local AI API proxy. ", "")}
</p>
<p className="text-text-muted">{t("welcomeDesc")}</p>
<div className="grid grid-cols-3 gap-3 mt-6">
{[
{ icon: "swap_horiz", label: t("multiProvider") },

View File

@@ -2,9 +2,9 @@
import { useTranslations } from "next-intl";
import { useState, useCallback, useEffect } from "react";
import { useState, useCallback, useEffect, useMemo } from "react";
import { Card, Button, Select, Badge } from "@/shared/components";
import { EXAMPLE_TEMPLATES, FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
import { getExampleTemplates, FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
import dynamic from "next/dynamic";
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
@@ -20,6 +20,7 @@ export default function PlaygroundMode() {
const [translating, setTranslating] = useState(false);
const [detecting, setDetecting] = useState(false);
const [activeTemplate, setActiveTemplate] = useState(null);
const templates = useMemo(() => getExampleTemplates(t), [t]);
// Auto-detect format when input changes
const detectFormatFromInput = useCallback(async (content) => {
@@ -314,7 +315,7 @@ export default function PlaygroundMode() {
<span className="text-xs text-text-muted">{t("exampleTemplatesHint")}</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-2">
{EXAMPLE_TEMPLATES.map((template) => (
{templates.map((template) => (
<button
key={template.id}
onClick={() => loadTemplate(template)}

View File

@@ -2,9 +2,9 @@
import { useTranslations } from "next-intl";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { Card, Button, Select, Badge } from "@/shared/components";
import { EXAMPLE_TEMPLATES, FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
import { getExampleTemplates, FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
import { useProviderOptions } from "../hooks/useProviderOptions";
import { useAvailableModels } from "../hooks/useAvailableModels";
@@ -37,6 +37,7 @@ export default function TestBenchMode() {
"system-prompt": t("scenarioSystemPrompt"),
streaming: t("scenarioStreaming"),
};
const templates = useMemo(() => getExampleTemplates(t), [t]);
const [sourceFormat, setSourceFormat] = useState("claude");
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels();
@@ -55,7 +56,7 @@ export default function TestBenchMode() {
const start = Date.now();
try {
// Find template
const template = EXAMPLE_TEMPLATES.find((t) => t.id === scenario.templateId);
const template = templates.find((item) => item.id === scenario.templateId);
const body = template?.formats[sourceFormat] || template?.formats.openai;
if (!body) {

View File

@@ -4,293 +4,295 @@
* quickly load a realistic payload and see how the translator converts it.
*/
export const EXAMPLE_TEMPLATES = [
{
id: "simple-chat",
name: "Simple Chat",
icon: "chat",
description: "Basic text message",
formats: {
openai: {
model: "gpt-4o",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello! How are you today?" },
],
stream: true,
},
claude: {
model: "claude-sonnet-4-20250514",
system: "You are a helpful assistant.",
max_tokens: 1024,
messages: [{ role: "user", content: "Hello! How are you today?" }],
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [
{
role: "user",
parts: [{ text: "Hello! How are you today?" }],
type TranslatorMessage = (key: string) => string;
export function getExampleTemplates(t: TranslatorMessage) {
const simpleChatSystem = t("templatePayloads.simpleChat.system");
const simpleChatUser = t("templatePayloads.simpleChat.userGreeting");
const toolUserWeather = t("templatePayloads.toolCalling.userWeather");
const toolDescription = t("templatePayloads.toolCalling.toolDescription");
const cityNameDescription = t("templatePayloads.toolCalling.cityNameDescription");
const multiTurnSystem = t("templatePayloads.multiTurn.system");
const multiTurnUserInitial = t("templatePayloads.multiTurn.userInitial");
const multiTurnAssistantExample = t("templatePayloads.multiTurn.assistantExample");
const multiTurnUserFollowUp = t("templatePayloads.multiTurn.userFollowUp");
const thinkingQuestion = t("templatePayloads.thinking.question");
const systemPromptInstruction = t("templatePayloads.systemPrompt.systemInstruction");
const systemPromptQuestion = t("templatePayloads.systemPrompt.question");
const streamingPrompt = t("templatePayloads.streaming.prompt");
return [
{
id: "simple-chat",
name: t("templateNames.simple-chat"),
icon: "chat",
description: t("templateDescriptions.simple-chat"),
formats: {
openai: {
model: "gpt-4o",
messages: [
{ role: "system", content: simpleChatSystem },
{ role: "user", content: simpleChatUser },
],
stream: true,
},
claude: {
model: "claude-sonnet-4-20250514",
system: simpleChatSystem,
max_tokens: 1024,
messages: [{ role: "user", content: simpleChatUser }],
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [
{
role: "user",
parts: [{ text: simpleChatUser }],
},
],
systemInstruction: {
parts: [{ text: simpleChatSystem }],
},
],
systemInstruction: {
parts: [{ text: "You are a helpful assistant." }],
},
"openai-responses": {
model: "gpt-4o",
input: simpleChatUser,
instructions: simpleChatSystem,
},
},
"openai-responses": {
model: "gpt-4o",
input: "Hello! How are you today?",
instructions: "You are a helpful assistant.",
},
},
},
{
id: "tool-calling",
name: "Tool Calling",
icon: "build",
description: "Function/tool invocation",
formats: {
openai: {
model: "gpt-4o",
messages: [{ role: "user", content: "What's the weather in São Paulo?" }],
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Get current weather for a location",
parameters: {
type: "object",
properties: {
location: { type: "string", description: "City name" },
unit: { type: "string", enum: ["celsius", "fahrenheit"] },
},
required: ["location"],
},
},
},
],
stream: true,
},
claude: {
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
messages: [{ role: "user", content: "What's the weather in São Paulo?" }],
tools: [
{
name: "get_weather",
description: "Get current weather for a location",
input_schema: {
type: "object",
properties: {
location: { type: "string", description: "City name" },
unit: { type: "string", enum: ["celsius", "fahrenheit"] },
},
required: ["location"],
},
},
],
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [
{
role: "user",
parts: [{ text: "What's the weather in São Paulo?" }],
},
],
tools: [
{
functionDeclarations: [
{
{
id: "tool-calling",
name: t("templateNames.tool-calling"),
icon: "build",
description: t("templateDescriptions.tool-calling"),
formats: {
openai: {
model: "gpt-4o",
messages: [{ role: "user", content: toolUserWeather }],
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Get current weather for a location",
description: toolDescription,
parameters: {
type: "object",
properties: {
location: { type: "string", description: "City name" },
location: { type: "string", description: cityNameDescription },
unit: { type: "string", enum: ["celsius", "fahrenheit"] },
},
required: ["location"],
},
},
],
},
],
},
},
},
{
id: "multi-turn",
name: "Multi-turn",
icon: "forum",
description: "Conversation with history",
formats: {
openai: {
model: "gpt-4o",
messages: [
{ role: "system", content: "You are a coding assistant." },
{ role: "user", content: "Write a function to sort an array in Python." },
{
role: "assistant",
content:
"Here's a simple sort function:\n\n```python\ndef sort_array(arr):\n return sorted(arr)\n```",
},
{ role: "user", content: "Now make it sort in descending order." },
],
stream: true,
},
claude: {
model: "claude-sonnet-4-20250514",
system: "You are a coding assistant.",
max_tokens: 1024,
messages: [
{ role: "user", content: "Write a function to sort an array in Python." },
{
role: "assistant",
content:
"Here's a simple sort function:\n\n```python\ndef sort_array(arr):\n return sorted(arr)\n```",
},
{ role: "user", content: "Now make it sort in descending order." },
],
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [
{ role: "user", parts: [{ text: "Write a function to sort an array in Python." }] },
{
role: "model",
parts: [
{
text: "Here's a simple sort function:\n\n```python\ndef sort_array(arr):\n return sorted(arr)\n```",
},
],
},
{ role: "user", parts: [{ text: "Now make it sort in descending order." }] },
],
systemInstruction: {
parts: [{ text: "You are a coding assistant." }],
},
],
stream: true,
},
},
},
},
{
id: "thinking",
name: "Thinking",
icon: "psychology",
description: "Extended thinking / reasoning",
formats: {
openai: {
model: "o3-mini",
messages: [{ role: "user", content: "What is the sum of the first 100 prime numbers?" }],
stream: true,
},
claude: {
model: "claude-sonnet-4-20250514",
max_tokens: 16000,
thinking: {
type: "enabled",
budget_tokens: 10000,
},
messages: [{ role: "user", content: "What is the sum of the first 100 prime numbers?" }],
stream: true,
},
gemini: {
model: "gemini-2.5-flash-thinking",
contents: [
{ role: "user", parts: [{ text: "What is the sum of the first 100 prime numbers?" }] },
],
generationConfig: {
thinkingConfig: {
thinkingBudget: 10000,
},
},
},
},
},
{
id: "system-prompt",
name: "System Prompt",
icon: "settings",
description: "Complex system instructions",
formats: {
openai: {
model: "gpt-4o",
messages: [
{
role: "system",
content:
"You are a senior software engineer specializing in distributed systems. Answer questions concisely using industry best practices. Always provide code examples when relevant. Format your responses using markdown.",
},
{ role: "user", content: "How do I implement a circuit breaker pattern?" },
],
temperature: 0.7,
stream: true,
},
claude: {
model: "claude-sonnet-4-20250514",
system:
"You are a senior software engineer specializing in distributed systems. Answer questions concisely using industry best practices. Always provide code examples when relevant. Format your responses using markdown.",
max_tokens: 2048,
messages: [{ role: "user", content: "How do I implement a circuit breaker pattern?" }],
temperature: 0.7,
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [
{ role: "user", parts: [{ text: "How do I implement a circuit breaker pattern?" }] },
],
systemInstruction: {
parts: [
claude: {
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
messages: [{ role: "user", content: toolUserWeather }],
tools: [
{
text: "You are a senior software engineer specializing in distributed systems. Answer questions concisely using industry best practices. Always provide code examples when relevant. Format your responses using markdown.",
name: "get_weather",
description: toolDescription,
input_schema: {
type: "object",
properties: {
location: { type: "string", description: cityNameDescription },
unit: { type: "string", enum: ["celsius", "fahrenheit"] },
},
required: ["location"],
},
},
],
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [
{
role: "user",
parts: [{ text: toolUserWeather }],
},
],
tools: [
{
functionDeclarations: [
{
name: "get_weather",
description: toolDescription,
parameters: {
type: "object",
properties: {
location: { type: "string", description: cityNameDescription },
unit: { type: "string", enum: ["celsius", "fahrenheit"] },
},
required: ["location"],
},
},
],
},
],
},
generationConfig: {
temperature: 0.7,
},
},
{
id: "multi-turn",
name: t("templateNames.multi-turn"),
icon: "forum",
description: t("templateDescriptions.multi-turn"),
formats: {
openai: {
model: "gpt-4o",
messages: [
{ role: "system", content: multiTurnSystem },
{ role: "user", content: multiTurnUserInitial },
{
role: "assistant",
content: multiTurnAssistantExample,
},
{ role: "user", content: multiTurnUserFollowUp },
],
stream: true,
},
claude: {
model: "claude-sonnet-4-20250514",
system: multiTurnSystem,
max_tokens: 1024,
messages: [
{ role: "user", content: multiTurnUserInitial },
{
role: "assistant",
content: multiTurnAssistantExample,
},
{ role: "user", content: multiTurnUserFollowUp },
],
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [
{ role: "user", parts: [{ text: multiTurnUserInitial }] },
{
role: "model",
parts: [
{
text: multiTurnAssistantExample,
},
],
},
{ role: "user", parts: [{ text: multiTurnUserFollowUp }] },
],
systemInstruction: {
parts: [{ text: multiTurnSystem }],
},
},
},
},
},
{
id: "streaming",
name: "Streaming",
icon: "stream",
description: "SSE streaming request",
formats: {
openai: {
model: "gpt-4o",
messages: [
{ role: "user", content: "Tell me a short story about a robot learning to paint." },
],
stream: true,
stream_options: { include_usage: true },
},
claude: {
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
messages: [
{ role: "user", content: "Tell me a short story about a robot learning to paint." },
],
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [
{
role: "user",
parts: [{ text: "Tell me a short story about a robot learning to paint." }],
{
id: "thinking",
name: t("templateNames.thinking"),
icon: "psychology",
description: t("templateDescriptions.thinking"),
formats: {
openai: {
model: "o3-mini",
messages: [{ role: "user", content: thinkingQuestion }],
stream: true,
},
claude: {
model: "claude-sonnet-4-20250514",
max_tokens: 16000,
thinking: {
type: "enabled",
budget_tokens: 10000,
},
],
messages: [{ role: "user", content: thinkingQuestion }],
stream: true,
},
gemini: {
model: "gemini-2.5-flash-thinking",
contents: [{ role: "user", parts: [{ text: thinkingQuestion }] }],
generationConfig: {
thinkingConfig: {
thinkingBudget: 10000,
},
},
},
},
},
},
];
{
id: "system-prompt",
name: t("templateNames.system-prompt"),
icon: "settings",
description: t("templateDescriptions.system-prompt"),
formats: {
openai: {
model: "gpt-4o",
messages: [
{
role: "system",
content: systemPromptInstruction,
},
{ role: "user", content: systemPromptQuestion },
],
temperature: 0.7,
stream: true,
},
claude: {
model: "claude-sonnet-4-20250514",
system: systemPromptInstruction,
max_tokens: 2048,
messages: [{ role: "user", content: systemPromptQuestion }],
temperature: 0.7,
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [{ role: "user", parts: [{ text: systemPromptQuestion }] }],
systemInstruction: {
parts: [{ text: systemPromptInstruction }],
},
generationConfig: {
temperature: 0.7,
},
},
},
},
{
id: "streaming",
name: t("templateNames.streaming"),
icon: "stream",
description: t("templateDescriptions.streaming"),
formats: {
openai: {
model: "gpt-4o",
messages: [{ role: "user", content: streamingPrompt }],
stream: true,
stream_options: { include_usage: true },
},
claude: {
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
messages: [{ role: "user", content: streamingPrompt }],
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [
{
role: "user",
parts: [{ text: streamingPrompt }],
},
],
},
},
},
];
}
/**
* Format metadata for display: colors, labels, icons

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import {
AI_PROVIDERS,
OPENAI_COMPATIBLE_PREFIX,
@@ -16,6 +17,7 @@ import {
* @returns {{ provider: string, setProvider: Function, providerOptions: Array<{value: string, label: string}>, loading: boolean }}
*/
export function useProviderOptions(initialProvider = "openai") {
const t = useTranslations("translator");
const [provider, setProvider] = useState(initialProvider);
const [providerOptions, setProviderOptions] = useState([]);
const [loading, setLoading] = useState(true);
@@ -38,9 +40,9 @@ export function useProviderOptions(initialProvider = "openai") {
const node: any = nodeMap.get(pid);
let label = info?.name || node?.name || pid;
if (!info && (pid as string).startsWith(OPENAI_COMPATIBLE_PREFIX))
label = node?.name || "OpenAI Compatible";
label = node?.name || t("openaiCompatibleLabel");
if (!info && (pid as string).startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
label = node?.name || "Anthropic Compatible";
label = node?.name || t("anthropicCompatibleLabel");
return { value: pid, label };
})
.sort((a, b) => a.label.localeCompare(b.label));
@@ -48,11 +50,16 @@ export function useProviderOptions(initialProvider = "openai") {
const nextOptions =
options.length > 0
? options
: Object.entries(AI_PROVIDERS).map(([id, info]: [string, any]) => ({ value: id, label: info.name }));
: Object.entries(AI_PROVIDERS).map(([id, info]: [string, any]) => ({
value: id,
label: info.name,
}));
setProviderOptions(nextOptions);
if (nextOptions.length > 0) {
setProvider((current: string): string =>
nextOptions.some((opt: any) => opt.value === current) ? current : (nextOptions[0] as any).value as string
nextOptions.some((opt: any) => opt.value === current)
? current
: ((nextOptions[0] as any).value as string)
);
}
} catch {
@@ -73,7 +80,7 @@ export function useProviderOptions(initialProvider = "openai") {
}
};
fetchProviders();
}, []);
}, [t]);
return { provider, setProvider, providerOptions, loading };
}

View File

@@ -36,15 +36,15 @@ export default function BudgetTelemetryCards() {
{telemetry ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">p50</span>
<span className="text-text-muted">{t("latencyP50")}</span>
<span className="font-mono">{fmt(telemetry.p50)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">p95</span>
<span className="text-text-muted">{t("latencyP95")}</span>
<span className="font-mono">{fmt(telemetry.p95)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">p99</span>
<span className="text-text-muted">{t("latencyP99")}</span>
<span className="font-mono">{fmt(telemetry.p99)}</span>
</div>
<div className="flex justify-between border-t border-border pt-2 mt-2">

View File

@@ -11,6 +11,7 @@ import { useSearchParams } from "next/navigation";
function CallbackContent() {
const searchParams = useSearchParams();
const [status, setStatus] = useState("processing");
const t = useTranslations("auth");
useEffect(() => {
const code = searchParams.get("code");
@@ -110,9 +111,7 @@ function CallbackContent() {
</div>
<h1 className="text-xl font-semibold mb-2">{t("authSuccess")}</h1>
<p className="text-text-muted">
{status === "success"
? "This window will close automatically..."
: "You can close this tab now."}
{status === "success" ? t("windowWillClose") : t("closeTabNow")}
</p>
</>
)}
@@ -123,9 +122,7 @@ function CallbackContent() {
<span className="material-symbols-outlined text-3xl text-yellow-600">info</span>
</div>
<h1 className="text-xl font-semibold mb-2">{t("copyUrl")}</h1>
<p className="text-text-muted mb-4">
Please copy the URL from the address bar and paste it in the application.
</p>
<p className="text-text-muted mb-4">{t("copyUrlManual")}</p>
<div className="bg-surface border border-border rounded-lg p-3 text-left">
<code className="text-xs break-all">
{typeof window !== "undefined" ? window.location.href : ""}
@@ -154,7 +151,7 @@ export default function CallbackPage() {
progress_activity
</span>
</div>
<p className="text-text-muted">Loading...</p>
<p className="text-text-muted">{t("loading")}</p>
</div>
</div>
}

View File

@@ -29,8 +29,7 @@ export default function ForbiddenPage() {
</div>
<h1 className="text-2xl font-semibold mb-2">{t("accessDenied")}</h1>
<p className="text-[15px] text-[var(--text-secondary,#888)] max-w-[400px] leading-relaxed mb-8">
You don&apos;t have permission to access this resource. Check your API key or contact the
administrator.
{t("accessDeniedDescription")}
</p>
<Link
href="/dashboard"
@@ -39,7 +38,7 @@ export default function ForbiddenPage() {
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
}}
>
Go to Dashboard
{t("goToDashboard")}
</Link>
</div>
);

View File

@@ -69,10 +69,10 @@ export default function LoginPage() {
router.refresh();
} else {
const data = await res.json();
setError(data.error || "Invalid password");
setError(data.error || t("invalidPassword"));
}
} catch (err) {
setError("An error occurred. Please try again.");
setError(t("errorOccurredRetry"));
} finally {
setLoading(false);
}
@@ -86,7 +86,7 @@ export default function LoginPage() {
<div className="w-10 h-10 border-2 border-primary/20 rounded-full"></div>
<div className="absolute inset-0 w-10 h-10 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
</div>
<span className="text-sm text-text-muted">Loading...</span>
<span className="text-sm text-text-muted">{t("loading")}</span>
</div>
</div>
);
@@ -105,23 +105,18 @@ export default function LoginPage() {
</span>
</div>
<h1 className="text-3xl font-bold text-text-main tracking-tight">{t("welcome")}</h1>
<p className="text-text-muted mt-2">
Let&apos;s get your OmniRoute instance configured
</p>
<p className="text-text-muted mt-2">{t("configureInstance")}</p>
</div>
<div className="bg-surface border border-border rounded-2xl p-8 shadow-soft">
<div className="text-center">
<p className="text-text-muted leading-relaxed mb-6">
Run the onboarding wizard to set up your password and connect your first AI
provider.
</p>
<p className="text-text-muted leading-relaxed mb-6">{t("runOnboardingWizard")}</p>
<Button
variant="primary"
className="w-full h-11 text-sm font-medium"
onClick={() => router.push("/dashboard/onboarding")}
>
Start Onboarding
{t("startOnboarding")}
</Button>
</div>
</div>
@@ -147,29 +142,26 @@ export default function LoginPage() {
</span>
</div>
<h1 className="text-3xl font-bold text-text-main tracking-tight">
Secure Your Instance
{t("secureYourInstance")}
</h1>
<p className="text-text-muted mt-2">{t("passwordNotEnabled")}</p>
</div>
<div className="bg-surface border border-border rounded-2xl p-8 shadow-soft">
<div className="text-center">
<p className="text-text-muted leading-relaxed mb-6">
Set a password to protect your dashboard and secure your API endpoints from
unauthorized access.
</p>
<p className="text-text-muted leading-relaxed mb-6">{t("setPasswordDescription")}</p>
<Button
variant="primary"
className="w-full h-11 text-sm font-medium"
onClick={() => router.push("/dashboard/settings?tab=security")}
>
Configure Password
{t("configurePassword")}
</Button>
</div>
</div>
<p className="text-center text-xs text-text-muted/60 mt-8">
OmniRoute Unified AI API Proxy
OmniRoute {t("unifiedAiApiProxy")}
</p>
</div>
</div>
@@ -198,7 +190,7 @@ export default function LoginPage() {
<label className="text-sm font-medium text-text-main">{t("password")}</label>
<Input
type="password"
placeholder="Enter your password"
placeholder={t("enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
@@ -219,7 +211,7 @@ export default function LoginPage() {
className="w-full h-11 text-sm font-medium"
loading={loading}
>
Continue
{t("continue")}
</Button>
</form>
@@ -228,7 +220,7 @@ export default function LoginPage() {
href="/forgot-password"
className="text-sm text-text-muted hover:text-primary transition-colors"
>
Forgot your password?
{t("forgotPassword")}
</a>
</div>
</div>
@@ -240,26 +232,27 @@ export default function LoginPage() {
>
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold text-text-main mb-3">Unified AI API Proxy</h2>
<p className="text-text-muted leading-relaxed">
Route requests to multiple AI providers through a single endpoint. Load balancing,
failover, and usage tracking built in.
</p>
<h2 className="text-2xl font-bold text-text-main mb-3">{t("unifiedAiApiProxy")}</h2>
<p className="text-text-muted leading-relaxed">{t("unifiedAiApiProxyDesc")}</p>
</div>
<div className="space-y-4">
{[
{
icon: "swap_horiz",
title: "Multi-Provider",
desc: "OpenAI, Anthropic, Google, and more",
title: t("featureMultiProviderTitle"),
desc: t("featureMultiProviderDesc"),
},
{
icon: "speed",
title: "Load Balancing",
desc: "Distribute requests intelligently",
title: t("featureLoadBalancingTitle"),
desc: t("featureLoadBalancingDesc"),
},
{
icon: "analytics",
title: t("featureUsageTrackingTitle"),
desc: t("featureUsageTrackingDesc"),
},
{ icon: "analytics", title: "Usage Tracking", desc: "Monitor costs and tokens" },
].map((item) => (
<div
key={item.icon}

View File

@@ -27,7 +27,7 @@ export default function PrivacyPage() {
<h1 className="text-3xl font-bold mb-2">{t("privacyPolicy")}</h1>
<p className="text-sm text-text-muted mb-10">
{t("lastUpdated", { date: "February 13, 2026" })}
{t("lastUpdated", { date: t("policyLastUpdatedDate") })}
</p>
<div className="space-y-8 text-text-muted leading-relaxed">

View File

@@ -27,7 +27,7 @@ export default function TermsPage() {
<h1 className="text-3xl font-bold mb-2">{t("termsOfService")}</h1>
<p className="text-sm text-text-muted mb-10">
{t("lastUpdated", { date: "February 13, 2026" })}
{t("lastUpdated", { date: t("policyLastUpdatedDate") })}
</p>
<div className="space-y-8 text-text-muted leading-relaxed">

View File

@@ -117,13 +117,13 @@
"quickStartDesc": "Get up and running in 4 steps. Connect providers, route models, monitor everything.",
"fullDocs": "Full Docs",
"step1Title": "1. Create API key",
"step1Desc": "Go to {endpoint} → Registered Keys. Generate one key per environment.",
"step1Desc": "Go to <endpoint>Endpoint</endpoint> -> Registered Keys. Generate one key per environment.",
"step2Title": "2. Connect providers",
"step2Desc": "Add accounts in {providers}. Supports OAuth, API Key, and free tiers.",
"step2Desc": "Add accounts in <providers>Providers</providers>. Supports OAuth, API Key, and free tiers.",
"step3Title": "3. Point your client",
"step3Desc": "Set base URL to {url} in your IDE or API client.",
"step4Title": "4. Monitor & optimize",
"step4Desc": "Track tokens, cost and errors in {logs} and {analytics}.",
"step4Desc": "Track tokens, cost and errors in <logs>Request Logs</logs> and <analytics>Analytics</analytics>.",
"providersOverview": "Providers Overview",
"configuredOf": "{configured} configured of {total} available providers",
"noModelsAvailable": "No models available for this provider.",
@@ -137,7 +137,13 @@
"documentation": "Documentation",
"healthMonitor": "Health Monitor",
"reportIssue": "Report issue",
"activeError": "{active} active · {errors} error"
"activeError": "{active} active · {errors} error",
"oauthLabel": "OAuth",
"apiKeyLabel": "API Key",
"requestsShort": "{count} reqs",
"providerModelsTitle": "{provider} - Models",
"copiedModel": "Copied: {model}",
"aliasLabel": "alias"
},
"analytics": {
"title": "Analytics",
@@ -243,6 +249,13 @@
"noEntries": "No audit entries found",
"filterByAction": "Filter by action...",
"filterByActor": "Filter by actor...",
"filterEntriesAria": "Filter audit log entries",
"filterByActionTypeAria": "Filter by action type",
"filterByActorAria": "Filter by actor",
"refreshAuditLogAria": "Refresh audit log",
"tableAria": "Audit log entries",
"failedFetchAuditLog": "Failed to fetch audit log",
"notAvailable": "—",
"description": "Administrative actions and security events",
"showing": "Showing {count} entries (offset {offset})",
"previous": "Previous"
@@ -258,7 +271,199 @@
"instructions": "Instructions",
"modelMapping": "Model Mapping",
"baseUrl": "Base URL",
"apiKey": "API Key"
"apiKey": "API Key",
"configured": "Configured",
"notConfigured": "Not configured",
"notInstalled": "Not installed",
"custom": "Custom",
"unknown": "Unknown",
"lastSavedAt": "Last saved: {date}",
"never": "Never",
"justNow": "just now",
"minutesAgoShort": "{count}m ago",
"hoursAgoShort": "{count}h ago",
"daysAgoShort": "{count}d ago",
"monthsAgoShort": "{count}mo ago",
"yearsAgoShort": "{count}y ago",
"runtimeCheckFailed": "Runtime check failed",
"yourApiKeyPlaceholder": "your-api-key",
"modelPlaceholder": "provider/model-id",
"configurationSaved": "Configuration saved successfully.",
"failedToSave": "Failed to save configuration.",
"noApiKeysCreateOne": "No API keys - Create one in Keys page",
"defaultOmnirouteKey": "sk_omniroute (default)",
"selectModel": "Select Model",
"selectModelForAlias": "Select model for {alias}",
"selectModelForTool": "Select Model for {tool}",
"select": "Select",
"clear": "Clear",
"comingSoon": "Coming soon",
"checkingRuntime": "Checking runtime status...",
"guideOnlyIntegration": "Guide-only integration (no local runtime required)",
"cliRuntimeDetected": "CLI runtime detected and ready",
"cliFoundNotRunnable": "CLI found but not runnable{reason}",
"cliRuntimeNotDetected": "CLI runtime not detected",
"binary": "Binary",
"configPath": "Config path",
"configPathShort": "Config",
"failedCheckRuntimeStatus": "Failed to check runtime status.",
"copy": "Copy",
"copied": "Copied",
"copyConfig": "Copy Config",
"saveConfig": "Save Config",
"selectionSaved": "Selection saved",
"guide": "Guide",
"detected": "Detected",
"notReady": "Not ready",
"active": "Active",
"inactive": "Inactive",
"startMitm": "Start MITM",
"stopMitm": "Stop MITM",
"mitmStarted": "MITM started successfully!",
"mitmStopped": "MITM stopped successfully!",
"failedStart": "Failed to start MITM",
"failedStop": "Failed to stop MITM",
"saveMappings": "Save Mappings",
"mappingsSaved": "Mappings saved!",
"failedSaveMappings": "Failed to save mappings",
"howItWorks": "How it works:",
"antigravityHowWorksDesc": "Antigravity sends requests to Google's endpoint. MITM intercepts and redirects them to OmniRoute.",
"antigravityStep1": "1. Start MITM to route requests through OmniRoute.",
"antigravityStep2Prefix": "2. Add",
"antigravityStep2Suffix": "to your hosts file as 127.0.0.1.",
"antigravityStep3": "3. Open Antigravity and requests will be proxied.",
"sudoPasswordRequiredTitle": "Sudo Password Required",
"sudoPasswordHint": "Administrator password is required to modify hosts file and system proxy settings.",
"enterSudoPassword": "Enter sudo password",
"sudoPasswordRequiredError": "Sudo password is required.",
"cancel": "Cancel",
"confirm": "Confirm",
"settingsApplied": "Settings applied successfully!",
"failedApplySettings": "Failed to apply settings",
"settingsReset": "Settings reset successfully!",
"failedResetSettings": "Failed to reset settings",
"backupRestored": "Backup restored!",
"failedRestore": "Failed to restore",
"checkingCli": "Checking {tool} CLI...",
"cliNotRunnable": "{tool} CLI installed but not runnable",
"cliNotInstalled": "{tool} CLI not installed",
"cliNotDetected": "{tool} CLI not detected",
"cliDetectedReady": "{tool} CLI detected and ready",
"cliFoundFailedHealthcheck": "{tool} CLI was found but failed runtime healthcheck{reason}.",
"installCliPrompt": "Please install {tool} CLI to use this feature.",
"installCodexPrompt": "Please install Codex CLI to use auto-apply feature.",
"hide": "Hide",
"howToInstall": "How to Install",
"installationGuide": "Installation Guide",
"platforms": "macOS / Linux / Windows:",
"afterInstallationRun": "After installation, run",
"toVerify": "to verify.",
"current": "Current",
"baseUrlPlaceholder": "https://.../v1",
"resetToDefault": "Reset to default",
"providerModelPlaceholder": "provider/model-id",
"apply": "Apply",
"reset": "Reset",
"manualConfig": "Manual Config",
"backups": "Backups",
"configBackups": "Config Backups",
"noBackupsYet": "No backups yet. Backups are created automatically before each Apply or Reset.",
"restore": "Restore",
"backupRestoredReloading": "Backup restored! Reloading status...",
"failedRestoreBackup": "Failed to restore backup",
"applied": "Applied!",
"failed": "Failed",
"resetDone": "Reset!",
"omnirouteConfiguredOpenAiCompatible": "OmniRoute is configured as OpenAI-compatible provider",
"provider": "Provider",
"model": "Model",
"providers": "Providers",
"auth": "Auth",
"noApiKeysAvailable": "No API keys available",
"usingDefaultOmniroute": "Using default: sk_omniroute",
"updateConfig": "Update Config",
"applyConfig": "Apply Config",
"noBackupsAvailable": "No backups available.",
"profileSaved": "Profile \"{name}\" saved!",
"failedSaveProfile": "Failed to save profile",
"profileActivated": "Profile activated!",
"failedActivateProfile": "Failed to activate profile",
"profiles": "Profiles",
"savedProfiles": "Saved Profiles",
"noProfilesYet": "No profiles saved yet. Save current config as a profile below.",
"activate": "Activate",
"deleteProfile": "Delete profile",
"profileNamePlaceholder": "Profile name (e.g. Personal Account)",
"saveCurrent": "Save Current",
"codexAuthNotePrefix": "Codex uses",
"codexAuthNoteMiddle": "with",
"codexAuthNoteSuffix": "Click \"Apply\" to auto-configure.",
"claudeManualConfiguration": "Claude CLI - Manual Configuration",
"codexManualConfiguration": "Codex CLI - Manual Configuration",
"droidManualConfiguration": "Factory Droid - Manual Configuration",
"openClawManualConfiguration": "Open Claw - Manual Configuration",
"clineManualConfiguration": "Cline Manual Configuration",
"kiloManualConfiguration": "Kilo Code Manual Configuration",
"toolDescriptions": {
"antigravity": "Google Antigravity IDE with MITM",
"claude": "Anthropic Claude Code CLI",
"codex": "OpenAI Codex CLI",
"droid": "Factory Droid AI Assistant",
"openclaw": "Open Claw AI Assistant",
"cline": "Cline AI Coding Assistant CLI",
"kilo": "Kilo Code AI Assistant CLI",
"cursor": "Cursor AI Code Editor",
"continue": "Continue AI Assistant"
},
"guides": {
"cursor": {
"notes": {
"0": "Requires Cursor Pro account to use this feature.",
"1": "Cursor routes requests through its own server, so local endpoint is not supported. Please enable Cloud Endpoint in Settings."
},
"steps": {
"1": {
"title": "Open Settings",
"desc": "Go to Settings -> Models"
},
"2": {
"title": "Enable OpenAI API",
"desc": "Enable \"OpenAI API key\" option"
},
"3": {
"title": "Base URL"
},
"4": {
"title": "API Key"
},
"5": {
"title": "Add Custom Model",
"desc": "Click \"View All Model\" -> \"Add Custom Model\""
},
"6": {
"title": "Select Model"
}
}
},
"continue": {
"steps": {
"1": {
"title": "Open Config",
"desc": "Open Continue configuration file"
},
"2": {
"title": "API Key"
},
"3": {
"title": "Select Model"
},
"4": {
"title": "Add Model Config",
"desc": "Add the following configuration to your models array:"
}
}
}
}
},
"combos": {
"title": "Combos",
@@ -390,10 +595,23 @@
"disabling": "Disabling...",
"cloudConnectedVerified": "Cloud Proxy connected and verified!",
"connectedVerificationPending": "Connected \u2014 verification pending",
"connectedVerificationPendingWithError": "Connected \u2014 verification pending: {error}",
"cloudDisabledSuccess": "Cloud disabled successfully",
"syncedSuccess": "Synced successfully",
"failedDisable": "Failed to disable cloud",
"failedEnable": "Failed to enable cloud"
"failedEnable": "Failed to enable cloud",
"cloudRequestTimeout": "Cloud request timeout",
"cloudRequestFailed": "Cloud request failed",
"cloudWorkerUnreachable": "Could not reach cloud worker. Make sure the cloud service is running (npm run dev in /cloud).",
"connectionFailed": "Connection failed",
"syncFailed": "Failed to sync cloud data",
"providerModelsTitle": "{provider} \u2014 Models",
"noModelsForProvider": "No models available for this provider.",
"chat": "Chat",
"embedding": "Embedding",
"image": "Image",
"custom": "custom",
"modelsCount": "{count, plural, one {# model} other {# models}}"
},
"health": {
"title": "System Health",
@@ -419,7 +637,13 @@
"retry": "Retry",
"allOperational": "All systems operational",
"issuesDetected": "System issues detected",
"updatedAt": "Updated {time}",
"latency": "Latency",
"latencyP50": "p50",
"latencyP95": "p95",
"latencyP99": "p99",
"millisecondsShort": "{value}ms",
"notAvailable": "—",
"totalRequests": "Total requests",
"noDataYet": "No data yet",
"promptCache": "Prompt Cache",
@@ -427,10 +651,18 @@
"hitRate": "Hit Rate",
"hitsMisses": "Hits / Misses",
"signatureCache": "Signature Cache",
"signatureDefaults": "Defaults",
"signatureTool": "Tool",
"signatureFamily": "Family",
"signatureSession": "Session",
"recovering": "Recovering",
"noCBData": "No circuit breaker data available. Make some requests first.",
"providerHealthStatusAria": "Provider health status",
"issuesLabel": "Issues Detected",
"operational": "Operational",
"providers": "Providers",
"healthyCount": "{count} healthy",
"nodeVersion": "Node {version}",
"failures": "{count} failure",
"failuresPlural": "{count} failures",
"lastFailure": "Last",
@@ -438,9 +670,13 @@
"activeLimiters": "{count} active limiter",
"activeLimitersPlural": "{count} active limiters",
"queued": "Queued",
"queuedCount": "{count} queued",
"running": "running",
"runningCount": "{count} running",
"ok": "OK",
"activeLockouts": "Active Lockouts",
"resetConfirm": "Reset all circuit breakers to healthy state? This will clear all failure counts and restore all providers to operational status.",
"resetAllTitle": "Reset all circuit breakers to healthy state",
"resetting": "Resetting...",
"resetAll": "Reset All",
"until": "Until {time}"
@@ -464,12 +700,21 @@
"refresh": "Refresh",
"filterByAction": "Filter by action...",
"filterByActor": "Filter by actor...",
"filterEntriesAria": "Filter audit log entries",
"filterByActionTypeAria": "Filter by action type",
"filterByActorAria": "Filter by actor",
"refreshAuditLogAria": "Refresh audit log",
"tableAria": "Audit log entries",
"failedFetchAuditLog": "Failed to fetch audit log",
"showing": "Showing {count} entries (offset {offset})",
"search": "Search",
"timestamp": "Timestamp",
"action": "Action",
"actor": "Actor",
"target": "Target",
"details": "Details",
"ipAddress": "IP Address",
"notAvailable": "—",
"noEntries": "No audit log entries found",
"previous": "Previous",
"next": "Next"
@@ -1206,6 +1451,51 @@
"scenarioThinking": "Thinking",
"scenarioSystemPrompt": "System Prompt",
"scenarioStreaming": "Streaming",
"templateNames": {
"simple-chat": "Simple Chat",
"tool-calling": "Tool Calling",
"multi-turn": "Multi-turn",
"thinking": "Thinking",
"system-prompt": "System Prompt",
"streaming": "Streaming"
},
"templateDescriptions": {
"simple-chat": "Basic text message",
"tool-calling": "Function/tool invocation",
"multi-turn": "Conversation with history",
"thinking": "Extended thinking / reasoning",
"system-prompt": "Complex system instructions",
"streaming": "SSE streaming request"
},
"templatePayloads": {
"simpleChat": {
"system": "You are a helpful assistant.",
"userGreeting": "Hello! How are you today?"
},
"toolCalling": {
"userWeather": "What's the weather in São Paulo?",
"toolDescription": "Get current weather for a location",
"cityNameDescription": "City name"
},
"multiTurn": {
"system": "You are a coding assistant.",
"userInitial": "Write a function to sort an array in Python.",
"assistantExample": "Here's a simple sort function:\n\n```python\ndef sort_array(arr):\n return sorted(arr)\n```",
"userFollowUp": "Now make it sort in descending order."
},
"thinking": {
"question": "What is the sum of the first 100 prime numbers?"
},
"systemPrompt": {
"systemInstruction": "You are a senior software engineer specializing in distributed systems. Answer questions concisely using industry best practices. Always provide code examples when relevant. Format your responses using markdown.",
"question": "How do I implement a circuit breaker pattern?"
},
"streaming": {
"prompt": "Tell me a short story about a robot learning to paint."
}
},
"openaiCompatibleLabel": "OpenAI Compatible",
"anthropicCompatibleLabel": "Anthropic Compatible",
"noTemplateForFormat": "No template for this format",
"translationFailed": "Translation failed: {error}",
"pipelineDebugger": "Pipeline Debugger",
@@ -1270,6 +1560,9 @@
"totalRequests": "Total requests",
"noDataYet": "No data yet",
"latency": "Latency",
"latencyP50": "p50",
"latencyP95": "p95",
"latencyP99": "p99",
"promptCache": "Prompt Cache",
"systemHealth": "System Health",
"entries": "Entries",
@@ -1457,7 +1750,30 @@
"enterPassword": "Enter your password to continue",
"password": "Password",
"unifiedProxy": "Unified AI API Proxy",
"unifiedAiApiProxy": "Unified AI API Proxy",
"unifiedAiApiProxyDesc": "Route requests to multiple AI providers through a single endpoint. Load balancing, failover, and usage tracking built in.",
"passwordNotEnabled": "Password protection is not enabled",
"loading": "Loading...",
"invalidPassword": "Invalid password",
"errorOccurredRetry": "An error occurred. Please try again.",
"configureInstance": "Let's get your OmniRoute instance configured",
"runOnboardingWizard": "Run the onboarding wizard to set up your password and connect your first AI provider.",
"startOnboarding": "Start Onboarding",
"secureYourInstance": "Secure Your Instance",
"setPasswordDescription": "Set a password to protect your dashboard and secure your API endpoints from unauthorized access.",
"configurePassword": "Configure Password",
"continue": "Continue",
"windowWillClose": "This window will close automatically...",
"closeTabNow": "You can close this tab now.",
"copyUrlManual": "Please copy the URL from the address bar and paste it in the application.",
"accessDeniedDescription": "You don't have permission to access this resource. Check your API key or contact the administrator.",
"goToDashboard": "Go to Dashboard",
"featureMultiProviderTitle": "Multi-Provider",
"featureMultiProviderDesc": "OpenAI, Anthropic, Google, and more",
"featureLoadBalancingTitle": "Load Balancing",
"featureLoadBalancingDesc": "Distribute requests intelligently",
"featureUsageTrackingTitle": "Usage Tracking",
"featureUsageTrackingDesc": "Monitor costs and tokens",
"resetPassword": "Reset Password",
"resetDescription": "Choose a method to recover access to your dashboard",
"stopServer": "Stop the OmniRoute server",
@@ -1678,6 +1994,7 @@
"termsMetadataDescription": "Terms of service for the OmniRoute AI API proxy router.",
"backToHome": "Back to home",
"lastUpdated": "Last updated: {date}",
"policyLastUpdatedDate": "February 13, 2026",
"listSeparator": "-",
"questionsVisit": "Questions? Visit our",
"githubRepository": "GitHub repository",

View File

@@ -117,13 +117,13 @@
"quickStartDesc": "Comece em 4 passos. Conecte provedores, roteie modelos, monitore tudo.",
"fullDocs": "Docs Completa",
"step1Title": "1. Criar chave de API",
"step1Desc": "Vá em {endpoint} → Chaves Registradas. Gere uma chave por ambiente.",
"step1Desc": "Vá em <endpoint>Endpoint</endpoint> -> Chaves Registradas. Gere uma chave por ambiente.",
"step2Title": "2. Conectar provedores",
"step2Desc": "Adicione contas em {providers}. Suporta OAuth, API Key e planos gratuitos.",
"step2Desc": "Adicione contas em <providers>Provedores</providers>. Suporta OAuth, API Key e planos gratuitos.",
"step3Title": "3. Apontar seu cliente",
"step3Desc": "Defina a URL base como {url} no seu IDE ou cliente de API.",
"step4Title": "4. Monitorar e otimizar",
"step4Desc": "Acompanhe tokens, custos e erros em {logs} e {analytics}.",
"step4Desc": "Acompanhe tokens, custos e erros em <logs>Logs de Requisições</logs> e <analytics>Análises</analytics>.",
"providersOverview": "Visão Geral dos Provedores",
"configuredOf": "{configured} configurados de {total} provedores disponíveis",
"noModelsAvailable": "Nenhum modelo disponível para este provedor.",
@@ -137,7 +137,13 @@
"documentation": "Documentação",
"healthMonitor": "Monitor de Saúde",
"reportIssue": "Reportar problema",
"activeError": "{active} ativo · {errors} erro"
"activeError": "{active} ativo · {errors} erro",
"oauthLabel": "OAuth",
"apiKeyLabel": "Chave de API",
"requestsShort": "{count} reqs",
"providerModelsTitle": "{provider} - Modelos",
"copiedModel": "Copiado: {model}",
"aliasLabel": "alias"
},
"analytics": {
"title": "Análises",
@@ -243,6 +249,13 @@
"noEntries": "Nenhum registro de auditoria",
"filterByAction": "Filtrar por ação...",
"filterByActor": "Filtrar por autor...",
"filterEntriesAria": "Filtrar entradas do log de auditoria",
"filterByActionTypeAria": "Filtrar por tipo de ação",
"filterByActorAria": "Filtrar por autor",
"refreshAuditLogAria": "Atualizar log de auditoria",
"tableAria": "Entradas do log de auditoria",
"failedFetchAuditLog": "Falha ao carregar log de auditoria",
"notAvailable": "—",
"description": "Ações administrativas e eventos de segurança",
"showing": "Mostrando {count} entradas (offset {offset})",
"previous": "Anterior"
@@ -258,7 +271,199 @@
"instructions": "Instruções",
"modelMapping": "Mapeamento de Modelos",
"baseUrl": "URL Base",
"apiKey": "Chave de API"
"apiKey": "Chave de API",
"configured": "Configurado",
"notConfigured": "Não configurado",
"notInstalled": "Não instalado",
"custom": "Customizado",
"unknown": "Desconhecido",
"lastSavedAt": "Último salvamento: {date}",
"never": "Nunca",
"justNow": "agora mesmo",
"minutesAgoShort": "{count} min atrás",
"hoursAgoShort": "{count} h atrás",
"daysAgoShort": "{count} d atrás",
"monthsAgoShort": "{count} mês(es) atrás",
"yearsAgoShort": "{count} ano(s) atrás",
"runtimeCheckFailed": "Falha ao verificar runtime",
"yourApiKeyPlaceholder": "sua-api-key",
"modelPlaceholder": "provedor/modelo-id",
"configurationSaved": "Configuração salva com sucesso.",
"failedToSave": "Falha ao salvar configuração.",
"noApiKeysCreateOne": "Sem chaves de API - crie uma na página Chaves",
"defaultOmnirouteKey": "sk_omniroute (padrão)",
"selectModel": "Selecionar Modelo",
"selectModelForAlias": "Selecionar modelo para {alias}",
"selectModelForTool": "Selecionar Modelo para {tool}",
"select": "Selecionar",
"clear": "Limpar",
"comingSoon": "Em breve",
"checkingRuntime": "Verificando status de runtime...",
"guideOnlyIntegration": "Integração apenas por guia (não requer runtime local)",
"cliRuntimeDetected": "Runtime de CLI detectado e pronto",
"cliFoundNotRunnable": "CLI encontrado, mas não executável{reason}",
"cliRuntimeNotDetected": "Runtime de CLI não detectado",
"binary": "Binário",
"configPath": "Caminho de config",
"configPathShort": "Config",
"failedCheckRuntimeStatus": "Falha ao verificar status de runtime.",
"copy": "Copiar",
"copied": "Copiado",
"copyConfig": "Copiar Config",
"saveConfig": "Salvar Config",
"selectionSaved": "Seleção salva",
"guide": "Guia",
"detected": "Detectado",
"notReady": "Não pronto",
"active": "Ativo",
"inactive": "Inativo",
"startMitm": "Iniciar MITM",
"stopMitm": "Parar MITM",
"mitmStarted": "MITM iniciado com sucesso!",
"mitmStopped": "MITM parado com sucesso!",
"failedStart": "Falha ao iniciar MITM",
"failedStop": "Falha ao parar MITM",
"saveMappings": "Salvar Mapeamentos",
"mappingsSaved": "Mapeamentos salvos!",
"failedSaveMappings": "Falha ao salvar mapeamentos",
"howItWorks": "Como funciona:",
"antigravityHowWorksDesc": "O Antigravity envia requisições para o endpoint do Google. O MITM intercepta e redireciona para o OmniRoute.",
"antigravityStep1": "1. Inicie o MITM para rotear as requisições pelo OmniRoute.",
"antigravityStep2Prefix": "2. Adicione",
"antigravityStep2Suffix": "ao arquivo hosts como 127.0.0.1.",
"antigravityStep3": "3. Abra o Antigravity e as requisições serão proxyadas.",
"sudoPasswordRequiredTitle": "Senha sudo necessária",
"sudoPasswordHint": "A senha de administrador é necessária para modificar hosts e configurações de proxy do sistema.",
"enterSudoPassword": "Digite a senha sudo",
"sudoPasswordRequiredError": "A senha sudo é obrigatória.",
"cancel": "Cancelar",
"confirm": "Confirmar",
"settingsApplied": "Configurações aplicadas com sucesso!",
"failedApplySettings": "Falha ao aplicar configurações",
"settingsReset": "Configurações resetadas com sucesso!",
"failedResetSettings": "Falha ao resetar configurações",
"backupRestored": "Backup restaurado!",
"failedRestore": "Falha ao restaurar",
"checkingCli": "Verificando CLI {tool}...",
"cliNotRunnable": "CLI {tool} instalado, mas não executável",
"cliNotInstalled": "CLI {tool} não instalado",
"cliNotDetected": "CLI {tool} não detectado",
"cliDetectedReady": "CLI {tool} detectado e pronto",
"cliFoundFailedHealthcheck": "CLI {tool} foi encontrado, mas falhou no healthcheck de runtime{reason}.",
"installCliPrompt": "Instale o CLI {tool} para usar este recurso.",
"installCodexPrompt": "Instale o Codex CLI para usar a aplicação automática.",
"hide": "Ocultar",
"howToInstall": "Como instalar",
"installationGuide": "Guia de instalação",
"platforms": "macOS / Linux / Windows:",
"afterInstallationRun": "Após a instalação, execute",
"toVerify": "para verificar.",
"current": "Atual",
"baseUrlPlaceholder": "https://.../v1",
"resetToDefault": "Redefinir para padrão",
"providerModelPlaceholder": "provedor/modelo-id",
"apply": "Aplicar",
"reset": "Resetar",
"manualConfig": "Configuração manual",
"backups": "Backups",
"configBackups": "Backups de configuração",
"noBackupsYet": "Ainda não há backups. Backups são criados automaticamente antes de cada Aplicar ou Resetar.",
"restore": "Restaurar",
"backupRestoredReloading": "Backup restaurado! Recarregando status...",
"failedRestoreBackup": "Falha ao restaurar backup",
"applied": "Aplicado!",
"failed": "Falhou",
"resetDone": "Resetado!",
"omnirouteConfiguredOpenAiCompatible": "OmniRoute está configurado como provedor compatível com OpenAI",
"provider": "Provedor",
"model": "Modelo",
"providers": "Provedores",
"auth": "Autenticação",
"noApiKeysAvailable": "Nenhuma chave de API disponível",
"usingDefaultOmniroute": "Usando padrão: sk_omniroute",
"updateConfig": "Atualizar config",
"applyConfig": "Aplicar config",
"noBackupsAvailable": "Nenhum backup disponível.",
"profileSaved": "Perfil \"{name}\" salvo!",
"failedSaveProfile": "Falha ao salvar perfil",
"profileActivated": "Perfil ativado!",
"failedActivateProfile": "Falha ao ativar perfil",
"profiles": "Perfis",
"savedProfiles": "Perfis salvos",
"noProfilesYet": "Nenhum perfil salvo ainda. Salve a configuração atual como perfil abaixo.",
"activate": "Ativar",
"deleteProfile": "Excluir perfil",
"profileNamePlaceholder": "Nome do perfil (ex: Conta Pessoal)",
"saveCurrent": "Salvar atual",
"codexAuthNotePrefix": "Codex usa",
"codexAuthNoteMiddle": "com",
"codexAuthNoteSuffix": "Clique em \"Aplicar\" para configurar automaticamente.",
"claudeManualConfiguration": "Claude CLI - Configuração manual",
"codexManualConfiguration": "Codex CLI - Configuração manual",
"droidManualConfiguration": "Factory Droid - Configuração manual",
"openClawManualConfiguration": "Open Claw - Configuração manual",
"clineManualConfiguration": "Configuração manual do Cline",
"kiloManualConfiguration": "Configuração manual do Kilo Code",
"toolDescriptions": {
"antigravity": "Google Antigravity IDE com MITM",
"claude": "CLI Claude Code da Anthropic",
"codex": "CLI Codex da OpenAI",
"droid": "Assistente de IA Factory Droid",
"openclaw": "Assistente de IA Open Claw",
"cline": "CLI assistente de codificação Cline",
"kilo": "CLI assistente de IA Kilo Code",
"cursor": "Editor de código com IA Cursor",
"continue": "Assistente de IA Continue"
},
"guides": {
"cursor": {
"notes": {
"0": "Requer conta Cursor Pro para usar este recurso.",
"1": "O Cursor roteia requisições pelo próprio servidor, então endpoint local não é suportado. Ative o Cloud Endpoint em Configurações."
},
"steps": {
"1": {
"title": "Abrir Configurações",
"desc": "Vá em Configurações -> Modelos"
},
"2": {
"title": "Ativar OpenAI API",
"desc": "Ative a opção \"OpenAI API key\""
},
"3": {
"title": "URL Base"
},
"4": {
"title": "Chave de API"
},
"5": {
"title": "Adicionar Modelo Customizado",
"desc": "Clique em \"View All Model\" -> \"Add Custom Model\""
},
"6": {
"title": "Selecionar Modelo"
}
}
},
"continue": {
"steps": {
"1": {
"title": "Abrir Config",
"desc": "Abra o arquivo de configuração do Continue"
},
"2": {
"title": "Chave de API"
},
"3": {
"title": "Selecionar Modelo"
},
"4": {
"title": "Adicionar Config de Modelo",
"desc": "Adicione a configuração abaixo ao array de modelos:"
}
}
}
}
},
"combos": {
"title": "Combos",
@@ -390,10 +595,23 @@
"disabling": "Desativando...",
"cloudConnectedVerified": "Proxy na Nuvem conectado e verificado!",
"connectedVerificationPending": "Conectado — verificação pendente",
"connectedVerificationPendingWithError": "Conectado — verificação pendente: {error}",
"cloudDisabledSuccess": "Nuvem desativada com sucesso",
"syncedSuccess": "Sincronizado com sucesso",
"failedDisable": "Falha ao desativar nuvem",
"failedEnable": "Falha ao ativar nuvem"
"failedEnable": "Falha ao ativar nuvem",
"cloudRequestTimeout": "Tempo limite da requisição em nuvem",
"cloudRequestFailed": "Falha na requisição em nuvem",
"cloudWorkerUnreachable": "Não foi possível alcançar o worker de nuvem. Verifique se o serviço cloud está rodando (npm run dev em /cloud).",
"connectionFailed": "Falha na conexão",
"syncFailed": "Falha ao sincronizar dados da nuvem",
"providerModelsTitle": "{provider} — Modelos",
"noModelsForProvider": "Nenhum modelo disponível para este provedor.",
"chat": "Chat",
"embedding": "Embedding",
"image": "Imagem",
"custom": "custom",
"modelsCount": "{count, plural, one {# modelo} other {# modelos}}"
},
"health": {
"title": "Saúde do Sistema",
@@ -419,7 +637,13 @@
"retry": "Tentar Novamente",
"allOperational": "Todos os sistemas operacionais",
"issuesDetected": "Problemas detectados no sistema",
"updatedAt": "Atualizado {time}",
"latency": "Latência",
"latencyP50": "p50",
"latencyP95": "p95",
"latencyP99": "p99",
"millisecondsShort": "{value}ms",
"notAvailable": "—",
"totalRequests": "Total de requisições",
"noDataYet": "Sem dados ainda",
"promptCache": "Cache de Prompt",
@@ -427,10 +651,18 @@
"hitRate": "Taxa de Acerto",
"hitsMisses": "Acertos / Erros",
"signatureCache": "Cache de Assinatura",
"signatureDefaults": "Padrões",
"signatureTool": "Ferramenta",
"signatureFamily": "Família",
"signatureSession": "Sessão",
"recovering": "Recuperando",
"noCBData": "Nenhum dado de circuit breaker disponível. Faça algumas requisições primeiro.",
"providerHealthStatusAria": "Status de saúde dos provedores",
"issuesLabel": "Problemas Detectados",
"operational": "Operacional",
"providers": "Provedores",
"healthyCount": "{count} saudáveis",
"nodeVersion": "Node {version}",
"failures": "{count} falha",
"failuresPlural": "{count} falhas",
"lastFailure": "Última",
@@ -438,9 +670,13 @@
"activeLimiters": "{count} limitador ativo",
"activeLimitersPlural": "{count} limitadores ativos",
"queued": "Na Fila",
"queuedCount": "{count} na fila",
"running": "executando",
"runningCount": "{count} executando",
"ok": "OK",
"activeLockouts": "Bloqueios Ativos",
"resetConfirm": "Resetar todos os circuit breakers para estado saudável? Isso limpará todos os contadores de falha e restaurará todos os provedores ao status operacional.",
"resetAllTitle": "Resetar todos os circuit breakers para estado saudável",
"resetting": "Resetando...",
"resetAll": "Resetar Tudo",
"until": "Até {time}"
@@ -464,12 +700,21 @@
"refresh": "Atualizar",
"filterByAction": "Filtrar por ação...",
"filterByActor": "Filtrar por ator...",
"filterEntriesAria": "Filtrar entradas do log de auditoria",
"filterByActionTypeAria": "Filtrar por tipo de ação",
"filterByActorAria": "Filtrar por ator",
"refreshAuditLogAria": "Atualizar log de auditoria",
"tableAria": "Entradas do log de auditoria",
"failedFetchAuditLog": "Falha ao carregar log de auditoria",
"showing": "Mostrando {count} entradas (offset {offset})",
"search": "Buscar",
"timestamp": "Data/Hora",
"action": "Ação",
"actor": "Ator",
"target": "Alvo",
"details": "Detalhes",
"ipAddress": "Endereço IP",
"notAvailable": "—",
"noEntries": "Nenhuma entrada de log de auditoria encontrada",
"previous": "Anterior",
"next": "Próximo"
@@ -1206,6 +1451,51 @@
"scenarioThinking": "Raciocínio",
"scenarioSystemPrompt": "Prompt de Sistema",
"scenarioStreaming": "Streaming",
"templateNames": {
"simple-chat": "Chat Simples",
"tool-calling": "Chamada de Ferramenta",
"multi-turn": "Multiturno",
"thinking": "Raciocínio",
"system-prompt": "Prompt de Sistema",
"streaming": "Streaming"
},
"templateDescriptions": {
"simple-chat": "Mensagem de texto básica",
"tool-calling": "Invocação de função/ferramenta",
"multi-turn": "Conversa com histórico",
"thinking": "Raciocínio estendido",
"system-prompt": "Instruções de sistema complexas",
"streaming": "Requisição de streaming SSE"
},
"templatePayloads": {
"simpleChat": {
"system": "Você é um assistente prestativo.",
"userGreeting": "Olá! Como você está hoje?"
},
"toolCalling": {
"userWeather": "Como está o tempo em São Paulo?",
"toolDescription": "Obtém o clima atual para uma localidade",
"cityNameDescription": "Nome da cidade"
},
"multiTurn": {
"system": "Você é um assistente de programação.",
"userInitial": "Escreva uma função para ordenar um array em Python.",
"assistantExample": "Aqui está uma função simples de ordenação:\n\n```python\ndef sort_array(arr):\n return sorted(arr)\n```",
"userFollowUp": "Agora faça para ordenar em ordem decrescente."
},
"thinking": {
"question": "Qual é a soma dos 100 primeiros números primos?"
},
"systemPrompt": {
"systemInstruction": "Você é um engenheiro de software sênior especializado em sistemas distribuídos. Responda de forma concisa usando boas práticas de mercado. Sempre forneça exemplos de código quando relevante. Formate suas respostas usando markdown.",
"question": "Como implemento o padrão circuit breaker?"
},
"streaming": {
"prompt": "Conte uma história curta sobre um robô aprendendo a pintar."
}
},
"openaiCompatibleLabel": "Compatível com OpenAI",
"anthropicCompatibleLabel": "Compatível com Anthropic",
"noTemplateForFormat": "Sem modelo para este formato",
"translationFailed": "Falha na tradução: {error}",
"pipelineDebugger": "Depurador de Pipeline",
@@ -1270,6 +1560,9 @@
"totalRequests": "Total de requisições",
"noDataYet": "Sem dados ainda",
"latency": "Latência",
"latencyP50": "p50",
"latencyP95": "p95",
"latencyP99": "p99",
"promptCache": "Cache de Prompt",
"systemHealth": "Saúde do Sistema",
"entries": "Entradas",
@@ -1457,7 +1750,30 @@
"enterPassword": "Digite sua senha para continuar",
"password": "Senha",
"unifiedProxy": "Proxy Unificado de API de IA",
"unifiedAiApiProxy": "Proxy Unificado de API de IA",
"unifiedAiApiProxyDesc": "Roteie requisições para múltiplos provedores de IA por um único endpoint. Balanceamento de carga, failover e rastreamento de uso integrados.",
"passwordNotEnabled": "Proteção por senha não está ativada",
"loading": "Carregando...",
"invalidPassword": "Senha inválida",
"errorOccurredRetry": "Ocorreu um erro. Tente novamente.",
"configureInstance": "Vamos configurar sua instância OmniRoute",
"runOnboardingWizard": "Execute o assistente de onboarding para definir sua senha e conectar seu primeiro provedor de IA.",
"startOnboarding": "Iniciar Onboarding",
"secureYourInstance": "Proteja sua Instância",
"setPasswordDescription": "Defina uma senha para proteger seu painel e garantir que seus endpoints de API não sejam acessados sem autorização.",
"configurePassword": "Configurar Senha",
"continue": "Continuar",
"windowWillClose": "Esta janela será fechada automaticamente...",
"closeTabNow": "Você já pode fechar esta aba.",
"copyUrlManual": "Copie a URL da barra de endereços e cole no aplicativo.",
"accessDeniedDescription": "Você não tem permissão para acessar este recurso. Verifique sua chave de API ou contate o administrador.",
"goToDashboard": "Ir para o Painel",
"featureMultiProviderTitle": "Multi-Provedor",
"featureMultiProviderDesc": "OpenAI, Anthropic, Google e outros",
"featureLoadBalancingTitle": "Balanceamento de Carga",
"featureLoadBalancingDesc": "Distribua requisições de forma inteligente",
"featureUsageTrackingTitle": "Rastreamento de Uso",
"featureUsageTrackingDesc": "Monitore custos e tokens",
"resetPassword": "Redefinir Senha",
"resetDescription": "Escolha um método para recuperar acesso ao painel",
"stopServer": "Pare o servidor OmniRoute",
@@ -1678,6 +1994,7 @@
"termsMetadataDescription": "Termos de serviço do roteador proxy de API de IA OmniRoute.",
"backToHome": "Voltar para a home",
"lastUpdated": "Última atualização: {date}",
"policyLastUpdatedDate": "13 de fevereiro de 2026",
"listSeparator": "-",
"questionsVisit": "Dúvidas? Visite nosso",
"githubRepository": "repositório no GitHub",