Merge branch 'main' into refactor-split-ports

This commit is contained in:
Steven
2026-02-26 15:17:56 +00:00
committed by GitHub
182 changed files with 13566 additions and 4012 deletions

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { useState, useEffect, useMemo, useCallback } from "react";
import PropTypes from "prop-types";
import Image from "next/image";
@@ -10,6 +12,9 @@ import { AI_PROVIDERS, FREE_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constant
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);
@@ -103,14 +108,14 @@ export default function HomePageClient({ machineId }) {
}, [selectedProvider, models]);
const quickStartLinks = [
{ label: "Documentation", href: "/docs", icon: "menu_book" },
{ label: "Providers", href: "/dashboard/providers", icon: "dns" },
{ label: "Combos", href: "/dashboard/combos", icon: "layers" },
{ label: "Analytics", href: "/dashboard/analytics", icon: "analytics" },
{ label: "Health Monitor", href: "/dashboard/health", icon: "health_and_safety" },
{ label: "CLI Tools", href: "/dashboard/cli-tools", icon: "terminal" },
{ label: t("documentation"), href: "/docs", icon: "menu_book" },
{ 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: ts("cliTools"), href: "/dashboard/cli-tools", icon: "terminal" },
{
label: "Report issue",
label: t("reportIssue"),
href: "https://github.com/diegosouzapw/OmniRoute/issues",
external: true,
icon: "bug_report",
@@ -135,17 +140,15 @@ export default function HomePageClient({ machineId }) {
<div className="flex flex-col gap-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Quick Start</h2>
<p className="text-sm text-text-muted">
Get up and running in 4 steps. Connect providers, route models, monitor everything.
</p>
<h2 className="text-lg font-semibold">{t("quickStart")}</h2>
<p className="text-sm text-text-muted">{t("quickStartDesc")}</p>
</div>
<Link
href="/docs"
className="hidden sm: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]">menu_book</span>
Full Docs
{t("fullDocs")}
</Link>
</div>
@@ -155,13 +158,15 @@ export default function HomePageClient({ machineId }) {
<span className="material-symbols-outlined text-[18px]">key</span>
</div>
<div>
<span className="font-semibold">1. Create API key</span>
<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>
@@ -170,13 +175,15 @@ export default function HomePageClient({ machineId }) {
<span className="material-symbols-outlined text-[18px]">dns</span>
</div>
<div>
<span className="font-semibold">2. Connect providers</span>
<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>
@@ -185,14 +192,8 @@ export default function HomePageClient({ machineId }) {
<span className="material-symbols-outlined text-[18px]">link</span>
</div>
<div>
<span className="font-semibold">3. Point your client</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>
<span className="font-semibold">{t("step3Title")}</span>
<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">
@@ -200,17 +201,20 @@ export default function HomePageClient({ machineId }) {
<span className="material-symbols-outlined text-[18px]">analytics</span>
</div>
<div>
<span className="font-semibold">4. Monitor & optimize</span>
<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>
@@ -239,22 +243,24 @@ export default function HomePageClient({ machineId }) {
<Card>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold">Providers Overview</h2>
<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
@@ -262,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>
@@ -297,14 +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;
@@ -348,14 +356,14 @@ function ProviderOverviewCard({ item, metrics, onClick }) {
</div>
<p className={`text-xs ${statusVariant}`}>
{item.total === 0
? "Not configured"
: `${item.connected} active · ${item.errors} error`}
? tc("notConfigured")
: 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>
@@ -365,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>
@@ -401,6 +409,9 @@ function ProviderModelsModal({ provider, models, onClose }) {
const [copiedModel, setCopiedModel] = useState(null);
const notify = useNotificationStore();
const router = useRouter();
const t = useTranslations("home");
const tc = useTranslations("common");
const ts = useTranslations("sidebar");
const navigateTo = (path) => {
onClose();
@@ -410,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>
@@ -433,15 +453,9 @@ function ProviderModelsModal({ provider, models, onClose }) {
<span className="material-symbols-outlined text-[32px] text-text-muted mb-2">
search_off
</span>
<p className="text-sm text-text-muted">No models available for this provider.</p>
<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>
) : (
@@ -454,13 +468,15 @@ 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
onClick={() => handleCopy(m.fullModel)}
className="shrink-0 ml-2 p-1.5 rounded-lg text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors opacity-0 group-hover:opacity-100"
title="Copy model name"
title={t("copyModelName")}
>
<span className="material-symbols-outlined text-[14px]">
{copiedModel === m.fullModel ? "check" : "content_copy"}
@@ -481,10 +497,10 @@ function ProviderModelsModal({ provider, models, onClose }) {
className="flex-1"
>
<span className="material-symbols-outlined text-[14px] mr-1">settings</span>
Configure Provider
{t("configureProvider")}
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
Close
{tc("close")}
</Button>
</div>
</div>

View File

@@ -3,15 +3,15 @@
import { useState, Suspense } from "react";
import { UsageAnalytics, CardSkeleton, SegmentedControl } from "@/shared/components";
import EvalsTab from "../usage/components/EvalsTab";
import { useTranslations } from "next-intl";
export default function AnalyticsPage() {
const [activeTab, setActiveTab] = useState("overview");
const t = useTranslations("analytics");
const tabDescriptions = {
overview:
"Monitor your API usage patterns, token consumption, costs, and activity trends across all providers and models.",
evals:
"Run evaluation suites to test and validate your LLM endpoints. Compare model quality, detect regressions, and benchmark latency.",
overview: t("overviewDescription"),
evals: t("evalsDescription"),
};
return (
@@ -20,15 +20,15 @@ export default function AnalyticsPage() {
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[28px]">analytics</span>
Analytics
{t("title")}
</h1>
<p className="text-sm text-text-muted mt-1">{tabDescriptions[activeTab]}</p>
</div>
<SegmentedControl
options={[
{ value: "overview", label: "Overview" },
{ value: "evals", label: "Evals" },
{ value: "overview", label: t("overview") },
{ value: "evals", label: t("evals") },
]}
value={activeTab}
onChange={setActiveTab}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
import ApiManagerPageClient from "./ApiManagerPageClient";
export default function ApiManagerPage() {
return <ApiManagerPageClient />;
}

View File

@@ -8,6 +8,7 @@
*/
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
interface AuditEntry {
id: number;
@@ -22,6 +23,8 @@ interface AuditEntry {
const PAGE_SIZE = 25;
export default function AuditLogPage() {
const t = useTranslations("auditLog");
const tc = useTranslations("common");
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -47,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();
@@ -87,20 +90,16 @@ export default function AuditLogPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--color-text-main)]">
Audit Log
</h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1">
Administrative actions and security events
</p>
<h1 className="text-2xl font-bold text-[var(--color-text-main)]">{t("title")}</h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1">{t("description")}</p>
</div>
<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 ? "Loading..." : "Refresh"}
{loading ? tc("loading") : tc("refresh")}
</button>
</div>
@@ -108,31 +107,31 @@ 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"
placeholder="Filter by action..."
placeholder={t("filterByAction")}
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
type="text"
placeholder="Filter by actor..."
placeholder={t("filterByActor")}
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
onClick={handleSearch}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)] transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
>
Search
{tc("search")}
</button>
</div>
@@ -148,26 +147,26 @@ 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)]">
Timestamp
{t("timestamp")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Action
{t("action")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Actor
{t("actor")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Target
{t("target")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Details
{tc("details")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
IP
{t("ipAddress")}
</th>
</tr>
</thead>
@@ -175,7 +174,7 @@ export default function AuditLogPage() {
{entries.length === 0 && !loading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-[var(--color-text-muted)]">
No audit log entries found
{t("noEntries")}
</td>
</tr>
) : (
@@ -194,17 +193,15 @@ export default function AuditLogPage() {
{entry.action}
</span>
</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-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>
))
@@ -216,7 +213,7 @@ export default function AuditLogPage() {
{/* 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
@@ -224,14 +221,14 @@ export default function AuditLogPage() {
disabled={offset === 0}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
Previous
{t("previous")}
</button>
<button
onClick={() => setOffset(offset + PAGE_SIZE)}
disabled={!hasMore}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
Next
{tc("next")}
</button>
</div>
</div>

View File

@@ -18,10 +18,12 @@ import {
DefaultToolCard,
AntigravityToolCard,
} from "./components";
import { useTranslations } from "next-intl";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
export default function CLIToolsPageClient({ machineId }) {
const t = useTranslations("cliTools");
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(true);
const [expandedTool, setExpandedTool] = useState(null);
@@ -71,13 +73,17 @@ export default function CLIToolsPageClient({ machineId }) {
const fetchToolStatuses = async () => {
try {
const res = await fetch("/api/cli-tools/status");
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000); // 8s client timeout
const res = await fetch("/api/cli-tools/status", { signal: controller.signal });
clearTimeout(timeoutId);
if (res.ok) {
const data = await res.json();
setToolStatuses(data || {});
}
} catch (error) {
console.log("Error fetching CLI tool statuses:", error);
// Timeout or network error — proceed without statuses
console.log("CLI tool status check timed out or failed:", error);
} finally {
setStatusesLoaded(true);
}
@@ -278,11 +284,9 @@ export default function CLIToolsPageClient({ machineId }) {
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div>
<p className="font-medium text-yellow-600 dark:text-yellow-400">
No active providers
</p>
<p className="text-sm text-text-muted">
Please add and connect providers first to configure CLI tools.
{t("noActiveProviders")}
</p>
<p className="text-sm text-text-muted">{t("noActiveProvidersDesc")}</p>
</div>
</div>
</Card>

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

@@ -14,6 +14,7 @@ import {
} from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
// Validate combo name: letters, numbers, -, _, /, .
const VALID_NAME_REGEX = /^[a-zA-Z0-9_/.-]+$/;
@@ -34,6 +35,8 @@ function getModelString(entry) {
// Main Page
// ─────────────────────────────────────────────
export default function CombosPage() {
const t = useTranslations("combos");
const tc = useTranslations("common");
const [combos, setCombos] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
@@ -91,13 +94,13 @@ export default function CombosPage() {
if (res.ok) {
await fetchData();
setShowCreateModal(false);
notify.success("Combo created successfully");
notify.success(t("comboCreated"));
} else {
const err = await res.json();
notify.error(err.error?.message || err.error || "Failed to create combo");
notify.error(err.error?.message || err.error || t("failedCreate"));
}
} catch (error) {
notify.error("Error creating combo");
notify.error(t("errorCreating"));
}
};
@@ -111,26 +114,26 @@ export default function CombosPage() {
if (res.ok) {
await fetchData();
setEditingCombo(null);
notify.success("Combo updated successfully");
notify.success(t("comboUpdated"));
} else {
const err = await res.json();
notify.error(err.error?.message || err.error || "Failed to update combo");
notify.error(err.error?.message || err.error || t("failedUpdate"));
}
} catch (error) {
notify.error("Error updating combo");
notify.error(t("errorUpdating"));
}
};
const handleDelete = async (id) => {
if (!confirm("Delete this combo?")) return;
if (!confirm(t("deleteConfirm"))) return;
try {
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
if (res.ok) {
setCombos(combos.filter((c) => c.id !== id));
notify.success("Combo deleted");
notify.success(t("comboDeleted"));
}
} catch (error) {
notify.error("Error deleting combo");
notify.error(t("errorDeleting"));
}
};
@@ -166,8 +169,8 @@ export default function CombosPage() {
const data = await res.json();
setTestResults(data);
} catch (error) {
setTestResults({ error: "Test request failed" });
notify.error("Test request failed");
setTestResults({ error: t("testFailed") });
notify.error(t("testFailed"));
}
};
@@ -186,7 +189,7 @@ export default function CombosPage() {
setCombos((prev) =>
prev.map((c) => (c.id === combo.id ? { ...c, isActive: !newActive } : c))
);
notify.error("Failed to toggle combo");
notify.error(t("failedToggle"));
}
};
@@ -204,13 +207,11 @@ export default function CombosPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Combos</h1>
<p className="text-sm text-text-muted mt-1">
Create model combos with weighted routing and fallback support
</p>
<h1 className="text-2xl font-semibold">{t("title")}</h1>
<p className="text-sm text-text-muted mt-1">{t("description")}</p>
</div>
<Button icon="add" onClick={() => setShowCreateModal(true)}>
Create Combo
{t("createCombo")}
</Button>
</div>
@@ -218,9 +219,9 @@ export default function CombosPage() {
{combos.length === 0 ? (
<EmptyState
icon="🧩"
title="No combos yet"
description="Create model combos with weighted routing and fallback support"
actionLabel="Create Combo"
title={t("noCombosYet")}
description={t("description")}
actionLabel={t("createCombo")}
onAction={() => setShowCreateModal(true)}
/>
) : (
@@ -253,7 +254,7 @@ export default function CombosPage() {
setTestResults(null);
setTestingCombo(null);
}}
title={`Test Results${testingCombo}`}
title={t("testResults", { name: testingCombo })}
>
<TestResultsView results={testResults} />
</Modal>
@@ -313,6 +314,8 @@ function ComboCard({
const strategy = combo.strategy || "priority";
const models = combo.models || [];
const isDisabled = combo.isActive === false;
const t = useTranslations("combos");
const tc = useTranslations("common");
return (
<Card padding="sm" className={`group ${isDisabled ? "opacity-50" : ""}`}>
@@ -346,7 +349,7 @@ function ComboCard({
{hasProxy && (
<span
className="text-[9px] uppercase font-semibold px-1.5 py-0.5 rounded-full bg-primary/15 text-primary flex items-center gap-0.5"
title="Proxy configured"
title={t("proxyConfigured")}
>
<span className="material-symbols-outlined text-[11px]">vpn_lock</span>
proxy
@@ -358,7 +361,7 @@ function ComboCard({
onCopy(combo.name, `combo-${combo.id}`);
}}
className="p-0.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors opacity-0 group-hover:opacity-100"
title="Copy combo name"
title={t("copyComboName")}
>
<span className="material-symbols-outlined text-[14px]">
{copied === `combo-${combo.id}` ? "check" : "content_copy"}
@@ -369,7 +372,7 @@ function ComboCard({
{/* Model tags with weights */}
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
{models.length === 0 ? (
<span className="text-xs text-text-muted italic">No models</span>
<span className="text-xs text-text-muted italic">{t("noModels")}</span>
) : (
models.slice(0, 3).map((entry, index) => {
const { model, weight } = normalizeModelEntry(entry);
@@ -385,7 +388,9 @@ function ComboCard({
})
)}
{models.length > 3 && (
<span className="text-[10px] text-text-muted">+{models.length - 3} more</span>
<span className="text-[10px] text-text-muted">
{t("more", { count: models.length - 3 })}
</span>
)}
</div>
@@ -394,9 +399,11 @@ function ComboCard({
<div className="flex items-center gap-3 mt-1">
<span className="text-[10px] text-text-muted">
<span className="text-emerald-500">{metrics.totalSuccesses}</span>/
{metrics.totalRequests} reqs
{metrics.totalRequests} {t("reqs")}
</span>
<span className="text-[10px] text-text-muted">
{metrics.successRate}% {t("success")}
</span>
<span className="text-[10px] text-text-muted">{metrics.successRate}% success</span>
<span className="text-[10px] text-text-muted">~{metrics.avgLatencyMs}ms</span>
{metrics.fallbackRate > 0 && (
<span className="text-[10px] text-amber-500">
@@ -414,14 +421,14 @@ function ComboCard({
size="sm"
checked={!isDisabled}
onChange={onToggle}
title={isDisabled ? "Enable combo" : "Disable combo"}
title={isDisabled ? t("enableCombo") : t("disableCombo")}
/>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={onTest}
disabled={testing}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-emerald-500 transition-colors"
title="Test combo"
title={t("testCombo")}
>
<span
className={`material-symbols-outlined text-[16px] ${testing ? "animate-spin" : ""}`}
@@ -432,28 +439,28 @@ function ComboCard({
<button
onClick={onDuplicate}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Duplicate"
title={t("duplicate")}
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={onProxy}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Proxy configuration"
title={t("proxyConfig")}
>
<span className="material-symbols-outlined text-[16px]">vpn_lock</span>
</button>
<button
onClick={onEdit}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Edit"
title={tc("edit")}
>
<span className="material-symbols-outlined text-[16px]">edit</span>
</button>
<button
onClick={onDelete}
className="p-1.5 hover:bg-red-500/10 rounded text-red-500 transition-colors"
title="Delete"
title={tc("delete")}
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
@@ -531,6 +538,8 @@ function TestResultsView({ results }) {
// Combo Form Modal
// ─────────────────────────────────────────────
function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const t = useTranslations("combos");
const tc = useTranslations("common");
const [name, setName] = useState(combo?.name || "");
const [models, setModels] = useState(() => {
return (combo?.models || []).map((m) => normalizeModelEntry(m));
@@ -575,11 +584,11 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const validateName = (value) => {
if (!value.trim()) {
setNameError("Name is required");
setNameError(t("nameRequired"));
return false;
}
if (!VALID_NAME_REGEX.test(value)) {
setNameError("Only letters, numbers, -, _, / and . allowed");
setNameError(t("nameInvalid"));
return false;
}
setNameError("");
@@ -631,8 +640,11 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const parts = modelValue.split("/");
if (parts.length !== 2) return modelValue;
const [providerId, modelId] = parts;
const matchedNode = providerNodes.find((node) => node.id === providerId);
const [providerIdentifier, modelId] = parts;
// Match by node ID or prefix
const matchedNode = providerNodes.find(
(node) => node.id === providerIdentifier || node.prefix === providerIdentifier
);
if (matchedNode) {
return `${matchedNode.name}/${modelId}`;
@@ -723,25 +735,23 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
return (
<>
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? "Edit Combo" : "Create Combo"}>
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? t("editCombo") : t("createCombo")}>
<div className="flex flex-col gap-3">
{/* Name */}
<div>
<Input
label="Combo Name"
label={t("comboName")}
value={name}
onChange={handleNameChange}
placeholder="my-combo"
placeholder={t("comboNamePlaceholder")}
error={nameError}
/>
<p className="text-[10px] text-text-muted mt-0.5">
Letters, numbers, -, _, / and . allowed
</p>
<p className="text-[10px] text-text-muted mt-0.5">{t("nameHint")}</p>
</div>
{/* Strategy Toggle */}
<div>
<label className="text-sm font-medium mb-1.5 block">Routing Strategy</label>
<label className="text-sm font-medium mb-1.5 block">{t("routingStrategy")}</label>
<div className="grid grid-cols-3 gap-1 p-0.5 bg-black/5 dark:bg-white/5 rounded-lg">
{[
{ value: "priority", label: "Priority", icon: "sort" },
@@ -785,13 +795,13 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
{/* Models */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-medium">Models</label>
<label className="text-sm font-medium">{t("models")}</label>
{strategy === "weighted" && models.length > 1 && (
<button
onClick={handleAutoBalance}
className="text-[10px] text-primary hover:text-primary/80 transition-colors"
>
Auto-balance
{t("autoBalance")}
</button>
)}
</div>
@@ -801,7 +811,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
<span className="material-symbols-outlined text-text-muted text-xl mb-1">
layers
</span>
<p className="text-xs text-text-muted">No models added yet</p>
<p className="text-xs text-text-muted">{t("noModelsYet")}</p>
</div>
) : (
<div className="flex flex-col gap-1 max-h-[240px] overflow-y-auto">
@@ -856,7 +866,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
onClick={() => handleMoveUp(index)}
disabled={index === 0}
className={`p-0.5 rounded ${index === 0 ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`}
title="Move up"
title={t("moveUp")}
>
<span className="material-symbols-outlined text-[12px]">
arrow_upward
@@ -866,7 +876,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
onClick={() => handleMoveDown(index)}
disabled={index === models.length - 1}
className={`p-0.5 rounded ${index === models.length - 1 ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`}
title="Move down"
title={t("moveDown")}
>
<span className="material-symbols-outlined text-[12px]">
arrow_downward
@@ -879,7 +889,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
<button
onClick={() => handleRemoveModel(index)}
className="p-0.5 hover:bg-red-500/10 rounded text-text-muted hover:text-red-500 transition-all"
title="Remove"
title={t("removeModel")}
>
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
@@ -897,7 +907,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
className="w-full mt-2 py-2 border border-dashed border-black/10 dark:border-white/10 rounded-lg text-xs text-text-muted hover:text-primary hover:border-primary/30 transition-colors flex items-center justify-center gap-1"
>
<span className="material-symbols-outlined text-[16px]">add</span>
Add Model
{t("addModel")}
</button>
</div>
@@ -909,14 +919,16 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
<span className="material-symbols-outlined text-[14px]">
{showAdvanced ? "expand_less" : "expand_more"}
</span>
Advanced Settings
{t("advancedSettings")}
</button>
{showAdvanced && (
<div className="flex flex-col gap-2 p-3 bg-black/[0.02] dark:bg-white/[0.02] rounded-lg border border-black/5 dark:border-white/5">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-[10px] text-text-muted mb-0.5 block">Max Retries</label>
<label className="text-[10px] text-text-muted mb-0.5 block">
{t("maxRetries")}
</label>
<input
type="number"
min="0"
@@ -934,7 +946,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
</div>
<div>
<label className="text-[10px] text-text-muted mb-0.5 block">
Retry Delay (ms)
{t("retryDelay")}
</label>
<input
type="number"
@@ -953,7 +965,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
/>
</div>
<div>
<label className="text-[10px] text-text-muted mb-0.5 block">Timeout (ms)</label>
<label className="text-[10px] text-text-muted mb-0.5 block">{t("timeout")}</label>
<input
type="number"
min="1000"
@@ -971,7 +983,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
/>
</div>
<div className="flex items-center gap-2">
<label className="text-[10px] text-text-muted">Healthcheck</label>
<label className="text-[10px] text-text-muted">{t("healthcheck")}</label>
<input
type="checkbox"
checked={config.healthCheckEnabled !== false}
@@ -984,7 +996,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-black/5 dark:border-white/5">
<div>
<label className="text-[10px] text-text-muted mb-0.5 block">
Concurrency / Model
{t("concurrencyPerModel")}
</label>
<input
type="number"
@@ -1003,7 +1015,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
</div>
<div>
<label className="text-[10px] text-text-muted mb-0.5 block">
Queue Timeout (ms)
{t("queueTimeout")}
</label>
<input
type="number"
@@ -1023,16 +1035,14 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
</div>
</div>
)}
<p className="text-[10px] text-text-muted">
Leave empty to use global defaults. These override per-provider settings.
</p>
<p className="text-[10px] text-text-muted">{t("advancedHint")}</p>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-1">
<Button onClick={onClose} variant="ghost" fullWidth size="sm">
Cancel
{tc("cancel")}
</Button>
<Button
onClick={handleSave}
@@ -1040,7 +1050,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
size="sm"
disabled={!name.trim() || !!nameError || saving}
>
{saving ? "Saving..." : isEdit ? "Save" : "Create"}
{saving ? t("saving") : isEdit ? tc("save") : t("createCombo")}
</Button>
</div>
</div>
@@ -1053,7 +1063,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
onSelect={handleAddModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Add Model to Combo"
title={t("addModelToCombo")}
selectedModel={null}
/>
</>

View File

@@ -4,16 +4,19 @@ import { useState } from "react";
import { SegmentedControl } from "@/shared/components";
import BudgetTab from "../usage/components/BudgetTab";
import PricingTab from "../settings/components/PricingTab";
import { useTranslations } from "next-intl";
export default function CostsPage() {
const [activeTab, setActiveTab] = useState("budget");
const t = useTranslations("costs");
const ts = useTranslations("settings");
return (
<div className="flex flex-col gap-6">
<SegmentedControl
options={[
{ value: "budget", label: "Budget" },
{ value: "pricing", label: "Pricing" },
{ value: "budget", label: t("budget") },
{ value: "pricing", label: ts("pricing") },
]}
value={activeTab}
onChange={setActiveTab}

View File

@@ -1,22 +1,20 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { useState, useEffect, useMemo } from "react";
import PropTypes from "prop-types";
import Image from "next/image";
import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
import { useTranslations } from "next-intl";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
const CLOUD_ACTION_TIMEOUT_MS = 15000;
export default function APIPageClient({ machineId }) {
const [keys, setKeys] = useState([]);
const t = useTranslations("endpoint");
const tc = useTranslations("common");
const [providerConnections, setProviderConnections] = useState([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [createdKey, setCreatedKey] = useState(null);
// Endpoints / models state
const [allModels, setAllModels] = useState([]);
@@ -53,16 +51,19 @@ export default function APIPageClient({ machineId }) {
};
// Categorize models by endpoint type
// Filter out parent models (models with parent field set) to avoid showing duplicates
const endpointData = useMemo(() => {
const chat = allModels.filter((m) => !m.type);
const embeddings = allModels.filter((m) => m.type === "embedding");
const images = allModels.filter((m) => m.type === "image");
const rerank = allModels.filter((m) => m.type === "rerank");
const chat = allModels.filter((m) => !m.type && !m.parent);
const embeddings = allModels.filter((m) => m.type === "embedding" && !m.parent);
const images = allModels.filter((m) => m.type === "image" && !m.parent);
const rerank = allModels.filter((m) => m.type === "rerank" && !m.parent);
const audioTranscription = allModels.filter(
(m) => m.type === "audio" && m.subtype === "transcription"
(m) => m.type === "audio" && m.subtype === "transcription" && !m.parent
);
const audioSpeech = allModels.filter((m) => m.type === "audio" && m.subtype === "speech");
const moderation = allModels.filter((m) => m.type === "moderation");
const audioSpeech = allModels.filter(
(m) => m.type === "audio" && m.subtype === "speech" && !m.parent
);
const moderation = allModels.filter((m) => m.type === "moderation" && !m.parent);
return { chat, embeddings, images, rerank, audioTranscription, audioSpeech, moderation };
}, [allModels]);
@@ -108,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);
}
@@ -130,16 +131,9 @@ export default function APIPageClient({ machineId }) {
const fetchData = async () => {
try {
const [keysRes, providersRes] = await Promise.all([
fetch("/api/keys"),
fetch("/api/providers"),
]);
const providersRes = await fetch("/api/providers");
const [keysData, providersData] = await Promise.all([keysRes.json(), providersRes.json()]);
if (keysRes.ok) {
setKeys(keysData.keys || []);
}
const providersData = await providersRes.json();
if (providersRes.ok) {
setProviderConnections(providersData.connections || []);
@@ -196,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"),
});
}
@@ -214,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);
@@ -246,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("");
@@ -269,52 +262,17 @@ 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);
}
};
const handleCreateKey = async () => {
if (!newKeyName.trim()) return;
try {
const res = await fetch("/api/keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newKeyName }),
});
const data = await res.json();
if (res.ok) {
setCreatedKey(data.key);
await fetchData();
setNewKeyName("");
setShowAddModal(false);
}
} catch (error) {
console.log("Error creating key:", error);
}
};
const handleDeleteKey = async (id) => {
if (!confirm("Delete this API key?")) return;
try {
const res = await fetch(`/api/keys/${id}`, { method: "DELETE" });
if (res.ok) {
setKeys(keys.filter((k) => k.id !== id));
}
} catch (error) {
console.log("Error deleting key:", error);
}
};
const [baseUrl, setBaseUrl] = useState("/v1");
const cloudEndpointNew = `${CLOUD_URL}/v1`;
@@ -337,36 +295,20 @@ 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 */}
<Card className={cloudEnabled ? "" : ""}>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold">API Endpoint</h2>
<h2 className="text-lg font-semibold">{t("title")}</h2>
<p className="text-sm text-text-muted">
{cloudEnabled ? "Using Cloud Proxy" : "Using Local Server"}
{cloudEnabled ? t("usingCloudProxy") : t("usingLocalServer")}
</p>
{machineId && (
<p className="text-xs text-text-muted mt-1">Machine ID: {machineId.slice(0, 8)}...</p>
<p className="text-xs text-text-muted mt-1">
{t("machineId", { id: machineId.slice(0, 8) })}
</p>
)}
</div>
<div className="flex items-center gap-2">
@@ -379,7 +321,7 @@ export default function APIPageClient({ machineId }) {
disabled={cloudSyncing}
className="bg-red-500/10! text-red-500! hover:bg-red-500/20! border-red-500/30!"
>
Disable Cloud
{t("disableCloud")}
</Button>
) : (
<Button
@@ -389,7 +331,7 @@ export default function APIPageClient({ machineId }) {
disabled={cloudSyncing}
className="bg-linear-to-r from-primary to-blue-500 hover:from-primary-hover hover:to-blue-600"
>
Enable Cloud
{t("enableCloud")}
</Button>
)}
</div>
@@ -435,109 +377,20 @@ export default function APIPageClient({ machineId }) {
icon={copied === "endpoint_url" ? "check" : "content_copy"}
onClick={() => copy(currentEndpoint, "endpoint_url")}
>
{copied === "endpoint_url" ? "Copied!" : "Copy"}
{copied === "endpoint_url" ? tc("copied") : tc("copy")}
</Button>
</div>
{/* Registered Keys — collapsible section inside API Endpoint card */}
<div className="border border-border rounded-lg overflow-hidden mt-4">
<button
onClick={() => setExpandedEndpoint(expandedEndpoint === "keys" ? null : "keys")}
className="w-full flex items-center gap-3 p-4 hover:bg-surface/50 transition-colors text-left"
>
<div className="flex items-center justify-center size-10 rounded-lg bg-amber-500/10 shrink-0">
<span className="material-symbols-outlined text-xl text-amber-500">vpn_key</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">Registered Keys</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-surface text-text-muted font-medium">
{keys.length} {keys.length === 1 ? "key" : "keys"}
</span>
</div>
<p className="text-xs text-text-muted mt-0.5">
Manage API keys used to authenticate requests to this endpoint
</p>
</div>
<span
className={`material-symbols-outlined text-text-muted text-lg transition-transform ${expandedEndpoint === "keys" ? "rotate-180" : ""}`}
>
expand_more
</span>
</button>
{expandedEndpoint === "keys" && (
<div className="border-t border-border px-4 pb-4">
<div className="flex items-center justify-between mt-3 mb-3">
<p className="text-xs text-text-muted">
Each key isolates usage tracking and can be revoked independently.
</p>
<Button size="sm" icon="add" onClick={() => setShowAddModal(true)}>
Create Key
</Button>
</div>
{keys.length === 0 ? (
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 text-primary mb-3">
<span className="material-symbols-outlined text-[24px]">vpn_key</span>
</div>
<p className="text-text-main font-medium mb-1 text-sm">No API keys yet</p>
<p className="text-xs text-text-muted mb-3">
Create your first API key to get started
</p>
<Button size="sm" icon="add" onClick={() => setShowAddModal(true)}>
Create Key
</Button>
</div>
) : (
<div className="flex flex-col">
{keys.map((key) => (
<div
key={key.id}
className="group flex items-center justify-between py-3 border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{key.name}</p>
<div className="flex items-center gap-2 mt-1">
<code className="text-xs text-text-muted font-mono">{key.key}</code>
<button
onClick={() => copy(key.key, key.id)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all"
>
<span className="material-symbols-outlined text-[14px]">
{copied === key.id ? "check" : "content_copy"}
</span>
</button>
</div>
<p className="text-xs text-text-muted mt-1">
Created {new Date(key.createdAt).toLocaleDateString()}
</p>
</div>
<button
onClick={() => handleDeleteKey(key.id)}
className="p-2 hover:bg-red-500/10 rounded text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
</Card>
{/* Available Endpoints */}
<Card>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold">Available Endpoints</h2>
<h2 className="text-lg font-semibold">{t("available")}</h2>
<p className="text-sm text-text-muted">
{allModels.length} models across{" "}
{
[
{t("modelsAcrossEndpoints", {
models: Object.values(endpointData).reduce((acc, models) => acc + models.length, 0),
endpoints: [
endpointData.chat,
endpointData.embeddings,
endpointData.images,
@@ -545,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>
@@ -558,9 +410,9 @@ export default function APIPageClient({ machineId }) {
icon="chat"
iconColor="text-blue-500"
iconBg="bg-blue-500/10"
title="Chat Completions"
title={t("chatCompletions")}
path="/v1/chat/completions"
description="Streaming & non-streaming chat with all providers"
description={t("chatDesc")}
models={endpointData.chat}
expanded={expandedEndpoint === "chat"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "chat" ? null : "chat")}
@@ -574,9 +426,9 @@ export default function APIPageClient({ machineId }) {
icon="data_array"
iconColor="text-emerald-500"
iconBg="bg-emerald-500/10"
title="Embeddings"
title={t("embeddings")}
path="/v1/embeddings"
description="Text embeddings for search & RAG pipelines"
description={t("embeddingsDesc")}
models={endpointData.embeddings}
expanded={expandedEndpoint === "embeddings"}
onToggle={() =>
@@ -592,9 +444,9 @@ export default function APIPageClient({ machineId }) {
icon="image"
iconColor="text-purple-500"
iconBg="bg-purple-500/10"
title="Image Generation"
title={t("imageGeneration")}
path="/v1/images/generations"
description="Generate images from text prompts"
description={t("imageDesc")}
models={endpointData.images}
expanded={expandedEndpoint === "images"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "images" ? null : "images")}
@@ -608,9 +460,9 @@ export default function APIPageClient({ machineId }) {
icon="sort"
iconColor="text-amber-500"
iconBg="bg-amber-500/10"
title="Rerank"
title={t("rerank")}
path="/v1/rerank"
description="Rerank documents by relevance to a query"
description={t("rerankDesc")}
models={endpointData.rerank}
expanded={expandedEndpoint === "rerank"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "rerank" ? null : "rerank")}
@@ -624,9 +476,9 @@ export default function APIPageClient({ machineId }) {
icon="mic"
iconColor="text-rose-500"
iconBg="bg-rose-500/10"
title="Audio Transcription"
title={t("audioTranscription")}
path="/v1/audio/transcriptions"
description="Transcribe audio files to text (Whisper)"
description={t("audioTranscriptionDesc")}
models={endpointData.audioTranscription}
expanded={expandedEndpoint === "audioTranscription"}
onToggle={() =>
@@ -644,9 +496,9 @@ export default function APIPageClient({ machineId }) {
icon="record_voice_over"
iconColor="text-cyan-500"
iconBg="bg-cyan-500/10"
title="Text to Speech"
title={t("textToSpeech")}
path="/v1/audio/speech"
description="Convert text to natural-sounding speech"
description={t("textToSpeechDesc")}
models={endpointData.audioSpeech}
expanded={expandedEndpoint === "audioSpeech"}
onToggle={() =>
@@ -662,9 +514,9 @@ export default function APIPageClient({ machineId }) {
icon="shield"
iconColor="text-orange-500"
iconBg="bg-orange-500/10"
title="Moderations"
title={t("moderations")}
path="/v1/moderations"
description="Content moderation and safety classification"
description={t("moderationsDesc")}
models={endpointData.moderation}
expanded={expandedEndpoint === "moderation"}
onToggle={() =>
@@ -677,97 +529,32 @@ 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}
title="Enable Cloud Proxy"
title={t("enableCloudTitle")}
onClose={() => setShowCloudModal(false)}
>
<div className="flex flex-col gap-4">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p className="text-sm text-blue-800 dark:text-blue-200 font-medium mb-2">
What you will get
{t("whatYouGet")}
</p>
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> Access your API from anywhere in the world</li>
<li> Share endpoint with your team easily</li>
<li> No need to open ports or configure firewall</li>
<li> Fast global edge network</li>
<li> {t("cloudBenefitAccess")}</li>
<li> {t("cloudBenefitShare")}</li>
<li> {t("cloudBenefitPorts")}</li>
<li> {t("cloudBenefitEdge")}</li>
</ul>
</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>
@@ -795,9 +582,9 @@ export default function APIPageClient({ machineId }) {
modalSuccess ? "text-green-500" : "text-primary"
}`}
>
{modalSuccess && "Cloud Proxy connected!"}
{!modalSuccess && syncStep === "syncing" && "Connecting to cloud..."}
{!modalSuccess && syncStep === "verifying" && "Verifying connection..."}
{modalSuccess && t("cloudConnected")}
{!modalSuccess && syncStep === "syncing" && t("connectingToCloud")}
{!modalSuccess && syncStep === "verifying" && t("verifyingConnection")}
</p>
</div>
</div>
@@ -810,15 +597,15 @@ export default function APIPageClient({ machineId }) {
<span className="material-symbols-outlined animate-spin text-sm">
progress_activity
</span>
{syncStep === "syncing" ? "Connecting..." : "Verifying..."}
{syncStep === "syncing" ? t("connecting") : t("verifying")}
</span>
) : modalSuccess ? (
<span className="flex items-center gap-2">
<span className="material-symbols-outlined text-sm">check</span>
Connected!
{t("connected")}
</span>
) : (
"Enable Cloud"
t("enableCloud")
)}
</Button>
<Button
@@ -827,77 +614,16 @@ export default function APIPageClient({ machineId }) {
fullWidth
disabled={cloudSyncing || modalSuccess}
>
Cancel
{tc("cancel")}
</Button>
</div>
</div>
</Modal>
{/* Add Key Modal */}
<Modal
isOpen={showAddModal}
title="Create API Key"
onClose={() => {
setShowAddModal(false);
setNewKeyName("");
}}
>
<div className="flex flex-col gap-4">
<Input
label="Key Name"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Production Key"
/>
<div className="flex gap-2">
<Button onClick={handleCreateKey} fullWidth disabled={!newKeyName.trim()}>
Create
</Button>
<Button
onClick={() => {
setShowAddModal(false);
setNewKeyName("");
}}
variant="ghost"
fullWidth
>
Cancel
</Button>
</div>
</div>
</Modal>
{/* Created Key Modal */}
<Modal isOpen={!!createdKey} title="API Key Created" onClose={() => setCreatedKey(null)}>
<div className="flex flex-col gap-4">
<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 mb-2 font-medium">
Save this key now!
</p>
<p className="text-sm text-yellow-700 dark:text-yellow-300">
This is the only time you will see this key. Store it securely.
</p>
</div>
<div className="flex gap-2">
<Input value={createdKey || ""} readOnly className="flex-1 font-mono text-sm" />
<Button
variant="secondary"
icon={copied === "created_key" ? "check" : "content_copy"}
onClick={() => copy(createdKey, "created_key")}
>
{copied === "created_key" ? "Copied!" : "Copy"}
</Button>
</div>
<Button onClick={() => setCreatedKey(null)} fullWidth>
Done
</Button>
</div>
</Modal>
{/* Disable Cloud Modal */}
<Modal
isOpen={showDisableModal}
title="Disable Cloud Proxy"
title={t("disableCloudTitle")}
onClose={() => !cloudSyncing && setShowDisableModal(false)}
>
<div className="flex flex-col gap-4">
@@ -907,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>
@@ -923,14 +649,14 @@ export default function APIPageClient({ machineId }) {
</span>
<div className="flex-1">
<p className="text-sm font-medium text-primary">
{syncStep === "syncing" && "Syncing latest data..."}
{syncStep === "disabling" && "Disabling cloud..."}
{syncStep === "syncing" && t("syncingData")}
{syncStep === "disabling" && t("disablingCloud")}
</p>
</div>
</div>
)}
<p className="text-sm text-text-muted">Are you sure you want to disable cloud proxy?</p>
<p className="text-sm text-text-muted">{t("disableConfirm")}</p>
<div className="flex gap-2">
<Button
@@ -944,10 +670,10 @@ export default function APIPageClient({ machineId }) {
<span className="material-symbols-outlined animate-spin text-sm">
progress_activity
</span>
{syncStep === "syncing" ? "Syncing..." : "Disabling..."}
{syncStep === "syncing" ? t("syncing") : t("disabling")}
</span>
) : (
"Disable Cloud"
t("disableCloud")
)}
</Button>
<Button
@@ -956,7 +682,7 @@ export default function APIPageClient({ machineId }) {
fullWidth
disabled={cloudSyncing}
>
Cancel
{tc("cancel")}
</Button>
</div>
</div>
@@ -979,83 +705,18 @@ APIPageClient.propTypes = {
machineId: PropTypes.string.isRequired,
};
function ProviderOverviewCard({ item, onClick }) {
const [imgError, setImgError] = useState(false);
const statusVariant =
item.errors > 0 ? "text-red-500" : item.connected > 0 ? "text-green-500" : "text-text-muted";
return (
<div
className="border border-border rounded-lg p-3 hover:bg-surface/40 transition-colors cursor-pointer"
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && onClick?.()}
>
<div className="flex items-center gap-2.5">
<div
className="size-8 rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${item.provider.color || "#888"}15` }}
>
{imgError ? (
<span
className="text-[10px] font-bold"
style={{ color: item.provider.color || "#888" }}
>
{item.provider.textIcon || item.provider.id.slice(0, 2).toUpperCase()}
</span>
) : (
<Image
src={`/providers/${item.provider.id}.png`}
alt={item.provider.name}
width={26}
height={26}
className="object-contain rounded-lg"
sizes="26px"
onError={() => setImgError(true)}
/>
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold truncate">{item.provider.name}</p>
<p className={`text-xs ${statusVariant}`}>
{item.total === 0
? "Not configured"
: `${item.connected} active · ${item.errors} error`}
</p>
</div>
<span className="text-xs text-text-muted">#{item.total}</span>
</div>
</div>
);
}
ProviderOverviewCard.propTypes = {
item: PropTypes.shape({
id: PropTypes.string.isRequired,
provider: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
color: PropTypes.string,
textIcon: PropTypes.string,
}).isRequired,
total: PropTypes.number.isRequired,
connected: PropTypes.number.isRequired,
errors: PropTypes.number.isRequired,
}).isRequired,
onClick: PropTypes.func,
};
// -- 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;
const providerModels = useMemo(() => {
return models.filter((m) => m.owned_by === providerAlias || m.owned_by === provider.id);
return models.filter(
(m) => !m.parent && (m.owned_by === providerAlias || m.owned_by === provider.id)
);
}, [models, providerAlias, provider.id]);
const chatModels = providerModels.filter((m) => !m.type);
@@ -1081,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"}
@@ -1102,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>
@@ -1144,6 +807,7 @@ function EndpointSection({
copied,
baseUrl,
}) {
const t = useTranslations("endpoint");
const grouped = useMemo(() => {
const map = {};
for (const m of models) {
@@ -1151,7 +815,9 @@ function EndpointSection({
if (!map[owner]) map[owner] = [];
map[owner].push(m);
}
return Object.entries(map).sort((a: any, b: any) => (b[1] as any).length - (a[1] as any).length);
return Object.entries(map).sort(
(a: any, b: any) => (b[1] as any).length - (a[1] as any).length
);
}, [models]);
const resolveProvider = (id) => AI_PROVIDERS[id] || getProviderByAlias(id);
@@ -1173,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>
@@ -1216,7 +882,9 @@ function EndpointSection({
<span className="text-xs font-semibold text-text-main">
{providerName(providerId)}
</span>
<span className="text-xs text-text-muted">({(providerModels as any).length})</span>
<span className="text-xs text-text-muted">
({(providerModels as any).length})
</span>
</div>
<div className="ml-5 flex flex-wrap gap-1.5">
{(providerModels as any).map((m) => (

View File

@@ -15,6 +15,7 @@
import { useState, useEffect, useCallback } from "react";
import { Card } from "@/shared/components";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { useTranslations } from "next-intl";
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
@@ -31,13 +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);
@@ -84,12 +88,7 @@ export default function HealthPage() {
}, [fetchHealth, fetchExtras]);
const handleResetHealth = async () => {
if (
!confirm(
"Reset all circuit breakers to healthy state? This will clear all failure counts and restore all providers to operational status."
)
)
return;
if (!confirm(t("resetConfirm"))) return;
setResetting(true);
try {
const res = await fetch("/api/monitoring/health", { method: "DELETE" });
@@ -104,14 +103,15 @@ 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 (
<div className="p-6 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-text-muted mt-4">Loading health data...</p>
<p className="text-text-muted mt-4">{t("loadingHealth")}</p>
</div>
</div>
);
@@ -122,12 +122,12 @@ export default function HealthPage() {
<div className="p-6">
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-6 text-center">
<span className="material-symbols-outlined text-red-500 text-[32px] mb-2">error</span>
<p className="text-red-400">Failed to load health data: {error}</p>
<p className="text-red-400">{t("failedToLoad", { error })}</p>
<button
onClick={fetchHealth}
className="mt-4 px-4 py-2 rounded-lg bg-primary/10 text-primary text-sm hover:bg-primary/20 transition-colors"
>
Retry
{t("retry")}
</button>
</div>
</div>
@@ -143,15 +143,13 @@ export default function HealthPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-main">System Health</h1>
<p className="text-sm text-text-muted mt-1">
Real-time monitoring of your OmniRoute instance
</p>
<h1 className="text-2xl font-bold text-text-main">{t("title")}</h1>
<p className="text-sm text-text-muted mt-1">{t("description")}</p>
</div>
<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
@@ -160,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>
@@ -185,7 +183,7 @@ export default function HealthPage() {
{data.status === "healthy" ? "check_circle" : "error"}
</span>
<span className={data.status === "healthy" ? "text-green-400" : "text-red-400"}>
{data.status === "healthy" ? "All systems operational" : "System issues detected"}
{data.status === "healthy" ? t("allOperational") : t("issuesDetected")}
</span>
</div>
@@ -196,7 +194,7 @@ export default function HealthPage() {
<div className="flex items-center justify-center size-8 rounded-lg bg-primary/10 text-primary">
<span className="material-symbols-outlined text-[18px]">timer</span>
</div>
<span className="text-sm text-text-muted">Uptime</span>
<span className="text-sm text-text-muted">{t("uptime")}</span>
</div>
<p className="text-xl font-semibold text-text-main">{formatUptime(system.uptime)}</p>
</Card>
@@ -206,10 +204,12 @@ export default function HealthPage() {
<div className="flex items-center justify-center size-8 rounded-lg bg-blue-500/10 text-blue-500">
<span className="material-symbols-outlined text-[18px]">info</span>
</div>
<span className="text-sm text-text-muted">Version</span>
<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">
@@ -217,13 +217,13 @@ export default function HealthPage() {
<div className="flex items-center justify-center size-8 rounded-lg bg-purple-500/10 text-purple-500">
<span className="material-symbols-outlined text-[18px]">memory</span>
</div>
<span className="text-sm text-text-muted">Memory (RSS)</span>
<span className="text-sm text-text-muted">{t("memoryRss")}</span>
</div>
<p className="text-xl font-semibold text-text-main">
{formatBytes(system.memoryUsage?.rss || 0)}
</p>
<p className="text-xs text-text-muted mt-1">
Heap: {formatBytes(system.memoryUsage?.heapUsed || 0)} /{" "}
{t("heap")}: {formatBytes(system.memoryUsage?.heapUsed || 0)} /{" "}
{formatBytes(system.memoryUsage?.heapTotal || 0)}
</p>
</Card>
@@ -233,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,29 +250,29 @@ export default function HealthPage() {
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">speed</span>
Latency
{t("latency")}
</h3>
{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">
<span className="text-text-muted">Total requests</span>
<span className="text-text-muted">{t("totalRequests")}</span>
<span className="font-mono">{telemetry.totalRequests ?? 0}</span>
</div>
</div>
) : (
<p className="text-sm text-text-muted">No data yet</p>
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
)}
</Card>
@@ -278,29 +280,29 @@ export default function HealthPage() {
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">cached</span>
Prompt Cache
{t("promptCache")}
</h3>
{cache ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">Entries</span>
<span className="text-text-muted">{t("entries")}</span>
<span className="font-mono">
{cache.size}/{cache.maxSize}
</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">Hit Rate</span>
<span className="text-text-muted">{t("hitRate")}</span>
<span className="font-mono">{cache.hitRate?.toFixed(1) ?? 0}%</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">Hits / Misses</span>
<span className="text-text-muted">{t("hitsMisses")}</span>
<span className="font-mono">
{cache.hits ?? 0} / {cache.misses ?? 0}
</span>
</div>
</div>
) : (
<p className="text-sm text-text-muted">No data yet</p>
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
)}
</Card>
@@ -308,24 +310,28 @@ export default function HealthPage() {
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">database</span>
Signature Cache
{t("signatureCache")}
</h3>
{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",
},
@@ -340,19 +346,19 @@ export default function HealthPage() {
))}
</div>
) : (
<p className="text-sm text-text-muted">No data yet</p>
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
)}
</Card>
</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">
health_and_safety
</span>
Provider Health
{t("providerHealth")}
</h2>
<div className="flex items-center gap-3">
{cbEntries.some(([, cb]: [string, any]) => cb.state !== "CLOSED") && (
@@ -364,19 +370,19 @@ 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 ? (
<>
<span className="material-symbols-outlined text-[14px] animate-spin">
progress_activity
</span>
Resetting...
{t("resetting")}
</>
) : (
<>
<span className="material-symbols-outlined text-[14px]">restart_alt</span>
Reset All
{t("resetAll")}
</>
)}
</button>
@@ -384,22 +390,20 @@ export default function HealthPage() {
{cbEntries.length > 0 && (
<div className="flex items-center gap-3 text-xs text-text-muted">
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-green-500" /> Healthy
<span className="size-2 rounded-full bg-green-500" /> {t("healthy")}
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-amber-500" /> Recovering
<span className="size-2 rounded-full bg-amber-500" /> {t("recovering")}
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-red-500" /> Down
<span className="size-2 rounded-full bg-red-500" /> {t("down")}
</span>
</div>
)}
</div>
</div>
{cbEntries.length === 0 ? (
<p className="text-sm text-text-muted text-center py-4">
No circuit breaker data available. Make some requests first.
</p>
<p className="text-sm text-text-muted text-center py-4">{t("noCBData")}</p>
) : (
(() => {
const unhealthy = cbEntries.filter(([, cb]: [string, any]) => cb.state !== "CLOSED");
@@ -410,10 +414,10 @@ export default function HealthPage() {
{unhealthy.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-red-400 uppercase tracking-wide">
Issues Detected
{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 (
@@ -438,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>
@@ -461,7 +468,7 @@ export default function HealthPage() {
<div>
{unhealthy.length > 0 && (
<p className="text-xs font-medium text-green-400 uppercase tracking-wide mb-2">
Operational
{t("operational")}
</p>
)}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
@@ -509,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})`;
@@ -548,10 +555,12 @@ export default function HealthPage() {
<span className="material-symbols-outlined text-[20px] text-amber-500">
speed
</span>
Rate Limit Status
{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">
@@ -605,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>
@@ -634,7 +643,7 @@ export default function HealthPage() {
<Card className="p-5">
<h2 className="text-lg font-semibold text-text-main mb-4 flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-red-500">lock</span>
Active Lockouts
{t("activeLockouts")}
</h2>
<div className="space-y-2">
{lockoutEntries.map(([key, lockout]: [string, any]) => (
@@ -650,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

@@ -6,6 +6,7 @@
*/
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
interface AuditEntry {
id: number;
@@ -27,6 +28,7 @@ export default function AuditLogTab() {
const [actorFilter, setActorFilter] = useState("");
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const t = useTranslations("logs");
const fetchEntries = useCallback(async () => {
setLoading(true);
@@ -45,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();
@@ -85,18 +87,16 @@ export default function AuditLogTab() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-[var(--color-text-main)]">Audit Log</h2>
<p className="text-sm text-[var(--color-text-muted)] mt-1">
Administrative actions and security events
</p>
<h2 className="text-xl font-bold text-[var(--color-text-main)]">{t("auditLog")}</h2>
<p className="text-sm text-[var(--color-text-muted)] mt-1">{t("auditLogDesc")}</p>
</div>
<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 ? "Loading..." : "Refresh"}
{loading ? t("loading") : t("refresh")}
</button>
</div>
@@ -104,31 +104,31 @@ 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"
placeholder="Filter by action..."
placeholder={t("filterByAction")}
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
type="text"
placeholder="Filter by actor..."
placeholder={t("filterByActor")}
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
onClick={handleSearch}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)] transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
>
Search
{t("search")}
</button>
</div>
@@ -144,32 +144,34 @@ 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)]">
Timestamp
{t("timestamp")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Action
{t("action")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Actor
{t("actor")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Target
{t("target")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Details
{t("details")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
{t("ipAddress")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">IP</th>
</tr>
</thead>
<tbody>
{entries.length === 0 && !loading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-[var(--color-text-muted)]">
No audit log entries found
{t("noEntries")}
</td>
</tr>
) : (
@@ -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
@@ -216,14 +218,14 @@ export default function AuditLogTab() {
disabled={offset === 0}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
Previous
{t("previous")}
</button>
<button
onClick={() => setOffset(offset + PAGE_SIZE)}
disabled={!hasMore}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
Next
{t("next")}
</button>
</div>
</div>

View File

@@ -4,18 +4,20 @@ import { useState } from "react";
import { RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components";
import ConsoleLogViewer from "@/shared/components/ConsoleLogViewer";
import AuditLogTab from "./AuditLogTab";
import { useTranslations } from "next-intl";
export default function LogsPage() {
const [activeTab, setActiveTab] = useState("request-logs");
const t = useTranslations("logs");
return (
<div className="flex flex-col gap-6">
<SegmentedControl
options={[
{ value: "request-logs", label: "Request Logs" },
{ value: "proxy-logs", label: "Proxy Logs" },
{ value: "audit-logs", label: "Audit Logs" },
{ value: "console", label: "Console" },
{ value: "request-logs", label: t("requestLogs") },
{ value: "proxy-logs", label: t("proxyLogs") },
{ value: "audit-logs", label: t("auditLog") },
{ value: "console", label: t("console") },
]}
value={activeTab}
onChange={setActiveTab}

View File

@@ -2,14 +2,10 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
const STEPS = [
{ id: "welcome", title: "Welcome", icon: "waving_hand" },
{ id: "security", title: "Security", icon: "lock" },
{ id: "provider", title: "Provider", icon: "dns" },
{ id: "test", title: "Test", icon: "play_circle" },
{ id: "done", title: "Ready!", icon: "check_circle" },
];
const STEP_IDS = ["welcome", "security", "provider", "test", "done"];
const STEP_ICONS = ["waving_hand", "lock", "dns", "play_circle", "check_circle"];
const COMMON_PROVIDERS = [
{ id: "openai", name: "OpenAI", color: "#10A37F" },
@@ -22,6 +18,8 @@ const COMMON_PROVIDERS = [
export default function OnboardingWizard() {
const router = useRouter();
const t = useTranslations("onboarding");
const tc = useTranslations("common");
const [step, setStep] = useState(0);
const [loading, setLoading] = useState(true);
const [apiEndpoint, setApiEndpoint] = useState("http://localhost:20128/api/v1");
@@ -70,6 +68,12 @@ export default function OnboardingWizard() {
checkSetup();
}, [router]);
const STEPS = STEP_IDS.map((id, i) => ({
id,
title: t(id === "done" ? "ready" : id),
icon: STEP_ICONS[i],
}));
const currentStep = STEPS[step];
const isLastStep = step === STEPS.length - 1;
@@ -98,12 +102,12 @@ export default function OnboardingWizard() {
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setErrorMessage(data.error || "Failed to set password. Try again.");
setErrorMessage(data.error || t("failedSetPassword"));
return;
}
handleNext();
} catch {
setErrorMessage("Connection error. Please try again.");
setErrorMessage(t("connectionError"));
}
};
@@ -133,18 +137,18 @@ export default function OnboardingWizard() {
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setErrorMessage(data.error || "Failed to add provider. Try again.");
setErrorMessage(data.error || t("failedAddProvider"));
return;
}
handleNext();
} catch {
setErrorMessage("Connection error. Please try again.");
setErrorMessage(t("connectionError"));
}
};
const handleTestProvider = async () => {
setTestStatus("testing");
setTestMessage("Testing connection...");
setTestMessage(t("testingConnection"));
try {
const res = await fetch("/api/providers");
if (!res.ok) throw new Error("Failed to fetch");
@@ -152,21 +156,21 @@ export default function OnboardingWizard() {
const conn = data.connections?.[0];
if (!conn) {
setTestStatus("error");
setTestMessage("No provider found. You can add one from the dashboard later.");
setTestMessage(t("noProviderFound"));
return;
}
const testRes = await fetch(`/api/providers/${conn.id}/test`, { method: "POST" });
if (testRes.ok) {
setTestStatus("success");
setTestMessage("Connection successful! Your provider is ready.");
setTestMessage(t("connectionSuccessful"));
} else {
const err = await testRes.json().catch(() => ({}));
setTestStatus("error");
setTestMessage(err.error || "Test failed, but you can configure this later.");
setTestMessage(err.error || t("testFailed"));
}
} catch {
setTestStatus("error");
setTestMessage("Could not test right now. You can test from the dashboard.");
setTestMessage(t("couldNotTest"));
}
};
@@ -186,7 +190,7 @@ export default function OnboardingWizard() {
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg">
<div className="animate-pulse text-text-muted">Loading...</div>
<div className="animate-pulse text-text-muted">{tc("loading")}</div>
</div>
);
}
@@ -243,16 +247,12 @@ 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> is your local AI API proxy.
It routes requests to multiple AI providers with load balancing, failover, and
usage tracking.
</p>
<p className="text-text-muted">{t("welcomeDesc")}</p>
<div className="grid grid-cols-3 gap-3 mt-6">
{[
{ icon: "swap_horiz", label: "Multi-Provider" },
{ icon: "monitoring", label: "Usage Tracking" },
{ icon: "shield", label: "API Key Mgmt" },
{ icon: "swap_horiz", label: t("multiProvider") },
{ icon: "monitoring", label: t("usageTracking") },
{ icon: "shield", label: t("apiKeyMgmt") },
].map((f) => (
<div
key={f.icon}
@@ -271,9 +271,7 @@ export default function OnboardingWizard() {
{/* Security */}
{currentStep.id === "security" && (
<div className="space-y-4">
<p className="text-sm text-text-muted text-center">
Set a password to protect your dashboard, or skip for now.
</p>
<p className="text-sm text-text-muted text-center">{t("securityDesc")}</p>
<label className="flex items-center gap-2 cursor-pointer text-sm text-text-muted">
<input
type="checkbox"
@@ -281,26 +279,26 @@ export default function OnboardingWizard() {
onChange={(e) => setSkipSecurity(e.target.checked)}
className="accent-primary"
/>
Skip password setup
{t("skipPassword")}
</label>
{!skipSecurity && (
<div className="space-y-3">
<input
type="password"
placeholder="Enter password"
placeholder={t("enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
<input
type="password"
placeholder="Confirm password"
placeholder={t("confirmPasswordPlaceholder")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
{password && confirmPassword && password !== confirmPassword && (
<p className="text-xs text-red-400">Passwords do not match</p>
<p className="text-xs text-red-400">{t("passwordsMismatch")}</p>
)}
</div>
)}
@@ -310,9 +308,7 @@ export default function OnboardingWizard() {
{/* Provider */}
{currentStep.id === "provider" && (
<div className="space-y-4">
<p className="text-sm text-text-muted text-center">
Connect your first AI provider. You can add more later.
</p>
<p className="text-sm text-text-muted text-center">{t("providerDesc")}</p>
<div className="grid grid-cols-3 gap-2">
{COMMON_PROVIDERS.map((p) => (
<button
@@ -335,14 +331,14 @@ export default function OnboardingWizard() {
<div className="space-y-3 mt-4">
<input
type="password"
placeholder="API Key (required)"
placeholder={t("apiKeyRequired")}
value={providerKey}
onChange={(e) => setProviderKey(e.target.value)}
className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
<input
type="text"
placeholder="Custom URL (optional)"
placeholder={t("customUrlOptional")}
value={providerUrl}
onChange={(e) => setProviderUrl(e.target.value)}
className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40"
@@ -355,15 +351,13 @@ export default function OnboardingWizard() {
{/* Test */}
{currentStep.id === "test" && (
<div className="text-center space-y-4">
<p className="text-sm text-text-muted">
Let&apos;s verify your provider connection works.
</p>
<p className="text-sm text-text-muted">{t("testDesc")}</p>
{testStatus === "idle" && (
<button
onClick={handleTestProvider}
className="px-6 py-2.5 bg-primary rounded-lg text-white font-medium text-sm hover:bg-primary/90 transition-colors cursor-pointer"
>
Run Connection Test
{t("runTest")}
</button>
)}
{testStatus === "testing" && (
@@ -390,7 +384,7 @@ export default function OnboardingWizard() {
onClick={handleTestProvider}
className="text-xs text-text-muted underline cursor-pointer"
>
Retry
{t("retry")}
</button>
</div>
)}
@@ -400,12 +394,9 @@ export default function OnboardingWizard() {
{/* Done */}
{currentStep.id === "done" && (
<div className="text-center space-y-4">
<p className="text-text-muted">
You&apos;re all set! Your OmniRoute instance is configured and ready to proxy AI
requests.
</p>
<p className="text-text-muted">{t("doneDesc")}</p>
<div className="bg-white/[0.03] rounded-xl p-4 border border-white/[0.06] text-left">
<p className="text-xs text-text-muted mb-2 font-medium">Your endpoint:</p>
<p className="text-xs text-text-muted mb-2 font-medium">{t("yourEndpoint")}</p>
<code className="text-sm text-primary">{apiEndpoint}</code>
</div>
</div>
@@ -420,7 +411,7 @@ export default function OnboardingWizard() {
onClick={handleBack}
className="px-4 py-2 text-sm text-text-muted hover:text-text-main transition-colors cursor-pointer"
>
Back
{tc("back")}
</button>
)}
</div>
@@ -430,7 +421,7 @@ export default function OnboardingWizard() {
onClick={handleNext}
className="px-4 py-2 text-sm text-text-muted hover:text-text-main transition-colors cursor-pointer"
>
Skip
{t("skip")}
</button>
)}
{currentStep.id === "welcome" && (
@@ -438,7 +429,7 @@ export default function OnboardingWizard() {
onClick={handleNext}
className="px-6 py-2.5 bg-primary rounded-lg text-white font-medium text-sm hover:bg-primary/90 transition-colors cursor-pointer"
>
Get Started
{t("getStarted")}
</button>
)}
{currentStep.id === "security" && (
@@ -447,7 +438,7 @@ export default function OnboardingWizard() {
disabled={!skipSecurity && (!password || password !== confirmPassword)}
className="px-6 py-2.5 bg-primary rounded-lg text-white font-medium text-sm hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{skipSecurity ? "Skip & Continue" : "Set Password"}
{skipSecurity ? t("skipAndContinue") : t("setPassword")}
</button>
)}
{currentStep.id === "provider" && (
@@ -456,7 +447,7 @@ export default function OnboardingWizard() {
disabled={!selectedProvider || !providerKey}
className="px-6 py-2.5 bg-primary rounded-lg text-white font-medium text-sm hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
Add Provider
{t("addProvider")}
</button>
)}
{currentStep.id === "test" && (
@@ -464,7 +455,7 @@ export default function OnboardingWizard() {
onClick={handleNext}
className="px-6 py-2.5 bg-primary rounded-lg text-white font-medium text-sm hover:bg-primary/90 transition-colors cursor-pointer"
>
{testStatus === "success" ? "Continue" : "Skip"}
{testStatus === "success" ? t("continue") : t("skip")}
</button>
)}
{isLastStep && (
@@ -472,7 +463,7 @@ export default function OnboardingWizard() {
onClick={handleFinish}
className="px-6 py-2.5 bg-green-500 rounded-lg text-white font-medium text-sm hover:bg-green-500/90 transition-colors cursor-pointer"
>
Go to Dashboard
{t("goToDashboard")}
</button>
)}
</div>
@@ -486,7 +477,7 @@ export default function OnboardingWizard() {
onClick={handleFinish}
className="text-xs text-text-muted/60 hover:text-text-muted transition-colors cursor-pointer"
>
Skip wizard entirely
{t("skipWizard")}
</button>
</div>
)}

File diff suppressed because it is too large Load Diff

View File

@@ -9,22 +9,26 @@
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
const STATUS_CONFIG = {
available: { icon: "check_circle", color: "#22c55e", label: "Available" },
cooldown: { icon: "schedule", color: "#f59e0b", label: "Cooldown" },
unavailable: { icon: "error", color: "#ef4444", label: "Unavailable" },
unknown: { icon: "help", color: "#6b7280", label: "Unknown" },
};
export default function ModelAvailabilityBadge() {
const [data, setData] = useState(null);
const t = useTranslations("providers");
const tc = useTranslations("common");
const STATUS_CONFIG = {
available: { icon: "check_circle", color: "#22c55e", label: t("available") },
cooldown: { icon: "schedule", color: "#f59e0b", label: t("cooldown") },
unavailable: { icon: "error", color: "#ef4444", label: t("unavailable") },
unknown: { icon: "help", color: "#6b7280", label: t("unknown") },
};
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
const [clearing, setClearing] = useState(null);
const ref = useRef(null);
const [clearing, setClearing] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
const notify = useNotificationStore();
const fetchStatus = useCallback(async () => {
@@ -49,8 +53,8 @@ export default function ModelAvailabilityBadge() {
// Close popover on outside click
useEffect(() => {
const handleClick = (e) => {
if (ref.current && !ref.current.contains(e.target)) {
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setExpanded(false);
}
};
@@ -58,7 +62,7 @@ export default function ModelAvailabilityBadge() {
return () => document.removeEventListener("mousedown", handleClick);
}, [expanded]);
const handleClearCooldown = async (provider, model) => {
const handleClearCooldown = async (provider: string, model: string) => {
setClearing(`${provider}:${model}`);
try {
const res = await fetch("/api/models/availability", {
@@ -67,13 +71,13 @@ export default function ModelAvailabilityBadge() {
body: JSON.stringify({ action: "clearCooldown", provider, model }),
});
if (res.ok) {
notify.success(`Cooldown cleared for ${model}`);
notify.success(t("cooldownCleared", { model }));
await fetchStatus();
} else {
notify.error("Failed to clear cooldown");
notify.error(t("failedClearCooldown"));
}
} catch {
notify.error("Failed to clear cooldown");
notify.error(t("failedClearCooldown"));
} finally {
setClearing(null);
}
@@ -83,12 +87,12 @@ export default function ModelAvailabilityBadge() {
const models = data?.models || [];
const unavailableCount =
data?.unavailableCount || models.filter((m) => m.status !== "available").length;
data?.unavailableCount || models.filter((m: any) => m.status !== "available").length;
const isHealthy = unavailableCount === 0;
// Group unhealthy models by provider
const byProvider = {};
models.forEach((m) => {
const byProvider: Record<string, any[]> = {};
models.forEach((m: any) => {
if (m.status === "available") return;
const key = m.provider || "unknown";
if (!byProvider[key]) byProvider[key] = [];
@@ -105,12 +109,10 @@ export default function ModelAvailabilityBadge() {
: "bg-amber-500/10 border-amber-500/20 text-amber-500 hover:bg-amber-500/15"
}`}
>
<span className="material-symbols-outlined text-[14px]">
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
{isHealthy ? "verified" : "warning"}
</span>
{isHealthy
? "All models operational"
: `${unavailableCount} model${unavailableCount !== 1 ? "s" : ""} with issues`}
{isHealthy ? t("allModelsOperational") : t("modelsWithIssues", { count: unavailableCount })}
</button>
{/* Expanded popover */}
@@ -121,25 +123,26 @@ export default function ModelAvailabilityBadge() {
<span
className="material-symbols-outlined text-[16px]"
style={{ color: isHealthy ? "#22c55e" : "#f59e0b" }}
aria-hidden="true"
>
{isHealthy ? "verified" : "warning"}
</span>
<span className="text-sm font-semibold text-text-main">Model Status</span>
<span className="text-sm font-semibold text-text-main">{t("modelStatus")}</span>
</div>
<button
onClick={fetchStatus}
className="p-1 rounded-lg hover:bg-surface text-text-muted hover:text-text-main transition-colors"
title="Refresh"
title={tc("refresh")}
>
<span className="material-symbols-outlined text-[14px]">refresh</span>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
refresh
</span>
</button>
</div>
<div className="px-4 py-3 max-h-60 overflow-y-auto">
{isHealthy ? (
<p className="text-sm text-text-muted text-center py-2">
All models are responding normally.
</p>
<p className="text-sm text-text-muted text-center py-2">{t("allModelsNormal")}</p>
) : (
<div className="flex flex-col gap-2.5">
{Object.entries(byProvider).map(([provider, provModels]) => (
@@ -148,8 +151,10 @@ export default function ModelAvailabilityBadge() {
{provider}
</p>
<div className="flex flex-col gap-1">
{(provModels as any).map((m) => {
const status = STATUS_CONFIG[m.status] || STATUS_CONFIG.unknown;
{provModels.map((m) => {
const status =
STATUS_CONFIG[m.status as keyof typeof STATUS_CONFIG] ||
STATUS_CONFIG.unknown;
const isClearing = clearing === `${m.provider}:${m.model}`;
return (
<div
@@ -160,6 +165,7 @@ export default function ModelAvailabilityBadge() {
<span
className="material-symbols-outlined text-[14px] shrink-0"
style={{ color: status.color }}
aria-hidden="true"
>
{status.icon}
</span>
@@ -175,7 +181,7 @@ export default function ModelAvailabilityBadge() {
disabled={isClearing}
className="text-[10px] px-1.5! py-0.5! ml-2"
>
{isClearing ? "..." : "Clear"}
{isClearing ? t("clearing") : t("clearCooldown")}
</Button>
)}
</div>

View File

@@ -8,20 +8,24 @@
*/
import { useState, useEffect, useCallback } from "react";
import { Card, Button, EmptyState } from "@/shared/components";
import { useTranslations } from "next-intl";
import { Card, Button } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
const STATUS_CONFIG = {
available: { icon: "check_circle", color: "#22c55e", label: "Available" },
cooldown: { icon: "schedule", color: "#f59e0b", label: "Cooldown" },
unavailable: { icon: "error", color: "#ef4444", label: "Unavailable" },
unknown: { icon: "help", color: "#6b7280", label: "Unknown" },
};
export default function ModelAvailabilityPanel() {
const [data, setData] = useState(null);
const t = useTranslations("providers");
const tc = useTranslations("common");
const STATUS_CONFIG = {
available: { icon: "check_circle", color: "#22c55e", label: t("available") },
cooldown: { icon: "schedule", color: "#f59e0b", label: t("cooldown") },
unavailable: { icon: "error", color: "#ef4444", label: t("unavailable") },
unknown: { icon: "help", color: "#6b7280", label: t("unknown") },
};
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [clearing, setClearing] = useState(null);
const [clearing, setClearing] = useState<string | null>(null);
const notify = useNotificationStore();
const fetchStatus = useCallback(async () => {
@@ -44,7 +48,7 @@ export default function ModelAvailabilityPanel() {
return () => clearInterval(interval);
}, [fetchStatus]);
const handleClearCooldown = async (provider, model) => {
const handleClearCooldown = async (provider: string, model: string) => {
setClearing(`${provider}:${model}`);
try {
const res = await fetch("/api/models/availability", {
@@ -53,13 +57,13 @@ export default function ModelAvailabilityPanel() {
body: JSON.stringify({ action: "clearCooldown", provider, model }),
});
if (res.ok) {
notify.success(`Cooldown cleared for ${model}`);
notify.success(t("cooldownCleared", { model }));
await fetchStatus();
} else {
notify.error("Failed to clear cooldown");
notify.error(t("failedClearCooldown"));
}
} catch {
notify.error("Failed to clear cooldown");
notify.error(t("failedClearCooldown"));
} finally {
setClearing(null);
}
@@ -69,8 +73,10 @@ export default function ModelAvailabilityPanel() {
return (
<Card className="p-6 mt-6">
<div className="flex items-center gap-2 text-text-muted animate-pulse">
<span className="material-symbols-outlined text-[20px]">monitoring</span>
Loading model availability...
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
monitoring
</span>
{t("loadingAvailability")}
</div>
</Card>
);
@@ -78,18 +84,20 @@ export default function ModelAvailabilityPanel() {
const models = data?.models || [];
const unavailableCount =
data?.unavailableCount || models.filter((m) => m.status !== "available").length;
data?.unavailableCount || models.filter((m: any) => m.status !== "available").length;
if (models.length === 0 || unavailableCount === 0) {
return (
<Card className="p-6 mt-6">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
<span className="material-symbols-outlined text-[20px]">verified</span>
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
verified
</span>
</div>
<div>
<h3 className="text-lg font-semibold text-text-main">Model Availability</h3>
<p className="text-sm text-text-muted">All models operational</p>
<h3 className="text-lg font-semibold text-text-main">{t("modelAvailability")}</h3>
<p className="text-sm text-text-muted">{t("allModelsOperational")}</p>
</div>
</div>
</Card>
@@ -97,8 +105,8 @@ export default function ModelAvailabilityPanel() {
}
// Group by provider
const byProvider = {};
models.forEach((m) => {
const byProvider: Record<string, any[]> = {};
models.forEach((m: any) => {
if (m.status === "available") return;
const key = m.provider || "unknown";
if (!byProvider[key]) byProvider[key] = [];
@@ -110,17 +118,27 @@ export default function ModelAvailabilityPanel() {
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
<span className="material-symbols-outlined text-[20px]">warning</span>
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
warning
</span>
</div>
<div>
<h3 className="text-lg font-semibold text-text-main">Model Availability</h3>
<h3 className="text-lg font-semibold text-text-main">{t("modelAvailability")}</h3>
<p className="text-sm text-text-muted">
{unavailableCount} model{unavailableCount !== 1 ? "s" : ""} with issues
{t("modelsWithIssues", { count: unavailableCount })}
</p>
</div>
</div>
<Button size="sm" variant="ghost" onClick={fetchStatus} className="text-text-muted">
<span className="material-symbols-outlined text-[16px]">refresh</span>
<Button
size="sm"
variant="ghost"
onClick={fetchStatus}
className="text-text-muted"
title={tc("refresh")}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
refresh
</span>
</Button>
</div>
@@ -129,8 +147,9 @@ export default function ModelAvailabilityPanel() {
<div key={provider} className="border border-border/30 rounded-lg p-3">
<p className="text-sm font-medium text-text-main mb-2 capitalize">{provider}</p>
<div className="flex flex-col gap-1.5">
{(provModels as any).map((m) => {
const status = STATUS_CONFIG[m.status] || STATUS_CONFIG.unknown;
{provModels.map((m) => {
const status =
STATUS_CONFIG[m.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.unknown;
const isClearing = clearing === `${m.provider}:${m.model}`;
return (
<div
@@ -141,6 +160,7 @@ export default function ModelAvailabilityPanel() {
<span
className="material-symbols-outlined text-[16px]"
style={{ color: status.color }}
aria-hidden="true"
>
{status.icon}
</span>
@@ -156,7 +176,7 @@ export default function ModelAvailabilityPanel() {
</span>
{m.cooldownUntil && (
<span className="text-xs text-text-muted">
until {new Date(m.cooldownUntil).toLocaleTimeString()}
{t("until", { time: new Date(m.cooldownUntil).toLocaleTimeString() })}
</span>
)}
</div>
@@ -168,7 +188,7 @@ export default function ModelAvailabilityPanel() {
disabled={isClearing}
className="text-xs"
>
{isClearing ? "Clearing..." : "Clear"}
{isClearing ? t("clearing") : t("clearCooldown")}
</Button>
)}
</div>

View File

@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { Card, Button, Input, Select, Toggle } from "@/shared/components";
import { AI_PROVIDERS, AUTH_METHODS } from "@/shared/constants/config";
import { useTranslations } from "next-intl";
const providerOptions = Object.values(AI_PROVIDERS).map((p) => ({
value: p.id,
@@ -19,6 +20,7 @@ const authMethodOptions = Object.values(AUTH_METHODS).map((m) => ({
export default function NewProviderPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const t = useTranslations("providers");
const [formData, setFormData] = useState({
provider: "",
authMethod: "api_key",
@@ -37,9 +39,9 @@ export default function NewProviderPage() {
const validate = () => {
const newErrors: any = {};
if (!formData.provider) newErrors.provider = "Please select a provider";
if (!formData.provider) newErrors.provider = t("selectProvider");
if (formData.authMethod === "api_key" && !formData.apiKey) {
newErrors.apiKey = "API Key is required";
newErrors.apiKey = t("apiKeyRequired");
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
@@ -61,10 +63,10 @@ export default function NewProviderPage() {
router.push("/dashboard/providers");
} else {
const data = await response.json();
setErrors({ submit: data.error || "Failed to create provider" });
setErrors({ submit: data.error || t("failedCreate") });
}
} catch (error) {
setErrors({ submit: "An error occurred. Please try again." });
setErrors({ submit: t("errorOccurred") });
} finally {
setLoading(false);
}
@@ -81,12 +83,10 @@ export default function NewProviderPage() {
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-primary transition-colors mb-4"
>
<span className="material-symbols-outlined text-lg">arrow_back</span>
Back to Providers
{t("backToProviders")}
</Link>
<h1 className="text-3xl font-semibold tracking-tight">Add New Provider</h1>
<p className="text-text-muted mt-2">
Configure a new AI provider to use with your applications.
</p>
<h1 className="text-3xl font-semibold tracking-tight">{t("addNewProvider")}</h1>
<p className="text-text-muted mt-2">{t("configureNewProvider")}</p>
</div>
{/* Form */}
@@ -94,11 +94,11 @@ export default function NewProviderPage() {
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
{/* Provider Selection */}
<Select
label="Provider"
label={t("providerLabel")}
options={providerOptions}
value={formData.provider}
onChange={(e) => handleChange("provider", e.target.value)}
placeholder="Select a provider"
placeholder={t("selectProvider")}
error={errors.provider as string}
required
/>
@@ -116,7 +116,7 @@ export default function NewProviderPage() {
</div>
<div>
<p className="font-medium">{selectedProvider.name}</p>
<p className="text-sm text-text-muted">Selected provider</p>
<p className="text-sm text-text-muted">{t("selectedProvider")}</p>
</div>
</Card.Section>
)}
@@ -124,7 +124,7 @@ export default function NewProviderPage() {
{/* Auth Method */}
<div className="flex flex-col gap-3">
<label className="text-sm font-medium">
Authentication Method <span className="text-red-500">*</span>
{t("authMethod")} <span className="text-red-500">*</span>
</label>
<div className="flex gap-3">
{authMethodOptions.map((method) => (
@@ -141,7 +141,9 @@ export default function NewProviderPage() {
<span className="material-symbols-outlined">
{method.value === "api_key" ? "key" : "lock"}
</span>
<span className="font-medium">{method.label}</span>
<span className="font-medium">
{method.value === "api_key" ? t("apiKeyLabel") : t("oauth2Label")}
</span>
</button>
))}
</div>
@@ -150,13 +152,13 @@ export default function NewProviderPage() {
{/* API Key Input */}
{formData.authMethod === "api_key" && (
<Input
label="API Key"
label={t("apiKeyLabel")}
type="password"
placeholder="Enter your API key"
placeholder={t("enterApiKey")}
value={formData.apiKey}
onChange={(e) => handleChange("apiKey", e.target.value)}
error={errors.apiKey as string}
hint="Your API key will be encrypted and stored securely."
hint={t("apiKeySecure")}
required
/>
)}
@@ -164,30 +166,28 @@ export default function NewProviderPage() {
{/* OAuth2 Button */}
{formData.authMethod === "oauth2" && (
<Card.Section className="">
<p className="text-sm text-text-muted mb-4">
Connect your account using OAuth2 authentication.
</p>
<p className="text-sm text-text-muted mb-4">{t("oauth2Desc")}</p>
<Button type="button" variant="secondary" icon="link">
Connect with OAuth2
{t("oauth2Connect")}
</Button>
</Card.Section>
)}
{/* Display Name */}
<Input
label="Display Name"
placeholder="e.g., Production API, Dev Environment"
label={t("displayName")}
placeholder={t("displayNamePlaceholder")}
value={formData.displayName}
onChange={(e) => handleChange("displayName", e.target.value)}
hint="Optional. A friendly name to identify this configuration."
hint={t("displayNameHint")}
/>
{/* Active Toggle */}
<Toggle
checked={formData.isActive}
onChange={(checked) => handleChange("isActive", checked)}
label="Active"
description="Enable this provider for use in your applications"
label={t("active")}
description={t("activeDescription")}
/>
{/* Error Message */}
@@ -201,11 +201,11 @@ export default function NewProviderPage() {
<div className="flex gap-3 pt-4 border-t border-border">
<Link href="/dashboard/providers" className="flex-1">
<Button type="button" variant="ghost" fullWidth>
Cancel
{t("cancel")}
</Button>
</Link>
<Button type="submit" loading={loading} fullWidth className="flex-1">
Create Provider
{t("createProvider")}
</Button>
</div>
</form>

View File

@@ -23,19 +23,22 @@ import Link from "next/link";
import { getErrorCode, getRelativeTime } from "@/shared/utils";
import { useNotificationStore } from "@/store/notificationStore";
import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge";
import { useTranslations } from "next-intl";
// Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard
function getStatusDisplay(connected, error, errorCode) {
function getStatusDisplay(connected, error, errorCode, t) {
const parts = [];
if (connected > 0) {
parts.push(
<Badge key="connected" variant="success" size="sm" dot>
{connected} Connected
{t("connected", { count: connected })}
</Badge>
);
}
if (error > 0) {
const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`;
const errText = errorCode
? t("errorCount", { count: error, code: errorCode })
: t("errorCountNoCode", { count: error });
parts.push(
<Badge key="error" variant="error" size="sm" dot>
{errText}
@@ -43,7 +46,7 @@ function getStatusDisplay(connected, error, errorCode) {
);
}
if (parts.length === 0) {
return <span className="text-text-muted">No connections</span>;
return <span className="text-text-muted">{t("noConnections")}</span>;
}
return parts;
}
@@ -89,14 +92,16 @@ function getConnectionErrorTag(connection) {
}
export default function ProvidersPage() {
const [connections, setConnections] = useState([]);
const [providerNodes, setProviderNodes] = useState([]);
const [connections, setConnections] = useState<any[]>([]);
const [providerNodes, setProviderNodes] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false);
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false);
const [testingMode, setTestingMode] = useState(null);
const [testResults, setTestResults] = useState(null);
const [testingMode, setTestingMode] = useState<string | null>(null);
const [testResults, setTestResults] = useState<any>(null);
const notify = useNotificationStore();
const t = useTranslations("providers");
const tc = useTranslations("common");
useEffect(() => {
const fetchData = async () => {
@@ -194,12 +199,12 @@ export default function ProvidersPage() {
setTestResults(data);
if (data.summary) {
const { passed, failed, total } = data.summary;
if (failed === 0) notify.success(`All ${total} tests passed`);
else notify.warning(`${passed}/${total} passed, ${failed} failed`);
if (failed === 0) notify.success(t("allTestsPassed", { total }));
else notify.warning(t("testSummary", { passed, failed, total }));
}
} catch (error) {
setTestResults({ error: "Test request failed" });
notify.error("Provider test failed");
setTestResults({ error: t("providerTestFailed") });
notify.error(t("providerTestFailed"));
} finally {
setTestingMode(null);
}
@@ -209,7 +214,7 @@ export default function ProvidersPage() {
.filter((node) => node.type === "openai-compatible")
.map((node) => ({
id: node.id,
name: node.name || "OpenAI Compatible",
name: node.name || t("openaiCompatibleName"),
color: "#10A37F",
textIcon: "OC",
apiType: node.apiType,
@@ -219,7 +224,7 @@ export default function ProvidersPage() {
.filter((node) => node.type === "anthropic-compatible")
.map((node) => ({
id: node.id,
name: node.name || "Anthropic Compatible",
name: node.name || t("anthropicCompatibleName"),
color: "#D97757",
textIcon: "AC",
}));
@@ -239,7 +244,8 @@ export default function ProvidersPage() {
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
OAuth Providers <span className="size-2.5 rounded-full bg-blue-500" title="OAuth" />
{t("oauthProviders")}{" "}
<span className="size-2.5 rounded-full bg-blue-500" title={t("oauthLabel")} />
</h2>
<div className="flex items-center gap-2">
<ModelAvailabilityBadge />
@@ -251,13 +257,13 @@ export default function ProvidersPage() {
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title="Test all OAuth connections"
aria-label="Test all OAuth connections"
title={t("testAllOAuth")}
aria-label={t("testAllOAuth")}
>
<span className="material-symbols-outlined text-[14px]">
{testingMode === "oauth" ? "sync" : "play_arrow"}
</span>
{testingMode === "oauth" ? "Testing..." : "Test All"}
{testingMode === "oauth" ? t("testing") : t("testAll")}
</button>
</div>
</div>
@@ -279,7 +285,8 @@ export default function ProvidersPage() {
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
Free Providers <span className="size-2.5 rounded-full bg-green-500" title="Free" />
{t("freeProviders")}{" "}
<span className="size-2.5 rounded-full bg-green-500" title={tc("free")} />
</h2>
<button
onClick={() => handleBatchTest("free")}
@@ -289,13 +296,13 @@ export default function ProvidersPage() {
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title="Test all Free connections"
aria-label="Test all Free provider connections"
title={t("testAllFree")}
aria-label={t("testAllFree")}
>
<span className="material-symbols-outlined text-[14px]">
{testingMode === "free" ? "sync" : "play_arrow"}
</span>
{testingMode === "free" ? "Testing..." : "Test All"}
{testingMode === "free" ? t("testing") : t("testAll")}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
@@ -316,8 +323,8 @@ export default function ProvidersPage() {
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
API Key Providers{" "}
<span className="size-2.5 rounded-full bg-amber-500" title="API Key" />
{t("apiKeyProviders")}{" "}
<span className="size-2.5 rounded-full bg-amber-500" title={t("apiKeyLabel")} />
</h2>
<button
onClick={() => handleBatchTest("apikey")}
@@ -327,13 +334,13 @@ export default function ProvidersPage() {
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title="Test all API Key connections"
aria-label="Test all API Key connections"
title={t("testAllApiKey")}
aria-label={t("testAllApiKey")}
>
<span className="material-symbols-outlined text-[14px]">
{testingMode === "apikey" ? "sync" : "play_arrow"}
</span>
{testingMode === "apikey" ? "Testing..." : "Test All"}
{testingMode === "apikey" ? t("testing") : t("testAll")}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
@@ -354,8 +361,8 @@ export default function ProvidersPage() {
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
API Key Compatible Providers{" "}
<span className="size-2.5 rounded-full bg-orange-500" title="Compatible" />
{t("compatibleProviders")}{" "}
<span className="size-2.5 rounded-full bg-orange-500" title={t("compatibleLabel")} />
</h2>
<div className="flex gap-2">
{(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && (
@@ -367,16 +374,16 @@ export default function ProvidersPage() {
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title="Test all Compatible connections"
title={t("testAllCompatible")}
>
<span className="material-symbols-outlined text-[14px]">
{testingMode === "compatible" ? "sync" : "play_arrow"}
</span>
{testingMode === "compatible" ? "Testing..." : "Test All"}
{testingMode === "compatible" ? t("testing") : t("testAll")}
</button>
)}
<Button size="sm" icon="add" onClick={() => setShowAddAnthropicCompatibleModal(true)}>
Add Anthropic Compatible
{t("addAnthropicCompatible")}
</Button>
<Button
size="sm"
@@ -385,7 +392,7 @@ export default function ProvidersPage() {
onClick={() => setShowAddCompatibleModal(true)}
className="!bg-white !text-black hover:!bg-gray-100"
>
Add OpenAI Compatible
{t("addOpenAICompatible")}
</Button>
</div>
</div>
@@ -394,10 +401,8 @@ export default function ProvidersPage() {
<span className="material-symbols-outlined text-[32px] text-text-muted mb-2">
extension
</span>
<p className="text-text-muted text-sm">No compatible providers added yet</p>
<p className="text-text-muted text-xs mt-1">
Use the buttons above to add OpenAI or Anthropic compatible endpoints
</p>
<p className="text-text-muted text-sm">{t("noCompatibleYet")}</p>
<p className="text-text-muted text-xs mt-1">{t("compatibleHint")}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
@@ -442,11 +447,11 @@ export default function ProvidersPage() {
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 z-10 flex items-center justify-between px-5 py-3 border-b border-border bg-bg-primary/95 backdrop-blur-sm rounded-t-xl">
<h3 className="font-semibold">Test Results</h3>
<h3 className="font-semibold">{t("testResults")}</h3>
<button
onClick={() => setTestResults(null)}
className="p-1 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
aria-label="Close test results"
aria-label={tc("close")}
>
<span className="material-symbols-outlined text-lg">close</span>
</button>
@@ -462,6 +467,8 @@ export default function ProvidersPage() {
}
function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
const t = useTranslations("providers");
const tc = useTranslations("common");
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const [imgError, setImgError] = useState(false);
@@ -471,7 +478,12 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
apikey: "bg-amber-500",
compatible: "bg-orange-500",
};
const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" };
const dotLabels = {
free: tc("free"),
oauth: t("oauthLabel"),
apikey: t("apiKeyLabel"),
compatible: t("compatibleLabel"),
};
return (
<Link href={`/dashboard/providers/${providerId}`} className="group">
@@ -506,7 +518,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
{provider.name}
<span
className={`size-2 rounded-full ${dotColors[authType] || dotColors.oauth} shrink-0`}
title={dotLabels[authType] || "OAuth"}
title={dotLabels[authType] || t("oauthLabel")}
/>
</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
@@ -514,12 +526,12 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
<Badge variant="default" size="sm">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">pause_circle</span>
Disabled
{t("disabled")}
</span>
</Badge>
) : (
<>
{getStatusDisplay(connected, error, errorCode)}
{getStatusDisplay(connected, error, errorCode, t)}
{errorTime && <span className="text-text-muted"> {errorTime}</span>}
</>
)}
@@ -540,7 +552,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
size="sm"
checked={!allDisabled}
onChange={() => {}}
title={allDisabled ? "Enable provider" : "Disable provider"}
title={allDisabled ? t("enableProvider") : t("disableProvider")}
/>
</div>
)}
@@ -573,6 +585,8 @@ ProviderCard.propTypes = {
// API Key providers - use image with textIcon fallback (same as OAuth providers)
function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle }) {
const t = useTranslations("providers");
const tc = useTranslations("common");
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
@@ -584,7 +598,12 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
apikey: "bg-amber-500",
compatible: "bg-orange-500",
};
const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" };
const dotLabels = {
free: tc("free"),
oauth: t("oauthLabel"),
apikey: t("apiKeyLabel"),
compatible: t("compatibleLabel"),
};
// Determine icon path: OpenAI Compatible providers use specialized icons
const getIconPath = () => {
@@ -630,7 +649,7 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
{provider.name}
<span
className={`size-2 rounded-full ${dotColors[authType] || dotColors.apikey} shrink-0`}
title={dotLabels[authType] || "API Key"}
title={dotLabels[authType] || t("apiKeyLabel")}
/>
</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
@@ -638,20 +657,20 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
<Badge variant="default" size="sm">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">pause_circle</span>
Disabled
{t("disabled")}
</span>
</Badge>
) : (
<>
{getStatusDisplay(connected, error, errorCode)}
{getStatusDisplay(connected, error, errorCode, t)}
{isCompatible && (
<Badge variant="default" size="sm">
{provider.apiType === "responses" ? "Responses" : "Chat"}
{provider.apiType === "responses" ? t("responses") : t("chat")}
</Badge>
)}
{isAnthropicCompatible && (
<Badge variant="default" size="sm">
Messages
{t("messages")}
</Badge>
)}
{errorTime && <span className="text-text-muted"> {errorTime}</span>}
@@ -674,7 +693,7 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
size="sm"
checked={!allDisabled}
onChange={() => {}}
title={allDisabled ? "Enable provider" : "Disable provider"}
title={allDisabled ? t("enableProvider") : t("disableProvider")}
/>
</div>
)}
@@ -707,6 +726,7 @@ ApiKeyProviderCard.propTypes = {
};
function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
const t = useTranslations("providers");
const [formData, setFormData] = useState({
name: "",
prefix: "",
@@ -716,11 +736,11 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
const [submitting, setSubmitting] = useState(false);
const [checkKey, setCheckKey] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null);
const apiTypeOptions = [
{ value: "chat", label: "Chat Completions" },
{ value: "responses", label: "Responses API" },
{ value: "chat", label: t("chatCompletions") },
{ value: "responses", label: t("responsesApi") },
];
useEffect(() => {
@@ -787,38 +807,38 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
};
return (
<Modal isOpen={isOpen} title="Add OpenAI Compatible" onClose={onClose}>
<Modal isOpen={isOpen} title={t("addOpenAICompatible")} onClose={onClose}>
<div className="flex flex-col gap-4">
<Input
label="Name"
label={t("nameLabel")}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="OpenAI Compatible (Prod)"
hint="Required. A friendly label for this node."
placeholder={t("compatibleProdPlaceholder", { type: t("openai") })}
hint={t("nameHint")}
/>
<Input
label="Prefix"
label={t("prefixLabel")}
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder="oc-prod"
hint="Required. Used as the provider prefix for model IDs."
placeholder={t("openaiPrefixPlaceholder")}
hint={t("prefixHint")}
/>
<Select
label="API Type"
label={t("apiTypeLabel")}
options={apiTypeOptions}
value={formData.apiType}
onChange={(e) => setFormData({ ...formData, apiType: e.target.value })}
/>
<Input
label="Base URL"
label={t("baseUrlLabel")}
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder="https://api.openai.com/v1"
hint="Use the base URL (ending in /v1) for your OpenAI-compatible API."
placeholder={t("openaiBaseUrlPlaceholder")}
hint={t("compatibleBaseUrlHint", { type: t("openai") })}
/>
<div className="flex gap-2">
<Input
label="API Key (for Check)"
label={t("apiKeyForCheck")}
type="password"
value={checkKey}
onChange={(e) => setCheckKey(e.target.value)}
@@ -830,13 +850,13 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
disabled={!checkKey || validating || !formData.baseUrl.trim()}
variant="secondary"
>
{validating ? "Checking..." : "Check"}
{validating ? t("checking") : t("check")}
</Button>
</div>
</div>
{validationResult && (
<Badge variant={validationResult === "success" ? "success" : "error"}>
{validationResult === "success" ? "Valid" : "Invalid"}
{validationResult === "success" ? t("valid") : t("invalid")}
</Badge>
)}
<div className="flex gap-2">
@@ -850,10 +870,10 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
submitting
}
>
{submitting ? "Creating..." : "Create"}
{submitting ? t("creating") : t("add")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
{t("cancel")}
</Button>
</div>
</div>
@@ -868,6 +888,7 @@ AddOpenAICompatibleModal.propTypes = {
};
function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
const t = useTranslations("providers");
const [formData, setFormData] = useState({
name: "",
prefix: "",
@@ -876,7 +897,7 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
const [submitting, setSubmitting] = useState(false);
const [checkKey, setCheckKey] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null);
useEffect(() => {
// Reset validation when modal opens
@@ -940,32 +961,32 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
};
return (
<Modal isOpen={isOpen} title="Add Anthropic Compatible" onClose={onClose}>
<Modal isOpen={isOpen} title={t("addAnthropicCompatible")} onClose={onClose}>
<div className="flex flex-col gap-4">
<Input
label="Name"
label={t("nameLabel")}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Anthropic Compatible (Prod)"
hint="Required. A friendly label for this node."
placeholder={t("compatibleProdPlaceholder", { type: t("anthropic") })}
hint={t("nameHint")}
/>
<Input
label="Prefix"
label={t("prefixLabel")}
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder="ac-prod"
hint="Required. Used as the provider prefix for model IDs."
placeholder={t("anthropicPrefixPlaceholder")}
hint={t("prefixHint")}
/>
<Input
label="Base URL"
label={t("baseUrlLabel")}
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder="https://api.anthropic.com/v1"
hint="Use the base URL (ending in /v1) for your Anthropic-compatible API. The system will append /messages."
placeholder={t("anthropicBaseUrlPlaceholder")}
hint={t("compatibleBaseUrlHint", { type: t("anthropic") })}
/>
<div className="flex gap-2">
<Input
label="API Key (for Check)"
label={t("apiKeyForCheck")}
type="password"
value={checkKey}
onChange={(e) => setCheckKey(e.target.value)}
@@ -977,13 +998,13 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
disabled={!checkKey || validating || !formData.baseUrl.trim()}
variant="secondary"
>
{validating ? "Checking..." : "Check"}
{validating ? t("checking") : t("check")}
</Button>
</div>
</div>
{validationResult && (
<Badge variant={validationResult === "success" ? "success" : "error"}>
{validationResult === "success" ? "Valid" : "Invalid"}
{validationResult === "success" ? t("valid") : t("invalid")}
</Badge>
)}
<div className="flex gap-2">
@@ -997,10 +1018,10 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
submitting
}
>
{submitting ? "Creating..." : "Create"}
{submitting ? t("creating") : t("add")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
{t("cancel")}
</Button>
</div>
</div>
@@ -1017,6 +1038,9 @@ AddAnthropicCompatibleModal.propTypes = {
// ─── Provider Test Results View (mirrors combo TestResultsView) ──────────────
function ProviderTestResultsView({ results }) {
const t = useTranslations("providers");
const tc = useTranslations("common");
if (results.error && !results.results) {
return (
<div className="text-center py-6">
@@ -1031,11 +1055,12 @@ function ProviderTestResultsView({ results }) {
const modeLabel =
{
oauth: "OAuth",
free: "Free",
apikey: "API Key",
provider: "Provider",
all: "All",
oauth: t("oauthLabel"),
free: tc("free"),
apikey: t("apiKeyLabel"),
compatible: t("compatibleLabel"),
provider: t("providerLabel"),
all: tc("all"),
}[mode] || mode;
return (
@@ -1043,16 +1068,18 @@ function ProviderTestResultsView({ results }) {
{/* Summary header */}
{summary && (
<div className="flex items-center gap-3 text-xs mb-1">
<span className="text-text-muted">{modeLabel} Test</span>
<span className="text-text-muted">{t("modeTest", { mode: modeLabel })}</span>
<span className="px-2 py-0.5 rounded bg-emerald-500/15 text-emerald-400 font-medium">
{summary.passed} passed
{t("passedCount", { count: summary.passed })}
</span>
{summary.failed > 0 && (
<span className="px-2 py-0.5 rounded bg-red-500/15 text-red-400 font-medium">
{summary.failed} failed
{t("failedCount", { count: summary.failed })}
</span>
)}
<span className="text-text-muted ml-auto">{summary.total} tested</span>
<span className="text-text-muted ml-auto">
{t("testedCount", { count: summary.total })}
</span>
</div>
)}
@@ -1074,21 +1101,23 @@ function ProviderTestResultsView({ results }) {
<span className="text-text-muted ml-1.5">({r.provider})</span>
</div>
{r.latencyMs !== undefined && (
<span className="text-text-muted font-mono tabular-nums">{r.latencyMs}ms</span>
<span className="text-text-muted font-mono tabular-nums">
{t("millisecondsAbbr", { value: r.latencyMs })}
</span>
)}
<span
className={`text-[10px] uppercase font-bold px-1.5 py-0.5 rounded ${
r.valid ? "bg-emerald-500/15 text-emerald-400" : "bg-red-500/15 text-red-400"
}`}
>
{r.valid ? "OK" : r.diagnosis?.type || "ERROR"}
{r.valid ? t("okShort") : r.diagnosis?.type || t("errorShort")}
</span>
</div>
))}
{items.length === 0 && (
<div className="text-center py-4 text-text-muted text-sm">
No active connections found for this group.
{t("noActiveConnectionsInGroup")}
</div>
)}
</div>

View File

@@ -1,11 +1,51 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Toggle } from "@/shared/components";
import { useTheme } from "@/shared/hooks/useTheme";
import { cn } from "@/shared/utils/cn";
import { useTranslations } from "next-intl";
export default function AppearanceTab() {
const { theme, setTheme, isDark } = useTheme();
const t = useTranslations("settings");
const [settings, setSettings] = useState<Record<string, any>>({});
const [loading, setLoading] = useState(true);
const themeOptionLabels: Record<string, string> = {
light: t("themeLight"),
dark: t("themeDark"),
system: t("themeSystem"),
};
useEffect(() => {
fetch("/api/settings")
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP error ${res.status}`);
}
return res.json();
})
.then((data) => {
setSettings(data);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const updateSetting = async (key: string, value: any) => {
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: value }),
});
if (res.ok) {
setSettings((prev) => ({ ...prev, [key]: value }));
}
} catch (err) {
console.error(`Failed to update ${key}:`, err);
}
};
return (
<Card>
@@ -15,13 +55,13 @@ export default function AppearanceTab() {
palette
</span>
</div>
<h3 className="text-lg font-semibold">Appearance</h3>
<h3 className="text-lg font-semibold">{t("appearance")}</h3>
</div>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Dark Mode</p>
<p className="text-sm text-text-muted">Switch between light and dark themes</p>
<p className="font-medium">{t("darkMode")}</p>
<p className="text-sm text-text-muted">{t("switchThemes")}</p>
</div>
<Toggle checked={isDark} onChange={() => setTheme(isDark ? "light" : "dark")} />
</div>
@@ -29,7 +69,7 @@ export default function AppearanceTab() {
<div className="pt-4 border-t border-border">
<div
role="tablist"
aria-label="Theme selection"
aria-label={t("themeSelectionAria")}
className="inline-flex p-1 rounded-lg bg-black/5 dark:bg-white/5"
>
{["light", "dark", "system"].map((option) => (
@@ -48,11 +88,25 @@ export default function AppearanceTab() {
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
{option === "light" ? "light_mode" : option === "dark" ? "dark_mode" : "contrast"}
</span>
<span className="capitalize">{option}</span>
<span>{themeOptionLabels[option] || option}</span>
</button>
))}
</div>
</div>
<div className="pt-4 border-t border-border">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{t("hideHealthLogs")}</p>
<p className="text-sm text-text-muted">{t("hideHealthLogsDesc")}</p>
</div>
<Toggle
checked={settings.hideHealthCheckLogs === true}
onChange={() => updateSetting("hideHealthCheckLogs", !settings.hideHealthCheckLogs)}
disabled={loading}
/>
</div>
</div>
</div>
</Card>
);

View File

@@ -2,10 +2,12 @@
import { useState, useEffect } from "react";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
export default function CacheStatsCard() {
const [cache, setCache] = useState(null);
const [flushing, setFlushing] = useState(false);
const t = useTranslations("settings");
const fetchStats = () => {
fetch("/api/cache/stats")
@@ -31,40 +33,40 @@ export default function CacheStatsCard() {
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[20px]">cached</span>
Prompt Cache
{t("promptCache")}
</h3>
<button
onClick={handleFlush}
disabled={flushing}
className="px-3 py-1.5 text-xs rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors disabled:opacity-50"
>
{flushing ? "Flushing" : "Flush Cache"}
{flushing ? t("flushing") : t("flushCache")}
</button>
</div>
{cache ? (
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-text-muted">Size</p>
<p className="text-text-muted">{t("size")}</p>
<p className="font-mono text-lg text-text-main">
{cache.size}/{cache.maxSize}
</p>
</div>
<div>
<p className="text-text-muted">Hit Rate</p>
<p className="text-text-muted">{t("hitRate")}</p>
<p className="font-mono text-lg text-text-main">{cache.hitRate?.toFixed(1) ?? 0}%</p>
</div>
<div>
<p className="text-text-muted">Hits</p>
<p className="text-text-muted">{t("hits")}</p>
<p className="font-mono text-text-main">{cache.hits ?? 0}</p>
</div>
<div>
<p className="text-text-muted">Evictions</p>
<p className="text-text-muted">{t("evictions")}</p>
<p className="font-mono text-text-main">{cache.evictions ?? 0}</p>
</div>
</div>
) : (
<p className="text-sm text-text-muted">Loading cache stats</p>
<p className="text-sm text-text-muted">{t("loadingCacheStats")}</p>
)}
</Card>
);

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { Card, Button, Input, Toggle } from "@/shared/components";
import { cn } from "@/shared/utils/cn";
import { useTranslations } from "next-intl";
export default function ComboDefaultsTab() {
const [comboDefaults, setComboDefaults] = useState<any>({
@@ -18,6 +19,22 @@ export default function ComboDefaultsTab() {
const [providerOverrides, setProviderOverrides] = useState<any>({});
const [newOverrideProvider, setNewOverrideProvider] = useState("");
const [saving, setSaving] = useState(false);
const t = useTranslations("settings");
const tc = useTranslations("common");
const strategyOptions = [
{ value: "priority", label: t("priority"), icon: "sort" },
{ value: "weighted", label: t("weighted"), icon: "percent" },
{ value: "round-robin", label: t("roundRobin"), icon: "autorenew" },
{ value: "random", label: t("random"), icon: "shuffle" },
{ value: "least-used", label: t("leastUsed"), icon: "low_priority" },
{ value: "cost-optimized", label: t("costOpt"), icon: "savings" },
];
const numericSettings = [
{ key: "maxRetries", label: t("maxRetriesLabel"), min: 0, max: 5 },
{ key: "retryDelayMs", label: t("retryDelayLabel"), min: 500, max: 10000, step: 500 },
{ key: "timeoutMs", label: t("timeoutLabel"), min: 5000, max: 300000, step: 5000 },
{ key: "maxComboDepth", label: t("maxNestingDepth"), min: 1, max: 10 },
];
useEffect(() => {
fetch("/api/settings/combo-defaults")
@@ -67,31 +84,22 @@ export default function ComboDefaultsTab() {
tune
</span>
</div>
<h3 className="text-lg font-semibold">Combo Defaults</h3>
<span className="text-xs text-text-muted ml-auto">Global combo configuration</span>
<h3 className="text-lg font-semibold">{t("comboDefaultsTitle")}</h3>
<span className="text-xs text-text-muted ml-auto">{t("globalComboConfig")}</span>
</div>
<div className="flex flex-col gap-4">
{/* Default Strategy */}
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Default Strategy</p>
<p className="text-xs text-text-muted">
Applied to new combos without explicit strategy
</p>
<p className="font-medium text-sm">{t("defaultStrategy")}</p>
<p className="text-xs text-text-muted">{t("defaultStrategyDesc")}</p>
</div>
<div
role="tablist"
aria-label="Combo strategy"
aria-label={t("comboStrategyAria")}
className="grid grid-cols-3 gap-1 p-0.5 rounded-md bg-black/5 dark:bg-white/5"
>
{[
{ value: "priority", label: "Priority", icon: "sort" },
{ value: "weighted", label: "Weighted", icon: "percent" },
{ value: "round-robin", label: "Round-Robin", icon: "autorenew" },
{ value: "random", label: "Random", icon: "shuffle" },
{ value: "least-used", label: "Least-Used", icon: "low_priority" },
{ value: "cost-optimized", label: "Cost-Opt", icon: "savings" },
].map((s) => (
{strategyOptions.map((s) => (
<button
key={s.value}
role="tab"
@@ -113,12 +121,7 @@ export default function ComboDefaultsTab() {
{/* Numeric settings */}
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-border/50">
{[
{ key: "maxRetries", label: "Max Retries", min: 0, max: 5 },
{ key: "retryDelayMs", label: "Retry Delay (ms)", min: 500, max: 10000, step: 500 },
{ key: "timeoutMs", label: "Timeout (ms)", min: 5000, max: 300000, step: 5000 },
{ key: "maxComboDepth", label: "Max Nesting Depth", min: 1, max: 10 },
].map(({ key, label, min, max, step }) => (
{numericSettings.map(({ key, label, min, max, step }) => (
<Input
key={key}
label={label}
@@ -139,7 +142,7 @@ export default function ComboDefaultsTab() {
{comboDefaults.strategy === "round-robin" && (
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-border/50">
<Input
label="Concurrency / Model"
label={t("concurrencyPerModel")}
type="number"
min={1}
max={20}
@@ -154,7 +157,7 @@ export default function ComboDefaultsTab() {
className="text-sm"
/>
<Input
label="Queue Timeout (ms)"
label={t("queueTimeout")}
type="number"
min={1000}
max={120000}
@@ -176,8 +179,8 @@ export default function ComboDefaultsTab() {
<div className="flex flex-col gap-3 pt-3 border-t border-border/50">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Health Check</p>
<p className="text-xs text-text-muted">Pre-check provider availability</p>
<p className="font-medium text-sm">{t("healthCheck")}</p>
<p className="text-xs text-text-muted">{t("healthCheckDesc")}</p>
</div>
<Toggle
checked={comboDefaults.healthCheckEnabled !== false}
@@ -191,8 +194,8 @@ export default function ComboDefaultsTab() {
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Track Metrics</p>
<p className="text-xs text-text-muted">Record per-combo request metrics</p>
<p className="font-medium text-sm">{t("trackMetrics")}</p>
<p className="text-xs text-text-muted">{t("trackMetricsDesc")}</p>
</div>
<Toggle
checked={comboDefaults.trackMetrics !== false}
@@ -205,10 +208,8 @@ export default function ComboDefaultsTab() {
{/* Provider Overrides */}
<div className="pt-3 border-t border-border/50">
<p className="font-medium text-sm mb-2">Provider Overrides</p>
<p className="text-xs text-text-muted mb-3">
Override timeout and retries per provider. Provider settings override global defaults.
</p>
<p className="font-medium text-sm mb-2">{t("providerOverrides")}</p>
<p className="text-xs text-text-muted mb-3">{t("providerOverridesDesc")}</p>
{Object.entries(providerOverrides).map(([provider, config]: [string, any]) => (
<div
@@ -228,9 +229,9 @@ export default function ComboDefaultsTab() {
}))
}
className="text-xs w-16"
aria-label={`${provider} max retries`}
aria-label={t("providerMaxRetriesAria", { provider })}
/>
<span className="text-[10px] text-text-muted">retries</span>
<span className="text-[10px] text-text-muted">{t("retries")}</span>
<Input
type="number"
min="5000"
@@ -247,13 +248,13 @@ export default function ComboDefaultsTab() {
}))
}
className="text-xs w-24"
aria-label={`${provider} timeout ms`}
aria-label={t("providerTimeoutAria", { provider })}
/>
<span className="text-[10px] text-text-muted">ms</span>
<span className="text-[10px] text-text-muted">{t("ms")}</span>
<button
onClick={() => removeProviderOverride(provider)}
className="ml-auto text-red-400 hover:text-red-500 transition-colors"
aria-label={`Remove ${provider} override`}
aria-label={t("removeProviderOverrideAria", { provider })}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
close
@@ -265,12 +266,12 @@ export default function ComboDefaultsTab() {
<div className="flex items-center gap-2 mt-2">
<Input
type="text"
placeholder="e.g. google, openai..."
placeholder={t("newProviderNamePlaceholder")}
value={newOverrideProvider}
onChange={(e) => setNewOverrideProvider(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addProviderOverride()}
className="text-xs flex-1"
aria-label="New provider name"
aria-label={t("newProviderNameAria")}
/>
<Button
variant="outline"
@@ -278,7 +279,7 @@ export default function ComboDefaultsTab() {
onClick={addProviderOverride}
disabled={!newOverrideProvider.trim()}
>
Add
{tc("add")}
</Button>
</div>
</div>
@@ -286,7 +287,7 @@ export default function ComboDefaultsTab() {
{/* Save */}
<div className="pt-3 border-t border-border/50">
<Button variant="primary" size="sm" onClick={saveComboDefaults} loading={saving}>
Save Combo Defaults
{t("saveComboDefaults")}
</Button>
</div>
</div>

View File

@@ -3,13 +3,7 @@
import { useState, useEffect, useCallback } from "react";
import { Card, DataTable, FilterBar, ColumnToggle } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
const ALL_COLUMNS = [
{ key: "timestamp", label: "Time" },
{ key: "action", label: "Action" },
{ key: "actor", label: "Actor" },
{ key: "details", label: "Details" },
];
import { useTranslations } from "next-intl";
export default function ComplianceTab() {
const [logs, setLogs] = useState([]);
@@ -23,6 +17,13 @@ export default function ComplianceTab() {
details: true,
});
const notify = useNotificationStore();
const t = useTranslations("settings");
const allColumns = [
{ key: "timestamp", label: t("time") },
{ key: "action", label: t("action") },
{ key: "actor", label: t("actor") },
{ key: "details", label: t("details") },
];
useEffect(() => {
fetch("/api/compliance/audit-log?limit=100")
@@ -33,7 +34,7 @@ export default function ComplianceTab() {
})
.catch(() => {
setLoading(false);
notify.error("Failed to load audit log");
notify.error(t("failedLoadAuditLog"));
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
@@ -54,56 +55,59 @@ export default function ComplianceTab() {
return true;
});
const columns = ALL_COLUMNS.filter((c) => visibleCols[c.key]);
const columns = allColumns.filter((c) => visibleCols[c.key]);
const handleToggleCol = useCallback((key) => {
setVisibleCols((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
const renderCell = useCallback((row, col) => {
switch (col.key) {
case "timestamp":
return (
<span className="font-mono text-xs text-text-muted whitespace-nowrap">
{row.timestamp ? new Date(row.timestamp).toLocaleString() : "—"}
</span>
);
case "action":
return (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">
{row.action || "—"}
</span>
);
case "actor":
return <span className="text-text-main">{row.actor || "system"}</span>;
case "details":
return (
<span className="text-text-muted text-xs max-w-xs truncate block">
{row.details ? JSON.stringify(row.details) : "—"}
</span>
);
default:
return row[col.key] || "—";
}
}, []);
const renderCell = useCallback(
(row, col) => {
switch (col.key) {
case "timestamp":
return (
<span className="font-mono text-xs text-text-muted whitespace-nowrap">
{row.timestamp ? new Date(row.timestamp).toLocaleString() : "—"}
</span>
);
case "action":
return (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">
{row.action || "—"}
</span>
);
case "actor":
return <span className="text-text-main">{row.actor || t("systemActor")}</span>;
case "details":
return (
<span className="text-text-muted text-xs max-w-xs truncate block">
{row.details ? JSON.stringify(row.details) : "—"}
</span>
);
default:
return row[col.key] || "—";
}
},
[t]
);
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[20px]">policy</span>
Audit Log
{t("auditLog")}
</h3>
<ColumnToggle columns={ALL_COLUMNS} visible={visibleCols} onToggle={handleToggleCol} />
<ColumnToggle columns={allColumns} visible={visibleCols} onToggle={handleToggleCol} />
</div>
<FilterBar
searchValue={search}
onSearchChange={setSearch}
placeholder="Search audit logs..."
placeholder={t("searchAuditLogs")}
filters={[
{ key: "action", label: "Action", options: actionOptions },
{ key: "actor", label: "Actor", options: actorOptions },
{ key: "action", label: t("action"), options: actionOptions },
{ key: "actor", label: t("actor"), options: actorOptions },
]}
activeFilters={filters}
onFilterChange={(key, val) => setFilters((prev) => ({ ...prev, [key]: val }))}
@@ -118,7 +122,7 @@ export default function ComplianceTab() {
loading={loading}
maxHeight="400px"
emptyIcon="📋"
emptyMessage="No audit events found"
emptyMessage={t("noAuditEvents")}
/>
</Card>
);

View File

@@ -11,6 +11,7 @@
import { useState, useEffect, useCallback } from "react";
import { Card, Button, Input, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
const CHAIN_COLORS = [
"#6366f1",
@@ -31,6 +32,8 @@ export default function FallbackChainsEditor() {
const [newProviders, setNewProviders] = useState("");
const [saving, setSaving] = useState(false);
const notify = useNotificationStore();
const t = useTranslations("settings");
const tc = useTranslations("common");
const fetchChains = useCallback(async () => {
try {
@@ -52,7 +55,7 @@ export default function FallbackChainsEditor() {
const handleCreate = async () => {
if (!newModel.trim() || !newProviders.trim()) {
notify.warning("Please fill model name and providers");
notify.warning(t("fillModelAndProviders"));
return;
}
@@ -63,7 +66,7 @@ export default function FallbackChainsEditor() {
.map((provider, i) => ({ provider, priority: i + 1, enabled: true }));
if (providers.length === 0) {
notify.warning("Add at least one provider");
notify.warning(t("addAtLeastOneProvider"));
return;
}
@@ -75,23 +78,23 @@ export default function FallbackChainsEditor() {
body: JSON.stringify({ model: newModel.trim(), chain: providers }),
});
if (res.ok) {
notify.success(`Chain created for ${newModel.trim()}`);
notify.success(t("chainCreated", { model: newModel.trim() }));
setNewModel("");
setNewProviders("");
setShowCreate(false);
await fetchChains();
} else {
notify.error("Failed to create chain");
notify.error(t("failedCreateChain"));
}
} catch {
notify.error("Failed to create chain");
notify.error(t("failedCreateChain"));
} finally {
setSaving(false);
}
};
const handleDelete = async (model) => {
if (!confirm(`Delete fallback chain for "${model}"?`)) return;
if (!confirm(t("deleteChainConfirm", { model }))) return;
try {
const res = await fetch("/api/fallback/chains", {
method: "DELETE",
@@ -99,13 +102,13 @@ export default function FallbackChainsEditor() {
body: JSON.stringify({ model }),
});
if (res.ok) {
notify.success(`Chain deleted for ${model}`);
notify.success(t("chainDeleted", { model }));
await fetchChains();
} else {
notify.error("Failed to delete chain");
notify.error(t("failedDeleteChain"));
}
} catch {
notify.error("Failed to delete chain");
notify.error(t("failedDeleteChain"));
}
};
@@ -114,7 +117,7 @@ export default function FallbackChainsEditor() {
<Card className="p-6 mt-6">
<div className="flex items-center gap-2 text-text-muted animate-pulse">
<span className="material-symbols-outlined text-[20px]">timeline</span>
Loading fallback chains...
{t("loadingFallbackChains")}
</div>
</Card>
);
@@ -129,11 +132,11 @@ export default function FallbackChainsEditor() {
<span className="material-symbols-outlined text-[20px]">timeline</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">Fallback Chains</h3>
<p className="text-sm text-text-muted">Define provider fallback order per model</p>
<h3 className="text-lg font-semibold">{t("fallbackChainsTitle")}</h3>
<p className="text-sm text-text-muted">{t("fallbackChainsDesc")}</p>
</div>
<Button size="sm" variant="primary" onClick={() => setShowCreate(!showCreate)}>
{showCreate ? "Cancel" : "+ Add Chain"}
{showCreate ? tc("cancel") : t("addChain")}
</Button>
</div>
@@ -142,20 +145,20 @@ export default function FallbackChainsEditor() {
<div className="mx-6 p-4 rounded-lg border border-border/30 bg-surface/20 mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
<Input
label="Model Name"
placeholder="claude-sonnet-4-20250514"
label={t("modelName")}
placeholder={t("modelNamePlaceholder")}
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
/>
<Input
label="Providers (comma-separated, in priority order)"
placeholder="anthropic, openai, gemini"
label={t("providersCommaSeparated")}
placeholder={t("providersCommaSeparatedPlaceholder")}
value={newProviders}
onChange={(e) => setNewProviders(e.target.value)}
/>
</div>
<Button variant="primary" size="sm" onClick={handleCreate} loading={saving}>
Create Chain
{t("createChain")}
</Button>
</div>
)}
@@ -165,8 +168,8 @@ export default function FallbackChainsEditor() {
{chainEntries.length === 0 ? (
<EmptyState
icon="timeline"
title="No Fallback Chains"
description="Create a chain to define provider fallback order for a model."
title={t("noFallbackChains")}
description={t("noFallbackChainsDesc")}
/>
) : (
<div className="flex flex-col gap-2">
@@ -201,7 +204,7 @@ export default function FallbackChainsEditor() {
<button
onClick={() => handleDelete(model)}
className="text-text-muted hover:text-red-400 transition-colors ml-2"
title="Delete chain"
title={t("deleteChain")}
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>

View File

@@ -1,20 +1,28 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, Input, Toggle } from "@/shared/components";
import { Card, Button, Input } from "@/shared/components";
import { useTranslations } from "next-intl";
const MODES = [
{ value: "disabled", label: "Disabled", icon: "block" },
{ value: "blacklist", label: "Blacklist", icon: "do_not_disturb" },
{ value: "whitelist", label: "Whitelist", icon: "verified_user" },
{ value: "whitelist-priority", label: "WL Priority", icon: "priority_high" },
{ value: "disabled", labelKey: "ipModeDisabled", icon: "block" },
{ value: "blacklist", labelKey: "ipModeBlacklist", icon: "do_not_disturb" },
{ value: "whitelist", labelKey: "ipModeWhitelist", icon: "verified_user" },
{ value: "whitelist-priority", labelKey: "ipModeWhitelistPriority", icon: "priority_high" },
];
export default function IPFilterSection() {
const [config, setConfig] = useState({ enabled: false, mode: "blacklist", blacklist: [], whitelist: [], tempBans: [] });
const [config, setConfig] = useState({
enabled: false,
mode: "blacklist",
blacklist: [],
whitelist: [],
tempBans: [],
});
const [loading, setLoading] = useState(true);
const [newIP, setNewIP] = useState("");
const [listTarget, setListTarget] = useState("blacklist"); // "blacklist" | "whitelist"
const [listTarget, setListTarget] = useState("blacklist");
const t = useTranslations("settings");
useEffect(() => {
loadConfig();
@@ -24,7 +32,8 @@ export default function IPFilterSection() {
try {
const res = await fetch("/api/settings/ip-filter");
if (res.ok) setConfig(await res.json());
} catch {} finally {
} catch {
} finally {
setLoading(false);
}
};
@@ -40,8 +49,6 @@ export default function IPFilterSection() {
} catch {}
};
const toggleEnabled = () => updateConfig({ enabled: !config.enabled });
const setMode = (mode) => {
if (mode === "disabled") {
updateConfig({ enabled: false });
@@ -75,8 +82,8 @@ export default function IPFilterSection() {
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">IP Access Control</h3>
<p className="text-sm text-text-muted">Block or allow specific IP addresses</p>
<h3 className="text-lg font-semibold">{t("ipAccessControl")}</h3>
<p className="text-sm text-text-muted">{t("ipAccessControlDesc")}</p>
</div>
</div>
@@ -93,11 +100,17 @@ export default function IPFilterSection() {
: "border-border/50 hover:border-border hover:bg-surface/30"
}`}
>
<span className={`material-symbols-outlined text-[20px] ${
activeMode === m.value ? "text-red-400" : "text-text-muted"
}`}>{m.icon}</span>
<span className={`text-xs font-medium ${activeMode === m.value ? "text-red-400" : "text-text-muted"}`}>
{m.label}
<span
className={`material-symbols-outlined text-[20px] ${
activeMode === m.value ? "text-red-400" : "text-text-muted"
}`}
>
{m.icon}
</span>
<span
className={`text-xs font-medium ${activeMode === m.value ? "text-red-400" : "text-text-muted"}`}
>
{t(m.labelKey)}
</span>
</button>
))}
@@ -109,8 +122,8 @@ export default function IPFilterSection() {
<div className="flex gap-2 items-end">
<div className="flex-1">
<Input
label="Add IP Address"
placeholder="192.168.1.0/24 or 10.0.*.*"
label={t("addIpAddress")}
placeholder={t("ipAddressPlaceholder")}
value={newIP}
onChange={(e) => setNewIP(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addIP()}
@@ -120,16 +133,22 @@ export default function IPFilterSection() {
<Button
size="sm"
variant={listTarget === "blacklist" ? "danger" : "secondary"}
onClick={() => { setListTarget("blacklist"); if (newIP.trim()) addIP(); }}
onClick={() => {
setListTarget("blacklist");
if (newIP.trim()) addIP();
}}
>
+ Block
{t("block")}
</Button>
<Button
size="sm"
variant={listTarget === "whitelist" ? "primary" : "secondary"}
onClick={() => { setListTarget("whitelist"); if (newIP.trim()) addIP(); }}
onClick={() => {
setListTarget("whitelist");
if (newIP.trim()) addIP();
}}
>
+ Allow
{t("allow")}
</Button>
</div>
</div>
@@ -138,7 +157,7 @@ export default function IPFilterSection() {
{config.blacklist.length > 0 && (
<div>
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
Blocked ({config.blacklist.length})
{t("blocked", { count: config.blacklist.length })}
</p>
<div className="flex flex-wrap gap-1.5">
{config.blacklist.map((ip) => (
@@ -148,7 +167,10 @@ export default function IPFilterSection() {
bg-red-500/10 text-red-400 border border-red-500/20"
>
{ip}
<button onClick={() => removeIP(ip, "blacklist")} className="hover:text-red-300">
<button
onClick={() => removeIP(ip, "blacklist")}
className="hover:text-red-300"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
</span>
@@ -161,7 +183,7 @@ export default function IPFilterSection() {
{config.whitelist.length > 0 && (
<div>
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
Allowed ({config.whitelist.length})
{t("allowed", { count: config.whitelist.length })}
</p>
<div className="flex flex-wrap gap-1.5">
{config.whitelist.map((ip) => (
@@ -171,7 +193,10 @@ export default function IPFilterSection() {
bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
>
{ip}
<button onClick={() => removeIP(ip, "whitelist")} className="hover:text-emerald-300">
<button
onClick={() => removeIP(ip, "whitelist")}
className="hover:text-emerald-300"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
</span>
@@ -184,7 +209,7 @@ export default function IPFilterSection() {
{config.tempBans.length > 0 && (
<div>
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
Temporary Bans ({config.tempBans.length})
{t("temporaryBans", { count: config.tempBans.length })}
</p>
<div className="flex flex-col gap-1.5">
{config.tempBans.map((ban) => (
@@ -199,9 +224,12 @@ export default function IPFilterSection() {
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted tabular-nums">
{Math.ceil(ban.remainingMs / 60000)}m left
{t("minLeft", { min: Math.ceil(ban.remainingMs / 60000) })}
</span>
<button onClick={() => removeBan(ban.ip)} className="text-text-muted hover:text-orange-400">
<button
onClick={() => removeBan(ban.ip)}
className="text-text-muted hover:text-orange-400"
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
</div>

View File

@@ -11,6 +11,7 @@
import { useState, useEffect, useCallback } from "react";
import { Card, Button, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
const CB_STATUS = {
closed: { icon: "check_circle", color: "#22c55e", label: "Closed" },
@@ -23,6 +24,7 @@ export default function PoliciesPanel() {
const [loading, setLoading] = useState(true);
const [unlocking, setUnlocking] = useState(null);
const notify = useNotificationStore();
const t = useTranslations("settings");
const fetchPolicies = useCallback(async () => {
try {
@@ -56,10 +58,10 @@ export default function PoliciesPanel() {
notify.success(`Unlocked: ${identifier}`);
await fetchPolicies();
} else {
notify.error("Failed to unlock");
notify.error(t("failedUnlock"));
}
} catch {
notify.error("Failed to unlock");
notify.error(t("failedUnlock"));
} finally {
setUnlocking(null);
}
@@ -70,7 +72,7 @@ export default function PoliciesPanel() {
<Card className="p-6 mt-6">
<div className="flex items-center gap-2 text-text-muted animate-pulse">
<span className="material-symbols-outlined text-[20px]">security</span>
Loading policies...
{t("loadingPolicies")}
</div>
</Card>
);
@@ -88,10 +90,8 @@ export default function PoliciesPanel() {
<span className="material-symbols-outlined text-[20px]">verified_user</span>
</div>
<div>
<h3 className="text-lg font-semibold text-text-main">Policies & Circuit Breakers</h3>
<p className="text-sm text-text-muted">
All systems operational no lockouts or tripped breakers
</p>
<h3 className="text-lg font-semibold text-text-main">{t("policiesCircuitBreakers")}</h3>
<p className="text-sm text-text-muted">{t("allOperational")}</p>
</div>
</div>
</Card>
@@ -106,8 +106,8 @@ export default function PoliciesPanel() {
<span className="material-symbols-outlined text-[20px]">gpp_maybe</span>
</div>
<div>
<h3 className="text-lg font-semibold text-text-main">Policies & Circuit Breakers</h3>
<p className="text-sm text-text-muted">Active issues detected</p>
<h3 className="text-lg font-semibold text-text-main">{t("policiesCircuitBreakers")}</h3>
<p className="text-sm text-text-muted">{t("activeIssuesDetected")}</p>
</div>
</div>
<Button size="sm" variant="ghost" onClick={fetchPolicies}>
@@ -118,7 +118,7 @@ export default function PoliciesPanel() {
{/* Circuit Breakers */}
{circuitBreakers.filter((cb) => cb.state !== "closed").length > 0 && (
<div className="mb-4">
<p className="text-sm font-medium text-text-muted mb-2">Circuit Breakers</p>
<p className="text-sm font-medium text-text-muted mb-2">{t("circuitBreakers")}</p>
<div className="flex flex-col gap-1.5">
{circuitBreakers
.filter((cb) => cb.state !== "closed")
@@ -162,7 +162,7 @@ export default function PoliciesPanel() {
{/* Locked Identifiers */}
{lockedIds.length > 0 && (
<div>
<p className="text-sm font-medium text-text-muted mb-2">Locked Identifiers</p>
<p className="text-sm font-medium text-text-muted mb-2">{t("lockedIdentifiers")}</p>
<div className="flex flex-col gap-1.5">
{lockedIds.map((id, i) => {
const identifier = typeof id === "string" ? id : id.identifier || id.id;
@@ -187,7 +187,7 @@ export default function PoliciesPanel() {
disabled={unlocking === identifier}
className="text-xs"
>
{unlocking === identifier ? "Unlocking..." : "Force Unlock"}
{unlocking === identifier ? t("unlocking") : t("forceUnlock")}
</Button>
</div>
);

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
const PRICING_FIELDS = ["input", "output", "cached", "reasoning", "cache_creation"];
const FIELD_LABELS = {
@@ -22,6 +23,7 @@ export default function PricingTab() {
const [expandedProviders, setExpandedProviders] = useState(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [editedProviders, setEditedProviders] = useState(new Set());
const t = useTranslations("settings");
// Load catalog + pricing
useEffect(() => {
@@ -50,9 +52,7 @@ export default function PricingTab() {
.map(([alias, info]: [string, any]) => ({
alias,
...info,
pricedModels: pricingData[alias]
? Object.keys(pricingData[alias]).length
: 0,
pricedModels: pricingData[alias] ? Object.keys(pricingData[alias]).length : 0,
}))
.sort((a, b) => b.modelCount - a.modelCount);
return providers;
@@ -66,11 +66,7 @@ export default function PricingTab() {
(p) =>
p.alias.toLowerCase().includes(q) ||
p.id.toLowerCase().includes(q) ||
p.models.some(
(m) =>
m.id.toLowerCase().includes(q) ||
m.name.toLowerCase().includes(q)
)
p.models.some((m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q))
);
}, [allProviders, searchQuery]);
@@ -97,23 +93,20 @@ export default function PricingTab() {
});
}, []);
const handlePricingChange = useCallback(
(provider, model, field, value) => {
const numValue = parseFloat(value);
if (isNaN(numValue) || numValue < 0) return;
const handlePricingChange = useCallback((provider, model, field, value) => {
const numValue = parseFloat(value);
if (isNaN(numValue) || numValue < 0) return;
setPricingData((prev) => {
const next = { ...prev };
if (!next[provider]) next[provider] = {};
if (!next[provider][model])
next[provider][model] = { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 };
next[provider][model] = { ...next[provider][model], [field]: numValue };
return next;
});
setEditedProviders((prev) => new Set(prev).add(provider));
},
[]
);
setPricingData((prev) => {
const next = { ...prev };
if (!next[provider]) next[provider] = {};
if (!next[provider][model])
next[provider][model] = { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 };
next[provider][model] = { ...next[provider][model], [field]: numValue };
return next;
});
setEditedProviders((prev) => new Set(prev).add(provider));
}, []);
const saveProvider = useCallback(
async (providerAlias) => {
@@ -148,36 +141,25 @@ export default function PricingTab() {
[pricingData]
);
const resetProvider = useCallback(
async (providerAlias) => {
if (
!confirm(
`Reset all pricing for ${providerAlias.toUpperCase()} to defaults?`
)
)
return;
try {
const response = await fetch(
`/api/pricing?provider=${providerAlias}`,
{ method: "DELETE" }
);
if (response.ok) {
const updated = await response.json();
setPricingData(updated);
setSaveStatus(`🔄 ${providerAlias.toUpperCase()} reset to defaults`);
setEditedProviders((prev) => {
const next = new Set(prev);
next.delete(providerAlias);
return next;
});
setTimeout(() => setSaveStatus(""), 3000);
}
} catch (error) {
setSaveStatus(`❌ Reset failed: ${error.message}`);
const resetProvider = useCallback(async (providerAlias) => {
if (!confirm(t("resetPricingConfirm", { provider: providerAlias.toUpperCase() }))) return;
try {
const response = await fetch(`/api/pricing?provider=${providerAlias}`, { method: "DELETE" });
if (response.ok) {
const updated = await response.json();
setPricingData(updated);
setSaveStatus(`🔄 ${providerAlias.toUpperCase()} reset to defaults`);
setEditedProviders((prev) => {
const next = new Set(prev);
next.delete(providerAlias);
return next;
});
setTimeout(() => setSaveStatus(""), 3000);
}
},
[]
);
} catch (error) {
setSaveStatus(`❌ Reset failed: ${error.message}`);
}
}, []);
const selectProviderFilter = useCallback((alias) => {
setSelectedProvider((prev) => (prev === alias ? null : alias));
@@ -194,9 +176,7 @@ export default function PricingTab() {
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<div className="text-text-muted animate-pulse">
Loading pricing data...
</div>
<div className="text-text-muted animate-pulse">{t("loadingPricing")}</div>
</div>
);
}
@@ -206,30 +186,21 @@ export default function PricingTab() {
{/* Header + Stats */}
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<h2 className="text-xl font-bold">Model Pricing</h2>
<p className="text-text-muted text-sm mt-1">
Configure cost rates per model All rates in{" "}
<strong>$/1M tokens</strong>
</p>
<h2 className="text-xl font-bold">{t("modelPricing")}</h2>
<p className="text-text-muted text-sm mt-1">{t("modelPricingDesc")}</p>
</div>
<div className="flex gap-3 text-sm">
<div className="bg-bg-subtle rounded-lg px-3 py-2 text-center">
<div className="text-text-muted text-xs font-semibold">
Providers
</div>
<div className="text-text-muted text-xs font-semibold">{t("providers")}</div>
<div className="text-lg font-bold">{stats.providers}</div>
</div>
<div className="bg-bg-subtle rounded-lg px-3 py-2 text-center">
<div className="text-text-muted text-xs font-semibold">
Registry
</div>
<div className="text-text-muted text-xs font-semibold">{t("registry")}</div>
<div className="text-lg font-bold">{stats.totalModels}</div>
</div>
<div className="bg-bg-subtle rounded-lg px-3 py-2 text-center">
<div className="text-text-muted text-xs font-semibold">Priced</div>
<div className="text-lg font-bold text-success">
{stats.pricedCount as number}
</div>
<div className="text-text-muted text-xs font-semibold">{t("priced")}</div>
<div className="text-lg font-bold text-success">{stats.pricedCount as number}</div>
</div>
</div>
</div>
@@ -249,7 +220,7 @@ export default function PricingTab() {
</span>
<input
type="text"
placeholder="Search providers or models..."
placeholder={t("searchProvidersModels")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-3 py-2 bg-bg-base border border-border rounded-lg focus:outline-none focus:border-primary text-sm"
@@ -261,7 +232,7 @@ export default function PricingTab() {
className="px-3 py-2 text-xs bg-primary/10 text-primary border border-primary/20 rounded-lg hover:bg-primary/20 transition-colors flex items-center gap-1"
>
<span className="material-symbols-outlined text-sm">close</span>
{selectedProvider.toUpperCase()} Show All
{selectedProvider.toUpperCase()} {t("showAll")}
</button>
)}
</div>
@@ -276,12 +247,11 @@ export default function PricingTab() {
selectedProvider === p.alias
? "bg-primary text-white shadow-sm"
: editedProviders.has(p.alias)
? "bg-yellow-500/15 text-yellow-400 border border-yellow-500/30"
: "bg-bg-subtle text-text-muted hover:bg-bg-hover border border-transparent"
? "bg-yellow-500/15 text-yellow-400 border border-yellow-500/30"
: "bg-bg-subtle text-text-muted hover:bg-bg-hover border border-transparent"
}`}
>
{p.alias.toUpperCase()}{" "}
<span className="opacity-60">({p.modelCount})</span>
{p.alias.toUpperCase()} <span className="opacity-60">({p.modelCount})</span>
</button>
))}
</div>
@@ -306,33 +276,22 @@ export default function PricingTab() {
))}
{displayProviders.length === 0 && (
<div className="text-center py-12 text-text-muted">
No providers match your search.
</div>
<div className="text-center py-12 text-text-muted">{t("noProvidersMatch")}</div>
)}
</div>
{/* Info Box */}
<Card className="p-4 mt-2">
<h3 className="text-sm font-semibold mb-2">
<span className="material-symbols-outlined text-sm align-middle mr-1">
info
</span>
How Pricing Works
<span className="material-symbols-outlined text-sm align-middle mr-1">info</span>
{t("howPricingWorks")}
</h3>
<div className="text-xs text-text-muted space-y-1">
<p>
<strong>Input</strong>: tokens sent to the model {" "}
<strong>Output</strong>: tokens generated {" "}
<strong>Cached</strong>: reused input (~50% of input rate) {" "}
<strong>Reasoning</strong>: thinking tokens (falls back to Output) {" "}
<strong>Cache Write</strong>: creating cache entries (falls back to
Input)
</p>
<p>
Cost = (input × input_rate) + (output × output_rate) + (cached ×
cached_rate) per million tokens.
{t("pricingDescInput")} {t("pricingDescOutput")} {t("pricingDescCached")} {" "}
{t("pricingDescReasoning")} {t("pricingDescCacheWrite")}
</p>
<p>{t("pricingDescFormula")}</p>
</div>
</Card>
</div>
@@ -352,20 +311,19 @@ function ProviderSection({
onReset,
saving,
}) {
const t = useTranslations("settings");
const pricedCount = Object.keys(pricingData).length;
const authBadge =
provider.authType === "oauth"
? "OAuth"
: provider.authType === "apikey"
? "API Key"
: provider.authType;
? "API Key"
: provider.authType;
return (
<div
className={`border rounded-lg overflow-hidden transition-colors ${
isEdited
? "border-yellow-500/40 bg-yellow-500/5"
: "border-border"
isEdited ? "border-yellow-500/40 bg-yellow-500/5" : "border-border"
}`}
>
{/* Header (click to expand) */}
@@ -385,9 +343,7 @@ function ProviderSection({
<span className="font-semibold text-sm">
{provider.id.charAt(0).toUpperCase() + provider.id.slice(1)}
</span>
<span className="text-text-muted text-xs ml-2">
({provider.alias.toUpperCase()})
</span>
<span className="text-text-muted text-xs ml-2">({provider.alias.toUpperCase()})</span>
</div>
<span className="px-1.5 py-0.5 bg-bg-subtle text-text-muted text-[10px] rounded uppercase font-semibold">
{authBadge}
@@ -397,11 +353,7 @@ function ProviderSection({
</span>
</div>
<div className="flex items-center gap-3">
{isEdited && (
<span className="text-yellow-500 text-xs font-medium">
unsaved
</span>
)}
{isEdited && <span className="text-yellow-500 text-xs font-medium">{t("unsaved")}</span>}
<span className="text-text-muted text-xs">
{pricedCount}/{provider.modelCount} priced
</span>
@@ -426,8 +378,7 @@ function ProviderSection({
{/* Actions bar */}
<div className="flex items-center justify-between px-4 py-2 bg-bg-subtle/50">
<span className="text-xs text-text-muted">
{provider.modelCount} models {" "}
{pricedCount} with pricing configured
{provider.modelCount} models {pricedCount} with pricing configured
</span>
<div className="flex items-center gap-2">
<button
@@ -437,7 +388,7 @@ function ProviderSection({
}}
className="px-2.5 py-1 text-[11px] text-red-400 hover:bg-red-500/10 rounded border border-red-500/20 transition-colors"
>
Reset Defaults
{t("resetDefaults")}
</button>
<button
onClick={(e) => {
@@ -447,7 +398,7 @@ function ProviderSection({
disabled={saving || !isEdited}
className="px-2.5 py-1 text-[11px] bg-primary text-white rounded hover:bg-primary/90 transition-colors disabled:opacity-40"
>
{saving ? "Saving..." : "Save Provider"}
{saving ? t("saving") : t("saveProvider")}
</button>
</div>
</div>
@@ -457,12 +408,9 @@ function ProviderSection({
<table className="w-full text-sm">
<thead className="text-[11px] text-text-muted uppercase bg-bg-subtle/30">
<tr>
<th className="px-4 py-2 text-left font-semibold">Model</th>
<th className="px-4 py-2 text-left font-semibold">{t("model")}</th>
{PRICING_FIELDS.map((field) => (
<th
key={field}
className="px-2 py-2 text-right font-semibold w-24"
>
<th key={field} className="px-2 py-2 text-right font-semibold w-24">
{FIELD_LABELS[field]}
</th>
))}
@@ -474,9 +422,7 @@ function ProviderSection({
key={model.id}
model={model}
pricing={pricingData[model.id]}
onPricingChange={(field, value) =>
onPricingChange(model.id, field, value)
}
onPricingChange={(field, value) => onPricingChange(model.id, field, value)}
/>
))}
</tbody>
@@ -498,9 +444,7 @@ function ModelRow({ model, pricing, onPricingChange }) {
<td className="px-4 py-1.5">
<div className="flex items-center gap-2">
<span
className={`w-1.5 h-1.5 rounded-full ${
hasPricing ? "bg-success" : "bg-text-muted/30"
}`}
className={`w-1.5 h-1.5 rounded-full ${hasPricing ? "bg-success" : "bg-text-muted/30"}`}
/>
<span className="font-medium text-xs">{model.name}</span>
{model.custom && (

View File

@@ -2,11 +2,14 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, ProxyConfigModal } from "@/shared/components";
import { useTranslations } from "next-intl";
export default function ProxyTab() {
const [proxyModalOpen, setProxyModalOpen] = useState(false);
const [globalProxy, setGlobalProxy] = useState(null);
const mountedRef = useRef(true);
const t = useTranslations("settings");
const tc = useTranslations("common");
const loadGlobalProxy = async () => {
try {
@@ -44,12 +47,9 @@ export default function ProxyTab() {
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
vpn_lock
</span>
<h2 className="text-lg font-bold">Global Proxy</h2>
<h2 className="text-lg font-bold">{t("globalProxy")}</h2>
</div>
<p className="text-sm text-text-muted mb-4">
Configure a global outbound proxy for all API calls. Individual providers, combos, and
keys can override this.
</p>
<p className="text-sm text-text-muted mb-4">{t("globalProxyDesc")}</p>
<div className="flex items-center gap-3">
{globalProxy ? (
<div className="flex items-center gap-2">
@@ -58,7 +58,7 @@ export default function ProxyTab() {
</span>
</div>
) : (
<span className="text-sm text-text-muted">No global proxy configured</span>
<span className="text-sm text-text-muted">{t("noGlobalProxy")}</span>
)}
<Button
size="sm"
@@ -69,7 +69,7 @@ export default function ProxyTab() {
setProxyModalOpen(true);
}}
>
{globalProxy ? "Edit" : "Configure"}
{globalProxy ? tc("edit") : t("configure")}
</Button>
</div>
</div>
@@ -79,7 +79,7 @@ export default function ProxyTab() {
isOpen={proxyModalOpen}
onClose={() => setProxyModalOpen(false)}
level="global"
levelLabel="Global"
levelLabel={t("globalLabel")}
onSaved={loadGlobalProxy}
/>
</>

View File

@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback } from "react";
import { Card, Button } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useLocale, useTranslations } from "next-intl";
// ─── State colors and labels ──────────────────────────────────────────────
const STATE_STYLES = {
@@ -10,31 +11,37 @@ const STATE_STYLES = {
bg: "bg-emerald-500/15",
text: "text-emerald-400",
border: "border-emerald-500/30",
label: "CLOSED",
icon: "check_circle",
},
OPEN: {
bg: "bg-red-500/15",
text: "text-red-400",
border: "border-red-500/30",
label: "OPEN",
icon: "error",
},
HALF_OPEN: {
bg: "bg-amber-500/15",
text: "text-amber-400",
border: "border-amber-500/30",
label: "HALF-OPEN",
icon: "warning",
},
};
const CB_STATUS = {
closed: { icon: "check_circle", color: "#22c55e", label: "Closed" },
"half-open": { icon: "pending", color: "#f59e0b", label: "Half-Open" },
open: { icon: "error", color: "#ef4444", label: "Open" },
closed: { icon: "check_circle", color: "#22c55e" },
"half-open": { icon: "pending", color: "#f59e0b" },
open: { icon: "error", color: "#ef4444" },
};
function getBreakerStateLabel(state, t) {
const normalized = String(state || "closed")
.toLowerCase()
.replaceAll("_", "-");
if (normalized === "open") return t("breakerStateOpen");
if (normalized === "half-open") return t("breakerStateHalfOpen");
return t("breakerStateClosed");
}
function formatMs(ms) {
if (!ms || ms <= 0) return "—";
if (ms < 1000) return `${ms}ms`;
@@ -42,21 +49,32 @@ function formatMs(ms) {
return `${(ms / 60000).toFixed(1)}m`;
}
function getErrorMessage(err, fallback) {
return err instanceof Error && err.message ? err.message : fallback;
}
// ─── Provider Profiles Card ──────────────────────────────────────────────
function ProviderProfilesCard({ profiles, onSave, saving }) {
const [editMode, setEditMode] = useState(false);
const [draft, setDraft] = useState(profiles);
const t = useTranslations("settings");
const tc = useTranslations("common");
useEffect(() => {
setDraft(profiles);
}, [profiles]);
const formatMsRaw = (value) => (value == null ? "—" : `${value}${t("ms")}`);
const fields = [
{ key: "transientCooldown", label: "Transient Cooldown", suffix: "ms" },
{ key: "rateLimitCooldown", label: "Rate Limit Cooldown", suffix: "ms" },
{ key: "maxBackoffLevel", label: "Max Backoff Level", suffix: "" },
{ key: "circuitBreakerThreshold", label: "CB Threshold", suffix: " fails" },
{ key: "circuitBreakerReset", label: "CB Reset Time", suffix: "ms" },
{ key: "transientCooldown", label: t("transientCooldown"), format: formatMsRaw },
{ key: "rateLimitCooldown", label: t("rateLimitCooldown"), format: formatMsRaw },
{ key: "maxBackoffLevel", label: t("maxBackoffLevel") },
{
key: "circuitBreakerThreshold",
label: t("cbThreshold"),
format: (value) => (value == null ? "—" : t("failures", { count: value })),
},
{ key: "circuitBreakerReset", label: t("cbResetTime"), format: formatMsRaw },
];
const handleSave = () => {
@@ -72,12 +90,12 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
tune
</span>
<h2 className="text-lg font-bold">Provider Profiles</h2>
<h2 className="text-lg font-bold">{t("providerProfiles")}</h2>
</div>
{editMode ? (
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setEditMode(false)}>
Cancel
{tc("cancel")}
</Button>
<Button
size="sm"
@@ -86,20 +104,17 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
onClick={handleSave}
disabled={saving}
>
Save
{tc("save")}
</Button>
</div>
) : (
<Button size="sm" variant="secondary" icon="edit" onClick={() => setEditMode(true)}>
Edit
{tc("edit")}
</Button>
)}
</div>
<p className="text-sm text-text-muted mb-4">
Separate resilience settings for OAuth (session-based) and API Key (metered) providers.
OAuth providers have stricter thresholds due to lower rate limits.
</p>
<p className="text-sm text-text-muted mb-4">{t("providerProfilesDesc")}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{["oauth", "apikey"].map((type) => (
@@ -108,10 +123,10 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
<span className="material-symbols-outlined text-base" aria-hidden="true">
{type === "oauth" ? "lock" : "key"}
</span>
{type === "oauth" ? "OAuth Providers" : "API Key Providers"}
{type === "oauth" ? t("oauthProviders") : t("apiKeyProviders")}
</h3>
<div className="space-y-2">
{fields.map(({ key, label, suffix }) => (
{fields.map(({ key, label, format }) => (
<div key={key} className="flex items-center justify-between">
<span className="text-xs text-text-muted">{label}</span>
{editMode ? (
@@ -129,8 +144,9 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
/>
) : (
<span className="text-sm font-mono">
{profiles?.[type]?.[key] ?? "—"}
{suffix && profiles?.[type]?.[key] != null ? suffix : ""}
{format
? format(profiles?.[type]?.[key])
: (profiles?.[type]?.[key] ?? "—")}
</span>
)}
</div>
@@ -148,6 +164,8 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
const [editMode, setEditMode] = useState(false);
const [draft, setDraft] = useState(defaults || {});
const t = useTranslations("settings");
const tc = useTranslations("common");
// Sync draft when defaults change from parent (standard prop-to-state sync)
/* eslint-disable react-hooks/set-state-in-effect */
@@ -169,12 +187,12 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
speed
</span>
<h2 className="text-lg font-bold">Rate Limiting</h2>
<h2 className="text-lg font-bold">{t("rateLimiting")}</h2>
</div>
{editMode ? (
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setEditMode(false)}>
Cancel
{tc("cancel")}
</Button>
<Button
size="sm"
@@ -183,30 +201,27 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
onClick={handleSave}
disabled={saving}
>
Save
{tc("save")}
</Button>
</div>
) : (
<Button size="sm" variant="secondary" icon="edit" onClick={() => setEditMode(true)}>
Edit
{tc("edit")}
</Button>
)}
</div>
<p className="text-sm text-text-muted mb-4">
API Key providers are automatically rate-limited with safe defaults. Limits are learned
from response headers and adapt over time.
</p>
<p className="text-sm text-text-muted mb-4">{t("rateLimitingDesc")}</p>
<div className="rounded-lg bg-black/5 dark:bg-white/5 p-4 mb-4">
<h3 className="text-xs font-bold uppercase tracking-wider mb-3 text-text-muted">
Default Safety Net
{t("defaultSafetyNet")}
</h3>
<div className="grid grid-cols-3 gap-4">
{[
{ key: "requestsPerMinute", label: "RPM", suffix: "" },
{ key: "minTimeBetweenRequests", label: "Min Gap", suffix: "ms", format: formatMs },
{ key: "concurrentRequests", label: "Max Concurrent", suffix: "" },
{ key: "requestsPerMinute", label: t("rpm") },
{ key: "minTimeBetweenRequests", label: t("minGap"), format: formatMs },
{ key: "concurrentRequests", label: t("maxConcurrent") },
].map(({ key, label, format }) => (
<div key={key}>
{editMode ? (
@@ -233,7 +248,7 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
{rateLimitStatus && rateLimitStatus.length > 0 ? (
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-text-muted">
Active Limiters
{t("activeLimiters")}
</h3>
{rateLimitStatus.map((rl, i) => (
<div
@@ -242,15 +257,27 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
>
<span className="text-sm font-medium">{rl.provider || rl.key}</span>
<div className="flex items-center gap-3 text-xs text-text-muted">
{rl.reservoir != null && <span>Reservoir: {rl.reservoir}</span>}
{rl.running != null && <span>Running: {rl.running}</span>}
{rl.queued != null && <span>Queued: {rl.queued}</span>}
{rl.reservoir != null && (
<span>
{t("reservoir")}: {rl.reservoir}
</span>
)}
{rl.running != null && (
<span>
{t("running")}: {rl.running}
</span>
)}
{rl.queued != null && (
<span>
{t("queued")}: {rl.queued}
</span>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-xs text-text-muted">No active rate limiters yet.</p>
<p className="text-xs text-text-muted">{t("noActiveLimiters")}</p>
)}
</div>
</Card>
@@ -261,6 +288,7 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
function CircuitBreakerCard({ breakers, onReset, loading }) {
const activeBreakers = breakers.filter((b) => b.state !== "CLOSED");
const totalBreakers = breakers.length;
const t = useTranslations("settings");
return (
<Card className="p-0 overflow-hidden">
@@ -270,13 +298,13 @@ function CircuitBreakerCard({ breakers, onReset, loading }) {
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
electrical_services
</span>
<h2 className="text-lg font-bold">Circuit Breakers</h2>
<h2 className="text-lg font-bold">{t("circuitBreakers")}</h2>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">
{activeBreakers.length > 0
? `${activeBreakers.length} tripped`
: `${totalBreakers} healthy`}
? t("tripped", { count: activeBreakers.length })
: t("healthy", { count: totalBreakers })}
</span>
{activeBreakers.length > 0 && (
<Button
@@ -286,17 +314,14 @@ function CircuitBreakerCard({ breakers, onReset, loading }) {
onClick={onReset}
disabled={loading}
>
Reset All
{t("resetAll")}
</Button>
)}
</div>
</div>
{breakers.length === 0 ? (
<p className="text-sm text-text-muted">
No circuit breakers active yet. They are created automatically when requests flow
through the combo pipeline.
</p>
<p className="text-sm text-text-muted">{t("noCircuitBreakers")}</p>
) : (
<div className="space-y-2">
{breakers.map((b) => {
@@ -318,13 +343,13 @@ function CircuitBreakerCard({ breakers, onReset, loading }) {
<div className="flex items-center gap-3">
{b.failureCount > 0 && (
<span className="text-xs text-text-muted">
{b.failureCount} failure{b.failureCount !== 1 ? "s" : ""}
{t("failures", { count: b.failureCount })}
</span>
)}
<span
className={`px-2 py-0.5 rounded text-xs font-bold uppercase ${style.bg} ${style.text} border ${style.border}`}
>
{style.label}
{getBreakerStateLabel(b.state, t)}
</span>
</div>
</div>
@@ -343,6 +368,8 @@ function PoliciesCard() {
const [loading, setLoading] = useState(true);
const [unlocking, setUnlocking] = useState(null);
const notify = useNotificationStore();
const locale = useLocale();
const t = useTranslations("settings");
const fetchPolicies = useCallback(async () => {
try {
@@ -373,13 +400,13 @@ function PoliciesCard() {
body: JSON.stringify({ action: "unlock", identifier }),
});
if (res.ok) {
notify.success(`Unlocked: ${identifier}`);
notify.success(t("unlockedIdentifier", { identifier }));
await fetchPolicies();
} else {
notify.error("Failed to unlock");
notify.error(t("failedUnlock"));
}
} catch {
notify.error("Failed to unlock");
notify.error(t("failedUnlock"));
} finally {
setUnlocking(null);
}
@@ -394,7 +421,7 @@ function PoliciesCard() {
<Card className="p-6">
<div className="flex items-center gap-2 text-text-muted animate-pulse">
<span className="material-symbols-outlined text-[20px]">policy</span>
Loading policies...
{t("loadingPolicies")}
</div>
</Card>
);
@@ -408,7 +435,7 @@ function PoliciesCard() {
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
policy
</span>
<h2 className="text-lg font-bold">Policies & Locked Identifiers</h2>
<h2 className="text-lg font-bold">{t("policiesLocked")}</h2>
</div>
{hasIssues && (
<Button size="sm" variant="ghost" onClick={fetchPolicies}>
@@ -423,9 +450,7 @@ function PoliciesCard() {
<span className="material-symbols-outlined text-[20px]">verified_user</span>
</div>
<div>
<p className="text-sm text-text-muted">
All systems operational no lockouts or tripped breakers
</p>
<p className="text-sm text-text-muted">{t("allOperational")}</p>
</div>
</div>
) : (
@@ -433,7 +458,7 @@ function PoliciesCard() {
{/* Circuit Breakers */}
{circuitBreakers.filter((cb) => cb.state !== "closed").length > 0 && (
<div className="mb-4">
<p className="text-sm font-medium text-text-muted mb-2">Circuit Breakers</p>
<p className="text-sm font-medium text-text-muted mb-2">{t("circuitBreakers")}</p>
<div className="flex flex-col gap-1.5">
{circuitBreakers
.filter((cb) => cb.state !== "closed")
@@ -452,7 +477,7 @@ function PoliciesCard() {
{status.icon}
</span>
<span className="text-sm text-text-main font-medium">
{cb.name || cb.provider || "Unknown"}
{cb.name || cb.provider || t("unknown")}
</span>
<span
className="text-xs px-1.5 py-0.5 rounded-full"
@@ -461,11 +486,11 @@ function PoliciesCard() {
color: status.color,
}}
>
{status.label}
{getBreakerStateLabel(cb.state, t)}
</span>
{cb.failures > 0 && (
<span className="text-xs text-text-muted">
{cb.failures} failures
{t("failures", { count: cb.failures })}
</span>
)}
</div>
@@ -479,7 +504,7 @@ function PoliciesCard() {
{/* Locked Identifiers */}
{lockedIds.length > 0 && (
<div>
<p className="text-sm font-medium text-text-muted mb-2">Locked Identifiers</p>
<p className="text-sm font-medium text-text-muted mb-2">{t("lockedIdentifiers")}</p>
<div className="flex flex-col gap-1.5">
{lockedIds.map((id, i) => {
const identifier = typeof id === "string" ? id : id.identifier || id.id;
@@ -495,7 +520,9 @@ function PoliciesCard() {
<span className="font-mono text-sm text-text-main">{identifier}</span>
{typeof id === "object" && id.lockedAt && (
<span className="text-xs text-text-muted">
since {new Date(id.lockedAt).toLocaleString()}
{t("sinceDate", {
date: new Date(id.lockedAt).toLocaleString(locale),
})}
</span>
)}
</div>
@@ -506,7 +533,7 @@ function PoliciesCard() {
disabled={unlocking === identifier}
className="text-xs"
>
{unlocking === identifier ? "Unlocking..." : "Force Unlock"}
{unlocking === identifier ? t("unlocking") : t("forceUnlock")}
</Button>
</div>
);
@@ -527,21 +554,22 @@ export default function ResilienceTab() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const t = useTranslations("settings");
const loadData = useCallback(async () => {
try {
setLoading(true);
const res = await fetch("/api/resilience");
if (!res.ok) throw new Error(`Failed to load: ${res.status}`);
if (!res.ok) throw new Error(t("failedLoadWithStatus", { status: res.status }));
const json = await res.json();
setData(json);
setError(null);
} catch (err) {
setError(err.message);
setError(getErrorMessage(err, t("failedLoadResilience")));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
loadData();
@@ -554,10 +582,10 @@ export default function ResilienceTab() {
try {
setLoading(true);
const res = await fetch("/api/resilience/reset", { method: "POST" });
if (!res.ok) throw new Error("Reset failed");
if (!res.ok) throw new Error(t("resetFailed"));
await loadData();
} catch (err) {
setError(err.message);
setError(getErrorMessage(err, t("resetFailed")));
} finally {
setLoading(false);
}
@@ -571,10 +599,10 @@ export default function ResilienceTab() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profiles }),
});
if (!res.ok) throw new Error("Save failed");
if (!res.ok) throw new Error(t("saveFailed"));
await loadData();
} catch (err) {
setError(err.message);
setError(getErrorMessage(err, t("saveFailed")));
} finally {
setSaving(false);
}
@@ -588,10 +616,10 @@ export default function ResilienceTab() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ defaults }),
});
if (!res.ok) throw new Error("Save failed");
if (!res.ok) throw new Error(t("saveFailed"));
await loadData();
} catch (err) {
setError(err.message);
setError(getErrorMessage(err, t("saveFailed")));
} finally {
setSaving(false);
}
@@ -601,7 +629,7 @@ export default function ResilienceTab() {
return (
<div className="flex items-center justify-center py-12 text-text-muted">
<span className="material-symbols-outlined animate-spin mr-2">hourglass_empty</span>
Loading resilience status...
{t("loadingResilience")}
</div>
);
}
@@ -614,7 +642,7 @@ export default function ResilienceTab() {
<span className="text-sm">{error}</span>
</div>
<Button size="sm" variant="secondary" icon="refresh" onClick={loadData} className="mt-3">
Retry
{t("retry")}
</Button>
</Card>
);

View File

@@ -1,29 +1,30 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Input, Toggle, Button } from "@/shared/components";
import { Card, Input, Button } from "@/shared/components";
import FallbackChainsEditor from "./FallbackChainsEditor";
import { useTranslations } from "next-intl";
const STRATEGIES = [
{
value: "fill-first",
label: "Fill First",
desc: "Use accounts in priority order",
labelKey: "fillFirst",
descKey: "fillFirstDesc",
icon: "vertical_align_top",
},
{ value: "round-robin", label: "Round Robin", desc: "Cycle through all accounts", icon: "loop" },
{ value: "p2c", label: "P2C", desc: "Pick 2 random, use the healthier one", icon: "balance" },
{ value: "random", label: "Random", desc: "Random account each request", icon: "shuffle" },
{ value: "round-robin", labelKey: "roundRobin", descKey: "roundRobinDesc", icon: "loop" },
{ value: "p2c", labelKey: "p2c", descKey: "p2cDesc", icon: "balance" },
{ value: "random", labelKey: "random", descKey: "randomDesc", icon: "shuffle" },
{
value: "least-used",
label: "Least Used",
desc: "Pick least recently used account",
labelKey: "leastUsed",
descKey: "leastUsedDesc",
icon: "low_priority",
},
{
value: "cost-optimized",
label: "Cost Opt",
desc: "Prefer cheapest available account",
labelKey: "costOpt",
descKey: "costOptDesc",
icon: "savings",
},
];
@@ -34,6 +35,15 @@ export default function RoutingTab() {
const [aliases, setAliases] = useState([]);
const [newPattern, setNewPattern] = useState("");
const [newTarget, setNewTarget] = useState("");
const t = useTranslations("settings");
const strategyHintKeyByValue: Record<string, string> = {
"fill-first": "fillFirstDesc",
"round-robin": "roundRobinDesc",
p2c: "p2cDesc",
random: "randomDesc",
"least-used": "leastUsedDesc",
"cost-optimized": "costOptDesc",
};
useEffect(() => {
fetch("/api/settings")
@@ -86,7 +96,7 @@ export default function RoutingTab() {
route
</span>
</div>
<h3 className="text-lg font-semibold">Routing Strategy</h3>
<h3 className="text-lg font-semibold">{t("routingStrategy")}</h3>
</div>
<div className="grid grid-cols-3 gap-2 mb-4" style={{ gridAutoRows: "1fr" }}>
@@ -112,9 +122,9 @@ export default function RoutingTab() {
<p
className={`text-sm font-medium ${settings.fallbackStrategy === s.value ? "text-blue-400" : ""}`}
>
{s.label}
{t(s.labelKey)}
</p>
<p className="text-xs text-text-muted mt-0.5">{s.desc}</p>
<p className="text-xs text-text-muted mt-0.5">{t(s.descKey)}</p>
</div>
</button>
))}
@@ -123,8 +133,8 @@ export default function RoutingTab() {
{settings.fallbackStrategy === "round-robin" && (
<div className="flex items-center justify-between pt-3 border-t border-border/30">
<div>
<p className="text-sm font-medium">Sticky Limit</p>
<p className="text-xs text-text-muted">Calls per account before switching</p>
<p className="text-sm font-medium">{t("stickyLimit")}</p>
<p className="text-xs text-text-muted">{t("stickyLimitDesc")}</p>
</div>
<Input
type="number"
@@ -139,18 +149,7 @@ export default function RoutingTab() {
)}
<p className="text-xs text-text-muted italic pt-3 border-t border-border/30 mt-3">
{settings.fallbackStrategy === "round-robin" &&
`Distributing requests across accounts with ${settings.stickyRoundRobinLimit || 3} calls per account.`}
{settings.fallbackStrategy === "fill-first" &&
"Using accounts in priority order (Fill First)."}
{settings.fallbackStrategy === "p2c" &&
"Power of Two Choices: picks 2 random accounts and routes to the healthier one."}
{settings.fallbackStrategy === "random" &&
"Randomly selects an available account for each request."}
{settings.fallbackStrategy === "least-used" &&
"Picks the account that was used least recently."}
{settings.fallbackStrategy === "cost-optimized" &&
"Prefers accounts with the lowest cost (priority-based, extensible with actual cost data)."}
{t(strategyHintKeyByValue[settings.fallbackStrategy] || "fillFirstDesc")}
</p>
</Card>
@@ -163,10 +162,8 @@ export default function RoutingTab() {
</span>
</div>
<div>
<h3 className="text-lg font-semibold">Model Aliases</h3>
<p className="text-sm text-text-muted">
Wildcard patterns to remap model names Use * and ?
</p>
<h3 className="text-lg font-semibold">{t("modelAliases")}</h3>
<p className="text-sm text-text-muted">{t("modelAliasesDesc")}</p>
</div>
</div>
@@ -198,22 +195,22 @@ export default function RoutingTab() {
<div className="flex gap-2 items-end">
<div className="flex-1">
<Input
label="Pattern"
placeholder="claude-sonnet-*"
label={t("pattern")}
placeholder={t("aliasPatternPlaceholder")}
value={newPattern}
onChange={(e) => setNewPattern(e.target.value)}
/>
</div>
<div className="flex-1">
<Input
label="Target Model"
placeholder="claude-sonnet-4-20250514"
label={t("targetModel")}
placeholder={t("aliasTargetPlaceholder")}
value={newTarget}
onChange={(e) => setNewTarget(e.target.value)}
/>
</div>
<Button size="sm" variant="primary" onClick={addAlias} className="mb-[2px]">
+ Add
{t("add")}
</Button>
</div>
</Card>

View File

@@ -5,6 +5,7 @@ import { Card, Button, Input, Toggle } from "@/shared/components";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import IPFilterSection from "./IPFilterSection";
import SessionInfoCard from "./SessionInfoCard";
import { useTranslations } from "next-intl";
export default function SecurityTab() {
const [settings, setSettings] = useState<any>({ requireLogin: false, hasPassword: false });
@@ -12,6 +13,7 @@ export default function SecurityTab() {
const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" });
const [passStatus, setPassStatus] = useState({ type: "", message: "" });
const [passLoading, setPassLoading] = useState(false);
const t = useTranslations("settings");
useEffect(() => {
fetch("/api/settings")
@@ -64,7 +66,7 @@ export default function SecurityTab() {
const handlePasswordChange = async (e) => {
e.preventDefault();
if (passwords.new !== passwords.confirm) {
setPassStatus({ type: "error", message: "Passwords do not match" });
setPassStatus({ type: "error", message: t("passwordsNoMatch") });
return;
}
@@ -82,13 +84,13 @@ export default function SecurityTab() {
});
const data = await res.json();
if (res.ok) {
setPassStatus({ type: "success", message: "Password updated successfully" });
setPassStatus({ type: "success", message: t("passwordUpdated") });
setPasswords({ current: "", new: "", confirm: "" });
} else {
setPassStatus({ type: "error", message: data.error || "Failed to update password" });
setPassStatus({ type: "error", message: data.error || t("failedUpdatePassword") });
}
} catch {
setPassStatus({ type: "error", message: "An error occurred" });
setPassStatus({ type: "error", message: t("errorOccurred") });
} finally {
setPassLoading(false);
}
@@ -105,15 +107,13 @@ export default function SecurityTab() {
shield
</span>
</div>
<h3 className="text-lg font-semibold">Security</h3>
<h3 className="text-lg font-semibold">{t("security")}</h3>
</div>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Require login</p>
<p className="text-sm text-text-muted">
When ON, dashboard requires password. When OFF, access without login.
</p>
<p className="font-medium">{t("requireLogin")}</p>
<p className="text-sm text-text-muted">{t("requireLoginDesc")}</p>
</div>
<Toggle
checked={settings.requireLogin === true}
@@ -128,9 +128,9 @@ export default function SecurityTab() {
>
{settings.hasPassword && (
<Input
label="Current Password"
label={t("currentPassword")}
type="password"
placeholder="Enter current password"
placeholder={t("enterCurrentPassword")}
value={passwords.current}
onChange={(e) => setPasswords({ ...passwords, current: e.target.value })}
required
@@ -138,17 +138,17 @@ export default function SecurityTab() {
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="New Password"
label={t("newPassword")}
type="password"
placeholder="Enter new password"
placeholder={t("enterNewPassword")}
value={passwords.new}
onChange={(e) => setPasswords({ ...passwords, new: e.target.value })}
required
/>
<Input
label="Confirm New Password"
label={t("confirmPassword")}
type="password"
placeholder="Confirm new password"
placeholder={t("confirmPasswordPlaceholder")}
value={passwords.confirm}
onChange={(e) => setPasswords({ ...passwords, confirm: e.target.value })}
required
@@ -165,7 +165,7 @@ export default function SecurityTab() {
<div className="pt-2">
<Button type="submit" variant="primary" loading={passLoading}>
{settings.hasPassword ? "Update Password" : "Set Password"}
{settings.hasPassword ? t("updatePassword") : t("setPassword")}
</Button>
</div>
</form>
@@ -181,21 +181,14 @@ export default function SecurityTab() {
api
</span>
</div>
<h3 className="text-lg font-semibold">API Endpoint Protection</h3>
<h3 className="text-lg font-semibold">{t("apiEndpointProtection")}</h3>
</div>
<div className="flex flex-col gap-4">
{/* Require auth for /models */}
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Require API key for /models</p>
<p className="text-sm text-text-muted">
When ON, the{" "}
<code className="text-xs bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded">
/v1/models
</code>{" "}
endpoint returns 404 for unauthenticated requests. Prevents model discovery by
unauthorized users.
</p>
<p className="font-medium">{t("requireAuthModels")}</p>
<p className="text-sm text-text-muted">{t("requireAuthModelsDesc")}</p>
</div>
<Toggle
checked={settings.requireAuthForModels === true}
@@ -207,14 +200,8 @@ export default function SecurityTab() {
{/* Blocked Providers */}
<div className="pt-4 border-t border-border/50">
<div className="mb-3">
<p className="font-medium">Blocked Providers</p>
<p className="text-sm text-text-muted">
Hide specific providers from the{" "}
<code className="text-xs bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded">
/v1/models
</code>{" "}
response. Blocked providers will not appear in model listings.
</p>
<p className="font-medium">{t("blockedProviders")}</p>
<p className="text-sm text-text-muted">{t("blockedProvidersDesc")}</p>
</div>
<div className="flex flex-wrap gap-2">
{Object.values(AI_PROVIDERS).map((provider: any) => {
@@ -229,7 +216,11 @@ export default function SecurityTab() {
? "bg-red-500/10 border-red-500/30 text-red-600 dark:text-red-400"
: "bg-black/[0.02] dark:bg-white/[0.02] border-transparent text-text-muted hover:bg-black/[0.05] dark:hover:bg-white/[0.05]"
}`}
title={isBlocked ? `Unblock ${provider.name}` : `Block ${provider.name}`}
title={
isBlocked
? t("unblockProviderTitle", { provider: provider.name })
: t("blockProviderTitle", { provider: provider.name })
}
>
<span
className="material-symbols-outlined text-[14px]"
@@ -250,8 +241,7 @@ export default function SecurityTab() {
{blockedProviders.length > 0 && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">warning</span>
{blockedProviders.length} provider{blockedProviders.length !== 1 ? "s" : ""} blocked
from /models
{t("providersBlocked", { count: blockedProviders.length })}
</p>
)}
</div>

View File

@@ -9,6 +9,7 @@
import { useState, useEffect } from "react";
import { Card, Button } from "@/shared/components";
import { useTranslations } from "next-intl";
interface SessionInfo {
authenticated: boolean;
@@ -21,6 +22,7 @@ interface SessionInfo {
export default function SessionInfoCard() {
const [session, setSession] = useState<SessionInfo | null>(null);
const [loading, setLoading] = useState(true);
const t = useTranslations("settings");
useEffect(() => {
let cancelled = false;
@@ -30,7 +32,7 @@ export default function SessionInfoCard() {
const loginTime = sessionStorage.getItem("omniroute_login_time");
const now = Date.now();
let sessionAge = "Unknown";
let sessionAge = t("unknown");
if (loginTime) {
const elapsed = now - parseInt(loginTime, 10);
const hours = Math.floor(elapsed / 3600000);
@@ -59,7 +61,7 @@ export default function SessionInfoCard() {
loginTime: loginTime ? new Date(parseInt(loginTime, 10)).toLocaleString() : null,
sessionAge,
ipAddress: "—", // Server-side only
userAgent: navigator.userAgent.split(" ").slice(-2).join(" ") || "Unknown",
userAgent: navigator.userAgent.split(" ").slice(-2).join(" ") || t("unknown"),
});
setLoading(false);
}
@@ -81,7 +83,7 @@ export default function SessionInfoCard() {
};
const handleClearStorage = () => {
if (confirm("Clear all local data? This will reset your preferences.")) {
if (confirm(t("clearLocalDataConfirm"))) {
localStorage.clear();
sessionStorage.clear();
window.location.reload();
@@ -104,46 +106,46 @@ export default function SessionInfoCard() {
person
</span>
</div>
<h3 className="text-lg font-semibold">Session</h3>
<h3 className="text-lg font-semibold">{t("session")}</h3>
</div>
<div className="flex flex-col gap-3" role="list" aria-label="Session details">
<div className="flex flex-col gap-3" role="list" aria-label={t("sessionDetailsAria")}>
<div className="flex justify-between items-center text-sm" role="listitem">
<span className="text-text-muted">Status</span>
<span className="text-text-muted">{t("status")}</span>
<span className="flex items-center gap-1.5">
<span
className={`w-2 h-2 rounded-full ${session?.authenticated ? "bg-green-500" : "bg-yellow-500"}`}
aria-hidden="true"
/>
{session?.authenticated ? "Authenticated" : "Guest"}
{session?.authenticated ? t("authenticated") : t("guest")}
</span>
</div>
{session?.loginTime && (
<div className="flex justify-between items-center text-sm" role="listitem">
<span className="text-text-muted">Login Time</span>
<span className="text-text-muted">{t("loginTime")}</span>
<span className="font-mono text-xs">{session.loginTime}</span>
</div>
)}
<div className="flex justify-between items-center text-sm" role="listitem">
<span className="text-text-muted">Session Age</span>
<span className="text-text-muted">{t("sessionAge")}</span>
<span className="font-mono text-xs">{session?.sessionAge}</span>
</div>
<div className="flex justify-between items-center text-sm" role="listitem">
<span className="text-text-muted">Browser</span>
<span className="text-text-muted">{t("browser")}</span>
<span className="font-mono text-xs truncate max-w-[200px]">{session?.userAgent}</span>
</div>
</div>
<div className="flex gap-3 mt-4 pt-4 border-t border-border/50">
<Button variant="secondary" onClick={handleClearStorage}>
Clear Local Data
{t("clearLocalData")}
</Button>
{session?.authenticated && (
<Button variant="danger" onClick={handleLogout}>
Logout
{t("logout")}
</Button>
)}
</div>

View File

@@ -2,12 +2,14 @@
import { useState, useEffect } from "react";
import { Card, Toggle } from "@/shared/components";
import { useTranslations } from "next-intl";
export default function SystemPromptTab() {
const [config, setConfig] = useState({ enabled: false, prompt: "" });
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState("");
const [debounceTimer, setDebounceTimer] = useState(null);
const t = useTranslations("settings");
useEffect(() => {
fetch("/api/settings/system-prompt")
@@ -57,13 +59,14 @@ export default function SystemPromptTab() {
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">Global System Prompt</h3>
<p className="text-sm text-text-muted">Injected into all requests at proxy level</p>
<h3 className="text-lg font-semibold">{t("globalSystemPrompt")}</h3>
<p className="text-sm text-text-muted">{t("systemPromptDesc")}</p>
</div>
<div className="flex items-center gap-3">
{status === "saved" && (
<span className="text-xs font-medium text-emerald-500 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">check_circle</span> Saved
<span className="material-symbols-outlined text-[14px]">check_circle</span>{" "}
{t("saved")}
</span>
)}
<Toggle
@@ -80,7 +83,7 @@ export default function SystemPromptTab() {
<textarea
value={config.prompt}
onChange={(e) => handlePromptChange(e.target.value)}
placeholder="Enter system prompt to inject into all requests..."
placeholder={t("systemPromptPlaceholder")}
rows={5}
className="w-full px-4 py-3 rounded-lg border border-border/50 bg-surface/30 text-sm
placeholder:text-text-muted/50 resize-y min-h-[120px]
@@ -94,8 +97,9 @@ export default function SystemPromptTab() {
</div>
<p className="text-xs text-text-muted/70 flex items-center gap-1.5">
<span className="material-symbols-outlined text-[14px]">info</span>
This prompt is prepended to the system message of every request. Use for global instructions,
safety guidelines, or response formatting rules. Send <code className="px-1 py-0.5 rounded bg-surface/50">_skipSystemPrompt: true</code> in a request to bypass.
{t("systemPromptHint")} Send{" "}
<code className="px-1 py-0.5 rounded bg-surface/50">_skipSystemPrompt: true</code> in a
request to bypass.
</p>
</div>
)}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, Badge } from "@/shared/components";
import { useLocale, useTranslations } from "next-intl";
export default function SystemStorageTab() {
const [backups, setBackups] = useState([]);
@@ -18,6 +19,9 @@ export default function SystemStorageTab() {
const [confirmImport, setConfirmImport] = useState(false);
const [pendingImportFile, setPendingImportFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const locale = useLocale();
const t = useTranslations("settings");
const tc = useTranslations("common");
const [storageHealth, setStorageHealth] = useState({
driver: "sqlite",
dbPath: "~/.omniroute/storage.sqlite",
@@ -58,20 +62,23 @@ export default function SystemStorageTab() {
const data = await res.json();
if (res.ok) {
if (data.filename) {
setManualBackupStatus({ type: "success", message: `Backup created: ${data.filename}` });
setManualBackupStatus({
type: "success",
message: t("backupCreated", { file: data.filename }),
});
} else {
setManualBackupStatus({
type: "info",
message: data.message || "No changes since last backup",
message: data.message || t("noChangesSinceBackup"),
});
}
await loadStorageHealth();
if (backupsExpanded) await loadBackups();
} else {
setManualBackupStatus({ type: "error", message: data.error || "Backup failed" });
setManualBackupStatus({ type: "error", message: data.error || t("backupFailed") });
}
} catch {
setManualBackupStatus({ type: "error", message: "An error occurred" });
setManualBackupStatus({ type: "error", message: t("errorOccurred") });
} finally {
setManualBackupLoading(false);
}
@@ -90,15 +97,20 @@ export default function SystemStorageTab() {
if (res.ok) {
setRestoreStatus({
type: "success",
message: `Restored! ${data.connectionCount} connections, ${data.nodeCount} nodes, ${data.comboCount} combos, ${data.apiKeyCount} API keys.`,
message: t("restoreSuccess", {
connections: data.connectionCount,
nodes: data.nodeCount,
combos: data.comboCount,
apiKeys: data.apiKeyCount,
}),
});
await loadBackups();
await loadStorageHealth();
} else {
setRestoreStatus({ type: "error", message: data.error || "Restore failed" });
setRestoreStatus({ type: "error", message: data.error || t("restoreFailed") });
}
} catch {
setRestoreStatus({ type: "error", message: "An error occurred during restore" });
setRestoreStatus({ type: "error", message: t("errorDuringRestore") });
} finally {
setRestoringId(null);
setConfirmRestoreId(null);
@@ -115,7 +127,7 @@ export default function SystemStorageTab() {
const res = await fetch("/api/db-backups/export");
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Export failed");
throw new Error(data.error || t("exportFailed"));
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
@@ -133,7 +145,10 @@ export default function SystemStorageTab() {
URL.revokeObjectURL(url);
} catch (err) {
console.error("Export failed:", err);
setImportStatus({ type: "error", message: `Export failed: ${(err as Error).message}` });
setImportStatus({
type: "error",
message: t("exportFailedWithError", { error: (err as Error).message }),
});
} finally {
setExportLoading(false);
}
@@ -149,7 +164,7 @@ export default function SystemStorageTab() {
if (!file.name.endsWith(".sqlite")) {
setImportStatus({
type: "error",
message: "Invalid file type. Only .sqlite files are accepted.",
message: t("invalidFileType"),
});
return;
}
@@ -174,15 +189,20 @@ export default function SystemStorageTab() {
if (res.ok) {
setImportStatus({
type: "success",
message: `Database imported! ${data.connectionCount} connections, ${data.nodeCount} nodes, ${data.comboCount} combos, ${data.apiKeyCount} API keys.`,
message: t("importSuccess", {
connections: data.connectionCount,
nodes: data.nodeCount,
combos: data.comboCount,
apiKeys: data.apiKeyCount,
}),
});
await loadStorageHealth();
if (backupsExpanded) await loadBackups();
} else {
setImportStatus({ type: "error", message: data.error || "Import failed" });
setImportStatus({ type: "error", message: data.error || t("importFailed") });
}
} catch {
setImportStatus({ type: "error", message: "An error occurred during import" });
setImportStatus({ type: "error", message: t("errorDuringImport") });
} finally {
setImportLoading(false);
setPendingImportFile(null);
@@ -207,12 +227,18 @@ export default function SystemStorageTab() {
const then = new Date(isoString);
const diffMs = (now as any) - (then as any);
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffMin < 1) return t("justNow");
if (diffMin < 60) return t("minutesAgo", { count: diffMin });
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
if (diffHr < 24) return t("hoursAgo", { count: diffHr });
const diffDays = Math.floor(diffHr / 24);
return `${diffDays}d ago`;
return t("daysAgo", { count: diffDays });
};
const formatBackupReason = (reason) => {
if (reason === "manual") return t("backupReasonManual");
if (reason === "pre-restore") return t("backupReasonPreRestore");
return reason;
};
return (
@@ -224,8 +250,8 @@ export default function SystemStorageTab() {
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">System & Storage</h3>
<p className="text-xs text-text-muted">All data stored locally on your machine</p>
<h3 className="text-lg font-semibold">{t("systemStorage")}</h3>
<p className="text-xs text-text-muted">{t("allDataLocal")}</p>
</div>
<Badge variant="success" size="sm">
{storageHealth.driver || "json"}
@@ -235,13 +261,17 @@ export default function SystemStorageTab() {
{/* Storage info grid */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="p-3 rounded-lg bg-bg border border-border">
<p className="text-[11px] text-text-muted uppercase tracking-wide mb-1">Database Path</p>
<p className="text-[11px] text-text-muted uppercase tracking-wide mb-1">
{t("databasePath")}
</p>
<p className="text-sm font-mono text-text-main break-all">
{storageHealth.dbPath || "~/.omniroute/storage.sqlite"}
</p>
</div>
<div className="p-3 rounded-lg bg-bg border border-border">
<p className="text-[11px] text-text-muted uppercase tracking-wide mb-1">Database Size</p>
<p className="text-[11px] text-text-muted uppercase tracking-wide mb-1">
{t("databaseSize")}
</p>
<p className="text-sm font-mono text-text-main">{formatBytes(storageHealth.sizeBytes)}</p>
</div>
</div>
@@ -252,7 +282,7 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
download
</span>
Export Database
{t("exportDatabase")}
</Button>
<Button
variant="outline"
@@ -261,7 +291,7 @@ export default function SystemStorageTab() {
setExportLoading(true);
try {
const res = await fetch("/api/db-backups/exportAll");
if (!res.ok) throw new Error("Export failed");
if (!res.ok) throw new Error(t("exportFailed"));
const blob = await res.blob();
const cd = res.headers.get("Content-Disposition") || "";
const filenameMatch = cd.match(/filename="?([^"]+)"?/);
@@ -277,7 +307,7 @@ export default function SystemStorageTab() {
} catch (err) {
setImportStatus({
type: "error",
message: `Full export failed: ${(err as Error).message}`,
message: t("fullExportFailedWithError", { error: (err as Error).message }),
});
} finally {
setExportLoading(false);
@@ -288,13 +318,13 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
folder_zip
</span>
Export All (.tar.gz)
{t("exportAll")}
</Button>
<Button variant="outline" size="sm" onClick={handleImportClick} loading={importLoading}>
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
upload
</span>
Import Database
{t("importDatabase")}
</Button>
<input
ref={fileInputRef}
@@ -316,11 +346,9 @@ export default function SystemStorageTab() {
warning
</span>
<div className="flex-1">
<p className="text-sm font-medium text-amber-500 mb-1">Confirm Database Import</p>
<p className="text-sm font-medium text-amber-500 mb-1">{t("confirmDbImport")}</p>
<p className="text-xs text-text-muted mb-2">
This will replace <strong>all current data</strong> with the content from{" "}
<span className="font-mono">{pendingImportFile.name}</span>. A backup will be
created automatically before the import.
{t("confirmDbImportDesc", { file: pendingImportFile.name })}
</p>
<div className="flex items-center gap-2">
<Button
@@ -329,10 +357,10 @@ export default function SystemStorageTab() {
onClick={handleImportConfirm}
className="!bg-amber-500 hover:!bg-amber-600"
>
Yes, Import
{t("yesImport")}
</Button>
<Button variant="outline" size="sm" onClick={handleImportCancel}>
Cancel
{tc("cancel")}
</Button>
</div>
</div>
@@ -364,11 +392,11 @@ export default function SystemStorageTab() {
schedule
</span>
<div>
<p className="text-sm font-medium">Last Backup</p>
<p className="text-sm font-medium">{t("lastBackup")}</p>
<p className="text-xs text-text-muted">
{storageHealth.lastBackupAt
? `${new Date(storageHealth.lastBackupAt).toLocaleString("pt-BR")} (${formatRelativeTime(storageHealth.lastBackupAt)})`
: "No backup yet"}
? `${new Date(storageHealth.lastBackupAt).toLocaleString(locale)} (${formatRelativeTime(storageHealth.lastBackupAt)})`
: t("noBackupYet")}
</p>
</div>
</div>
@@ -381,7 +409,7 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
backup
</span>
Backup Now
{t("backupNow")}
</Button>
</div>
@@ -419,7 +447,7 @@ export default function SystemStorageTab() {
>
restore
</span>
<p className="font-medium">Backup & Restore</p>
<p className="font-medium">{t("backupRestore")}</p>
</div>
<Button
variant="outline"
@@ -429,13 +457,10 @@ export default function SystemStorageTab() {
if (!backupsExpanded && backups.length === 0) loadBackups();
}}
>
{backupsExpanded ? "Hide" : "View Backups"}
{backupsExpanded ? t("hide") : t("viewBackups")}
</Button>
</div>
<p className="text-xs text-text-muted mb-3">
Database snapshots are created automatically before restore and every 15 minutes when data
changes. Retention: 24 hourly + 30 daily backups with smart rotation.
</p>
<p className="text-xs text-text-muted mb-3">{t("backupRetentionDesc")}</p>
{restoreStatus.message && (
<div
@@ -465,7 +490,7 @@ export default function SystemStorageTab() {
>
progress_activity
</span>
Loading backups...
{t("loadingBackups")}
</div>
) : backups.length === 0 ? (
<div className="text-center py-6 text-text-muted text-sm">
@@ -475,13 +500,13 @@ export default function SystemStorageTab() {
>
folder_off
</span>
No backups available yet. Backups will be created automatically when data changes.
{t("noBackupsYet")}
</div>
) : (
<>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-text-muted">
{backups.length} backup(s) available
{t("backupsAvailable", { count: backups.length })}
</span>
<button
onClick={loadBackups}
@@ -490,7 +515,7 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
refresh
</span>
Refresh
{t("refresh")}
</button>
</div>
{backups.map((backup) => (
@@ -507,7 +532,7 @@ export default function SystemStorageTab() {
description
</span>
<span className="text-sm font-medium truncate">
{new Date(backup.createdAt).toLocaleString("pt-BR")}
{new Date(backup.createdAt).toLocaleString(locale)}
</span>
<Badge
variant={
@@ -519,11 +544,11 @@ export default function SystemStorageTab() {
}
size="sm"
>
{backup.reason}
{formatBackupReason(backup.reason)}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-text-muted ml-6">
<span>{backup.connectionCount} connection(s)</span>
<span>{t("connectionsCount", { count: backup.connectionCount })}</span>
<span></span>
<span>{formatBytes(backup.size)}</span>
</div>
@@ -531,7 +556,7 @@ export default function SystemStorageTab() {
<div className="flex items-center gap-2 ml-3">
{confirmRestoreId === backup.id ? (
<>
<span className="text-xs text-amber-500 font-medium">Confirm?</span>
<span className="text-xs text-amber-500 font-medium">{t("confirm")}</span>
<Button
variant="primary"
size="sm"
@@ -539,14 +564,14 @@ export default function SystemStorageTab() {
loading={restoringId === backup.id}
className="!bg-amber-500 hover:!bg-amber-600"
>
Yes
{t("yes")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmRestoreId(null)}
>
No
{t("no")}
</Button>
</>
) : (
@@ -561,7 +586,7 @@ export default function SystemStorageTab() {
>
restore
</span>
Restore
{t("restore")}
</Button>
)}
</div>

View File

@@ -1,40 +1,41 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, Select } from "@/shared/components";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
const MODES = [
{
value: "passthrough",
label: "Passthrough",
desc: "No changes — client controls thinking budget",
labelKey: "passthrough",
descKey: "passthroughDesc",
icon: "arrow_forward",
},
{
value: "auto",
label: "Auto",
desc: "Strip all thinking config — let provider decide",
labelKey: "auto",
descKey: "autoDesc",
icon: "auto_awesome",
},
{
value: "custom",
label: "Custom",
desc: "Set a fixed token budget for all requests",
labelKey: "custom",
descKey: "customDesc",
icon: "tune",
},
{
value: "adaptive",
label: "Adaptive",
desc: "Scale budget based on request complexity",
labelKey: "adaptive",
descKey: "adaptiveDesc",
icon: "trending_up",
},
];
const EFFORTS = [
{ value: "none", label: "None (0 tokens)" },
{ value: "low", label: "Low (1K tokens)" },
{ value: "medium", label: "Medium (10K tokens)" },
{ value: "high", label: "High (128K tokens)" },
{ value: "none", labelKey: "effortNone" },
{ value: "low", labelKey: "effortLow" },
{ value: "medium", labelKey: "effortMedium" },
{ value: "high", labelKey: "effortHigh" },
];
export default function ThinkingBudgetTab() {
@@ -46,6 +47,7 @@ export default function ThinkingBudgetTab() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState("");
const t = useTranslations("settings");
useEffect(() => {
fetch("/api/settings/thinking-budget")
@@ -90,12 +92,12 @@ export default function ThinkingBudgetTab() {
</span>
</div>
<div>
<h3 className="text-lg font-semibold">Thinking Budget</h3>
<p className="text-sm text-text-muted">Control AI reasoning token usage across all requests</p>
<h3 className="text-lg font-semibold">{t("thinkingBudgetTitle")}</h3>
<p className="text-sm text-text-muted">{t("thinkingBudgetDesc")}</p>
</div>
{status === "saved" && (
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">check_circle</span> Saved
<span className="material-symbols-outlined text-[14px]">check_circle</span> {t("saved")}
</span>
)}
</div>
@@ -121,10 +123,12 @@ export default function ThinkingBudgetTab() {
{m.icon}
</span>
<div className="min-w-0">
<p className={`text-sm font-medium ${config.mode === m.value ? "text-violet-400" : ""}`}>
{m.label}
<p
className={`text-sm font-medium ${config.mode === m.value ? "text-violet-400" : ""}`}
>
{t(m.labelKey)}
</p>
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">{m.desc}</p>
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">{t(m.descKey)}</p>
</div>
</button>
))}
@@ -134,9 +138,9 @@ export default function ThinkingBudgetTab() {
{config.mode === "custom" && (
<div className="p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium">Token Budget</p>
<p className="text-sm font-medium">{t("tokenBudget")}</p>
<span className="text-sm font-mono tabular-nums text-violet-400">
{config.customBudget.toLocaleString()} tokens
{config.customBudget.toLocaleString()} {t("tokens")}
</span>
</div>
<input
@@ -149,7 +153,7 @@ export default function ThinkingBudgetTab() {
className="w-full accent-violet-500"
/>
<div className="flex justify-between text-xs text-text-muted mt-1">
<span>Off</span>
<span>{t("off")}</span>
<span>1K</span>
<span>10K</span>
<span>64K</span>
@@ -161,10 +165,8 @@ export default function ThinkingBudgetTab() {
{/* Adaptive effort level */}
{config.mode === "adaptive" && (
<div className="p-4 rounded-lg bg-surface/30 border border-border/30">
<p className="text-sm font-medium mb-3">Base Effort Level</p>
<p className="text-xs text-text-muted mb-3">
Adaptive mode scales from this base level based on message count, tool usage, and prompt length.
</p>
<p className="text-sm font-medium mb-3">{t("baseEffortLevel")}</p>
<p className="text-xs text-text-muted mb-3">{t("adaptiveHint")}</p>
<div className="grid grid-cols-4 gap-2">
{EFFORTS.map((e) => (
<button
@@ -177,7 +179,7 @@ export default function ThinkingBudgetTab() {
: "border-border/50 text-text-muted hover:border-border"
}`}
>
{e.value.charAt(0).toUpperCase() + e.value.slice(1)}
{t(e.labelKey)}
</button>
))}
</div>

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { cn } from "@/shared/utils/cn";
import { APP_CONFIG } from "@/shared/constants/config";
import { useTranslations } from "next-intl";
import SystemStorageTab from "./components/SystemStorageTab";
import SecurityTab from "./components/SecurityTab";
import RoutingTab from "./components/RoutingTab";
@@ -17,15 +18,16 @@ import CacheStatsCard from "./components/CacheStatsCard";
import ResilienceTab from "./components/ResilienceTab";
const tabs = [
{ id: "general", label: "General", icon: "settings" },
{ id: "ai", label: "AI", icon: "smart_toy" },
{ id: "security", label: "Security", icon: "shield" },
{ id: "routing", label: "Routing", icon: "route" },
{ id: "resilience", label: "Resilience", icon: "electrical_services" },
{ id: "advanced", label: "Advanced", icon: "tune" },
{ id: "general", labelKey: "general", icon: "settings" },
{ id: "ai", labelKey: "ai", icon: "smart_toy" },
{ id: "security", labelKey: "security", icon: "shield" },
{ id: "routing", labelKey: "routing", icon: "route" },
{ id: "resilience", labelKey: "resilience", icon: "electrical_services" },
{ id: "advanced", labelKey: "advanced", icon: "tune" },
];
export default function SettingsPage() {
const t = useTranslations("settings");
const searchParams = useSearchParams();
const tabParam = searchParams.get("tab");
const [userSelectedTab, setUserSelectedTab] = useState(null);
@@ -37,7 +39,7 @@ export default function SettingsPage() {
{/* Tab navigation */}
<div
role="tablist"
aria-label="Settings sections"
aria-label={t("settingsSectionsAria")}
className="inline-flex items-center p-1 rounded-lg bg-black/5 dark:bg-white/5 self-start"
>
{tabs.map((tab) => (
@@ -57,13 +59,16 @@ export default function SettingsPage() {
<span className="material-symbols-outlined text-[18px]" aria-hidden="true">
{tab.icon}
</span>
<span className="hidden sm:inline">{tab.label}</span>
<span className="hidden sm:inline">{t(tab.labelKey)}</span>
</button>
))}
</div>
{/* Tab contents */}
<div role="tabpanel" aria-label={tabs.find((t) => t.id === activeTab)?.label}>
<div
role="tabpanel"
aria-label={t(tabs.find((t2) => t2.id === activeTab)?.labelKey || "general")}
>
{activeTab === "general" && (
<>
<div className="flex flex-col gap-6">
@@ -100,7 +105,7 @@ export default function SettingsPage() {
<p>
{APP_CONFIG.name} v{APP_CONFIG.version}
</p>
<p className="mt-1">Local Mode All data stored on your machine</p>
<p className="mt-1">{t("localMode")}</p>
</div>
</div>
</div>

View File

@@ -4,12 +4,14 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Card from "@/shared/components/Card";
import PricingModal from "@/shared/components/PricingModal";
import { useTranslations } from "next-intl";
export default function PricingSettingsPage() {
const router = useRouter();
const [showModal, setShowModal] = useState(false);
const [currentPricing, setCurrentPricing] = useState(null);
const [loading, setLoading] = useState(true);
const t = useTranslations("settings");
useEffect(() => {
loadPricing();
@@ -55,92 +57,83 @@ export default function PricingSettingsPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Pricing Settings</h1>
<p className="text-text-muted mt-1">
Configure pricing rates for cost tracking and calculations
</p>
<h1 className="text-3xl font-bold">{t("pricingSettingsTitle")}</h1>
<p className="text-text-muted mt-1">{t("modelPricingDesc")}</p>
</div>
<button
onClick={() => setShowModal(true)}
className="px-4 py-2 bg-primary text-white rounded hover:bg-primary/90 transition-colors"
>
Edit Pricing
{t("editPricing")}
</button>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="p-4">
<div className="text-text-muted text-sm uppercase font-semibold">Total Models</div>
<div className="text-text-muted text-sm uppercase font-semibold">{t("totalModels")}</div>
<div className="text-2xl font-bold mt-1">{loading ? "..." : getModelCount()}</div>
</Card>
<Card className="p-4">
<div className="text-text-muted text-sm uppercase font-semibold">Providers</div>
<div className="text-text-muted text-sm uppercase font-semibold">{t("providers")}</div>
<div className="text-2xl font-bold mt-1">{loading ? "..." : getProviders().length}</div>
</Card>
<Card className="p-4">
<div className="text-text-muted text-sm uppercase font-semibold">Status</div>
<div className="text-2xl font-bold mt-1 text-success">{loading ? "..." : "Active"}</div>
<div className="text-text-muted text-sm uppercase font-semibold">{t("status")}</div>
<div className="text-2xl font-bold mt-1 text-success">
{loading ? "..." : t("active")}
</div>
</Card>
</div>
{/* Info Section */}
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4">How Pricing Works</h2>
<h2 className="text-xl font-semibold mb-4">{t("howPricingWorks")}</h2>
<div className="space-y-3 text-sm text-text-muted">
<p>
<strong>Cost Calculation:</strong> Costs are calculated based on token usage and pricing
rates. Each request&apos;s cost is determined by: (input_tokens × input_rate) +
(output_tokens × output_rate) + (cached_tokens × cached_rate)
<strong>{t("costCalculation")}:</strong> {t("costCalculationDesc")}
</p>
<p>
<strong>Pricing Format:</strong> All rates are in{" "}
<strong>dollars per million tokens</strong> ($/1M tokens). Example: An input rate of
2.50 means $2.50 per 1,000,000 input tokens.
<strong>{t("pricingFormat")}:</strong> {t("pricingFormatDesc")}
</p>
<p>
<strong>Token Types:</strong>
<strong>{t("tokenTypes")}:</strong>
</p>
<ul className="list-disc list-inside ml-4 space-y-1">
<li>
<strong>Input:</strong> Standard prompt tokens
<strong>{t("input")}:</strong> {t("inputTokenDesc")}
</li>
<li>
<strong>Output:</strong> Completion/response tokens
<strong>{t("output")}:</strong> {t("outputTokenDesc")}
</li>
<li>
<strong>Cached:</strong> Cached input tokens (typically 50% of input rate)
<strong>{t("cached")}:</strong> {t("cachedTokenDesc")}
</li>
<li>
<strong>Reasoning:</strong> Special reasoning/thinking tokens (fallback to output
rate)
<strong>{t("reasoning")}:</strong> {t("reasoningTokenDesc")}
</li>
<li>
<strong>Cache Creation:</strong> Tokens used to create cache entries (fallback to
input rate)
<strong>{t("cacheCreation")}:</strong> {t("cacheCreationTokenDesc")}
</li>
</ul>
<p>
<strong>Custom Pricing:</strong> You can override default pricing for specific models.
Reset to defaults anytime to restore standard rates.
</p>
<p>{t("customPricingNote")}</p>
</div>
</Card>
{/* Current Pricing Preview */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Current Pricing Overview</h2>
<h2 className="text-xl font-semibold">{t("currentPricing")}</h2>
<button
onClick={() => setShowModal(true)}
className="text-primary hover:underline text-sm"
>
View Full Details
{t("viewFullDetails")}
</button>
</div>
{loading ? (
<div className="text-center py-4 text-text-muted">Loading pricing data...</div>
<div className="text-center py-4 text-text-muted">{t("loadingPricing")}</div>
) : currentPricing ? (
<div className="space-y-3">
{Object.keys(currentPricing)
@@ -149,18 +142,18 @@ export default function PricingSettingsPage() {
<div key={provider} className="text-sm">
<span className="font-semibold">{provider.toUpperCase()}:</span>{" "}
<span className="text-text-muted">
{Object.keys(currentPricing[provider]).length} models
{Object.keys(currentPricing[provider]).length} {t("models")}
</span>
</div>
))}
{Object.keys(currentPricing).length > 5 && (
<div className="text-sm text-text-muted">
+ {Object.keys(currentPricing).length - 5} more providers
+ {t("moreProviders", { count: Object.keys(currentPricing).length - 5 })}
</div>
)}
</div>
) : (
<div className="text-text-muted">No pricing data available</div>
<div className="text-text-muted">{t("noPricing")}</div>
)}
</Card>

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { SegmentedControl } from "@/shared/components";
import PlaygroundMode from "./components/PlaygroundMode";
@@ -7,26 +9,21 @@ import ChatTesterMode from "./components/ChatTesterMode";
import TestBenchMode from "./components/TestBenchMode";
import LiveMonitorMode from "./components/LiveMonitorMode";
const MODES = [
{ value: "playground", label: "Playground", icon: "code" },
{ value: "chat-tester", label: "Chat Tester", icon: "chat" },
{ value: "test-bench", label: "Test Bench", icon: "science" },
{ value: "live-monitor", label: "Live Monitor", icon: "monitoring" },
];
const MODE_DESCRIPTIONS: Record<string, string> = {
playground:
"Paste any API request body and see how OmniRoute translates it between provider formats (OpenAI ↔ Claude ↔ Gemini ↔ Responses API)",
"chat-tester":
"Send real chat requests through OmniRoute and see the full round-trip: your input, the translated request, the provider response, and the translated output",
"test-bench":
"Define multiple test cases with different inputs and expected outputs, run them all at once, and compare results across providers and models",
"live-monitor":
"Watch incoming requests in real-time as they flow through OmniRoute — see format translations happening live and identify issues instantly",
};
export default function TranslatorPageClient() {
const t = useTranslations("translator");
const [mode, setMode] = useState("playground");
const modes = [
{ value: "playground", label: t("playground"), icon: "code" },
{ value: "chat-tester", label: t("chatTester"), icon: "chat" },
{ value: "test-bench", label: t("testBench"), icon: "science" },
{ value: "live-monitor", label: t("liveMonitor"), icon: "monitoring" },
];
const modeDescriptions: Record<string, string> = {
playground: t("modeDescriptionPlayground"),
"chat-tester": t("modeDescriptionChatTester"),
"test-bench": t("modeDescriptionTestBench"),
"live-monitor": t("modeDescriptionLiveMonitor"),
};
return (
<div className="p-8 space-y-6">
@@ -35,14 +32,13 @@ export default function TranslatorPageClient() {
<div>
<h1 className="text-2xl font-bold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[28px]">translate</span>
Translator Playground
{t("playgroundTitle")}
</h1>
<p className="text-sm text-text-muted mt-1">
{MODE_DESCRIPTIONS[mode] ||
"Debug, test, and visualize how OmniRoute translates API requests between providers"}
{modeDescriptions[mode] || t("modeDescriptionFallback")}
</p>
</div>
<SegmentedControl options={MODES} value={mode} onChange={setMode} size="md" />
<SegmentedControl options={modes} value={mode} onChange={setMode} size="md" />
</div>
{/* Mode Content */}

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { useState, useEffect, useRef } from "react";
import { Card, Button, Select, Badge } from "@/shared/components";
import { FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
@@ -12,7 +14,7 @@ const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
/**
* Chat Tester Mode:
* - Left: Chat interface (send messages as a specific client format)
* - Right: Pipeline visualization showing each translation step
* - Right: {t("pipelineVisualization")} showing each translation step
*
* How it works:
* 1. You type a message and select a "Client Format" (how the request is structured)
@@ -22,6 +24,7 @@ const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
*/
export default function ChatTesterMode() {
const t = useTranslations("translator");
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels();
const [clientFormat, setClientFormat] = useState("openai");
@@ -96,8 +99,8 @@ export default function ChatTesterMode() {
steps.push({
id: 1,
name: "Client Request",
description: "The request body as your client would send it",
name: t("clientRequest"),
description: t("clientRequestDescription"),
format: clientFormat,
content: JSON.stringify(clientRequest, null, 2),
status: "done",
@@ -114,8 +117,8 @@ export default function ChatTesterMode() {
steps.push({
id: 2,
name: "Format Detected",
description: "OmniRoute auto-detects the API format from the request structure",
name: t("formatDetected"),
description: t("formatDetectedDescription"),
format: detectedFormat,
content: JSON.stringify(
{ detectedFormat, clientFormat, match: detectedFormat === clientFormat },
@@ -140,8 +143,8 @@ export default function ChatTesterMode() {
steps.push({
id: 3,
name: "OpenAI Intermediate",
description: "All formats are first normalized to OpenAI format (the universal bridge)",
name: t("openaiIntermediate"),
description: t("openaiIntermediateDescription"),
format: "openai",
content: JSON.stringify(toOpenaiData.result || toOpenaiData, null, 2),
status: toOpenaiData.success ? "done" : "error",
@@ -163,8 +166,8 @@ export default function ChatTesterMode() {
steps.push({
id: 4,
name: "Provider Format",
description: `OpenAI format is translated to the provider's native format`,
name: t("providerFormat"),
description: t("providerFormatDescription"),
format: targetFmt,
content: JSON.stringify(providerTargetData.result || providerTargetData, null, 2),
status: providerTargetData.success ? "done" : "error",
@@ -178,18 +181,21 @@ export default function ChatTesterMode() {
});
if (!sendRes.ok) {
const errData = await sendRes.json().catch(() => ({ error: "Request failed" }));
const errData = await sendRes.json().catch(() => ({ error: t("requestFailed") }));
steps.push({
id: 5,
name: "Provider Response",
description: "The raw response from the provider API",
name: t("providerResponse"),
description: t("providerResponseRawDescription"),
format: targetFmt,
content: JSON.stringify(errData, null, 2),
status: "error",
});
setChatHistory((prev) => [
...prev,
{ role: "assistant", content: `Error: ${errData.error || "Request failed"}` },
{
role: "assistant",
content: t("errorMessage", { message: errData.error || t("requestFailed") }),
},
]);
} else {
// Read streaming response
@@ -205,8 +211,8 @@ export default function ChatTesterMode() {
steps.push({
id: 5,
name: "Provider Response",
description: "The raw SSE stream from the provider API",
name: t("providerResponse"),
description: t("providerResponseSseDescription"),
format: targetFmt,
content:
fullResponse.slice(0, 5000) + (fullResponse.length > 5000 ? "\n... (truncated)" : ""),
@@ -217,19 +223,22 @@ export default function ChatTesterMode() {
const assistantText = extractAssistantText(fullResponse);
setChatHistory((prev) => [
...prev,
{ role: "assistant", content: assistantText || "(No text extracted)" },
{ role: "assistant", content: assistantText || t("noTextExtracted") },
]);
}
} catch (err) {
steps.push({
id: steps.length + 1,
name: "Error",
description: "An unexpected error occurred",
name: t("error"),
description: t("unexpectedError"),
format: "error",
content: JSON.stringify({ error: err.message }, null, 2),
status: "error",
});
setChatHistory((prev) => [...prev, { role: "assistant", content: `Error: ${err.message}` }]);
setChatHistory((prev) => [
...prev,
{ role: "assistant", content: t("errorMessage", { message: err.message }) },
]);
}
setPipeline(steps);
@@ -246,14 +255,11 @@ export default function ChatTesterMode() {
info
</span>
<div>
<p className="font-medium text-text-main mb-0.5">Pipeline Debugger</p>
<p className="font-medium text-text-main mb-0.5">{t("pipelineDebugger")}</p>
<p>{t("chatTesterDescription")}</p>
<p>
Send messages as a specific client format and see how each step of the translation
pipeline works. The right panel shows the full flow:{" "}
<strong className="text-text-main">
Client Request Format Detection OpenAI Intermediate Provider Format Response
</strong>
. Click any step to inspect the data at that stage.
<strong className="text-text-main">{t("chatTesterFlow")}</strong>.{" "}
{t("clickStepToInspect")}
</p>
</div>
</div>
@@ -267,7 +273,7 @@ export default function ChatTesterMode() {
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
Client Format
{t("clientFormat")}
</label>
<Select
value={clientFormat}
@@ -279,7 +285,7 @@ export default function ChatTesterMode() {
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
Provider
{t("provider")}
</label>
<Select
value={provider}
@@ -290,7 +296,7 @@ export default function ChatTesterMode() {
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
Model
{t("model")}
</label>
<div className="relative">
<input
@@ -298,7 +304,7 @@ export default function ChatTesterMode() {
value={model}
onChange={(e) => setModel(e.target.value)}
list="model-suggestions"
placeholder="Select or type a model name..."
placeholder={t("modelPlaceholder")}
className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
/>
<datalist id="model-suggestions">
@@ -319,13 +325,10 @@ export default function ChatTesterMode() {
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
chat
</span>
<p className="text-sm font-medium mb-1">
Send a message to see the translation pipeline
</p>
<p className="text-sm font-medium mb-1">{t("sendMessageToSeePipeline")}</p>
<p className="text-xs text-center max-w-xs">
Your message will be formatted as a{" "}
<strong>{FORMAT_META[clientFormat]?.label}</strong> request, translated through
the pipeline, and sent to the selected provider.
{t("chatMessageHintPrefix")} <strong>{FORMAT_META[clientFormat]?.label}</strong>{" "}
{t("chatMessageHintSuffix")}
</p>
</div>
)}
@@ -343,8 +346,8 @@ export default function ChatTesterMode() {
>
<p className="text-[10px] font-semibold text-text-muted mb-1 uppercase">
{msg.role === "user"
? `You (${FORMAT_META[clientFormat]?.label})`
: "Assistant"}
? t("youWithFormat", { format: FORMAT_META[clientFormat]?.label })
: t("assistant")}
</p>
<p className="whitespace-pre-wrap">{msg.content}</p>
</div>
@@ -361,7 +364,7 @@ export default function ChatTesterMode() {
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()}
placeholder="Type a message..."
placeholder={t("typeMessage")}
className="flex-1 bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
disabled={sending}
/>
@@ -371,7 +374,7 @@ export default function ChatTesterMode() {
loading={sending}
disabled={!message.trim() || sending}
>
Send
{t("send")}
</Button>
</div>
</div>
@@ -386,11 +389,9 @@ export default function ChatTesterMode() {
<span className="material-symbols-outlined text-[18px] text-primary">
account_tree
</span>
<h3 className="text-sm font-semibold text-text-main">Translation Pipeline</h3>
<h3 className="text-sm font-semibold text-text-main">{t("translationPipeline")}</h3>
</div>
<p className="text-xs text-text-muted">
Click on any step to inspect the data at that stage
</p>
<p className="text-xs text-text-muted">{t("clickStepToInspect")}</p>
</div>
</Card>
@@ -400,11 +401,8 @@ export default function ChatTesterMode() {
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
account_tree
</span>
<p className="text-sm font-medium mb-1">Pipeline visualization</p>
<p className="text-xs text-center max-w-xs">
Send a message to see how your request flows through detection translation
provider call.
</p>
<p className="text-sm font-medium mb-1">{t("pipelineVisualization")}</p>
<p className="text-xs text-center max-w-xs">{t("pipelineVisualizationHint")}</p>
</div>
</Card>
) : (

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { useState, useEffect, useRef } from "react";
import { Card, Badge } from "@/shared/components";
import { FORMAT_META } from "../exampleTemplates";
@@ -10,10 +12,14 @@ import { FORMAT_META } from "../exampleTemplates";
* Polls /api/translator/history for translation events.
*/
export default function LiveMonitorMode() {
const t = useTranslations("translator");
const tc = useTranslations("common");
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const intervalRef = useRef(null);
const notAvailable = t("notAvailableSymbol");
const formatLatency = (value) => t("millisecondsShort", { value });
const fetchHistory = async () => {
try {
@@ -51,27 +57,39 @@ export default function LiveMonitorMode() {
<div className="space-y-5">
{/* Info Banner */}
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
<span
className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0"
aria-hidden="true"
>
info
</span>
<div>
<p className="font-medium text-text-main mb-0.5">Real-Time Translation Activity</p>
<p className="font-medium text-text-main mb-0.5">{t("realtime")}</p>
<p>
Shows translation events as API calls flow through OmniRoute. Events come from the
in-memory buffer (resets on restart). Use{" "}
<strong className="text-text-main">Chat Tester</strong>,{" "}
<strong className="text-text-main">Test Bench</strong>, or external API calls to
generate events.
{t("liveMonitorDescriptionPrefix")}{" "}
<strong className="text-text-main">{t("chatTester")}</strong>,{" "}
<strong className="text-text-main">{t("testBench")}</strong>
{t("liveMonitorDescriptionSuffix")}
</p>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard icon="translate" label="Total Translations" value={events.length} color="blue" />
<StatCard icon="check_circle" label="Successful" value={successCount} color="green" />
<StatCard icon="error" label="Errors" value={errorCount} color="red" />
<StatCard icon="speed" label="Avg Latency" value={`${avgLatency}ms`} color="purple" />
<StatCard
icon="translate"
label={t("totalTranslations")}
value={events.length}
color="blue"
/>
<StatCard icon="check_circle" label={t("successful")} value={successCount} color="green" />
<StatCard icon="error" label={t("errors")} value={errorCount} color="red" />
<StatCard
icon="speed"
label={t("avgLatency")}
value={formatLatency(avgLatency)}
color="purple"
/>
</div>
{/* Controls */}
@@ -80,6 +98,7 @@ export default function LiveMonitorMode() {
<div className="flex items-center gap-2">
<span
className={`material-symbols-outlined text-[18px] ${autoRefresh ? "text-green-500 animate-pulse" : "text-text-muted"}`}
aria-hidden="true"
>
{autoRefresh ? "radio_button_checked" : "radio_button_unchecked"}
</span>
@@ -87,15 +106,17 @@ export default function LiveMonitorMode() {
onClick={() => setAutoRefresh(!autoRefresh)}
className="text-sm text-text-main hover:text-primary transition-colors"
>
{autoRefresh ? "LiveAuto-refreshing" : "Paused"}
{autoRefresh ? t("liveAutoRefreshing") : t("paused")}
</button>
</div>
<button
onClick={fetchHistory}
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[16px]">refresh</span>
Refresh
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
refresh
</span>
{tc("refresh")}
</button>
</div>
</Card>
@@ -103,53 +124,53 @@ export default function LiveMonitorMode() {
{/* Events Table */}
<Card>
<div className="p-4">
<h3 className="text-sm font-semibold text-text-main mb-3">Recent Translations</h3>
<h3 className="text-sm font-semibold text-text-main mb-3">{t("recentTranslations")}</h3>
{loading ? (
<div className="flex items-center justify-center py-12 text-text-muted">
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
Loading...
<span className="material-symbols-outlined animate-spin mr-2" aria-hidden="true">
progress_activity
</span>
{tc("loading")}
</div>
) : events.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
<span
className="material-symbols-outlined text-[48px] mb-3 opacity-30"
aria-hidden="true"
>
monitoring
</span>
<p className="text-sm font-medium mb-1">No translations yet</p>
<p className="text-xs text-center max-w-sm">
Translation events appear here as requests flow through OmniRoute. Use any of these
methods to generate events:
</p>
<p className="text-sm font-medium mb-1">{t("noTranslations")}</p>
<p className="text-xs text-center max-w-sm">{t("eventsAppearHint")}</p>
<div className="flex flex-wrap gap-2 mt-3 text-xs">
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
Chat Tester tab
{t("chatTesterTab")}
</span>
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
Test Bench tab
{t("testBenchTab")}
</span>
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
External API calls
{t("externalApiCalls")}
</span>
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
IDE/CLI integrations
{t("ideCliIntegrations")}
</span>
</div>
<p className="text-[10px] mt-3 text-text-muted/70">
Note: Events are stored in-memory and reset when the server restarts.
</p>
<p className="text-[10px] mt-3 text-text-muted/70">{t("inMemoryNote")}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-text-muted border-b border-border">
<th className="pb-2 pr-4">Time</th>
<th className="pb-2 pr-4">Source</th>
<th className="pb-2 pr-4">{t("time")}</th>
<th className="pb-2 pr-4">{t("source")}</th>
<th className="pb-2 pr-4"></th>
<th className="pb-2 pr-4">Target</th>
<th className="pb-2 pr-4">Model</th>
<th className="pb-2 pr-4">Status</th>
<th className="pb-2 text-right">Latency</th>
<th className="pb-2 pr-4">{t("target")}</th>
<th className="pb-2 pr-4">{t("model")}</th>
<th className="pb-2 pr-4">{t("status")}</th>
<th className="pb-2 text-right">{t("latency")}</th>
</tr>
</thead>
<tbody>
@@ -169,7 +190,9 @@ export default function LiveMonitorMode() {
className="border-b border-border/50 hover:bg-bg-subtle/50 transition-colors"
>
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
{event.timestamp ? new Date(event.timestamp).toLocaleTimeString() : "—"}
{event.timestamp
? new Date(event.timestamp).toLocaleTimeString()
: notAvailable}
</td>
<td className="py-2 pr-4">
<Badge variant="default" size="sm">
@@ -177,7 +200,10 @@ export default function LiveMonitorMode() {
</Badge>
</td>
<td className="py-2 pr-4 text-text-muted">
<span className="material-symbols-outlined text-[14px]">
<span
className="material-symbols-outlined text-[14px]"
aria-hidden="true"
>
arrow_forward
</span>
</td>
@@ -187,21 +213,21 @@ export default function LiveMonitorMode() {
</Badge>
</td>
<td className="py-2 pr-4 text-xs font-mono text-text-muted">
{event.model || "—"}
{event.model || notAvailable}
</td>
<td className="py-2 pr-4">
{event.status === "success" ? (
<Badge variant="success" size="sm" dot>
OK
{t("ok")}
</Badge>
) : (
<Badge variant="error" size="sm" dot>
{event.statusCode || "ERR"}
{event.statusCode || t("errorShort")}
</Badge>
)}
</td>
<td className="py-2 text-right text-xs text-text-muted">
{event.latency ? `${event.latency}ms` : "—"}
{event.latency ? formatLatency(event.latency) : notAvailable}
</td>
</tr>
);
@@ -221,7 +247,12 @@ function StatCard({ icon, label, value, color }) {
<Card>
<div className="p-4 flex items-center gap-3">
<div className={`flex items-center justify-center w-10 h-10 rounded-lg bg-${color}-500/10`}>
<span className={`material-symbols-outlined text-[22px] text-${color}-500`}>{icon}</span>
<span
className={`material-symbols-outlined text-[22px] text-${color}-500`}
aria-hidden="true"
>
{icon}
</span>
</div>
<div>
<p className="text-lg font-bold text-text-main">{value}</p>

View File

@@ -1,13 +1,17 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { useTranslations } from "next-intl";
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 });
export default function PlaygroundMode() {
const t = useTranslations("translator");
const tc = useTranslations("common");
const [sourceFormat, setSourceFormat] = useState("claude");
const [targetFormat, setTargetFormat] = useState("openai");
const [inputContent, setInputContent] = useState("");
@@ -16,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) => {
@@ -114,12 +119,8 @@ export default function PlaygroundMode() {
info
</span>
<div>
<p className="font-medium text-text-main mb-0.5">Format Converter</p>
<p>
Paste or type a JSON request body. The translator will auto-detect the source format and
convert it to the target format. Use this to debug how OmniRoute translates requests
between formats (OpenAI Claude Gemini Responses API).
</p>
<p className="font-medium text-text-main mb-0.5">{t("formatConverter")}</p>
<p>{t("formatConverterDescription")}</p>
</div>
</div>
{/* Format Controls Bar */}
@@ -128,7 +129,7 @@ export default function PlaygroundMode() {
{/* Source Format */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Source Format
{t("source")}
</label>
<div className="flex items-center gap-2">
<span className={`material-symbols-outlined text-[20px] text-${srcMeta.color}-500`}>
@@ -145,7 +146,7 @@ export default function PlaygroundMode() {
/>
{detectedFormat && (
<Badge variant="primary" size="sm" icon="auto_awesome">
Auto
{t("auto")}
</Badge>
)}
</div>
@@ -155,7 +156,7 @@ export default function PlaygroundMode() {
<button
onClick={handleSwapFormats}
className="p-2 rounded-full hover:bg-primary/10 text-text-muted hover:text-primary transition-all mt-4 sm:mt-5"
title="Swap formats"
title={t("swapFormats")}
>
<span className="material-symbols-outlined text-[24px]">swap_horiz</span>
</button>
@@ -163,7 +164,7 @@ export default function PlaygroundMode() {
{/* Target Format */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Target Format
{t("target")}
</label>
<div className="flex items-center gap-2">
<span className={`material-symbols-outlined text-[20px] text-${tgtMeta.color}-500`}>
@@ -187,7 +188,7 @@ export default function PlaygroundMode() {
disabled={!inputContent.trim() || translating}
className="whitespace-nowrap"
>
Translate
{t("translateAction")}
</Button>
</div>
</div>
@@ -201,7 +202,7 @@ export default function PlaygroundMode() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">input</span>
<h3 className="text-sm font-semibold text-text-main">Input</h3>
<h3 className="text-sm font-semibold text-text-main">{t("input")}</h3>
{detectedFormat && (
<Badge variant="info" size="sm" dot>
{FORMAT_META[detectedFormat]?.label || detectedFormat}
@@ -217,7 +218,7 @@ export default function PlaygroundMode() {
<button
onClick={() => handleCopy(inputContent)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
title={tc("copy")}
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
@@ -229,7 +230,7 @@ export default function PlaygroundMode() {
setActiveTemplate(null);
}}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Clear"
title={t("clear")}
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
@@ -250,7 +251,7 @@ export default function PlaygroundMode() {
wordWrap: "on",
automaticLayout: true,
formatOnPaste: true,
placeholder: "Paste a request body here or select a template below...",
placeholder: t("inputPlaceholder"),
}}
/>
</div>
@@ -265,7 +266,7 @@ export default function PlaygroundMode() {
<span className="material-symbols-outlined text-[18px] text-text-muted">
output
</span>
<h3 className="text-sm font-semibold text-text-main">Output</h3>
<h3 className="text-sm font-semibold text-text-main">{t("output")}</h3>
{outputContent && (
<Badge variant="success" size="sm" dot>
{FORMAT_META[targetFormat]?.label || targetFormat}
@@ -276,7 +277,7 @@ export default function PlaygroundMode() {
<button
onClick={() => handleCopy(outputContent)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
title={tc("copy")}
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
@@ -303,18 +304,18 @@ export default function PlaygroundMode() {
</Card>
</div>
{/* Example Templates */}
{/* {t("exampleTemplates")} */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-primary">
library_books
</span>
<h3 className="text-sm font-semibold text-text-main">Example Templates</h3>
<span className="text-xs text-text-muted"> Click to load</span>
<h3 className="text-sm font-semibold text-text-main">{t("exampleTemplates")}</h3>
<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)}
@@ -339,11 +340,9 @@ export default function PlaygroundMode() {
{activeTemplate && (
<div className="flex items-center gap-2 text-xs text-text-muted">
<span className="material-symbols-outlined text-[14px]">info</span>
Template loads the request in{" "}
<strong className="text-text-main">
{FORMAT_META[sourceFormat]?.label || sourceFormat}
</strong>{" "}
format. Change Source Format to load in a different format.
{t("templateLoadHint", {
format: FORMAT_META[sourceFormat]?.label || sourceFormat,
})}
</div>
)}
</div>

View File

@@ -1,8 +1,10 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
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";
@@ -17,15 +19,25 @@ import { useAvailableModels } from "../hooks/useAvailableModels";
*/
const SCENARIOS = [
{ id: "simple-chat", name: "Simple Chat", icon: "chat", templateId: "simple-chat" },
{ id: "tool-calling", name: "Tool Calling", icon: "build", templateId: "tool-calling" },
{ id: "multi-turn", name: "Multi-turn", icon: "forum", templateId: "multi-turn" },
{ id: "thinking", name: "Thinking", icon: "psychology", templateId: "thinking" },
{ id: "system-prompt", name: "System Prompt", icon: "settings", templateId: "system-prompt" },
{ id: "streaming", name: "Streaming", icon: "stream", templateId: "streaming" },
{ id: "simple-chat", icon: "chat", templateId: "simple-chat" },
{ id: "tool-calling", icon: "build", templateId: "tool-calling" },
{ id: "multi-turn", icon: "forum", templateId: "multi-turn" },
{ id: "thinking", icon: "psychology", templateId: "thinking" },
{ id: "system-prompt", icon: "settings", templateId: "system-prompt" },
{ id: "streaming", icon: "stream", templateId: "streaming" },
];
export default function TestBenchMode() {
const t = useTranslations("translator");
const scenarioLabels: Record<string, string> = {
"simple-chat": t("scenarioSimpleChat"),
"tool-calling": t("scenarioToolCalling"),
"multi-turn": t("scenarioMultiTurn"),
thinking: t("scenarioThinking"),
"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();
@@ -44,13 +56,13 @@ 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) {
setResults((prev) => ({
...prev,
[scenario.id]: { status: "error", error: "No template for this format", latency: 0 },
[scenario.id]: { status: "error", error: t("noTemplateForFormat"), latency: 0 },
}));
return;
}
@@ -73,7 +85,7 @@ export default function TestBenchMode() {
...prev,
[scenario.id]: {
status: "error",
error: `Translation failed: ${translateData.error}`,
error: t("translationFailed", { error: translateData.error }),
latency: Date.now() - start,
},
}));
@@ -147,13 +159,8 @@ export default function TestBenchMode() {
info
</span>
<div>
<p className="font-medium text-text-main mb-0.5">Compatibility Tester</p>
<p>
Run predefined scenarios (Simple Chat, Tool Calling, etc.) to verify translation and
provider compatibility. Select a source format and target provider, then run all tests
to see a compatibility percentage. Use this to find which features work across
providers.
</p>
<p className="font-medium text-text-main mb-0.5">{t("compatibilityTester")}</p>
<p>{t("testBenchDescription")}</p>
</div>
</div>
@@ -163,7 +170,7 @@ export default function TestBenchMode() {
<div className="flex flex-col sm:flex-row items-end gap-4">
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Source Format
{t("source")}
</label>
<Select
value={sourceFormat}
@@ -183,7 +190,7 @@ export default function TestBenchMode() {
</div>
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Target Provider
{t("targetProvider")}
</label>
<Select
value={provider}
@@ -200,12 +207,12 @@ export default function TestBenchMode() {
loading={runningAll}
disabled={runningAll}
>
Run All Tests
{t("runAllTests")}
</Button>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Model
{t("model")}
</label>
<div className="relative">
<input
@@ -213,7 +220,7 @@ export default function TestBenchMode() {
value={model}
onChange={(e) => setModel(e.target.value)}
list="testbench-model-suggestions"
placeholder="Select or type a model name..."
placeholder={t("modelPlaceholder")}
className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
/>
<datalist id="testbench-model-suggestions">
@@ -232,7 +239,7 @@ export default function TestBenchMode() {
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<h3 className="text-sm font-semibold text-text-main">Compatibility Report</h3>
<h3 className="text-sm font-semibold text-text-main">{t("compatibilityReport")}</h3>
<Badge
variant={
compatibility >= 80 ? "success" : compatibility >= 50 ? "warning" : "error"
@@ -244,10 +251,10 @@ export default function TestBenchMode() {
</div>
<div className="flex items-center gap-3 text-xs text-text-muted">
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-green-500" /> {passCount} passed
<span className="size-2 rounded-full bg-green-500" /> {passCount} {t("passed")}
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-red-500" /> {failCount} failed
<span className="size-2 rounded-full bg-red-500" /> {failCount} {t("failed")}
</span>
</div>
</div>
@@ -296,7 +303,9 @@ export default function TestBenchMode() {
</span>
</div>
<div>
<p className="text-sm font-medium text-text-main">{scenario.name}</p>
<p className="text-sm font-medium text-text-main">
{scenarioLabels[scenario.id] || scenario.id}
</p>
<p className="text-[10px] text-text-muted uppercase">
{srcMeta.label} {" "}
{providerOptions.find((o) => o.value === provider)?.label || provider}
@@ -312,9 +321,9 @@ export default function TestBenchMode() {
>
{result.status === "pass" ? (
<div className="flex items-center justify-between">
<span> Passed</span>
<span>{t("passedIconLabel")}</span>
<span className="text-text-muted">
{result.latency}ms {result.chunks} chunks
{result.latency}ms {result.chunks} {t("chunks")}
</span>
</div>
) : (
@@ -334,7 +343,7 @@ export default function TestBenchMode() {
disabled={isRunning || runningAll}
className="w-full"
>
{isRunning ? "Running..." : result ? "Re-run" : "Run Test"}
{isRunning ? t("running") : result ? t("reRun") : t("runTest")}
</Button>
</div>
</Card>

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

@@ -1,9 +1,13 @@
import TranslatorPageClient from "./TranslatorPageClient";
import { getTranslations } from "next-intl/server";
export const metadata = {
title: "Translator Playground | OmniRoute",
description: "Debug, test, and visualize API format translations between providers",
};
export async function generateMetadata() {
const t = await getTranslations("translator");
return {
title: t("metaTitle"),
description: t("metaDescription"),
};
}
export default function TranslatorPage() {
return <TranslatorPageClient />;

View File

@@ -1,5 +1,7 @@
"use client";
import { useLocale, useTranslations } from "next-intl";
/**
* BudgetTab — Batch C
*
@@ -12,7 +14,7 @@ import { useState, useEffect, useCallback } from "react";
import { Card, Button, Input, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
function ProgressBar({ value, max, warningAt = 0.8 }) {
function ProgressBar({ value, max, warningAt = 0.8, formatCurrency }) {
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
const ratio = max > 0 ? value / max : 0;
const color = ratio >= 1 ? "#ef4444" : ratio >= warningAt ? "#f59e0b" : "#22c55e";
@@ -20,8 +22,8 @@ function ProgressBar({ value, max, warningAt = 0.8 }) {
return (
<div className="w-full">
<div className="flex justify-between text-xs mb-1">
<span className="text-text-muted">${value.toFixed(2)}</span>
<span className="text-text-muted">${max.toFixed(2)}</span>
<span className="text-text-muted">{formatCurrency(value)}</span>
<span className="text-text-muted">{formatCurrency(max)}</span>
</div>
<div className="w-full h-2 rounded-full bg-surface/50 overflow-hidden">
<div
@@ -34,6 +36,8 @@ function ProgressBar({ value, max, warningAt = 0.8 }) {
}
export default function BudgetTab() {
const t = useTranslations("usage");
const locale = useLocale();
const [keys, setKeys] = useState([]);
const [selectedKey, setSelectedKey] = useState(null);
const [budget, setBudget] = useState(null);
@@ -45,6 +49,13 @@ export default function BudgetTab() {
warningThreshold: "80",
});
const notify = useNotificationStore();
const formatCurrency = (value) =>
new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(Number(value || 0));
// Load API keys
useEffect(() => {
@@ -97,13 +108,13 @@ export default function BudgetTab() {
}),
});
if (res.ok) {
notify.success("Budget limits saved");
notify.success(t("budgetSaved"));
await fetchBudget();
} else {
notify.error("Failed to save budget");
notify.error(t("budgetSaveFailed"));
}
} catch {
notify.error("Failed to save budget");
notify.error(t("budgetSaveFailed"));
} finally {
setSaving(false);
}
@@ -112,8 +123,10 @@ export default function BudgetTab() {
if (loading) {
return (
<div className="flex items-center gap-2 text-text-muted p-8 animate-pulse">
<span className="material-symbols-outlined text-[20px]">account_balance_wallet</span>
Loading budget data...
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
account_balance_wallet
</span>
{t("loadingBudgetData")}
</div>
);
}
@@ -122,8 +135,8 @@ export default function BudgetTab() {
return (
<EmptyState
icon="vpn_key"
title="No API Keys"
description="Add API keys first to set up budget limits."
title={t("noApiKeysTitle")}
description={t("noApiKeysDescription")}
/>
);
}
@@ -140,13 +153,15 @@ export default function BudgetTab() {
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
<span className="material-symbols-outlined text-[20px]">account_balance_wallet</span>
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
account_balance_wallet
</span>
</div>
<h3 className="text-lg font-semibold">Budget Management</h3>
<h3 className="text-lg font-semibold">{t("budgetManagement")}</h3>
</div>
<div className="mb-4">
<label className="text-sm text-text-muted mb-1 block">API Key</label>
<label className="text-sm text-text-muted mb-1 block">{t("apiKey")}</label>
<select
value={selectedKey || ""}
onChange={(e) => setSelectedKey(e.target.value)}
@@ -163,55 +178,65 @@ export default function BudgetTab() {
{/* Current Spend */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div className="p-4 rounded-lg border border-border/30 bg-surface/20">
<p className="text-sm text-text-muted mb-2">Today&apos;s Spend</p>
<p className="text-2xl font-bold text-text-main">${dailyCost.toFixed(2)}</p>
<p className="text-sm text-text-muted mb-2">{t("todaysSpend")}</p>
<p className="text-2xl font-bold text-text-main">{formatCurrency(dailyCost)}</p>
{dailyLimit > 0 && (
<ProgressBar value={dailyCost} max={dailyLimit} warningAt={warnPct} />
<ProgressBar
value={dailyCost}
max={dailyLimit}
warningAt={warnPct}
formatCurrency={formatCurrency}
/>
)}
</div>
<div className="p-4 rounded-lg border border-border/30 bg-surface/20">
<p className="text-sm text-text-muted mb-2">This Month</p>
<p className="text-2xl font-bold text-text-main">${monthlyCost.toFixed(2)}</p>
<p className="text-sm text-text-muted mb-2">{t("thisMonth")}</p>
<p className="text-2xl font-bold text-text-main">{formatCurrency(monthlyCost)}</p>
{monthlyLimit > 0 && (
<ProgressBar value={monthlyCost} max={monthlyLimit} warningAt={warnPct} />
<ProgressBar
value={monthlyCost}
max={monthlyLimit}
warningAt={warnPct}
formatCurrency={formatCurrency}
/>
)}
</div>
</div>
{/* Budget Form */}
<div className="border-t border-border/30 pt-4">
<p className="text-sm font-medium mb-3">Set Limits</p>
<p className="text-sm font-medium mb-3">{t("setLimits")}</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<Input
label="Daily Limit (USD)"
label={t("dailyLimitUsd")}
type="number"
step="0.01"
min="0"
placeholder="e.g. 5.00"
placeholder={t("dailyLimitPlaceholder")}
value={form.dailyLimitUsd}
onChange={(e) => setForm({ ...form, dailyLimitUsd: e.target.value })}
/>
<Input
label="Monthly Limit (USD)"
label={t("monthlyLimitUsd")}
type="number"
step="0.01"
min="0"
placeholder="e.g. 50.00"
placeholder={t("monthlyLimitPlaceholder")}
value={form.monthlyLimitUsd}
onChange={(e) => setForm({ ...form, monthlyLimitUsd: e.target.value })}
/>
<Input
label="Warning Threshold (%)"
label={t("warningThresholdPercent")}
type="number"
min="1"
max="100"
placeholder="80"
placeholder={t("warningThresholdPlaceholder")}
value={form.warningThreshold}
onChange={(e) => setForm({ ...form, warningThreshold: e.target.value })}
/>
</div>
<Button variant="primary" onClick={handleSave} loading={saving}>
Save Limits
{t("saveLimits")}
</Button>
</div>
</Card>
@@ -222,14 +247,15 @@ export default function BudgetTab() {
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-[20px]"
aria-hidden="true"
style={{ color: budget.budgetCheck.allowed ? "#22c55e" : "#ef4444" }}
>
{budget.budgetCheck.allowed ? "check_circle" : "block"}
</span>
<span className="text-sm">
{budget.budgetCheck.allowed
? `Budget OK — $${(budget.budgetCheck.remaining || 0).toFixed(2)} remaining`
: "Budget exceeded — requests may be blocked"}
? t("budgetOk", { remaining: formatCurrency(budget.budgetCheck.remaining || 0) })
: t("budgetExceeded")}
</span>
</div>
</Card>

View File

@@ -1,9 +1,12 @@
"use client";
import { useTranslations } from "next-intl";
import { useState, useEffect } from "react";
import { Card } from "@/shared/components";
export default function BudgetTelemetryCards() {
const t = useTranslations("usage");
const [telemetry, setTelemetry] = useState(null);
const [cache, setCache] = useState(null);
const [policies, setPolicies] = useState(null);
@@ -28,29 +31,29 @@ export default function BudgetTelemetryCards() {
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">speed</span>
Latency
{t("latency")}
</h3>
{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">
<span className="text-text-muted">Total requests</span>
<span className="text-text-muted">{t("totalRequests")}</span>
<span className="font-mono">{telemetry.totalRequests ?? 0}</span>
</div>
</div>
) : (
<p className="text-sm text-text-muted">No data yet</p>
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
)}
</Card>
@@ -58,29 +61,29 @@ export default function BudgetTelemetryCards() {
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">cached</span>
Prompt Cache
{t("promptCache")}
</h3>
{cache ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">Entries</span>
<span className="text-text-muted">{t("entries")}</span>
<span className="font-mono">
{cache.size}/{cache.maxSize}
</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">Hit Rate</span>
<span className="text-text-muted">{t("hitRate")}</span>
<span className="font-mono">{cache.hitRate?.toFixed(1) ?? 0}%</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">Hits / Misses</span>
<span className="text-text-muted">{t("hitsMisses")}</span>
<span className="font-mono">
{cache.hits ?? 0} / {cache.misses ?? 0}
</span>
</div>
</div>
) : (
<p className="text-sm text-text-muted">No data yet</p>
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
)}
</Card>
@@ -88,26 +91,28 @@ export default function BudgetTelemetryCards() {
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">monitor_heart</span>
System Health
{t("systemHealth")}
</h3>
{policies ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">Circuit Breakers</span>
<span className="font-mono">{policies.circuitBreakers?.length ?? 0} active</span>
<span className="text-text-muted">{t("circuitBreakers")}</span>
<span className="font-mono">
{t("activeCount", { count: policies.circuitBreakers?.length ?? 0 })}
</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">Locked IPs</span>
<span className="text-text-muted">{t("lockedIPs")}</span>
<span className="font-mono">{policies.lockedIdentifiers?.length ?? 0}</span>
</div>
{policies.circuitBreakers?.some((cb) => cb.state === "OPEN") && (
<div className="mt-2 px-2 py-1 rounded bg-red-500/10 text-red-400 text-xs">
Open circuit breakers detected
{t("openCircuitBreakersDetected")}
</div>
)}
</div>
) : (
<p className="text-sm text-text-muted">No data yet</p>
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
)}
</Card>
</div>

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
/**
* EvalsTab — Batch F
*
@@ -16,39 +18,40 @@ import { useNotificationStore } from "@/store/notificationStore";
const STRATEGIES = [
{
name: "contains",
label: "Contains",
labelKey: "evalsStrategyContainsLabel",
icon: "search",
color: "text-sky-400",
bg: "bg-sky-500/10",
description: "Checks if the response contains a specific text (case-insensitive)",
descriptionKey: "evalsStrategyContainsDescription",
},
{
name: "exact",
label: "Exact Match",
labelKey: "evalsStrategyExactLabel",
icon: "check_circle",
color: "text-emerald-400",
bg: "bg-emerald-500/10",
description: "Response must be an exact character-for-character match",
descriptionKey: "evalsStrategyExactDescription",
},
{
name: "regex",
label: "Regex Pattern",
labelKey: "evalsStrategyRegexLabel",
icon: "code",
color: "text-amber-400",
bg: "bg-amber-500/10",
description: "Matches response against a regular expression pattern",
descriptionKey: "evalsStrategyRegexDescription",
},
{
name: "custom",
label: "Custom Function",
labelKey: "evalsStrategyCustomLabel",
icon: "tune",
color: "text-violet-400",
bg: "bg-violet-500/10",
description: "Uses a custom function for advanced evaluation logic",
descriptionKey: "evalsStrategyCustomDescription",
},
];
export default function EvalsTab() {
const t = useTranslations("usage");
const [suites, setSuites] = useState([]);
const [apiKey, setApiKey] = useState(null);
const [loading, setLoading] = useState(true);
@@ -126,7 +129,7 @@ export default function EvalsTab() {
const handleRunEval = async (suite) => {
const cases = suite.cases || [];
if (cases.length === 0) {
notify.warning("No test cases defined for this suite");
notify.warning(t("notifyNoTestCases"));
return;
}
@@ -158,16 +161,22 @@ export default function EvalsTab() {
if (data.summary) {
const { passed, failed, total } = data.summary;
if (failed === 0) {
notify.success(`All ${total} cases passed ✅`, `Eval: ${suite.name}`);
notify.success(
t("notifyAllCasesPassed", { total }),
t("notifyEvalTitle", { name: suite.name || suite.id })
);
} else {
notify.warning(`${passed}/${total} passed, ${failed} failed`, `Eval: ${suite.name}`);
notify.warning(
t("notifySomeCasesFailed", { passed, total, failed }),
t("notifyEvalTitle", { name: suite.name || suite.id })
);
}
}
// Auto-expand to show results
setExpanded(suite.id);
} catch {
notify.error("Eval run failed");
notify.error(t("notifyEvalRunFailed"));
} finally {
setRunning(null);
setProgress({ current: 0, total: 0 });
@@ -194,7 +203,7 @@ export default function EvalsTab() {
return (
<div className="flex items-center gap-2 text-text-muted p-8 animate-pulse">
<span className="material-symbols-outlined text-[20px]">science</span>
Loading eval suites...
{t("evalsLoading")}
</div>
);
}
@@ -206,56 +215,56 @@ export default function EvalsTab() {
<HeroSection />
<EmptyState
icon="science"
title="No Eval Suites Found"
description="Eval suites can be defined via the API or in code. They test model outputs against expected results using strategies like contains, regex, exact match, and custom functions."
title={t("noEvalSuitesFound")}
description={t("noEvalSuitesDescription")}
/>
</div>
);
}
const RESULT_COLUMNS = [
{ key: "caseName", label: "Case" },
{ key: "status", label: "Status" },
{ key: "durationMs", label: "Latency" },
{ key: "details", label: "Details" },
{ key: "caseName", label: t("columnCase") },
{ key: "status", label: t("columnStatus") },
{ key: "durationMs", label: t("columnLatency") },
{ key: "details", label: t("columnDetails") },
];
return (
<div className="flex flex-col gap-6">
{/* Hero Section */}
<HeroSection />
<HeroSection t={t} />
{/* Stats Bar */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card className="px-4 py-3 text-center">
<span className="text-xs text-text-muted uppercase font-semibold tracking-wide">
Suites
{t("statsSuites")}
</span>
<div className="text-2xl font-bold mt-1 text-violet-400">{suites.length}</div>
</Card>
<Card className="px-4 py-3 text-center">
<span className="text-xs text-text-muted uppercase font-semibold tracking-wide">
Test Cases
{t("statsTestCases")}
</span>
<div className="text-2xl font-bold mt-1 text-sky-400">{totalCases}</div>
</Card>
<Card className="px-4 py-3 text-center">
<span className="text-xs text-text-muted uppercase font-semibold tracking-wide">
Models
{t("statsModels")}
</span>
<div className="text-2xl font-bold mt-1 text-emerald-400">{uniqueModels.length}</div>
</Card>
<Card className="px-4 py-3 text-center">
<span className="text-xs text-text-muted uppercase font-semibold tracking-wide">
Coverage
{t("statsCoverage")}
</span>
<div className="text-2xl font-bold mt-1 text-amber-400">
{STRATEGIES.length} strategies
{t("statsStrategiesCount", { count: STRATEGIES.length })}
</div>
</Card>
</div>
{/* How It Works — Collapsible */}
{/* {t("howItWorks")} — Collapsible */}
<Card className="p-0 overflow-hidden">
<button
onClick={() => setShowHowItWorks(!showHowItWorks)}
@@ -266,10 +275,8 @@ export default function EvalsTab() {
<span className="material-symbols-outlined text-[20px]">help</span>
</div>
<div>
<h3 className="text-sm font-semibold text-text-main">How It Works</h3>
<p className="text-xs text-text-muted">
Learn how evaluations validate your LLM responses
</p>
<h3 className="text-sm font-semibold text-text-main">{t("howItWorks")}</h3>
<p className="text-xs text-text-muted">{t("howItWorksSubtitle")}</p>
</div>
</div>
<span
@@ -289,38 +296,29 @@ export default function EvalsTab() {
<div className="w-10 h-10 rounded-full bg-violet-500/20 flex items-center justify-center mb-3">
<span className="text-lg font-bold text-violet-400">1</span>
</div>
<h4 className="text-sm font-semibold text-text-main mb-1">Define</h4>
<p className="text-xs text-text-muted">
Create test cases with input prompts and expected output criteria using strategies
like contains, regex, or exact match.
</p>
<h4 className="text-sm font-semibold text-text-main mb-1">{t("define")}</h4>
<p className="text-xs text-text-muted">{t("defineStepDescription")}</p>
</div>
<div className="flex flex-col items-center text-center p-4 rounded-lg bg-sky-500/5 border border-sky-500/10">
<div className="w-10 h-10 rounded-full bg-sky-500/20 flex items-center justify-center mb-3">
<span className="text-lg font-bold text-sky-400">2</span>
</div>
<h4 className="text-sm font-semibold text-text-main mb-1">Run</h4>
<p className="text-xs text-text-muted">
Execute test cases against your LLM endpoints through OmniRoute. Each case is sent
as a real API request.
</p>
<h4 className="text-sm font-semibold text-text-main mb-1">{t("run")}</h4>
<p className="text-xs text-text-muted">{t("runStepDescription")}</p>
</div>
<div className="flex flex-col items-center text-center p-4 rounded-lg bg-emerald-500/5 border border-emerald-500/10">
<div className="w-10 h-10 rounded-full bg-emerald-500/20 flex items-center justify-center mb-3">
<span className="text-lg font-bold text-emerald-400">3</span>
</div>
<h4 className="text-sm font-semibold text-text-main mb-1">Evaluate</h4>
<p className="text-xs text-text-muted">
Responses are compared against expected criteria. See pass/fail for each case with
latency metrics and detailed feedback.
</p>
<h4 className="text-sm font-semibold text-text-main mb-1">{t("evaluate")}</h4>
<p className="text-xs text-text-muted">{t("evaluateStepDescription")}</p>
</div>
</div>
{/* Evaluation Strategies */}
<div className="mt-6">
<h4 className="text-xs font-semibold text-text-muted uppercase tracking-wide mb-3">
Evaluation Strategies
{t("evaluationStrategies")}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{STRATEGIES.map((s) => (
@@ -332,8 +330,10 @@ export default function EvalsTab() {
{s.icon}
</span>
<div>
<span className={`text-xs font-mono font-semibold ${s.color}`}>{s.name}</span>
<p className="text-xs text-text-muted mt-0.5">{s.description}</p>
<span className={`text-xs font-mono font-semibold ${s.color}`}>
{t(s.labelKey)}
</span>
<p className="text-xs text-text-muted mt-0.5">{t(s.descriptionKey)}</p>
</div>
</div>
))}
@@ -344,7 +344,7 @@ export default function EvalsTab() {
{uniqueModels.length > 0 && (
<div className="mt-6">
<h4 className="text-xs font-semibold text-text-muted uppercase tracking-wide mb-3">
Models Under Test
{t("modelsUnderTest")}
</h4>
<div className="flex flex-wrap gap-2">
{uniqueModels.map((m) => (
@@ -369,17 +369,15 @@ export default function EvalsTab() {
<span className="material-symbols-outlined text-[20px]">science</span>
</div>
<div>
<h3 className="text-lg font-semibold">Evaluation Suites</h3>
<p className="text-xs text-text-muted">
Click a suite to view test cases, then run to evaluate your LLM endpoints
</p>
<h3 className="text-lg font-semibold">{t("evalSuites")}</h3>
<p className="text-xs text-text-muted">{t("evalSuitesHint")}</p>
</div>
</div>
<FilterBar
searchValue={search}
onSearchChange={setSearch}
placeholder="Search suites..."
placeholder={t("searchSuitesPlaceholder")}
filters={[]}
activeFilters={{}}
onFilterChange={() => {}}
@@ -424,12 +422,12 @@ export default function EvalsTab() {
: "bg-red-500/10 text-red-400"
}`}
>
{suiteResult.summary.passRate}% pass
{suiteResult.summary.passRate}% {t("passSuffix")}
</span>
)}
</div>
<p className="text-xs text-text-muted">
{caseCount} case{caseCount !== 1 ? "s" : ""}
{t("casesCount", { count: caseCount })}
{suite.description && <span className="ml-1"> {suite.description}</span>}
</p>
{suiteModels.length > 0 && (
@@ -472,7 +470,9 @@ export default function EvalsTab() {
loading={isRunning}
disabled={isRunning}
>
{isRunning ? `Running ${progress.current}/${progress.total}...` : "Run Eval"}
{isRunning
? t("runningProgress", { current: progress.current, total: progress.total })
: t("runEval")}
</Button>
</div>
</div>
@@ -496,11 +496,14 @@ export default function EvalsTab() {
>
{suiteResult.summary.passRate}%
</span>
<span className="text-xs text-text-muted">pass rate</span>
<span className="text-xs text-text-muted">{t("passRate")}</span>
</div>
<div className="text-xs text-text-muted">
{suiteResult.summary.passed} passed · {suiteResult.summary.failed}{" "}
failed · {suiteResult.summary.total} total
{t("summaryBreakdown", {
passed: suiteResult.summary.passed,
failed: suiteResult.summary.failed,
total: suiteResult.summary.total,
})}
</div>
{/* Visual pass/fail bar */}
<div className="flex-1 h-2 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden">
@@ -528,9 +531,9 @@ export default function EvalsTab() {
renderCell={(row, col) => {
if (col.key === "status") {
return row.passed ? (
<span className="text-emerald-400"> Passed</span>
<span className="text-emerald-400">{t("passedIconLabel")}</span>
) : (
<span className="text-red-400"> Failed</span>
<span className="text-red-400">{t("failedIconLabel")}</span>
);
}
if (col.key === "durationMs") {
@@ -546,11 +549,13 @@ export default function EvalsTab() {
<span className="text-text-muted text-xs truncate max-w-[300px] block">
{String(
(d as any).searchTerm
? `Contains: "${(d as any).searchTerm}"`
? t("detailsContains", { term: (d as any).searchTerm })
: (d as any).pattern
? `Regex: ${(d as any).pattern}`
? t("detailsRegex", { pattern: (d as any).pattern })
: (d as any).expected
? `Expected: "${String((d as any).expected).slice(0, 50)}"`
? t("detailsExpected", {
expected: String((d as any).expected).slice(0, 50),
})
: row.error || "—"
)}
</span>
@@ -563,7 +568,7 @@ export default function EvalsTab() {
);
}}
maxHeight="400px"
emptyMessage="No results yet"
emptyMessage={t("noResultsYet")}
/>
</>
) : (
@@ -574,15 +579,15 @@ export default function EvalsTab() {
checklist
</span>
<span className="text-xs text-text-muted font-medium">
Test Cases ({(suite.cases || []).length})
{t("testCasesCount", { count: (suite.cases || []).length })}
</span>
</div>
<DataTable
columns={[
{ key: "name", label: "Case" },
{ key: "model", label: "Model" },
{ key: "strategy", label: "Strategy" },
{ key: "expected", label: "Expected" },
{ key: "name", label: t("columnCase") },
{ key: "model", label: t("columnModel") },
{ key: "strategy", label: t("columnStrategy") },
{ key: "expected", label: t("columnExpected") },
]}
data={(suite.cases || []).map((c, i) => ({
id: c.id || i,
@@ -627,12 +632,11 @@ export default function EvalsTab() {
);
}}
maxHeight="400px"
emptyMessage="No test cases defined"
emptyMessage={t("noTestCasesDefined")}
/>
<p className="text-xs text-text-muted mt-3 flex items-center gap-1.5">
<span className="material-symbols-outlined text-[14px]">info</span>
Click &quot;Run Eval&quot; to execute all cases against your LLM endpoint.
Each test sends a real request through OmniRoute.
{t("runEvalHint")}
</p>
</>
)}
@@ -648,7 +652,7 @@ export default function EvalsTab() {
}
// ── Hero Section Component ─────────────────────────────────────────────
function HeroSection() {
function HeroSection({ t }: { t: (key: string, values?: Record<string, unknown>) => string }) {
return (
<Card className="p-0 overflow-hidden">
<div
@@ -663,33 +667,30 @@ function HeroSection() {
<span className="material-symbols-outlined text-[28px]">science</span>
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-text-main mb-1">Model Evaluations</h2>
<h2 className="text-xl font-bold text-text-main mb-1">{t("modelEvals")}</h2>
<p className="text-sm text-text-muted leading-relaxed max-w-2xl">
Test and validate your LLM endpoints by running predefined evaluation suites. Each
suite contains test cases that send real prompts through OmniRoute and compare
responses against expected criteria helping you detect regressions, compare models,
and ensure response quality across providers.
{t("evalsHeroDescription")}
</p>
<div className="flex flex-wrap items-center gap-4 mt-4">
<div className="flex items-center gap-1.5 text-xs text-text-muted">
<span className="material-symbols-outlined text-[16px] text-emerald-400">
verified
</span>
Quality Validation
{t("qualityValidation")}
</div>
<div className="flex items-center gap-1.5 text-xs text-text-muted">
<span className="material-symbols-outlined text-[16px] text-sky-400">compare</span>
Model Comparison
{t("modelComparison")}
</div>
<div className="flex items-center gap-1.5 text-xs text-text-muted">
<span className="material-symbols-outlined text-[16px] text-amber-400">
bug_report
</span>
Regression Detection
{t("regressionDetection")}
</div>
<div className="flex items-center gap-1.5 text-xs text-text-muted">
<span className="material-symbols-outlined text-[16px] text-violet-400">speed</span>
Latency Benchmarks
{t("latencyBenchmarks")}
</div>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { useState } from "react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import Card from "@/shared/components/Card";
import Badge from "@/shared/components/Badge";
import QuotaProgressBar from "./QuotaProgressBar";
@@ -26,6 +27,7 @@ export default function ProviderLimitCard({
}) {
const [refreshing, setRefreshing] = useState(false);
const [imgError, setImgError] = useState(false);
const t = useTranslations("usage");
const handleRefresh = async () => {
if (!onRefresh || refreshing) return;
@@ -70,7 +72,7 @@ export default function ProviderLimitCard({
) : (
<Image
src={`/providers/${provider}.png`}
alt={provider || "Provider"}
alt={provider || t("providerLimits")}
width={40}
height={40}
className="object-contain rounded-lg"
@@ -83,7 +85,10 @@ export default function ProviderLimitCard({
<div>
<h3 className="font-semibold text-text-primary">{name || provider}</h3>
{plan && (
<Badge variant={(planVariants as any)[plan?.toLowerCase()] || "default"} size={"xs" as any}>
<Badge
variant={(planVariants as any)[plan?.toLowerCase()] || "default"}
size={"xs" as any}
>
{plan}
</Badge>
)}
@@ -95,7 +100,7 @@ export default function ProviderLimitCard({
onClick={handleRefresh}
disabled={refreshing || loading}
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Refresh quota"
title={t("refreshQuota")}
>
<span
className={`material-symbols-outlined text-[20px] text-text-muted ${
@@ -171,7 +176,7 @@ export default function ProviderLimitCard({
{!loading && !error && !message && quotas?.length === 0 && (
<div className="text-center py-8 text-text-muted">
<span className="material-symbols-outlined text-[48px] opacity-20">data_usage</span>
<p className="text-sm mt-2">No quota data available</p>
<p className="text-sm mt-2">{t("noQuotaDataAvailable")}</p>
</div>
)}
</Card>

View File

@@ -1,11 +1,12 @@
"use client";
import { formatResetTime, calculatePercentage } from "./utils";
import { useLocale, useTranslations } from "next-intl";
/**
* Format reset time display (Today, 12:00 PM)
*/
function formatResetTimeDisplay(resetTime) {
function formatResetTimeDisplay(resetTime, locale, t) {
if (!resetTime) return null;
try {
@@ -17,20 +18,19 @@ function formatResetTimeDisplay(resetTime) {
let dayStr = "";
if (date >= today && date < tomorrow) {
dayStr = "Today";
dayStr = t("today");
} else if (date >= tomorrow && date < new Date(tomorrow.getTime() + 24 * 60 * 60 * 1000)) {
dayStr = "Tomorrow";
dayStr = t("tomorrow");
} else {
dayStr = date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
dayStr = date.toLocaleDateString(locale, { month: "short", day: "numeric" });
}
const timeStr = date.toLocaleTimeString("en-US", {
const timeStr = date.toLocaleTimeString(locale, {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
return `${dayStr}, ${timeStr}`;
return t("dayTimeFormat", { day: dayStr, time: timeStr });
} catch {
return null;
}
@@ -71,6 +71,9 @@ function getColorClasses(remainingPercentage) {
* Quota Table Component - Table-based display for quota data
*/
export default function QuotaTable({ quotas = [] }) {
const t = useTranslations("usage");
const locale = useLocale();
if (!quotas || quotas.length === 0) {
return null;
}
@@ -92,7 +95,7 @@ export default function QuotaTable({ quotas = [] }) {
const colors = getColorClasses(remaining);
const countdown = formatResetTime(quota.resetAt);
const resetDisplay = formatResetTimeDisplay(quota.resetAt);
const resetDisplay = formatResetTimeDisplay(quota.resetAt, locale, t);
return (
<tr
@@ -137,17 +140,19 @@ export default function QuotaTable({ quotas = [] }) {
{/* Reset Time */}
<td className="py-2 px-3">
{countdown !== "-" || resetDisplay ? (
{countdown !== t("notAvailableSymbol") || resetDisplay ? (
<div className="space-y-0.5">
{countdown !== "-" && (
<div className="text-sm text-text-primary font-medium">in {countdown}</div>
{countdown !== t("notAvailableSymbol") && (
<div className="text-sm text-text-primary font-medium">
{t("inDuration", { duration: countdown })}
</div>
)}
{resetDisplay && (
<div className="text-xs text-text-muted">{resetDisplay}</div>
)}
</div>
) : (
<div className="text-sm text-text-muted italic">N/A</div>
<div className="text-sm text-text-muted italic">{t("notApplicable")}</div>
)}
</td>
</tr>

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import Image from "next/image";
import { parseQuotaData, calculatePercentage, normalizePlanTier } from "./utils";
@@ -21,13 +23,15 @@ const PROVIDER_CONFIG = {
};
const TIER_FILTERS = [
{ key: "all", label: "All" },
{ key: "enterprise", label: "Enterprise" },
{ key: "business", label: "Business" },
{ key: "ultra", label: "Ultra" },
{ key: "pro", label: "Pro" },
{ key: "free", label: "Free" },
{ key: "unknown", label: "Unknown" },
{ key: "all", labelKey: "tierAll" },
{ key: "enterprise", labelKey: "tierEnterprise" },
{ key: "team", labelKey: "tierTeam" },
{ key: "business", labelKey: "tierBusiness" },
{ key: "ultra", labelKey: "tierUltra" },
{ key: "pro", labelKey: "tierPro" },
{ key: "plus", labelKey: "tierPlus" },
{ key: "free", labelKey: "tierFree" },
{ key: "unknown", labelKey: "tierUnknown" },
];
// Short model display names for quota bars
@@ -79,6 +83,7 @@ function formatCountdown(resetAt) {
}
export default function ProviderLimits() {
const t = useTranslations("usage");
const [connections, setConnections] = useState([]);
const [quotaData, setQuotaData] = useState({});
const [loading, setLoading] = useState({});
@@ -249,6 +254,7 @@ export default function ProviderLimits() {
const counts = {
all: sortedConnections.length,
enterprise: 0,
team: 0,
business: 0,
ultra: 0,
pro: 0,
@@ -283,9 +289,9 @@ export default function ProviderLimits() {
<Card padding="lg">
<div className="text-center py-12">
<span className="material-symbols-outlined text-[64px] opacity-15">cloud_off</span>
<h3 className="mt-4 text-lg font-semibold text-text-main">No Providers Connected</h3>
<h3 className="mt-4 text-lg font-semibold text-text-main">{t("noProviders")}</h3>
<p className="mt-2 text-sm text-text-muted max-w-[400px] mx-auto">
Connect to providers with OAuth to track your API quota limits and usage.
{t("connectProvidersForQuota")}
</p>
</div>
</Card>
@@ -297,12 +303,11 @@ export default function ProviderLimits() {
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-text-main m-0">Provider Limits</h2>
<h2 className="text-lg font-semibold text-text-main m-0">{t("providerLimits")}</h2>
<span className="text-[13px] text-text-muted">
{visibleConnections.length} account{visibleConnections.length !== 1 ? "s" : ""}
{visibleConnections.length !== sortedConnections.length
? ` (filtered from ${sortedConnections.length})`
: ""}
{t("accountsCount", { count: visibleConnections.length })}
{visibleConnections.length !== sortedConnections.length &&
` ${t("filteredFromCount", { count: sortedConnections.length })}`}
</span>
</div>
@@ -319,7 +324,7 @@ export default function ProviderLimits() {
>
{autoRefresh ? "toggle_on" : "toggle_off"}
</span>
Auto-refresh
{t("autoRefresh")}
{autoRefresh && <span className="text-xs text-text-muted">({countdown}s)</span>}
</button>
@@ -333,7 +338,7 @@ export default function ProviderLimits() {
>
refresh
</span>
Refresh All
{t("refreshAll")}
</button>
</div>
</div>
@@ -356,7 +361,7 @@ export default function ProviderLimits() {
color: active ? "var(--primary, #E54D5E)" : "var(--text-muted)",
}}
>
<span>{tier.label}</span>
<span>{t(tier.labelKey)}</span>
<span className="opacity-85">{tierCounts[tier.key] || 0}</span>
</button>
);
@@ -370,10 +375,10 @@ export default function ProviderLimits() {
className="items-center px-4 py-2.5 border-b border-white/[0.06] text-[11px] font-semibold uppercase tracking-wider text-text-muted"
style={{ display: "grid", gridTemplateColumns: "280px 1fr 100px 48px" }}
>
<div>Account</div>
<div>Model Quotas</div>
<div className="text-center">Last Used</div>
<div className="text-center">Actions</div>
<div>{t("account")}</div>
<div>{t("modelQuotas")}</div>
<div className="text-center">{t("lastUsed")}</div>
<div className="text-center">{t("actions")}</div>
</div>
{visibleConnections.map((conn, idx) => {
@@ -411,7 +416,13 @@ export default function ProviderLimits() {
{conn.name || config.label}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span title={quota?.plan ? `Raw plan: ${quota.plan}` : "No plan from provider"}>
<span
title={
quota?.plan
? t("rawPlanWithValue", { plan: quota.plan })
: t("noPlanFromProvider")
}
>
<Badge variant={tierMeta.variant} size="sm" dot>
{tierMeta.label}
</Badge>
@@ -428,7 +439,7 @@ export default function ProviderLimits() {
<span className="material-symbols-outlined animate-spin text-[14px]">
progress_activity
</span>
Loading...
{t("loadingQuotas")}
</div>
) : error ? (
<div className="flex items-center gap-1.5 text-xs text-red-500">
@@ -488,7 +499,7 @@ export default function ProviderLimits() {
);
})
) : (
<div className="text-xs text-text-muted italic">No quota data</div>
<div className="text-xs text-text-muted italic">{t("noQuotaData")}</div>
)}
</div>
@@ -508,7 +519,7 @@ export default function ProviderLimits() {
<button
onClick={() => refreshProvider(conn.id, conn.provider)}
disabled={isLoading}
title="Refresh quota"
title={t("refreshQuota")}
className="p-1 rounded-md border-none bg-transparent cursor-pointer disabled:cursor-not-allowed disabled:opacity-30 opacity-60 hover:opacity-100 flex items-center justify-center transition-opacity duration-150"
>
<span
@@ -524,8 +535,11 @@ export default function ProviderLimits() {
{visibleConnections.length === 0 && (
<div className="py-6 px-4 text-center text-text-muted text-[13px]">
No accounts found for tier filter{" "}
<strong>{TIER_FILTERS.find((t) => t.key === tierFilter)?.label || tierFilter}</strong>.
{t("noAccountsForTierFilter")}{" "}
<strong>
{t(TIER_FILTERS.find((tier) => tier.key === tierFilter)?.labelKey || "tierUnknown")}
</strong>
.
</div>
)}
</div>

View File

@@ -202,7 +202,7 @@ export function parseQuotaData(provider, data) {
/**
* Normalize provider-specific plan labels into a shared tier taxonomy.
* Supported tiers: enterprise, business, ultra, pro, free, unknown.
* Supported tiers: enterprise, business, team, ultra, pro, free, unknown.
*/
export function normalizePlanTier(plan) {
const raw = typeof plan === "string" ? plan.trim() : "";
@@ -213,15 +213,15 @@ export function normalizePlanTier(plan) {
const upper = raw.toUpperCase();
if (upper.includes("ENTERPRISE") || upper.includes("CORP") || upper.includes("ORG")) {
return { key: "enterprise", label: "Enterprise", variant: "info", rank: 6, raw };
return { key: "enterprise", label: "Enterprise", variant: "info", rank: 7, raw };
}
if (
upper.includes("BUSINESS") ||
upper.includes("TEAM") ||
upper.includes("STANDARD") ||
upper.includes("BIZ")
) {
// Team plan (e.g., ChatGPT Team, GitHub Team)
if (upper.includes("TEAM") || upper.includes("CHATGPTTEAM")) {
return { key: "team", label: "Team", variant: "info", rank: 6, raw };
}
if (upper.includes("BUSINESS") || upper.includes("STANDARD") || upper.includes("BIZ")) {
return { key: "business", label: "Business", variant: "warning", rank: 5, raw };
}
@@ -229,15 +229,14 @@ export function normalizePlanTier(plan) {
return { key: "ultra", label: "Ultra", variant: "success", rank: 4, raw };
}
if (
upper.includes("PRO") ||
upper.includes("PLUS") ||
upper.includes("PREMIUM") ||
upper.includes("PAID")
) {
if (upper.includes("PRO") || upper.includes("PREMIUM")) {
return { key: "pro", label: "Pro", variant: "primary", rank: 3, raw };
}
if (upper.includes("PLUS") || upper.includes("PAID")) {
return { key: "plus", label: "Plus", variant: "secondary", rank: 2, raw };
}
if (
upper.includes("FREE") ||
upper.includes("INDIVIDUAL") ||

View File

@@ -1,9 +1,13 @@
"use client";
import { useTranslations } from "next-intl";
import { useState, useEffect, useCallback } from "react";
import { Card } from "@/shared/components";
export default function RateLimitStatus() {
const t = useTranslations("usage");
const tc = useTranslations("common");
const [data, setData] = useState({ lockouts: [], cacheStats: null });
const [loading, setLoading] = useState(true);
@@ -24,14 +28,14 @@ export default function RateLimitStatus() {
}, [load]);
const formatMs = (ms) => {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${Math.ceil(ms / 1000)}s`;
return `${Math.ceil(ms / 60000)}m`;
if (ms < 1000) return t("durationMillisecondsShort", { value: ms });
if (ms < 60000) return t("durationSecondsShort", { value: Math.ceil(ms / 1000) });
return t("durationMinutesShort", { value: Math.ceil(ms / 60000) });
};
return (
<div className="flex flex-col gap-4">
{/* Model Lockouts */}
{/* {t("modelLockouts")} */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-orange-500/10 text-orange-500">
@@ -40,22 +44,25 @@ export default function RateLimitStatus() {
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">Model Lockouts</h3>
<p className="text-sm text-text-muted">Per-model rate limit locks Auto-refresh 10s</p>
<h3 className="text-lg font-semibold">{t("modelLockouts")}</h3>
<p className="text-sm text-text-muted">{t("lockoutsAutoRefreshHint")}</p>
</div>
{data.lockouts.length > 0 && (
<span className="px-2.5 py-1 rounded-full text-xs font-semibold bg-orange-500/10 text-orange-400 border border-orange-500/20">
{data.lockouts.length} locked
{t("lockedCount", { count: data.lockouts.length })}
</span>
)}
</div>
{data.lockouts.length === 0 ? (
<div className="text-center py-6 text-text-muted">
<span className="material-symbols-outlined text-[32px] mb-2 block opacity-40">
<span
className="material-symbols-outlined text-[32px] mb-2 block opacity-40"
aria-hidden="true"
>
lock_open
</span>
<p className="text-sm">No models currently locked</p>
<p className="text-sm">{t("noLockouts")}</p>
</div>
) : (
<div className="flex flex-col gap-2">
@@ -66,20 +73,30 @@ export default function RateLimitStatus() {
bg-orange-500/5 border border-orange-500/15"
>
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-[16px] text-orange-400">
<span
className="material-symbols-outlined text-[16px] text-orange-400"
aria-hidden="true"
>
lock
</span>
<div>
<p className="text-sm font-medium">{lock.model}</p>
<p className="text-xs text-text-muted">
Account:{" "}
<span className="font-mono">{lock.accountId?.slice(0, 12) || "N/A"}</span>
{lock.reason && <> {lock.reason}</>}
{t("account")}:{" "}
<span className="font-mono">
{lock.accountId?.slice(0, 12) || tc("none")}
</span>
{lock.reason && (
<>
{t("reasonSeparator")}
{lock.reason}
</>
)}
</p>
</div>
</div>
<span className="text-xs font-mono tabular-nums text-orange-400">
{formatMs(lock.remainingMs)} left
{t("timeLeft", { time: formatMs(lock.remainingMs) })}
</span>
</div>
))}

View File

@@ -1,9 +1,12 @@
"use client";
import { useTranslations } from "next-intl";
import { useState, useEffect, useCallback } from "react";
import { Card } from "@/shared/components";
export default function SessionsTab() {
const t = useTranslations("usage");
const [data, setData] = useState({ count: 0, sessions: [] });
const [loading, setLoading] = useState(true);
@@ -11,7 +14,8 @@ export default function SessionsTab() {
try {
const res = await fetch("/api/sessions");
if (res.ok) setData(await res.json());
} catch {} finally {
} catch {
} finally {
setLoading(false);
}
}, []);
@@ -23,9 +27,9 @@ export default function SessionsTab() {
}, [loadSessions]);
const formatAge = (ms) => {
if (ms < 60000) return `${Math.floor(ms / 1000)}s`;
if (ms < 3600000) return `${Math.floor(ms / 60000)}m`;
return `${Math.floor(ms / 3600000)}h`;
if (ms < 60000) return t("durationSecondsShort", { value: Math.floor(ms / 1000) });
if (ms < 3600000) return t("durationMinutesShort", { value: Math.floor(ms / 60000) });
return t("durationHoursShort", { value: Math.floor(ms / 3600000) });
};
return (
@@ -37,41 +41,53 @@ export default function SessionsTab() {
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">Active Sessions</h3>
<p className="text-sm text-text-muted">Tracked via request fingerprinting Auto-refresh 5s</p>
<h3 className="text-lg font-semibold">{t("activeSessions")}</h3>
<p className="text-sm text-text-muted">{t("sessionsTrackedHint")}</p>
</div>
<div className="flex items-center gap-2">
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-cyan-500/10 border border-cyan-500/20">
<span className="w-2 h-2 rounded-full bg-cyan-500 animate-pulse" />
<span className="text-sm font-semibold tabular-nums text-cyan-400">
{data.count}
</span>
<span className="text-sm font-semibold tabular-nums text-cyan-400">{data.count}</span>
</span>
</div>
</div>
{data.sessions.length === 0 ? (
<div className="text-center py-8 text-text-muted">
<span className="material-symbols-outlined text-[40px] mb-2 block opacity-40">
<span
className="material-symbols-outlined text-[40px] mb-2 block opacity-40"
aria-hidden="true"
>
fingerprint
</span>
<p className="text-sm">No active sessions</p>
<p className="text-xs mt-1">Sessions appear as requests flow through the proxy</p>
<p className="text-sm">{t("noSessions")}</p>
<p className="text-xs mt-1">{t("sessionsHint")}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/30">
<th className="text-left py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">Session</th>
<th className="text-left py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">Age</th>
<th className="text-right py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">Requests</th>
<th className="text-left py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">Connection</th>
<th className="text-left py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("session")}
</th>
<th className="text-left py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("age")}
</th>
<th className="text-right py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("requests")}
</th>
<th className="text-left py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("connection")}
</th>
</tr>
</thead>
<tbody>
{data.sessions.map((s) => (
<tr key={s.sessionId} className="border-b border-border/10 hover:bg-surface/20 transition-colors">
<tr
key={s.sessionId}
className="border-b border-border/10 hover:bg-surface/20 transition-colors"
>
<td className="py-2.5 px-3">
<span className="font-mono text-xs px-2 py-1 rounded bg-surface/40 text-text-muted">
{s.sessionId.slice(0, 12)}
@@ -83,9 +99,11 @@ export default function SessionsTab() {
</td>
<td className="py-2.5 px-3">
{s.connectionId ? (
<span className="text-xs font-mono text-cyan-400">{s.connectionId.slice(0, 10)}</span>
<span className="text-xs font-mono text-cyan-400">
{s.connectionId.slice(0, 10)}
</span>
) : (
<span className="text-text-muted/40"></span>
<span className="text-text-muted/40">{t("notAvailableSymbol")}</span>
)}
</td>
</tr>

View File

@@ -1,17 +1,20 @@
"use client";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components";
export default function UsagePage() {
const t = useTranslations("usage");
const [activeTab, setActiveTab] = useState("logs");
return (
<div className="flex flex-col gap-6">
<SegmentedControl
options={[
{ value: "logs", label: "Logger" },
{ value: "proxy-logs", label: "Proxy" },
{ value: "logs", label: t("loggerTab") },
{ value: "proxy-logs", label: t("proxyTab") },
]}
value={activeTab}
onChange={setActiveTab}

View File

@@ -59,11 +59,23 @@ export async function GET() {
try {
const statuses = {};
// Run all runtime checks in parallel
// Run all runtime checks in parallel with individual timeouts
const RUNTIME_CHECK_TIMEOUT = 5000; // 5s per tool max
await Promise.all(
CLI_TOOL_IDS.map(async (toolId) => {
try {
const runtime = await getCliRuntimeStatus(toolId);
const runtime = (await Promise.race([
getCliRuntimeStatus(toolId),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), RUNTIME_CHECK_TIMEOUT)
),
])) as {
installed: boolean;
runnable: boolean;
command?: string;
commandPath?: string;
reason?: string;
};
statuses[toolId] = {
installed: runtime.installed,
runnable: runtime.runnable,
@@ -75,7 +87,7 @@ export async function GET() {
statuses[toolId] = {
installed: false,
runnable: false,
reason: error.message,
reason: error.message || "Check failed",
};
}
})

View File

@@ -1,8 +1,71 @@
import { NextResponse } from "next/server";
import { deleteApiKey, isCloudEnabled } from "@/lib/localDb";
import {
deleteApiKey,
getApiKeyById,
updateApiKeyPermissions,
isCloudEnabled,
} from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
// GET /api/keys/[id] - Get single API key
export async function GET(request, { params }) {
try {
const { id } = await params;
const key = await getApiKeyById(id);
if (!key) {
return NextResponse.json({ error: "Key not found" }, { status: 404 });
}
// Mask the key value
return NextResponse.json({
...key,
key: key.key ? key.key.slice(0, 8) + "****" + key.key.slice(-4) : null,
});
} catch (error) {
console.log("Error fetching key:", error);
return NextResponse.json({ error: "Failed to fetch key" }, { status: 500 });
}
}
// PATCH /api/keys/[id] - Update API key permissions
export async function PATCH(request, { params }) {
try {
const { id } = await params;
const body = await request.json();
const { allowedModels } = body;
// Validate allowedModels is an array
if (!Array.isArray(allowedModels)) {
return NextResponse.json({ error: "allowedModels must be an array" }, { status: 400 });
}
// Validate each model ID is a string
for (const model of allowedModels) {
if (typeof model !== "string") {
return NextResponse.json({ error: "Each model ID must be a string" }, { status: 400 });
}
}
const updated = await updateApiKeyPermissions(id, allowedModels);
if (!updated) {
return NextResponse.json({ error: "Key not found" }, { status: 404 });
}
// Auto sync to Cloud if enabled
await syncKeysToCloudIfEnabled();
return NextResponse.json({
message: "Permissions updated successfully",
allowedModels,
});
} catch (error) {
console.log("Error updating key permissions:", error);
return NextResponse.json({ error: "Failed to update permissions" }, { status: 500 });
}
}
// DELETE /api/keys/[id] - Delete API key
export async function DELETE(request, { params }) {
try {

View File

@@ -2,10 +2,16 @@ import { NextResponse } from "next/server";
import { getModelAliases, setModelAlias, deleteModelAlias, isCloudEnabled } from "@/models";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { isAuthenticated } from "@/shared/utils/apiAuth";
// GET /api/models/alias - Get all aliases
export async function GET() {
export async function GET(request) {
try {
// Require authentication for security
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
}
const aliases = await getModelAliases();
return NextResponse.json({ aliases });
} catch (error) {
@@ -17,6 +23,11 @@ export async function GET() {
// PUT /api/models/alias - Set model alias
export async function PUT(request) {
try {
// Require authentication for security
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
}
const body = await request.json();
const { model, alias } = body;
@@ -37,6 +48,11 @@ export async function PUT(request) {
// DELETE /api/models/alias?alias=xxx - Delete alias
export async function DELETE(request) {
try {
// Require authentication for security
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const alias = searchParams.get("alias");

View File

@@ -10,6 +10,8 @@ import { createProviderConnection, isCloudEnabled } from "@/models";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { startLocalServer } from "@/lib/oauth/utils/server";
import { getProxyConfig } from "@/lib/localDb";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
// Use globalThis to persist callback server state across Next.js HMR reloads
if (!globalThis.__codexCallbackState) {
@@ -23,7 +25,10 @@ if (!globalThis.__codexCallbackState) {
// GET /api/oauth/[provider]/authorize - Generate auth URL
// GET /api/oauth/[provider]/device-code - Request device code (for device_code flow)
export async function GET(request: Request, { params }: { params: Promise<{ provider: string; action: string }> }) {
export async function GET(
request: Request,
{ params }: { params: Promise<{ provider: string; action: string }> }
) {
try {
const { provider, action } = await params;
const { searchParams } = new URL(request.url);
@@ -141,7 +146,10 @@ async function handleStartCallbackServer(provider: string, searchParams: URLSear
// POST /api/oauth/[provider]/exchange - Exchange code for tokens and save
// POST /api/oauth/[provider]/poll - Poll for token (device_code flow)
export async function POST(request: Request, { params }: { params: Promise<{ provider: string; action: string }> }) {
export async function POST(
request: Request,
{ params }: { params: Promise<{ provider: string; action: string }> }
) {
try {
const { provider, action } = await params;
const body = await request.json();
@@ -153,8 +161,14 @@ export async function POST(request: Request, { params }: { params: Promise<{ pro
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// Exchange code for tokens
const tokenData = await exchangeTokens(provider, code, redirectUri, codeVerifier, state);
// Resolve proxy for this provider (provider-level → global → direct)
const proxyConfig = await getProxyConfig();
const proxy = proxyConfig.providers?.[provider] || proxyConfig.global || null;
// Exchange code for tokens (through proxy if configured)
const tokenData = await runWithProxyContext(proxy, () =>
exchangeTokens(provider, code, redirectUri, codeVerifier, state)
);
// Save to database
const connection: any = await createProviderConnection({
@@ -289,13 +303,13 @@ export async function POST(request: Request, { params }: { params: Promise<{ pro
}
try {
// Exchange code for tokens
const tokenData = await exchangeTokens(
provider,
params.code,
redirectUri,
codeVerifier,
params.state
// Resolve proxy for this provider
const proxyConfig = await getProxyConfig();
const proxy = proxyConfig.providers?.[provider] || proxyConfig.global || null;
// Exchange code for tokens (through proxy if configured)
const tokenData = await runWithProxyContext(proxy, () =>
exchangeTokens(provider, params.code, redirectUri, codeVerifier, params.state)
);
// Save to database

View File

@@ -4,6 +4,7 @@ import {
addCustomModel,
removeCustomModel,
} from "@/lib/localDb";
import { isAuthenticated } from "@/shared/utils/apiAuth";
/**
* GET /api/provider-models?provider=<id>
@@ -11,6 +12,14 @@ import {
*/
export async function GET(request) {
try {
// Require authentication for security
if (!(await isAuthenticated(request))) {
return Response.json(
{ error: { message: "Authentication required", type: "invalid_api_key" } },
{ status: 401 }
);
}
const { searchParams } = new URL(request.url);
const provider = searchParams.get("provider");
@@ -31,6 +40,14 @@ export async function GET(request) {
*/
export async function POST(request) {
try {
// Require authentication for security
if (!(await isAuthenticated(request))) {
return Response.json(
{ error: { message: "Authentication required", type: "invalid_api_key" } },
{ status: 401 }
);
}
const body = await request.json();
const { provider, modelId, modelName, source } = body;
@@ -56,6 +73,14 @@ export async function POST(request) {
*/
export async function DELETE(request) {
try {
// Require authentication for security
if (!(await isAuthenticated(request))) {
return Response.json(
{ error: { message: "Authentication required", type: "invalid_api_key" } },
{ status: 401 }
);
}
const { searchParams } = new URL(request.url);
const provider = searchParams.get("provider");
const modelId = searchParams.get("model");

View File

@@ -198,6 +198,14 @@ const PROVIDER_MODELS_CONFIG = {
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
kilocode: {
url: "https://api.kilo.ai/api/openrouter/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
};
/**
@@ -220,8 +228,17 @@ export async function GET(request, { params }) {
{ status: 400 }
);
}
const url = `${baseUrl.replace(/\/$/, "")}/models`;
const response = await fetch(url, {
let modelsUrl = baseUrl.replace(/\/$/, "");
if (modelsUrl.endsWith("/chat/completions")) {
modelsUrl = modelsUrl.slice(0, -17) + "/models";
} else if (modelsUrl.endsWith("/completions")) {
modelsUrl = modelsUrl.slice(0, -12) + "/models";
} else {
modelsUrl = `${modelsUrl}/models`;
}
const response = await fetch(modelsUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",

View File

@@ -1,5 +1,10 @@
import { NextResponse } from "next/server";
import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb";
import {
getProviderConnectionById,
updateProviderConnection,
isCloudEnabled,
resolveProxyForConnection,
} from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { validateProviderApiKey } from "@/lib/providers/validation";
@@ -8,6 +13,7 @@ import { getCliRuntimeStatus } from "@/shared/services/cliRuntime";
import { getAccessToken } from "@omniroute/open-sse/services/tokenRefresh.ts";
import { saveCallLog } from "@/lib/usageDb";
import { logProxyEvent } from "@/lib/proxyLogger";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
// OAuth provider test endpoints
const OAUTH_TEST_CONFIG = {
@@ -91,7 +97,12 @@ function toSafeMessage(value: any, fallback = "Unknown error"): string {
return trimmed || fallback;
}
function makeDiagnosis(type: string, source: string, message: string | null, code: string | null = null) {
function makeDiagnosis(
type: string,
source: string,
message: string | null,
code: string | null = null
) {
return {
type,
source,
@@ -100,7 +111,17 @@ function makeDiagnosis(type: string, source: string, message: string | null, cod
};
}
function classifyFailure({ error, statusCode = null, refreshFailed = false, unsupported = false }: { error: string; statusCode?: number | null; refreshFailed?: boolean; unsupported?: boolean }) {
function classifyFailure({
error,
statusCode = null,
refreshFailed = false,
unsupported = false,
}: {
error: string;
statusCode?: number | null;
refreshFailed?: boolean;
unsupported?: boolean;
}) {
const message = toSafeMessage(error, "Connection test failed");
const normalized = message.toLowerCase();
const numericStatus = Number.isFinite(statusCode) ? Number(statusCode) : null;
@@ -510,6 +531,14 @@ export async function testSingleConnection(connectionId: string) {
return { valid: false, error: "Connection not found", diagnosis: null, latencyMs: 0 };
}
// Resolve proxy for this connection (key → combo → provider → global → direct)
let proxyInfo: any = null;
try {
proxyInfo = await resolveProxyForConnection(connectionId);
} catch (proxyErr: any) {
console.log(`[ConnectionTest] Failed to resolve proxy for ${connectionId}:`, proxyErr?.message);
}
let result;
const startTime = Date.now();
const runtime = await getProviderRuntimeStatus(connection.provider);
@@ -522,9 +551,13 @@ export async function testSingleConnection(connectionId: string) {
diagnosis: (runtime as any).diagnosis,
};
} else if (connection.authType === "apikey") {
result = await testApiKeyConnection(connection);
result = await runWithProxyContext(proxyInfo?.proxy || null, () =>
testApiKeyConnection(connection)
);
} else {
result = await testOAuthConnection(connection);
result = await runWithProxyContext(proxyInfo?.proxy || null, () =>
testOAuthConnection(connection)
);
}
const latencyMs = Date.now() - startTime;
@@ -591,9 +624,9 @@ export async function testSingleConnection(connectionId: string) {
try {
logProxyEvent({
status: result.valid ? "success" : "error",
proxy: null,
level: "provider-test",
levelId: null,
proxy: proxyInfo?.proxy || null,
level: proxyInfo?.level || "provider-test",
levelId: proxyInfo?.levelId || null,
provider: connection.provider,
targetUrl: `${connection.provider}/connection-test`,
latencyMs,

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
import { clearHealthCheckLogCache } from "@/lib/tokenHealthCheck";
import bcrypt from "bcryptjs";
import { updateSettingsSchema, validateBody } from "@/shared/validation/schemas";
import { getRuntimePorts } from "@/lib/runtime/ports";
@@ -66,6 +67,12 @@ export async function PATCH(request) {
}
const settings = await updateSettings(body);
// Clear health check log cache if that setting was updated
if ("hideHealthCheckLogs" in body) {
clearHealthCheckLogCache();
}
const { password, ...safeSettings } = settings;
return NextResponse.json(safeSettings);
} catch (error) {

View File

@@ -4,6 +4,7 @@ import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/serv
import { parseSpeechModel } from "@omniroute/open-sse/config/audioRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
/**
* Handle CORS preflight
@@ -41,6 +42,10 @@ export async function POST(request) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
}
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;
const { provider } = parseSpeechModel(body.model);
if (!provider) {
return errorResponse(

View File

@@ -4,6 +4,7 @@ import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/serv
import { parseTranscriptionModel } from "@omniroute/open-sse/config/audioRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
/**
* Handle CORS preflight
@@ -43,6 +44,10 @@ export async function POST(request) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
}
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, model as string);
if (policy.rejection) return policy.rejection;
const { provider } = parseTranscriptionModel(model);
if (!provider) {
return errorResponse(

View File

@@ -9,6 +9,7 @@ import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import * as log from "@/sse/utils/logger";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
/**
* Handle CORS preflight
@@ -78,6 +79,10 @@ export async function POST(request) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing input");
}
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;
// Parse model to get provider
const { provider } = parseEmbeddingModel(body.model);
if (!provider) {

View File

@@ -6,6 +6,7 @@ import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import * as log from "@/sse/utils/logger";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
/**
* Handle CORS preflight
@@ -75,6 +76,10 @@ export async function POST(request) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid prompt: expected a non-empty string");
}
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;
// Parse model to get provider
const { provider } = parseImageModel(body.model);
if (!provider) {

View File

@@ -1,8 +1,14 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { getProviderConnections, getCombos, getAllCustomModels, getSettings } from "@/lib/localDb";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderConnections,
getCombos,
getAllCustomModels,
getSettings,
getProviderNodes,
} from "@/lib/localDb";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.ts";
import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.ts";
import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry.ts";
@@ -97,16 +103,25 @@ export async function OPTIONS() {
*/
export async function GET(request: Request) {
try {
// Issue #100: Optionally require API key for /models (security hardening)
// When enabled, unauthenticated requests get 404 to hide endpoint existence
// Issue #100: Optionally require authentication for /models (security hardening)
// When enabled, unauthenticated requests get 401 with proper error response.
// Supports API key (Bearer token) for external clients and JWT cookie for dashboard.
let settings: Record<string, any> = {};
try {
settings = await getSettings();
} catch {}
if (settings.requireAuthForModels === true) {
const apiKey = extractApiKey(request);
if (!apiKey || !(await isValidApiKey(apiKey))) {
return new Response("Not Found", { status: 404 });
if (!(await isAuthenticated(request))) {
return Response.json(
{
error: {
message: "Authentication required",
type: "invalid_request_error",
code: "invalid_api_key",
},
},
{ status: 401 }
);
}
}
@@ -130,6 +145,26 @@ export async function GET(request: Request) {
console.log("Could not fetch providers, showing only combos/custom models");
}
// Get provider nodes (for compatible providers with custom prefixes)
let providerNodes = [];
try {
providerNodes = await getProviderNodes();
} catch (e) {
console.log("Could not fetch provider nodes");
}
// Build map of provider node ID to prefix and type for compatible providers
const providerIdToPrefix: Record<string, string> = {};
const nodeIdToProviderType: Record<string, string> = {};
for (const node of providerNodes) {
if (node.prefix) {
providerIdToPrefix[node.id] = node.prefix;
}
if (node.type) {
nodeIdToProviderType[node.id] = node.type;
}
}
// Get combos
let combos = [];
try {
@@ -279,14 +314,19 @@ export async function GET(request: Request) {
try {
const customModelsMap: Record<string, any[]> = await getAllCustomModels();
for (const [providerId, providerCustomModels] of Object.entries(customModelsMap)) {
const alias = providerIdToAlias[providerId] || providerId;
// For compatible providers, use the prefix from provider nodes
const prefix = providerIdToPrefix[providerId];
const alias = prefix || providerIdToAlias[providerId] || providerId;
const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId;
// Only include if provider is active — check alias, canonical ID, or raw providerId
// (raw check needed for OpenAI-compatible providers whose ID isn't in the alias map)
// Only include if provider is active — check alias, canonical ID, raw providerId,
// or the parent provider type (for compatible providers whose node ID is a UUID)
const parentProviderType = nodeIdToProviderType[providerId];
if (
!activeAliases.has(alias) &&
!activeAliases.has(canonicalProviderId) &&
!activeAliases.has(providerId)
!activeAliases.has(providerId) &&
!(parentProviderType && activeAliases.has(parentProviderType))
)
continue;
@@ -306,7 +346,8 @@ export async function GET(request: Request) {
custom: true,
});
if (canonicalProviderId !== alias) {
// Only add provider-prefixed version if different from alias
if (canonicalProviderId !== alias && !prefix) {
const providerPrefixedId = `${canonicalProviderId}/${model.id}`;
if (models.some((m) => m.id === providerPrefixedId)) continue;
models.push({

View File

@@ -4,6 +4,7 @@ import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/serv
import { parseModerationModel } from "@omniroute/open-sse/config/moderationRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
/**
* Handle CORS preflight
@@ -38,6 +39,11 @@ export async function POST(request) {
}
const model = body.model || "omni-moderation-latest";
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, model);
if (policy.rejection) return policy.rejection;
const { provider } = parseModerationModel(model);
// Default to openai if no provider prefix

View File

@@ -5,6 +5,7 @@ import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { handleEmbedding } from "@omniroute/open-sse/handlers/embeddings.ts";
import * as log from "@/sse/utils/logger";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
/**
* Handle CORS preflight
@@ -53,6 +54,10 @@ export async function POST(request, { params }) {
body.model = `${providerAlias}/${body.model}`;
}
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;
// Validate provider match
if (body.model) {
const prefix = body.model.split("/")[0];

View File

@@ -6,6 +6,7 @@ import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/serv
import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry.ts";
import * as log from "@/sse/utils/logger";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
/**
* Handle CORS preflight
@@ -60,6 +61,10 @@ export async function POST(request, { params }) {
body.model = `${rawProvider}/${body.model}`;
}
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;
// Validate provider match
const modelProvider = body.model.split("/")[0];
if (modelProvider !== rawProvider) {

View File

@@ -4,6 +4,7 @@ import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/serv
import { parseRerankModel } from "@omniroute/open-sse/config/rerankRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
/**
* Handle CORS preflight
@@ -44,6 +45,10 @@ export async function POST(request) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
}
// Enforce API key policies (model restrictions + budget limits)
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;
const { provider } = parseRerankModel(body.model);
if (!provider) {
return errorResponse(

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
@@ -9,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");
@@ -94,8 +97,8 @@ function CallbackContent() {
progress_activity
</span>
</div>
<h1 className="text-xl font-semibold mb-2">Processing...</h1>
<p className="text-text-muted">Please wait while we complete the authorization.</p>
<h1 className="text-xl font-semibold mb-2">{t("processing")}</h1>
<p className="text-text-muted">{t("pleaseWait")}</p>
</>
)}
@@ -106,11 +109,9 @@ function CallbackContent() {
check_circle
</span>
</div>
<h1 className="text-xl font-semibold mb-2">Authorization Successful!</h1>
<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>
</>
)}
@@ -120,10 +121,8 @@ function CallbackContent() {
<div className="size-16 mx-auto mb-4 rounded-full bg-yellow-100 dark:bg-yellow-900/30 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-yellow-600">info</span>
</div>
<h1 className="text-xl font-semibold mb-2">Copy This URL</h1>
<p className="text-text-muted mb-4">
Please copy the URL from the address bar and paste it in the application.
</p>
<h1 className="text-xl font-semibold mb-2">{t("copyUrl")}</h1>
<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 : ""}
@@ -141,6 +140,7 @@ function CallbackContent() {
* Receives callback from OAuth providers and sends data back via multiple methods
*/
export default function CallbackPage() {
const t = useTranslations("auth");
return (
<Suspense
fallback={
@@ -151,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

@@ -1,101 +1,76 @@
import Link from "next/link";
import { useTranslations } from "next-intl";
import { APP_CONFIG } from "@/shared/constants/config";
import { FREE_PROVIDERS, OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/providers";
const endpointRows = [
{
path: "/v1/chat/completions",
method: "POST",
note: "OpenAI-compatible chat endpoint (default).",
},
{ path: "/v1/responses", method: "POST", note: "Responses API endpoint (Codex, o-series)." },
{ path: "/v1/models", method: "GET", note: "Model catalog for all connected providers." },
{
path: "/v1/audio/transcriptions",
method: "POST",
note: "Audio transcription (Deepgram, AssemblyAI).",
},
{ path: "/v1/images/generations", method: "POST", note: "Image generation (NanoBanana)." },
{ path: "/chat/completions", method: "POST", note: "Rewrite helper for clients without /v1." },
{ path: "/responses", method: "POST", note: "Rewrite helper for Responses without /v1." },
{ path: "/models", method: "GET", note: "Rewrite helper for model discovery without /v1." },
];
const ENDPOINT_ROWS = [
{ path: "/v1/chat/completions", method: "POST", noteKey: "endpointChatNote" },
{ path: "/v1/responses", method: "POST", noteKey: "endpointResponsesNote" },
{ path: "/v1/models", method: "GET", noteKey: "endpointModelsNote" },
{ path: "/v1/audio/transcriptions", method: "POST", noteKey: "endpointAudioNote" },
{ path: "/v1/images/generations", method: "POST", noteKey: "endpointImagesNote" },
{ path: "/chat/completions", method: "POST", noteKey: "endpointRewriteChatNote" },
{ path: "/responses", method: "POST", noteKey: "endpointRewriteResponsesNote" },
{ path: "/models", method: "GET", noteKey: "endpointRewriteModelsNote" },
] as const;
const featureItems = [
{
icon: "hub",
title: "Multi-Provider Routing",
text: "Route requests to 30+ AI providers through a single OpenAI-compatible endpoint. Supports chat, responses, audio, and image APIs.",
},
{
icon: "layers",
title: "Combos & Balancing",
text: "Create model combos with fallback chains and balancing strategies: round-robin, priority, random, least-used, and cost-optimized.",
},
{
icon: "bar_chart",
title: "Usage & Cost Tracking",
text: "Real-time token counting, cost calculation per provider/model, and detailed usage breakdown by API key and account.",
},
{
icon: "analytics",
title: "Analytics Dashboard",
text: "Visual analytics with charts for requests, tokens, errors, latency, costs, and model popularity over time.",
},
{
icon: "health_and_safety",
title: "Health Monitoring",
text: "Live health checks, provider status, circuit breaker states, and automatic rate limit detection with exponential backoff.",
},
{
icon: "terminal",
title: "CLI Tools",
text: "Manage IDE configurations, export/import backups, discover codex profiles, and configure settings from the dashboard.",
},
{
icon: "shield",
title: "Security & Policies",
text: "API key authentication, IP filtering, prompt injection guard, domain policies, session management, and audit logging.",
},
{
icon: "cloud_sync",
title: "Cloud Sync",
text: "Sync your configuration to Cloudflare Workers for remote access with encrypted credentials and automatic failover.",
},
];
const FEATURE_ITEMS = [
{ icon: "hub", titleKey: "featureRoutingTitle", textKey: "featureRoutingText" },
{ icon: "layers", titleKey: "featureCombosTitle", textKey: "featureCombosText" },
{ icon: "bar_chart", titleKey: "featureUsageTitle", textKey: "featureUsageText" },
{ icon: "analytics", titleKey: "featureAnalyticsTitle", textKey: "featureAnalyticsText" },
{ icon: "health_and_safety", titleKey: "featureHealthTitle", textKey: "featureHealthText" },
{ icon: "terminal", titleKey: "featureCliTitle", textKey: "featureCliText" },
{ icon: "shield", titleKey: "featureSecurityTitle", textKey: "featureSecurityText" },
{ icon: "cloud_sync", titleKey: "featureCloudSyncTitle", textKey: "featureCloudSyncText" },
] as const;
const useCases = [
{
title: "Single endpoint for many providers",
text: "Point clients to one base URL and route by model prefix (for example: gh/, cc/, kr/, openai/).",
},
{
title: "Fallback and model switching with combos",
text: "Create combo models in Dashboard and keep client config stable while providers rotate internally.",
},
{
title: "Usage, cost and debug visibility",
text: "Track tokens/cost by provider, account and API key in Usage + Analytics tabs.",
},
];
const USE_CASE_ITEMS = [
{ titleKey: "useCaseSingleEndpointTitle", textKey: "useCaseSingleEndpointText" },
{ titleKey: "useCaseFallbackTitle", textKey: "useCaseFallbackText" },
{ titleKey: "useCaseUsageVisibilityTitle", textKey: "useCaseUsageVisibilityText" },
] as const;
const troubleshootingItems = [
"If the client fails with model routing, use explicit provider/model (for example: gh/gpt-5.1-codex).",
"If you receive ambiguous model errors, pick a provider prefix instead of a bare model ID.",
"For GitHub Codex-family models, keep model as gh/<codex-model>; router selects /responses automatically.",
"Use Dashboard > Providers > Test Connection before testing from IDEs or external clients.",
"If a provider shows circuit breaker open, wait for the cooldown or check Health page for details.",
"For OAuth providers, re-authenticate if tokens expire. Check the provider card status indicator.",
];
const TROUBLESHOOTING_KEYS = [
"troubleshootingModelRouting",
"troubleshootingAmbiguousModels",
"troubleshootingCodexFamily",
"troubleshootingTestConnection",
"troubleshootingCircuitBreaker",
"troubleshootingOAuth",
] as const;
const TOC_ITEMS = [
{ href: "#quick-start", labelKey: "quickStart" },
{ href: "#features", labelKey: "features" },
{ href: "#supported-providers", labelKey: "supportedProvidersToc" },
{ href: "#use-cases", labelKey: "commonUseCases" },
{ href: "#client-compatibility", labelKey: "clientCompatibility" },
{ href: "#api-reference", labelKey: "apiReference" },
{ href: "#model-prefixes", labelKey: "modelPrefixes" },
{ href: "#troubleshooting", labelKey: "troubleshooting" },
] as const;
function ProviderTable({
title,
providers,
colorDot,
}: {
title: string;
providers: Record<string, any>;
colorDot: string;
}) {
const t = useTranslations("docs");
const entries = Object.values(providers) as any[];
function ProviderTable({ title, providers, colorDot }: { title: string; providers: Record<string, any>; colorDot: string }) {
const entries: any[] = Object.values(providers);
return (
<div className="rounded-lg border border-border bg-bg p-4">
<div className="flex items-center gap-2 mb-3">
<span className={`size-2.5 rounded-full ${colorDot}`} />
<h3 className="font-semibold">{title}</h3>
<span className="text-xs text-text-muted ml-auto">{entries.length} providers</span>
<span className="text-xs text-text-muted ml-auto">
{t("providersCount", { count: entries.length })}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 text-sm">
{entries.map((p) => (
@@ -115,6 +90,45 @@ function ProviderTable({ title, providers, colorDot }: { title: string; provider
}
export default function DocsPage() {
const t = useTranslations("docs");
const totalProviders =
Object.keys(FREE_PROVIDERS).length +
Object.keys(OAUTH_PROVIDERS).length +
Object.keys(APIKEY_PROVIDERS).length;
const endpointRows = ENDPOINT_ROWS.map((row) => ({
...row,
note: t(row.noteKey),
}));
const featureItems = FEATURE_ITEMS.map((item) => ({
...item,
title: t(item.titleKey),
text: t(item.textKey),
}));
const useCases = USE_CASE_ITEMS.map((item) => ({
...item,
title: t(item.titleKey),
text: t(item.textKey),
}));
const troubleshootingItems = TROUBLESHOOTING_KEYS.map((key) => t(key));
const tocItems = TOC_ITEMS.map((item) => ({ ...item, label: t(item.labelKey) }));
const providerPrefixRows = [
...Object.values(FREE_PROVIDERS).map((p) => ({ ...p, type: "free" as const })),
...Object.values(OAUTH_PROVIDERS).map((p) => ({ ...p, type: "oauth" as const })),
...Object.values(APIKEY_PROVIDERS).map((p) => ({ ...p, type: "apiKey" as const })),
];
const getProviderTypeLabel = (type: "free" | "oauth" | "apiKey") => {
if (type === "free") return t("providerTypeFree");
if (type === "oauth") return t("providerTypeOAuth");
return t("providerTypeApiKey");
};
return (
<div className="min-h-screen bg-bg text-text-main">
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8 py-10 md:py-14 flex flex-col gap-8">
@@ -122,12 +136,13 @@ export default function DocsPage() {
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-wider text-text-muted">
Documentation v{APP_CONFIG.version}
{t("documentationVersion", { version: APP_CONFIG.version })}
</p>
<h1 className="text-3xl md:text-4xl font-bold mt-1">{APP_CONFIG.name} Docs</h1>
<h1 className="text-3xl md:text-4xl font-bold mt-1">
{APP_CONFIG.name} {t("docsLabel")}
</h1>
<p className="text-sm md:text-base text-text-muted mt-2 max-w-3xl">
AI gateway for multi-provider LLMs. One endpoint for OpenAI, Anthropic, Gemini,
GitHub Copilot, Claude Code, Cursor, and 20+ more providers.
{t("docsHeroDescription")}
</p>
</div>
<div className="flex flex-wrap gap-2">
@@ -135,13 +150,13 @@ export default function DocsPage() {
href="/dashboard"
className="px-3 py-2 rounded-lg border border-border text-sm hover:bg-bg transition-colors"
>
Open Dashboard
{t("openDashboard")}
</Link>
<Link
href="/dashboard/endpoint"
className="px-3 py-2 rounded-lg border border-border text-sm hover:bg-bg transition-colors"
>
Endpoint Page
{t("endpointPage")}
</Link>
<a
href="https://github.com/diegosouzapw/OmniRoute"
@@ -149,7 +164,8 @@ export default function DocsPage() {
rel="noopener noreferrer"
className="px-3 py-2 rounded-lg border border-border text-sm hover:bg-bg transition-colors flex items-center gap-1"
>
GitHub <span className="material-symbols-outlined text-[14px]">open_in_new</span>
{t("github")}{" "}
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
</a>
<a
href="https://github.com/diegosouzapw/OmniRoute/issues"
@@ -157,28 +173,18 @@ export default function DocsPage() {
rel="noopener noreferrer"
className="px-3 py-2 rounded-lg border border-border text-sm hover:bg-bg transition-colors"
>
Report Issue
{t("reportIssue")}
</a>
</div>
</div>
</header>
{/* Table of Contents */}
<nav className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-sm font-semibold uppercase tracking-wider text-text-muted mb-3">
On this page
{t("onThisPage")}
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
{[
{ href: "#quick-start", label: "Quick Start" },
{ href: "#features", label: "Features" },
{ href: "#supported-providers", label: "Providers" },
{ href: "#use-cases", label: "Use Cases" },
{ href: "#client-compatibility", label: "Client Compatibility" },
{ href: "#api-reference", label: "API Reference" },
{ href: "#model-prefixes", label: "Model Prefixes" },
{ href: "#troubleshooting", label: "Troubleshooting" },
].map((item) => (
{tocItems.map((item) => (
<a
key={item.href}
href={item.href}
@@ -192,46 +198,43 @@ export default function DocsPage() {
</nav>
<section id="quick-start" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">Quick Start</h2>
<h2 className="text-xl font-semibold">{t("quickStart")}</h2>
<ol className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<li className="rounded-lg border border-border p-3 bg-bg">
<span className="font-semibold">1. Install & run</span>
<span className="font-semibold">{t("quickStartStep1Title")}</span>
<p className="text-text-muted mt-1">
<code className="px-1 rounded bg-bg-subtle">npx omniroute</code> or clone from
GitHub and run <code className="px-1 rounded bg-bg-subtle">npm start</code>.
{t("quickStartStep1Prefix")}{" "}
<code className="px-1 rounded bg-bg-subtle">npx omniroute</code>{" "}
{t("quickStartStep1Middle")}{" "}
<code className="px-1 rounded bg-bg-subtle">npm start</code>.
</p>
</li>
<li className="rounded-lg border border-border p-3 bg-bg">
<span className="font-semibold">2. Create API key</span>
<p className="text-text-muted mt-1">
Go to Endpoint Registered Keys. Generate one key per environment.
</p>
<span className="font-semibold">{t("quickStartStep2Title")}</span>
<p className="text-text-muted mt-1">{t("quickStartStep2Text")}</p>
</li>
<li className="rounded-lg border border-border p-3 bg-bg">
<span className="font-semibold">3. Connect providers</span>
<p className="text-text-muted mt-1">
Add provider accounts via OAuth login, API key, or free-tier auto-connect.
</p>
<span className="font-semibold">{t("quickStartStep3Title")}</span>
<p className="text-text-muted mt-1">{t("quickStartStep3Text")}</p>
</li>
<li className="rounded-lg border border-border p-3 bg-bg">
<span className="font-semibold">4. Set client base URL</span>
<span className="font-semibold">{t("quickStartStep4Title")}</span>
<p className="text-text-muted mt-1">
Point your IDE or API client to{" "}
<code className="px-1 rounded bg-bg-subtle">https://&lt;host&gt;/v1</code>. Use
provider prefix, e.g.{" "}
{t("quickStartStep4Prefix")}{" "}
<code className="px-1 rounded bg-bg-subtle">https://&lt;host&gt;/v1</code>.{" "}
{t("quickStartStep4Suffix")}{" "}
<code className="px-1 rounded bg-bg-subtle">gh/gpt-5.1-codex</code>.
</p>
</li>
</ol>
</section>
{/* Features */}
<section id="features" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">Features</h2>
<h2 className="text-xl font-semibold">{t("features")}</h2>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
{featureItems.map((item) => (
<article
key={item.title}
key={item.titleKey}
className="rounded-lg border border-border p-4 bg-bg flex gap-3"
>
<span className="material-symbols-outlined text-[20px] text-primary shrink-0 mt-0.5">
@@ -246,40 +249,48 @@ export default function DocsPage() {
</div>
</section>
{/* Supported Providers */}
<section
id="supported-providers"
className="rounded-2xl border border-border bg-bg-subtle p-6"
>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-semibold">Supported Providers</h2>
<h2 className="text-xl font-semibold">{t("supportedProviders")}</h2>
<p className="text-sm text-text-muted mt-1">
{Object.keys(FREE_PROVIDERS).length +
Object.keys(OAUTH_PROVIDERS).length +
Object.keys(APIKEY_PROVIDERS).length}{" "}
providers across three connection types.
{t("providersAcrossConnectionTypes", { count: totalProviders })}
</p>
</div>
<Link
href="/dashboard/providers"
className="px-3 py-2 rounded-lg border border-border text-sm hover:bg-bg transition-colors"
>
Manage Providers
{t("manageProviders")}
</Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
<ProviderTable title="Free Tier" providers={FREE_PROVIDERS} colorDot="bg-green-500" />
<ProviderTable title="OAuth" providers={OAUTH_PROVIDERS} colorDot="bg-blue-500" />
<ProviderTable title="API Key" providers={APIKEY_PROVIDERS} colorDot="bg-amber-500" />
<ProviderTable
title={t("providerTypeFree")}
providers={FREE_PROVIDERS}
colorDot="bg-green-500"
/>
<ProviderTable
title={t("providerTypeOAuth")}
providers={OAUTH_PROVIDERS}
colorDot="bg-blue-500"
/>
<ProviderTable
title={t("providerTypeApiKey")}
providers={APIKEY_PROVIDERS}
colorDot="bg-amber-500"
/>
</div>
</section>
<section id="use-cases" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">Common Use Cases</h2>
<h2 className="text-xl font-semibold">{t("commonUseCases")}</h2>
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
{useCases.map((item) => (
<article key={item.title} className="rounded-lg border border-border p-4 bg-bg">
<article key={item.titleKey} className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{item.title}</h3>
<p className="text-sm text-text-muted mt-2">{item.text}</p>
</article>
@@ -291,76 +302,79 @@ export default function DocsPage() {
id="client-compatibility"
className="rounded-2xl border border-border bg-bg-subtle p-6"
>
<h2 className="text-xl font-semibold">Client Compatibility</h2>
<h2 className="text-xl font-semibold">{t("clientCompatibility")}</h2>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">Cherry Studio</h3>
<h3 className="font-semibold">{t("clientCherryStudioTitle")}</h3>
<ul className="mt-2 text-text-muted space-y-1">
<li>
Base URL:{" "}
{t("baseUrlLabel")}:{" "}
<code className="px-1 rounded bg-bg-subtle">https://&lt;host&gt;/v1</code>
</li>
<li>
Chat endpoint:{" "}
{t("chatEndpointLabel")}:{" "}
<code className="px-1 rounded bg-bg-subtle">/chat/completions</code>
</li>
<li>
Model recommendation: explicit prefix (
{t("modelRecommendationLabel")} (
<code className="px-1 rounded bg-bg-subtle">gh/...</code>,{" "}
<code className="px-1 rounded bg-bg-subtle">cc/...</code>)
</li>
</ul>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">Codex / GitHub Copilot Models</h3>
<h3 className="font-semibold">{t("clientCodexTitle")}</h3>
<ul className="mt-2 text-text-muted space-y-1">
<li>
Use model IDs with <code className="px-1 rounded bg-bg-subtle">gh/</code> prefix.
{t("clientCodexBullet1")} <code className="px-1 rounded bg-bg-subtle">gh/</code>.
</li>
<li>
Codex-family models auto-route to{" "}
{t("clientCodexBullet2")}{" "}
<code className="px-1 rounded bg-bg-subtle">/responses</code>.
</li>
<li>
Non-Codex models continue on{" "}
{t("clientCodexBullet3")}{" "}
<code className="px-1 rounded bg-bg-subtle">/chat/completions</code>.
</li>
</ul>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">Cursor IDE</h3>
<h3 className="font-semibold">{t("clientCursorTitle")}</h3>
<ul className="mt-2 text-text-muted space-y-1">
<li>
Use <code className="px-1 rounded bg-bg-subtle">cu/</code> prefix for Cursor
models.
{t("clientCursorBullet1")} <code className="px-1 rounded bg-bg-subtle">cu/</code>{" "}
{t("clientCursorBullet1Suffix")}
</li>
<li>OAuth connection login from the Providers page.</li>
<li>Supports both chat and responses endpoints.</li>
<li>{t("clientCursorBullet2")}</li>
<li>{t("supportsChat")}</li>
</ul>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">Claude Code / Antigravity</h3>
<h3 className="font-semibold">{t("clientClaudeTitle")}</h3>
<ul className="mt-2 text-text-muted space-y-1">
<li>
Use <code className="px-1 rounded bg-bg-subtle">cc/</code> (Claude) or{" "}
<code className="px-1 rounded bg-bg-subtle">ag/</code> (Antigravity) prefix.
{t("clientClaudeBullet1Prefix")}{" "}
<code className="px-1 rounded bg-bg-subtle">cc/</code>{" "}
{t("clientClaudeBullet1Middle")}{" "}
<code className="px-1 rounded bg-bg-subtle">ag/</code>{" "}
{t("clientClaudeBullet1Suffix")}
</li>
<li>OAuth connection with automatic token refresh.</li>
<li>Full streaming support for all models.</li>
<li>{t("oauthAutoRefresh")}</li>
<li>{t("fullStreaming")}</li>
</ul>
</article>
</div>
</section>
<section id="api-reference" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">API Reference</h2>
<h2 className="text-xl font-semibold">{t("apiReference")}</h2>
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-4">Method</th>
<th className="text-left py-2 pr-4">Path</th>
<th className="text-left py-2">Notes</th>
<th className="text-left py-2 pr-4">{t("method")}</th>
<th className="text-left py-2 pr-4">{t("path")}</th>
<th className="text-left py-2">{t("notes")}</th>
</tr>
</thead>
<tbody>
@@ -380,28 +394,24 @@ export default function DocsPage() {
</div>
</section>
{/* Model prefixes */}
<section id="model-prefixes" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">Model Prefixes</h2>
<h2 className="text-xl font-semibold">{t("modelPrefixes")}</h2>
<p className="text-sm text-text-muted mt-2 mb-4">
Use the provider prefix before the model name to route to a specific provider. Example:{" "}
<code className="px-1 rounded bg-bg">gh/gpt-5.1-codex</code> routes to GitHub Copilot.
{t("modelPrefixesDescriptionStart")}{" "}
<code className="px-1 rounded bg-bg">gh/gpt-5.1-codex</code>{" "}
{t("modelPrefixesDescriptionEnd")}
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-4">Prefix</th>
<th className="text-left py-2 pr-4">Provider</th>
<th className="text-left py-2">Type</th>
<th className="text-left py-2 pr-4">{t("prefix")}</th>
<th className="text-left py-2 pr-4">{t("provider")}</th>
<th className="text-left py-2">{t("type")}</th>
</tr>
</thead>
<tbody>
{[
...Object.values(FREE_PROVIDERS).map((p) => ({ ...p, type: "Free" })),
...Object.values(OAUTH_PROVIDERS).map((p) => ({ ...p, type: "OAuth" })),
...Object.values(APIKEY_PROVIDERS).map((p) => ({ ...p, type: "API Key" })),
].map((p) => (
{providerPrefixRows.map((p) => (
<tr key={p.id} className="border-b border-border/60">
<td className="py-2 pr-4 font-mono">
<code className="px-1.5 py-0.5 rounded bg-bg">{p.alias}/</code>
@@ -410,23 +420,23 @@ export default function DocsPage() {
<td className="py-2">
<span
className={`inline-flex items-center gap-1 text-xs ${
p.type === "Free"
p.type === "free"
? "text-green-500"
: p.type === "OAuth"
: p.type === "oauth"
? "text-blue-500"
: "text-amber-500"
}`}
>
<span
className={`size-1.5 rounded-full ${
p.type === "Free"
p.type === "free"
? "bg-green-500"
: p.type === "OAuth"
: p.type === "oauth"
? "bg-blue-500"
: "bg-amber-500"
}`}
/>
{p.type}
{getProviderTypeLabel(p.type)}
</span>
</td>
</tr>
@@ -437,7 +447,7 @@ export default function DocsPage() {
</section>
<section id="troubleshooting" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">Troubleshooting</h2>
<h2 className="text-xl font-semibold">{t("troubleshooting")}</h2>
<ul className="mt-4 list-disc list-inside text-sm text-text-muted space-y-2">
{troubleshootingItems.map((item) => (
<li key={item}>{item}</li>

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
/**
* 403 Forbidden Page — Phase 8.1
*
@@ -12,6 +14,7 @@
import Link from "next/link";
export default function ForbiddenPage() {
const t = useTranslations("auth");
return (
<div className="flex flex-col items-center justify-center min-h-screen p-6 bg-[var(--bg-primary,#0a0a0f)] text-[var(--text-primary,#e0e0e0)] text-center">
<div
@@ -24,10 +27,9 @@ export default function ForbiddenPage() {
>
403
</div>
<h1 className="text-2xl font-semibold mb-2">Access Denied</h1>
<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"
@@ -36,7 +38,7 @@ export default function ForbiddenPage() {
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
}}
>
Go to Dashboard
{t("goToDashboard")}
</Link>
</div>
);

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
/**
* Forgot Password Page — Phase 8.2
*
@@ -12,31 +14,30 @@ import Link from "next/link";
import { Card } from "@/shared/components";
export default function ForgotPasswordPage() {
const t = useTranslations("auth");
return (
<div className="min-h-screen flex items-center justify-center bg-bg p-4">
<div className="w-full max-w-lg">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary mb-2">Reset Password</h1>
<p className="text-text-muted">Choose a method to recover access to your dashboard</p>
<h1 className="text-3xl font-bold text-primary mb-2">{t("resetPassword")}</h1>
<p className="text-text-muted">{t("resetDescription")}</p>
</div>
{/* Method 1: CLI Reset */}
<Card className="mb-4">
<div className="flex items-start gap-4 p-2">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary shrink-0 mt-0.5">
<span className="material-symbols-outlined text-[20px]">terminal</span>
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
terminal
</span>
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold mb-1">Method 1: CLI Reset</h2>
<p className="text-sm text-text-muted mb-3">
Run the following command on the server where OmniRoute is running:
</p>
<h2 className="text-lg font-semibold mb-1">{t("methodCliTitle")}</h2>
<p className="text-sm text-text-muted mb-3">{t("methodCliDescription")}</p>
<div className="bg-black/30 rounded-lg p-3 mb-3 font-mono text-sm text-green-400 border border-white/5">
<code>npx omniroute reset-password</code>
</div>
<p className="text-xs text-text-muted">
This will prompt you to set a new password. The server must be stopped first.
</p>
<p className="text-xs text-text-muted">{t("methodCliHint")}</p>
</div>
</div>
</Card>
@@ -45,32 +46,31 @@ export default function ForgotPasswordPage() {
<Card className="mb-6">
<div className="flex items-start gap-4 p-2">
<div className="flex items-center justify-center size-10 rounded-lg bg-amber-500/10 text-amber-500 shrink-0 mt-0.5">
<span className="material-symbols-outlined text-[20px]">database</span>
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
database
</span>
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold mb-1">Method 2: Manual Reset</h2>
<p className="text-sm text-text-muted mb-3">
Delete the password from the database and set a new one on startup:
</p>
<h2 className="text-lg font-semibold mb-1">{t("methodManualTitle")}</h2>
<p className="text-sm text-text-muted mb-3">{t("methodManualDescription")}</p>
<ol className="text-sm text-text-muted space-y-2 list-decimal list-inside mb-3">
<li>Stop the OmniRoute server</li>
<li>{t("stopServer")}</li>
<li>
Set a new password in your{" "}
<code className="bg-black/30 px-1 rounded text-text-main">.env</code> file:
{t("setPasswordInYour")}{" "}
<code className="bg-black/30 px-1 rounded text-text-main">.env</code>{" "}
{t("fileLabelSuffix")}
<div className="bg-black/30 rounded-lg p-2 mt-1 font-mono text-xs text-green-400 border border-white/5">
INITIAL_PASSWORD=your_new_password
INITIAL_PASSWORD={t("newPasswordPlaceholder")}
</div>
</li>
<li>
Delete{" "}
{t("deleteSettingsFile")}{" "}
<code className="bg-black/30 px-1 rounded text-text-main">
data/settings.json
</code>{" "}
(or remove the{" "}
<code className="bg-black/30 px-1 rounded text-text-main">passwordHash</code>{" "}
field)
({t("orRemovePasswordHashField")})
</li>
<li>Restart the server it will use the new password</li>
<li>{t("restartServerWithNewPassword")}</li>
</ol>
</div>
</div>
@@ -81,8 +81,10 @@ export default function ForgotPasswordPage() {
href="/login"
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
>
<span className="material-symbols-outlined text-[16px]">arrow_back</span>
Back to Login
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
arrow_back
</span>
{t("backToLogin")}
</Link>
</div>
</div>

View File

@@ -1,10 +1,11 @@
"use client";
import { useTranslations } from "next-intl";
const FEATURES = [
{
icon: "link",
title: "Unified Endpoint",
desc: "Access all providers via a single standard API URL.",
titleKey: "featureUnifiedEndpointTitle",
descKey: "featureUnifiedEndpointDesc",
colors: {
border: "hover:border-blue-500/50",
bg: "hover:bg-blue-500/5",
@@ -15,8 +16,8 @@ const FEATURES = [
},
{
icon: "bolt",
title: "Easy Setup",
desc: "Get up and running in minutes with npx command.",
titleKey: "featureEasySetupTitle",
descKey: "featureEasySetupDesc",
colors: {
border: "hover:border-orange-500/50",
bg: "hover:bg-orange-500/5",
@@ -27,8 +28,8 @@ const FEATURES = [
},
{
icon: "shield_with_heart",
title: "Model Fallback",
desc: "Automatically switch providers on failure or high latency.",
titleKey: "featureModelFallbackTitle",
descKey: "featureModelFallbackDesc",
colors: {
border: "hover:border-rose-500/50",
bg: "hover:bg-rose-500/5",
@@ -39,8 +40,8 @@ const FEATURES = [
},
{
icon: "monitoring",
title: "Usage Tracking",
desc: "Detailed analytics and cost monitoring across all models.",
titleKey: "featureUsageTrackingTitle",
descKey: "featureUsageTrackingDesc",
colors: {
border: "hover:border-purple-500/50",
bg: "hover:bg-purple-500/5",
@@ -51,8 +52,8 @@ const FEATURES = [
},
{
icon: "key",
title: "OAuth & API Keys",
desc: "Securely manage credentials in one vault.",
titleKey: "featureOAuthApiKeysTitle",
descKey: "featureOAuthApiKeysDesc",
colors: {
border: "hover:border-amber-500/50",
bg: "hover:bg-amber-500/5",
@@ -63,8 +64,8 @@ const FEATURES = [
},
{
icon: "cloud_sync",
title: "Cloud Sync",
desc: "Sync your configurations across devices instantly.",
titleKey: "featureCloudSyncTitle",
descKey: "featureCloudSyncDesc",
colors: {
border: "hover:border-sky-500/50",
bg: "hover:bg-sky-500/5",
@@ -75,8 +76,8 @@ const FEATURES = [
},
{
icon: "terminal",
title: "CLI Support",
desc: "Works with Claude Code, Codex, Cline, Cursor, and more.",
titleKey: "featureCliSupportTitle",
descKey: "featureCliSupportDesc",
colors: {
border: "hover:border-emerald-500/50",
bg: "hover:bg-emerald-500/5",
@@ -87,8 +88,8 @@ const FEATURES = [
},
{
icon: "dashboard",
title: "Dashboard",
desc: "Visual dashboard for real-time traffic analysis.",
titleKey: "featureDashboardTitle",
descKey: "featureDashboardDesc",
colors: {
border: "hover:border-fuchsia-500/50",
bg: "hover:bg-fuchsia-500/5",
@@ -100,33 +101,35 @@ const FEATURES = [
];
export default function Features() {
const t = useTranslations("landing");
return (
<section className="py-24 px-6" id="features">
<div className="max-w-7xl mx-auto">
<div className="mb-16">
<h2 className="text-3xl md:text-4xl font-bold mb-4">Powerful Features</h2>
<p className="text-gray-400 max-w-xl text-lg">
Everything you need to manage your AI infrastructure in one place, built for scale.
</p>
<h2 className="text-3xl md:text-4xl font-bold mb-4">{t("powerfulFeatures")}</h2>
<p className="text-gray-400 max-w-xl text-lg">{t("featuresSubtitle")}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{FEATURES.map((feature) => (
<div
key={feature.title}
key={feature.titleKey}
className={`p-6 rounded-xl bg-[#23180f] border border-[#3a2f27] ${feature.colors.border} ${feature.colors.bg} transition-all duration-300 group`}
>
<div
className={`w-10 h-10 rounded-lg ${feature.colors.iconBg} flex items-center justify-center mb-4 ${feature.colors.iconText} group-hover:scale-110 transition-transform duration-300`}
>
<span className="material-symbols-outlined">{feature.icon}</span>
<span className="material-symbols-outlined" aria-hidden="true">
{feature.icon}
</span>
</div>
<h3
className={`text-lg font-bold mb-2 ${feature.colors.titleHover} transition-colors`}
>
{feature.title}
{t(feature.titleKey)}
</h3>
<p className="text-sm text-gray-400 leading-relaxed">{feature.desc}</p>
<p className="text-sm text-gray-400 leading-relaxed">{t(feature.descKey)}</p>
</div>
))}
</div>

View File

@@ -1,149 +1,177 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
const CLI_TOOLS = [
{ id: "claude", name: "Claude Code", image: "/providers/claude.png" },
{ id: "codex", name: "OpenAI Codex", image: "/providers/codex.png" },
{ id: "cline", name: "Cline", image: "/providers/cline.png" },
{ id: "cursor", name: "Cursor", image: "/providers/cursor.png" },
];
const PROVIDERS = [
{ id: "openai", name: "OpenAI", color: "bg-emerald-500", textColor: "text-white" },
{ id: "anthropic", name: "Anthropic", color: "bg-orange-400", textColor: "text-white" },
{ id: "gemini", name: "Gemini", color: "bg-blue-500", textColor: "text-white" },
{ id: "github", name: "GitHub Copilot", color: "bg-gray-700", textColor: "text-white" },
];
import { useTranslations } from "next-intl";
export default function FlowAnimation() {
const t = useTranslations("landing");
const [activeFlow, setActiveFlow] = useState(0);
const cliTools = [
{ id: "claude", name: t("flowToolClaudeCode"), image: "/providers/claude.png" },
{ id: "codex", name: t("flowToolOpenAICodex"), image: "/providers/codex.png" },
{ id: "cline", name: t("flowToolCline"), image: "/providers/cline.png" },
{ id: "cursor", name: t("flowToolCursor"), image: "/providers/cursor.png" },
];
const providers = [
{
id: "openai",
name: t("flowProviderOpenAI"),
color: "bg-emerald-500",
textColor: "text-white",
},
{
id: "anthropic",
name: t("flowProviderAnthropic"),
color: "bg-orange-400",
textColor: "text-white",
},
{
id: "gemini",
name: t("flowProviderGemini"),
color: "bg-blue-500",
textColor: "text-white",
},
{
id: "github",
name: t("flowProviderGithubCopilot"),
color: "bg-gray-700",
textColor: "text-white",
},
];
useEffect(() => {
const interval = setInterval(() => {
setActiveFlow((prev) => (prev + 1) % PROVIDERS.length);
setActiveFlow((prev) => (prev + 1) % providers.length);
}, 2000);
return () => clearInterval(interval);
}, []);
}, [providers.length]);
return (
<div className="mt-16 w-full max-w-4xl relative h-[360px] hidden md:flex items-center justify-center animate-[float_6s_ease-in-out_infinite]">
{/* OmniRoute Hub - Center */}
<div className="relative z-20 w-32 h-32 rounded-full bg-[#111520] border-2 border-[#E54D5E] shadow-[0_0_40px_rgba(229,77,94,0.3)] flex flex-col items-center justify-center gap-1 group cursor-pointer hover:scale-105 transition-transform duration-500">
<span className="material-symbols-outlined text-4xl text-[#E54D5E]">hub</span>
<span className="text-xs font-bold text-white tracking-widest uppercase">OmniRoute</span>
<div className="absolute inset-0 rounded-full border border-[#E54D5E]/30 animate-ping opacity-20"></div>
</div>
<div className="mt-16 w-full max-w-4xl">
<div className="relative h-[360px] hidden md:flex items-center justify-center animate-[float_6s_ease-in-out_infinite]">
{/* OmniRoute Hub - Center */}
<div className="relative z-20 w-32 h-32 rounded-full bg-[#111520] border-2 border-[#E54D5E] shadow-[0_0_40px_rgba(229,77,94,0.3)] flex flex-col items-center justify-center gap-1 group cursor-pointer hover:scale-105 transition-transform duration-500">
<span className="material-symbols-outlined text-4xl text-[#E54D5E]" aria-hidden="true">
hub
</span>
<span className="text-xs font-bold text-white tracking-widest uppercase">
{t("brandName")}
</span>
<div className="absolute inset-0 rounded-full border border-[#E54D5E]/30 animate-ping opacity-20"></div>
</div>
{/* CLI Tools - Left side */}
<div className="absolute left-0 top-1/2 -translate-y-1/2 flex flex-col gap-7">
{CLI_TOOLS.map((tool) => (
<div
key={tool.id}
className="flex items-center gap-3 opacity-70 hover:opacity-100 transition-opacity group"
>
<div className="w-16 h-16 rounded-2xl bg-[#111520] border border-[#2D333B] flex items-center justify-center overflow-hidden p-2 hover:border-[#E54D5E]/50 transition-all hover:scale-105">
<Image
src={tool.image}
alt={tool.name}
width={48}
height={48}
className="object-contain rounded-xl max-w-[48px] max-h-[48px]"
sizes="48px"
/>
{/* CLI Tools - Left side */}
<div className="absolute left-0 top-1/2 -translate-y-1/2 flex flex-col gap-7">
{cliTools.map((tool) => (
<div
key={tool.id}
className="flex items-center gap-3 opacity-70 hover:opacity-100 transition-opacity group"
>
<div className="w-16 h-16 rounded-2xl bg-[#111520] border border-[#2D333B] flex items-center justify-center overflow-hidden p-2 hover:border-[#E54D5E]/50 transition-all hover:scale-105">
<Image
src={tool.image}
alt={tool.name}
width={48}
height={48}
className="object-contain rounded-xl max-w-[48px] max-h-[48px]"
sizes="48px"
/>
</div>
</div>
</div>
))}
</div>
))}
</div>
{/* SVG Lines from CLI to OmniRoute */}
<svg
className="absolute inset-0 w-full h-full z-10 pointer-events-none stroke-yellow-700"
xmlns="http://www.w3.org/2000/svg"
>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 50 C 250 70, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 140 C 250 140, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 210 C 250 210, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 300 C 250 280, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
</svg>
{/* SVG Lines from CLI to OmniRoute */}
<svg
className="absolute inset-0 w-full h-full z-10 pointer-events-none stroke-yellow-700"
xmlns="http://www.w3.org/2000/svg"
>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 50 C 250 70, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 140 C 250 140, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 210 C 250 210, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 300 C 250 280, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
</svg>
{/* SVG Lines from OmniRoute to Providers */}
<svg
className="absolute inset-0 w-full h-full z-10 pointer-events-none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M 440 180 C 550 180, 550 50, 740 50"
fill="none"
stroke={activeFlow === 0 ? "#E54D5E" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 0 ? "3" : "2"}
className={activeFlow === 0 ? "animate-pulse" : ""}
></path>
<path
d="M 440 180 C 550 180, 550 130, 740 130"
fill="none"
stroke={activeFlow === 1 ? "#E54D5E" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 1 ? "3" : "2"}
className={activeFlow === 1 ? "animate-pulse" : ""}
></path>
<path
d="M 440 180 C 550 180, 550 230, 740 230"
fill="none"
stroke={activeFlow === 2 ? "#E54D5E" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 2 ? "3" : "2"}
className={activeFlow === 2 ? "animate-pulse" : ""}
></path>
<path
d="M 440 180 C 550 180, 550 310, 740 310"
fill="none"
stroke={activeFlow === 3 ? "#E54D5E" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 3 ? "3" : "2"}
className={activeFlow === 3 ? "animate-pulse" : ""}
></path>
</svg>
{/* SVG Lines from OmniRoute to Providers */}
<svg
className="absolute inset-0 w-full h-full z-10 pointer-events-none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M 440 180 C 550 180, 550 50, 740 50"
fill="none"
stroke={activeFlow === 0 ? "#E54D5E" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 0 ? "3" : "2"}
className={activeFlow === 0 ? "animate-pulse" : ""}
></path>
<path
d="M 440 180 C 550 180, 550 130, 740 130"
fill="none"
stroke={activeFlow === 1 ? "#E54D5E" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 1 ? "3" : "2"}
className={activeFlow === 1 ? "animate-pulse" : ""}
></path>
<path
d="M 440 180 C 550 180, 550 230, 740 230"
fill="none"
stroke={activeFlow === 2 ? "#E54D5E" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 2 ? "3" : "2"}
className={activeFlow === 2 ? "animate-pulse" : ""}
></path>
<path
d="M 440 180 C 550 180, 550 310, 740 310"
fill="none"
stroke={activeFlow === 3 ? "#E54D5E" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 3 ? "3" : "2"}
className={activeFlow === 3 ? "animate-pulse" : ""}
></path>
</svg>
{/* AI Providers - Right side */}
<div className="absolute right-0 top-0 bottom-0 flex flex-col justify-between py-6">
{PROVIDERS.map((provider, idx) => (
<div
key={provider.id}
className={`px-4 py-2 rounded-lg ${provider.color} ${provider.textColor} flex items-center justify-center font-bold text-xs shadow-lg hover:scale-110 transition-all cursor-help min-w-[140px] ${
activeFlow === idx ? "ring-4 ring-[#E54D5E]/50 scale-110" : ""
}`}
title={provider.name}
>
{provider.name}
</div>
))}
{/* AI Providers - Right side */}
<div className="absolute right-0 top-0 bottom-0 flex flex-col justify-between py-6">
{providers.map((provider, idx) => (
<div
key={provider.id}
className={`px-4 py-2 rounded-lg ${provider.color} ${provider.textColor} flex items-center justify-center font-bold text-xs shadow-lg hover:scale-110 transition-all cursor-help min-w-[140px] ${
activeFlow === idx ? "ring-4 ring-[#E54D5E]/50 scale-110" : ""
}`}
title={provider.name}
>
{provider.name}
</div>
))}
</div>
</div>
{/* Mobile fallback */}
<div className="md:hidden mt-8 w-full p-4 rounded-lg bg-[#111520] border border-[#2D333B]">
<p className="text-sm text-center text-gray-400">Interactive diagram visible on desktop</p>
<p className="text-sm text-center text-gray-400">{t("interactiveDiagram")}</p>
</div>
</div>
);

View File

@@ -1,7 +1,11 @@
"use client";
import { useTranslations } from "next-intl";
import OmniRouteLogo from "@/shared/components/OmniRouteLogo";
export default function Footer() {
const t = useTranslations("landing");
const year = new Date().getFullYear();
return (
<footer className="border-t border-[#2D333B] bg-[#080A0F] pt-16 pb-8 px-6">
<div className="max-w-7xl mx-auto">
@@ -12,12 +16,9 @@ export default function Footer() {
<div className="size-6 rounded bg-[#E54D5E] flex items-center justify-center text-white">
<OmniRouteLogo size={16} className="text-white" />
</div>
<h3 className="text-white text-lg font-bold">OmniRoute</h3>
<h3 className="text-white text-lg font-bold">{t("brandName")}</h3>
</div>
<p className="text-gray-500 text-sm max-w-xs mb-6">
The unified endpoint for AI generation. Connect, route, and manage your AI providers
with ease.
</p>
<p className="text-gray-500 text-sm max-w-xs mb-6">{t("footerTagline")}</p>
<div className="flex gap-4">
<a
className="text-gray-400 hover:text-white transition-colors"
@@ -25,25 +26,27 @@ export default function Footer() {
target="_blank"
rel="noopener noreferrer"
>
<span className="material-symbols-outlined">code</span>
<span className="material-symbols-outlined" aria-hidden="true">
code
</span>
</a>
</div>
</div>
{/* Product */}
<div className="flex flex-col gap-4">
<h4 className="font-bold text-white">Product</h4>
<h4 className="font-bold text-white">{t("product")}</h4>
<a
className="text-gray-400 hover:text-[#E54D5E] text-sm transition-colors"
href="#features"
>
Features
{t("featuresLink")}
</a>
<a
className="text-gray-400 hover:text-[#E54D5E] text-sm transition-colors"
href="/dashboard"
>
Dashboard
{t("dashboardLink")}
</a>
<a
className="text-gray-400 hover:text-[#E54D5E] text-sm transition-colors"
@@ -51,18 +54,18 @@ export default function Footer() {
target="_blank"
rel="noopener noreferrer"
>
Changelog
{t("changelog")}
</a>
</div>
{/* Resources */}
<div className="flex flex-col gap-4">
<h4 className="font-bold text-white">Resources</h4>
<h4 className="font-bold text-white">{t("resources")}</h4>
<a
className="text-gray-400 hover:text-[#E54D5E] text-sm transition-colors"
href="/docs"
>
Documentation
{t("documentation")}
</a>
<a
className="text-gray-400 hover:text-[#E54D5E] text-sm transition-colors"
@@ -70,7 +73,7 @@ export default function Footer() {
target="_blank"
rel="noopener noreferrer"
>
GitHub
{t("github")}
</a>
<a
className="text-gray-400 hover:text-[#E54D5E] text-sm transition-colors"
@@ -78,27 +81,27 @@ export default function Footer() {
target="_blank"
rel="noopener noreferrer"
>
NPM
{t("npm")}
</a>
</div>
{/* Legal */}
<div className="flex flex-col gap-4">
<h4 className="font-bold text-white">Legal</h4>
<h4 className="font-bold text-white">{t("legal")}</h4>
<a
className="text-gray-400 hover:text-[#E54D5E] text-sm transition-colors"
href="https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
>
MIT License
{t("mitLicense")}
</a>
</div>
</div>
{/* Bottom */}
<div className="border-t border-[#2D333B] pt-8 flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-gray-600 text-sm">© 2025 OmniRoute. All rights reserved.</p>
<p className="text-gray-600 text-sm">{t("copyright", { year })}</p>
<div className="flex gap-6">
<a
className="text-gray-600 hover:text-white text-sm transition-colors"
@@ -106,7 +109,7 @@ export default function Footer() {
target="_blank"
rel="noopener noreferrer"
>
GitHub
{t("github")}
</a>
<a
className="text-gray-600 hover:text-white text-sm transition-colors"
@@ -114,7 +117,7 @@ export default function Footer() {
target="_blank"
rel="noopener noreferrer"
>
NPM
{t("npm")}
</a>
</div>
</div>

View File

@@ -1,10 +1,16 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
export default function GetStarted() {
const t = useTranslations("landing");
const [copied, setCopied] = useState(false);
const handleCopy = (text) => {
const endpoint = "http://localhost:20128";
const dashboardUrl = `${endpoint}/dashboard`;
const command = "npx omniroute";
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
@@ -16,11 +22,8 @@ export default function GetStarted() {
<div className="flex flex-col lg:flex-row gap-16 items-start">
{/* Left: Steps */}
<div className="flex-1">
<h2 className="text-3xl md:text-4xl font-bold mb-6">Get Started in 30 Seconds</h2>
<p className="text-gray-400 text-lg mb-8">
Install OmniRoute, configure your providers via web dashboard, and start routing AI
requests.
</p>
<h2 className="text-3xl md:text-4xl font-bold mb-6">{t("getStartedIn30Seconds")}</h2>
<p className="text-gray-400 text-lg mb-8">{t("getStartedDescription")}</p>
<div className="flex flex-col gap-6">
<div className="flex gap-4">
@@ -28,10 +31,8 @@ export default function GetStarted() {
1
</div>
<div>
<h4 className="font-bold text-lg">Install OmniRoute</h4>
<p className="text-sm text-gray-500 mt-1">
Run npx command to start the server instantly
</p>
<h4 className="font-bold text-lg">{t("installOmniRoute")}</h4>
<p className="text-sm text-gray-500 mt-1">{t("installStepDescription")}</p>
</div>
</div>
@@ -40,10 +41,8 @@ export default function GetStarted() {
2
</div>
<div>
<h4 className="font-bold text-lg">Open Dashboard</h4>
<p className="text-sm text-gray-500 mt-1">
Configure providers and API keys via web interface
</p>
<h4 className="font-bold text-lg">{t("openDashboard")}</h4>
<p className="text-sm text-gray-500 mt-1">{t("openDashboardStepDescription")}</p>
</div>
</div>
@@ -52,9 +51,9 @@ export default function GetStarted() {
3
</div>
<div>
<h4 className="font-bold text-lg">Route Requests</h4>
<h4 className="font-bold text-lg">{t("routeRequests")}</h4>
<p className="text-sm text-gray-500 mt-1">
Point your CLI tools to http://localhost:20128
{t("routeRequestsStepDescription", { endpoint })}
</p>
</div>
</div>
@@ -69,44 +68,46 @@ export default function GetStarted() {
<div className="w-3 h-3 rounded-full bg-red-500"></div>
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
<div className="w-3 h-3 rounded-full bg-green-500"></div>
<div className="ml-2 text-xs text-gray-500 font-mono">terminal</div>
<div className="ml-2 text-xs text-gray-500 font-mono">{t("terminal")}</div>
</div>
{/* Terminal content */}
<div className="p-6 font-mono text-sm leading-relaxed overflow-x-auto">
<div
className="flex items-center gap-2 mb-4 group cursor-pointer"
onClick={() => handleCopy("npx omniroute")}
onClick={() => handleCopy(command)}
>
<span className="text-green-400">$</span>
<span className="text-white">npx omniroute</span>
<span className="text-white">{command}</span>
<span className="ml-auto text-gray-500 text-xs opacity-0 group-hover:opacity-100">
{copied ? "✓ Copied" : "Copy"}
{copied ? t("copied") : t("copy")}
</span>
</div>
<div className="text-gray-400 mb-6">
<span className="text-[#E54D5E]">&gt;</span> Starting OmniRoute...
<span className="text-[#E54D5E]">&gt;</span> {t("startingOmniRoute")}
<br />
<span className="text-[#E54D5E]">&gt;</span> Server running on{" "}
<span className="text-blue-400">http://localhost:20128</span>
<span className="text-[#E54D5E]">&gt;</span> {t("serverRunningOnLabel")}{" "}
<span className="text-blue-400">{endpoint}</span>
<br />
<span className="text-[#E54D5E]">&gt;</span> Dashboard:{" "}
<span className="text-blue-400">http://localhost:20128/dashboard</span>
<span className="text-[#E54D5E]">&gt;</span> {t("dashboardLabel")}:{" "}
<span className="text-blue-400">{dashboardUrl}</span>
<br />
<span className="text-green-400">&gt;</span> Ready to route!
<span className="text-green-400">&gt;</span> {t("readyToRoute")}
</div>
<div className="text-xs text-gray-500 mb-2 border-t border-gray-700 pt-4">
📝 Configure providers in dashboard or use environment variables
{t("configureProvidersNote")}
</div>
<div className="text-gray-400 text-xs">
<span className="text-purple-400">Data Location:</span>
<span className="text-purple-400">{t("dataLocation")}</span>
<br />
<span className="text-gray-500"> macOS/Linux:</span> ~/.omniroute/db.json
<span className="text-gray-500">{t("dataLocationMacLinux")}</span>{" "}
~/.omniroute/db.json
<br />
<span className="text-gray-500"> Windows:</span> %APPDATA%/omniroute/db.json
<span className="text-gray-500">{t("dataLocationWindows")}</span>{" "}
%APPDATA%/omniroute/db.json
</div>
</div>
</div>

View File

@@ -1,6 +1,11 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
export default function HeroSection() {
const t = useTranslations("landing");
const router = useRouter();
return (
<section className="relative pt-32 pb-20 px-6 min-h-[90vh] flex flex-col items-center justify-center overflow-hidden">
{/* Glow effect */}
@@ -10,26 +15,30 @@ export default function HeroSection() {
{/* Version badge */}
<div className="inline-flex items-center gap-2 rounded-full border border-[#2D333B] bg-[#111520]/50 px-3 py-1 text-xs font-medium text-[#E54D5E]">
<span className="flex h-2 w-2 rounded-full bg-[#E54D5E] animate-pulse"></span>
v1.0 is now live
{t("versionLive")}
</div>
{/* Main heading */}
<h1 className="text-5xl md:text-7xl font-black leading-[1.1] tracking-tight">
One Endpoint for <br />
<span className="text-[#E54D5E]">All AI Providers</span>
{t("oneEndpoint")} <br />
<span className="text-[#E54D5E]">{t("allProviders")}</span>
</h1>
{/* Description */}
<p className="text-lg md:text-xl text-gray-400 max-w-2xl mx-auto font-light">
AI endpoint proxy with web dashboard - A JavaScript port of CLIProxyAPI. Works seamlessly
with Claude Code, OpenAI Codex, Cline, RooCode, and other CLI tools.
{t("heroDescription")}
</p>
{/* CTA Buttons */}
<div className="flex flex-wrap items-center justify-center gap-4 w-full">
<button className="h-12 px-8 rounded-lg bg-[#E54D5E] hover:bg-[#C93D4E] text-white text-base font-bold transition-all shadow-[0_0_15px_rgba(229,77,94,0.4)] flex items-center gap-2">
<span className="material-symbols-outlined">rocket_launch</span>
Get Started
<button
onClick={() => router.push("/dashboard")}
className="h-12 px-8 rounded-lg bg-[#E54D5E] hover:bg-[#C93D4E] text-white text-base font-bold transition-all shadow-[0_0_15px_rgba(229,77,94,0.4)] flex items-center gap-2"
>
<span className="material-symbols-outlined" aria-hidden="true">
rocket_launch
</span>
{t("getStarted")}
</button>
<a
href="https://github.com/diegosouzapw/OmniRoute"
@@ -37,8 +46,10 @@ export default function HeroSection() {
rel="noopener noreferrer"
className="h-12 px-8 rounded-lg border border-[#2D333B] bg-[#111520] hover:bg-[#2D333B] text-white text-base font-bold transition-all flex items-center gap-2"
>
<span className="material-symbols-outlined">code</span>
View on GitHub
<span className="material-symbols-outlined" aria-hidden="true">
code
</span>
{t("viewOnGithub")}
</a>
</div>
</div>

View File

@@ -1,15 +1,15 @@
"use client";
import { useTranslations } from "next-intl";
export default function HowItWorks() {
const t = useTranslations("landing");
return (
<section className="py-24 border-y border-[#2D333B] bg-[#111520]/30" id="how-it-works">
<div className="max-w-7xl mx-auto px-6">
<div className="mb-16">
<h2 className="text-3xl md:text-4xl font-bold mb-4">How OmniRoute Works</h2>
<p className="text-gray-400 max-w-xl text-lg">
Data flows seamlessly from your application through our intelligent routing layer to the
best provider for the job.
</p>
<h2 className="text-3xl md:text-4xl font-bold mb-4">{t("howItWorks")}</h2>
<p className="text-gray-400 max-w-xl text-lg">{t("howItWorksDescription")}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 relative">
@@ -19,30 +19,29 @@ export default function HowItWorks() {
{/* Step 1: CLI & SDKs */}
<div className="flex flex-col gap-6 relative group">
<div className="w-24 h-24 rounded-2xl bg-[#0B0E14] border border-[#2D333B] flex items-center justify-center shadow-xl group-hover:border-gray-500 transition-colors z-10 mx-auto md:mx-0">
<span className="material-symbols-outlined text-4xl text-gray-300">terminal</span>
<span className="material-symbols-outlined text-4xl text-gray-300" aria-hidden="true">
terminal
</span>
</div>
<div>
<h3 className="text-xl font-bold mb-2">1. CLI &amp; SDKs</h3>
<p className="text-sm text-gray-400">
Your requests start from your favorite tools or our unified SDK. Just change the
base URL.
</p>
<h3 className="text-xl font-bold mb-2">{t("howItWorksStep1Title")}</h3>
<p className="text-sm text-gray-400">{t("howItWorksStep1Description")}</p>
</div>
</div>
{/* Step 2: OmniRoute Hub */}
<div className="flex flex-col gap-6 relative group md:items-center md:text-center">
<div className="w-24 h-24 rounded-2xl bg-[#0B0E14] border-2 border-[#E54D5E] flex items-center justify-center shadow-[0_0_30px_rgba(229,77,94,0.2)] z-10 mx-auto">
<span className="material-symbols-outlined text-4xl text-[#E54D5E] animate-pulse">
<span
className="material-symbols-outlined text-4xl text-[#E54D5E] animate-pulse"
aria-hidden="true"
>
hub
</span>
</div>
<div>
<h3 className="text-xl font-bold mb-2 text-[#E54D5E]">2. OmniRoute Hub</h3>
<p className="text-sm text-gray-400">
Our engine analyzes the prompt, checks provider health, and routes for lowest
latency or cost.
</p>
<h3 className="text-xl font-bold mb-2 text-[#E54D5E]">{t("howItWorksStep2Title")}</h3>
<p className="text-sm text-gray-400">{t("howItWorksStep2Description")}</p>
</div>
</div>
@@ -57,10 +56,8 @@ export default function HowItWorks() {
</div>
</div>
<div>
<h3 className="text-xl font-bold mb-2">3. AI Providers</h3>
<p className="text-sm text-gray-400">
The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.
</p>
<h3 className="text-xl font-bold mb-2">{t("howItWorksStep3Title")}</h3>
<p className="text-sm text-gray-400">{t("howItWorksStep3Description")}</p>
</div>
</div>
</div>

View File

@@ -1,9 +1,11 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import OmniRouteLogo from "@/shared/components/OmniRouteLogo";
export default function Navigation() {
const t = useTranslations("landing");
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const router = useRouter();
@@ -15,12 +17,12 @@ export default function Navigation() {
type="button"
className="flex items-center gap-3 cursor-pointer bg-transparent border-none p-0"
onClick={() => router.push("/")}
aria-label="Navigate to home"
aria-label={t("navigateHome")}
>
<div className="size-8 rounded bg-linear-to-br from-[#E54D5E] to-[#C93D4E] flex items-center justify-center text-white">
<OmniRouteLogo size={20} className="text-white" />
</div>
<h2 className="text-white text-xl font-bold tracking-tight">OmniRoute</h2>
<h2 className="text-white text-xl font-bold tracking-tight">{t("brandName")}</h2>
</button>
{/* Desktop menu */}
@@ -29,19 +31,19 @@ export default function Navigation() {
className="text-gray-300 hover:text-white text-sm font-medium transition-colors"
href="#features"
>
Features
{t("featuresLink")}
</a>
<a
className="text-gray-300 hover:text-white text-sm font-medium transition-colors"
href="#how-it-works"
>
How it Works
{t("howItWorks")}
</a>
<a
className="text-gray-300 hover:text-white text-sm font-medium transition-colors"
href="/docs"
>
Docs
{t("docsLink")}
</a>
<a
className="text-gray-300 hover:text-white text-sm font-medium transition-colors flex items-center gap-1"
@@ -49,7 +51,10 @@ export default function Navigation() {
target="_blank"
rel="noopener noreferrer"
>
GitHub <span className="material-symbols-outlined text-[14px]">open_in_new</span>
{t("github")}
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
open_in_new
</span>
</a>
</div>
@@ -59,13 +64,16 @@ export default function Navigation() {
onClick={() => router.push("/dashboard")}
className="hidden sm:flex h-9 items-center justify-center rounded-lg px-4 bg-[#E54D5E] hover:bg-[#C93D4E] transition-all text-white text-sm font-bold shadow-[0_0_15px_rgba(229,77,94,0.4)] hover:shadow-[0_0_20px_rgba(229,77,94,0.6)]"
>
Get Started
{t("getStarted")}
</button>
<button
className="md:hidden text-white"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label={t("toggleMenu")}
>
<span className="material-symbols-outlined">{mobileMenuOpen ? "close" : "menu"}</span>
<span className="material-symbols-outlined" aria-hidden="true">
{mobileMenuOpen ? "close" : "menu"}
</span>
</button>
</div>
</div>
@@ -79,20 +87,20 @@ export default function Navigation() {
href="#features"
onClick={() => setMobileMenuOpen(false)}
>
Features
{t("featuresLink")}
</a>
<a
className="text-gray-300 hover:text-white text-sm font-medium transition-colors"
href="#how-it-works"
onClick={() => setMobileMenuOpen(false)}
>
How it Works
{t("howItWorks")}
</a>
<a
className="text-gray-300 hover:text-white text-sm font-medium transition-colors"
href="/docs"
>
Docs
{t("docsLink")}
</a>
<a
className="text-gray-300 hover:text-white text-sm font-medium transition-colors"
@@ -100,13 +108,13 @@ export default function Navigation() {
target="_blank"
rel="noopener noreferrer"
>
GitHub
{t("github")}
</a>
<button
onClick={() => router.push("/dashboard")}
className="h-9 rounded-lg bg-[#E54D5E] hover:bg-[#C93D4E] text-white text-sm font-bold"
>
Get Started
{t("getStarted")}
</button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import Navigation from "./components/Navigation";
import HeroSection from "./components/HeroSection";
import FlowAnimation from "./components/FlowAnimation";
@@ -9,6 +10,7 @@ import GetStarted from "./components/GetStarted";
import Footer from "./components/Footer";
export default function LandingPage() {
const t = useTranslations("landing");
const router = useRouter();
return (
<div className="relative text-white font-sans overflow-x-hidden antialiased selection:bg-[#E54D5E] selection:text-white">
@@ -64,25 +66,20 @@ export default function LandingPage() {
<section className="py-32 px-6 relative overflow-hidden">
<div className="absolute inset-0 bg-linear-to-t from-[#E54D5E]/5 to-transparent pointer-events-none"></div>
<div className="max-w-4xl mx-auto text-center relative z-10">
<h2 className="text-4xl md:text-5xl font-black mb-6">
Ready to Simplify Your AI Infrastructure?
</h2>
<p className="text-xl text-gray-400 mb-10 max-w-2xl mx-auto">
Join developers who are streamlining their AI integrations with OmniRoute. Open
source and free to start.
</p>
<h2 className="text-4xl md:text-5xl font-black mb-6">{t("ctaTitle")}</h2>
<p className="text-xl text-gray-400 mb-10 max-w-2xl mx-auto">{t("ctaDescription")}</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<button
onClick={() => router.push("/dashboard")}
className="w-full sm:w-auto h-14 px-10 rounded-lg bg-[#E54D5E] hover:bg-[#C93D4E] text-white text-lg font-bold transition-all shadow-[0_0_20px_rgba(229,77,94,0.5)]"
>
Start Free
{t("startFree")}
</button>
<button
onClick={() => router.push("/docs")}
className="w-full sm:w-auto h-14 px-10 rounded-lg border border-[#2D333B] hover:bg-[#111520] text-white text-lg font-bold transition-all"
>
Read Documentation
{t("readDocumentation")}
</button>
</div>
</div>

View File

@@ -2,6 +2,8 @@ import { Inter } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/shared/components/ThemeProvider";
import "@/lib/initCloudSync"; // Auto-initialize cloud sync
import { NextIntlClientProvider } from "next-intl";
import { getMessages, getLocale } from "next-intl/server";
const inter = Inter({
subsets: ["latin"],
@@ -18,9 +20,12 @@ export const metadata = {
},
};
export default function RootLayout({ children }) {
export default async function RootLayout({ children }) {
const locale = await getLocale();
const messages = await getMessages();
return (
<html lang="en" suppressHydrationWarning>
<html lang={locale} suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
@@ -37,7 +42,9 @@ export default function RootLayout({ children }) {
>
Skip to content
</a>
<ThemeProvider>{children}</ThemeProvider>
<NextIntlClientProvider locale={locale} messages={messages}>
<ThemeProvider>{children}</ThemeProvider>
</NextIntlClientProvider>
</body>
</html>
);

View File

@@ -1,10 +1,13 @@
"use client";
import { useTranslations } from "next-intl";
import { useState, useEffect } from "react";
import { Button, Input } from "@/shared/components";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const t = useTranslations("auth");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
@@ -66,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);
}
@@ -83,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>
);
@@ -101,30 +104,25 @@ export default function LoginPage() {
rocket_launch
</span>
</div>
<h1 className="text-3xl font-bold text-text-main tracking-tight">Welcome</h1>
<p className="text-text-muted mt-2">
Let&apos;s get your OmniRoute instance configured
</p>
<h1 className="text-3xl font-bold text-text-main tracking-tight">{t("welcome")}</h1>
<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>
<p className="text-center text-xs text-text-muted/60 mt-8">
OmniRoute Unified AI API Proxy
OmniRoute {t("unifiedProxy")}
</p>
</div>
</div>
@@ -144,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">Password protection is not enabled</p>
<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>
@@ -186,16 +181,16 @@ export default function LoginPage() {
</div>
<span className="text-xl font-semibold text-text-main tracking-tight">OmniRoute</span>
</div>
<h1 className="text-2xl font-bold text-text-main tracking-tight">Sign in</h1>
<p className="text-text-muted mt-1.5">Enter your password to continue</p>
<h1 className="text-2xl font-bold text-text-main tracking-tight">{t("signIn")}</h1>
<p className="text-text-muted mt-1.5">{t("enterPassword")}</p>
</div>
<form onSubmit={handleLogin} className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium text-text-main">Password</label>
<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
@@ -216,7 +211,7 @@ export default function LoginPage() {
className="w-full h-11 text-sm font-medium"
loading={loading}
>
Continue
{t("continue")}
</Button>
</form>
@@ -225,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>
@@ -237,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

@@ -1,11 +1,19 @@
import type { Metadata } from "next";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
export const metadata = {
title: "Privacy Policy | OmniRoute",
description: "Privacy policy for the OmniRoute AI API proxy router.",
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("legal");
return {
title: t("privacyMetadataTitle"),
description: t("privacyMetadataDescription"),
};
}
export default function PrivacyPage() {
const t = useTranslations("legal");
return (
<main className="min-h-screen bg-bg text-text-main">
<div className="max-w-3xl mx-auto px-6 py-16">
@@ -14,68 +22,62 @@ export default function PrivacyPage() {
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-primary transition-colors mb-8"
>
<span className="material-symbols-outlined text-[18px]">arrow_back</span>
Back to home
{t("backToHome")}
</Link>
<h1 className="text-3xl font-bold mb-2">Privacy Policy</h1>
<p className="text-sm text-text-muted mb-10">Last updated: February 13, 2026</p>
<h1 className="text-3xl font-bold mb-2">{t("privacyPolicy")}</h1>
<p className="text-sm text-text-muted mb-10">
{t("lastUpdated", { date: t("policyLastUpdatedDate") })}
</p>
<div className="space-y-8 text-text-muted leading-relaxed">
<section>
<h2 className="text-lg font-semibold text-text-main mb-3">
1. Local-First Architecture
{t("privacySection1Title")}
</h2>
<p>
OmniRoute is designed as a <strong className="text-text-main">local-first</strong>{" "}
application. All data processing and storage occurs entirely on your machine. There is
no centralized server collecting your information.
</p>
<p>{t("privacySection1Text")}</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-main mb-3">2. Data We Store</h2>
<h2 className="text-lg font-semibold text-text-main mb-3">
{t("privacySection2Title")}
</h2>
<p className="mb-3">
The following data is stored locally in{" "}
{t("privacyDataStoredIn")}{" "}
<code className="text-primary text-sm">~/.omniroute/storage.sqlite</code>:
</p>
<ul className="list-disc pl-6 space-y-2">
<li>
<strong className="text-text-main">Provider configurations</strong> connection
URLs, provider types, and priority settings
<strong className="text-text-main">{t("providerConfigurations")}</strong>{" "}
{t("listSeparator")} {t("privacyDataProviderConfigurationsDesc")}
</li>
<li>
<strong className="text-text-main">API keys</strong> encrypted and stored locally
for authenticating with AI providers
<strong className="text-text-main">{t("apiKeys")}</strong> {t("listSeparator")}{" "}
{t("privacyDataApiKeysDesc")}
</li>
<li>
<strong className="text-text-main">Usage logs</strong> request counts, token
usage, model names, timestamps, and response times
<strong className="text-text-main">{t("usageLogs")}</strong> {t("listSeparator")}{" "}
{t("privacyDataUsageLogsDesc")}
</li>
<li>
<strong className="text-text-main">Application settings</strong> theme
preferences, routing strategy, and combo configurations
<strong className="text-text-main">{t("applicationSettings")}</strong>{" "}
{t("listSeparator")} {t("privacyDataApplicationSettingsDesc")}
</li>
</ul>
</section>
<section>
<h2 className="text-lg font-semibold text-text-main mb-3">3. No Telemetry</h2>
<p>
OmniRoute does <strong className="text-text-main">not</strong> collect telemetry,
analytics, or crash reports. No data is sent to us or any third party. Your usage
patterns, API calls, and configurations remain entirely private.
</p>
<h2 className="text-lg font-semibold text-text-main mb-3">
{t("privacySection3Title")}
</h2>
<p>{t("privacySection3Text")}</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-main mb-3">
4. Third-Party AI Providers
{t("privacySection4Title")}
</h2>
<p>
When you make API calls through OmniRoute, your requests are forwarded to the AI
providers you have configured (e.g., OpenAI, Anthropic, Google). These providers have
their own privacy policies that govern how they handle your data. Please review:
</p>
<p>{t("privacySection4Text")}</p>
<ul className="list-disc pl-6 space-y-2 mt-3">
<li>
<a
@@ -84,7 +86,7 @@ export default function PrivacyPage() {
target="_blank"
rel="noopener noreferrer"
>
OpenAI Privacy Policy
{t("privacyOpenAiPolicy")}
</a>
</li>
<li>
@@ -94,7 +96,7 @@ export default function PrivacyPage() {
target="_blank"
rel="noopener noreferrer"
>
Anthropic Privacy Policy
{t("privacyAnthropicPolicy")}
</a>
</li>
<li>
@@ -104,53 +106,54 @@ export default function PrivacyPage() {
target="_blank"
rel="noopener noreferrer"
>
Google Privacy Policy
{t("privacyGooglePolicy")}
</a>
</li>
</ul>
</section>
<section>
<h2 className="text-lg font-semibold text-text-main mb-3">5. Cloud Sync (Optional)</h2>
<p>
If you enable the optional cloud sync feature, provider configurations and API keys
may be transmitted to a configured cloud endpoint. This feature is{" "}
<strong className="text-text-main">disabled by default</strong> and requires explicit
opt-in.
</p>
<h2 className="text-lg font-semibold text-text-main mb-3">
{t("privacySection5Title")}
</h2>
<p>{t("privacySection5Text")}</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-main mb-3">6. Logging</h2>
<p>Request logs can be configured through the dashboard settings. You can:</p>
<h2 className="text-lg font-semibold text-text-main mb-3">
{t("privacySection6Title")}
</h2>
<p>{t("privacyLoggingIntro")}</p>
<ul className="list-disc pl-6 space-y-2 mt-3">
<li>View and export usage analytics</li>
<li>Clear usage history at any time</li>
<li>Configure log retention policies</li>
<li>Back up and restore your database</li>
<li>{t("viewExportAnalytics")}</li>
<li>{t("clearHistory")}</li>
<li>{t("configureRetention")}</li>
<li>{t("backupRestore")}</li>
</ul>
</section>
<section>
<h2 className="text-lg font-semibold text-text-main mb-3">7. Your Rights</h2>
<h2 className="text-lg font-semibold text-text-main mb-3">
{t("privacySection7Title")}
</h2>
<p>
Since all data is stored locally, you have full control. You can delete your data at
any time by removing the <code className="text-primary text-sm">~/.omniroute/</code>{" "}
directory or using the database backup/restore features in the dashboard.
{t("privacySection7TextStart")}{" "}
<code className="text-primary text-sm">~/.omniroute/</code>{" "}
{t("privacySection7TextEnd")}
</p>
</section>
</div>
<div className="mt-12 pt-8 border-t border-white/[0.06] text-sm text-text-muted">
<p>
Questions? Visit our{" "}
{t("questionsVisit")}{" "}
<a
href="https://github.com/diegosouzapw/OmniRoute"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
GitHub repository
{t("githubRepository")}
</a>
.
</p>

View File

@@ -1,11 +1,19 @@
import type { Metadata } from "next";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
export const metadata = {
title: "Terms of Service | OmniRoute",
description: "Terms of service for the OmniRoute AI API proxy router.",
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("legal");
return {
title: t("termsMetadataTitle"),
description: t("termsMetadataDescription"),
};
}
export default function TermsPage() {
const t = useTranslations("legal");
return (
<main className="min-h-screen bg-bg text-text-main">
<div className="max-w-3xl mx-auto px-6 py-16">
@@ -14,95 +22,67 @@ export default function TermsPage() {
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-primary transition-colors mb-8"
>
<span className="material-symbols-outlined text-[18px]">arrow_back</span>
Back to home
{t("backToHome")}
</Link>
<h1 className="text-3xl font-bold mb-2">Terms of Service</h1>
<p className="text-sm text-text-muted mb-10">Last updated: February 13, 2026</p>
<h1 className="text-3xl font-bold mb-2">{t("termsOfService")}</h1>
<p className="text-sm text-text-muted mb-10">
{t("lastUpdated", { date: t("policyLastUpdatedDate") })}
</p>
<div className="space-y-8 text-text-muted leading-relaxed">
<section>
<h2 className="text-lg font-semibold text-text-main mb-3">1. Overview</h2>
<p>
OmniRoute is a <strong className="text-text-main">local-first</strong> AI API proxy
router that operates entirely on your machine. It routes requests to multiple AI
providers with load balancing, failover, and usage tracking.
</p>
<h2 className="text-lg font-semibold text-text-main mb-3">{t("termsSection1Title")}</h2>
<p>{t("termsSection1Text")}</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-main mb-3">2. User Responsibilities</h2>
<h2 className="text-lg font-semibold text-text-main mb-3">{t("termsSection2Title")}</h2>
<ul className="list-disc pl-6 space-y-2">
<li>
You are solely responsible for managing your own API keys and credentials for
third-party AI providers (OpenAI, Anthropic, Google, etc.).
</li>
<li>
You must comply with the terms of service of each AI provider whose API you access
through OmniRoute.
</li>
<li>
You are responsible for the security of your local OmniRoute installation, including
setting a password and restricting network access.
</li>
<li>{t("termsResponsibilityApiKeys")}</li>
<li>{t("termsResponsibilityCompliance")}</li>
<li>{t("termsResponsibilitySecurity")}</li>
</ul>
</section>
<section>
<h2 className="text-lg font-semibold text-text-main mb-3">3. How It Works</h2>
<p>
OmniRoute acts as an intermediary proxy. API calls sent to OmniRoute are translated
and forwarded to your configured AI providers. OmniRoute does not modify the content
of your requests or responses beyond the necessary protocol translation.
</p>
<h2 className="text-lg font-semibold text-text-main mb-3">{t("termsSection3Title")}</h2>
<p>{t("termsSection3Text")}</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-main mb-3">4. Data Handling</h2>
<h2 className="text-lg font-semibold text-text-main mb-3">{t("termsSection4Title")}</h2>
<ul className="list-disc pl-6 space-y-2">
<li>{t("termsDataStoredLocally")}</li>
<li>{t("termsNoTransmission")}</li>
<li>
All data is stored <strong className="text-text-main">locally</strong> on your
machine in a SQLite database.
</li>
<li>
OmniRoute does not transmit any data to external servers unless you explicitly
enable cloud sync features.
</li>
<li>
Usage logs, API keys, and configuration are stored in{" "}
{t("termsDataLocationText")}{" "}
<code className="text-primary text-sm">~/.omniroute/</code>.
</li>
</ul>
</section>
<section>
<h2 className="text-lg font-semibold text-text-main mb-3">5. Disclaimer</h2>
<p>
OmniRoute is provided &ldquo;as is&rdquo; without warranty of any kind. We are not
responsible for any costs incurred through API usage, service disruptions, or data
loss. Always maintain backups of your configuration.
</p>
<h2 className="text-lg font-semibold text-text-main mb-3">{t("termsSection5Title")}</h2>
<p>{t("termsSection5Text")}</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-main mb-3">6. Open Source</h2>
<p>
OmniRoute is open-source software. You are free to inspect, modify, and distribute it
under the terms of its license.
</p>
<h2 className="text-lg font-semibold text-text-main mb-3">{t("termsSection6Title")}</h2>
<p>{t("termsSection6Text")}</p>
</section>
</div>
<div className="mt-12 pt-8 border-t border-white/[0.06] text-sm text-text-muted">
<p>
Questions? Visit our{" "}
{t("questionsVisit")}{" "}
<a
href="https://github.com/diegosouzapw/OmniRoute"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
GitHub repository
{t("githubRepository")}
</a>
.
</p>

15
src/i18n/config.ts Normal file
View File

@@ -0,0 +1,15 @@
export const LOCALES = ["en", "pt-BR"] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "en";
export const LANGUAGES: readonly {
code: Locale;
label: string;
name: string;
flag: string;
}[] = [
{ code: "en", label: "EN", name: "English", flag: "🇺🇸" },
{ code: "pt-BR", label: "PT-BR", name: "Português (Brasil)", flag: "🇧🇷" },
] as const;
export const LOCALE_COOKIE = "NEXT_LOCALE";

2040
src/i18n/messages/en.json Normal file

File diff suppressed because it is too large Load Diff

2040
src/i18n/messages/pt-BR.json Normal file

File diff suppressed because it is too large Load Diff

28
src/i18n/request.ts Normal file
View File

@@ -0,0 +1,28 @@
import { getRequestConfig } from "next-intl/server";
import { cookies, headers } from "next/headers";
import { LOCALES, DEFAULT_LOCALE, LOCALE_COOKIE } from "./config";
import type { Locale } from "./config";
export default getRequestConfig(async () => {
// 1. Try cookie
const cookieStore = await cookies();
let locale: string = cookieStore.get(LOCALE_COOKIE)?.value || "";
// 2. Try custom header (set by middleware)
if (!locale) {
const headerStore = await headers();
locale = headerStore.get("x-locale") || "";
}
// 3. Validate & fallback
if (!LOCALES.includes(locale as Locale)) {
locale = DEFAULT_LOCALE;
}
const messages = (await import(`./messages/${locale}.json`)).default;
return {
locale,
messages,
};
});

Some files were not shown because too many files have changed in this diff Show More