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

* fix(i18n): preserve remaining Vietnamese localization

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

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

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

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

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

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

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

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

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

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

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

---------

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

View File

@@ -179,19 +179,6 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx": {
"no-restricted-syntax": {
"count": 3
}
},
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": {
"no-restricted-syntax": {
"count": 2
},
"react-hooks/exhaustive-deps": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/cli-agents/CliAgentsPageClient.tsx": {
"no-restricted-syntax": {
"count": 4
@@ -207,11 +194,6 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/media-providers/components/ImageExampleCard.tsx": {
"@next/next/no-img-element": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": {
"react-hooks/exhaustive-deps": {
"count": 1
@@ -232,11 +214,6 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx": {
"react-hooks/exhaustive-deps": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/SessionInfoCard.tsx": {
"react-hooks/exhaustive-deps": {
"count": 1
@@ -247,11 +224,6 @@
"count": 3
}
},
"src/app/(dashboard)/dashboard/tools/agent-bridge/components/ModelSelectorModal.tsx": {
"no-restricted-syntax": {
"count": 2
}
},
"src/app/api/services/[name]/logs/route.ts": {
"no-restricted-syntax": {
"count": 1

View File

@@ -1,4 +1,5 @@
{
"_rebaseline_2026_07_21_7935_vi_locale_residual_ui": "PR #7935 (nguyenha935, fix/vietnamese-locale-residual) own growth: 9 dashboard components gained `useTranslations()` wiring (import + hook call + a handful of `t(\"key\")` call-sites replacing hardcoded English strings) as part of restoring i18n coverage — ComboHealthTab.tsx 1028->1031 (+3), cloud-agents/page.tsx 922->931 (+9), PoolWizard.tsx 1007->1022 (+15), EndpointPageClient.tsx 2612->2615 (+3), health/page.tsx 1091->1095 (+4), ProviderOnboardingWizard.tsx 912->948 (+36, largest — several previously-hardcoded wizard step labels/descriptions), PricingTab.tsx 1012->1017 (+5), ProxyRegistryManager.tsx 1461->1464 (+3), BudgetTab.tsx 1016->1028 (+12). All additions are literal `t(...)`/`tc(...)` call-site swaps for existing UI text, verified byte-identical in intent against the corresponding new `src/i18n/messages/{en,vi}.json` keys (see tests/unit/dashboard-localization-contract.test.ts, tests/unit/i18n-vi-completeness.test.ts, tests/unit/gamification-display-contract.test.ts, tests/unit/cli-catalog-display-contract.test.ts added by the same PR). Fast-gates PR->release do not run check:file-size, so this surfaced only during rebase-onto-tip reconciliation.",
"_rebaseline_2026_07_21_7908_chathelpers_abort_guard": "PR #7908 (insoln, don't cool down accounts or trip the breaker on client-side stream aborts, #7907) own growth: src/sse/handlers/chatHelpers.ts 876->877 (+1 = the single `isLocalStreamLifecycleError(failure?.message ?? failure)` clause added to executeChatWithBreaker's onStreamFailure connection-disable check, verified working by the existing #4602 test + the PR's own circuit-breaker-client-abort.test.ts, no regressions). Irreducible call-site wiring at the existing failure-classification chokepoint. Fast-gates PR->release do not run check:file-size, so this surfaced only during the /green-prs pre-merge pass.",
"_rebaseline_2026_07_21_7908_combo_breaker_abort_guard": "PR #7908 pre-green fix (green-prs pipeline): shouldRecordProviderBreakerFailure() (open-sse/services/combo/comboPredicates.ts, not frozen) gained an `error` field so a client-side stream abort no longer trips the whole-provider circuit breaker in the combo path (mirrors the connection-cooldown fix shouldSkipConnDisable() already applies for the same #4602/#7907 policy). Own growth: open-sse/services/combo.ts 3387->3388 (+1, irreducible call-site wiring — the single new `error: errorText,` field passed at the existing shouldRecordProviderBreakerFailure() call site inside handleComboChat's executeTarget). Covered by tests/unit/circuit-breaker-abort-provider-trip-7907.test.ts.",
"_rebaseline_2026_07_20_7818_provider_tier_field": "Issue #7818 (explicit tier override for any provider connection) own growth: EditConnectionModal.tsx 1285->1287 (+2 = import + a single <ProviderTierField .../> render call, mirroring the m365Tier.ts precedent). All actual selector logic (fetch/save against the new /api/settings/tier-config route) lives in the new leaf src/app/(dashboard)/dashboard/providers/[id]/components/modals/ProviderTierField.tsx + providerTierField.ts (both well under cap). Covered by tests/unit/tier-config-provider-override-route.test.ts and tests/unit/tier-resolver-provider-override.test.ts.",
@@ -207,18 +208,18 @@
"open-sse/utils/cursorAgentProtobuf.ts": 1521,
"open-sse/utils/stream.ts": 2814,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1385,
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1028,
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1031,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3120,
"src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1105,
"src/app/(dashboard)/dashboard/cache/page.tsx": 845,
"src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": 900,
"src/app/(dashboard)/dashboard/cloud-agents/page.tsx": 922,
"src/app/(dashboard)/dashboard/cloud-agents/page.tsx": 931,
"_rebaseline_2026_07_15_7070_combos_memo": "PR #7070 (perf/p1-memo) own growth: src/app/(dashboard)/dashboard/combos/page.tsx 4655->4656 (+1 = React.memo wrapping of ComboCard). Covered by tests/unit/ui/combos-page-smoke.test.tsx.",
"src/app/(dashboard)/dashboard/combos/page.tsx": 4656,
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1495,
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1007,
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2612,
"src/app/(dashboard)/dashboard/health/page.tsx": 1091,
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1022,
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2615,
"src/app/(dashboard)/dashboard/health/page.tsx": 1095,
"src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": 847,
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 798,
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": 942,
@@ -228,19 +229,19 @@
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderModels.ts": 155,
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": 264,
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1054,
"src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 912,
"src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 948,
"src/app/(dashboard)/dashboard/providers/page.tsx": 1927,
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1201,
"src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx": 819,
"src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx": 884,
"src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx": 974,
"src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx": 898,
"src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1012,
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1461,
"src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1017,
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1464,
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1183,
"src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1629,
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1924,
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1016,
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1028,
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1127,
"src/app/api/oauth/[provider]/[action]/route.ts": 970,

View File

@@ -4,10 +4,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import type { AuditLogEntry } from "@/lib/compliance/index";
import ActivityFeed from "./components/ActivityFeed";
import EventTypeFilter, {
type EventCategory,
matchesCategory,
} from "./components/EventTypeFilter";
import EventTypeFilter, { type EventCategory, matchesCategory } from "./components/EventTypeFilter";
const FEED_LIMIT = 200;
@@ -36,7 +33,7 @@ export default function ActivityFeedClient() {
referenceNowMs.current = Date.now();
setAllEntries(Array.isArray(data) ? data : []);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Failed to fetch activity";
const msg = err instanceof Error ? err.message : t("fetchFailed");
setError(msg);
} finally {
setLoading(false);
@@ -68,7 +65,7 @@ export default function ActivityFeedClient() {
onClick={() => fetchEntries()}
disabled={loading}
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"
aria-label="Refresh activity feed"
aria-label={t("refreshAria")}
>
{loading ? (
<span className="flex items-center gap-2">
@@ -78,14 +75,14 @@ export default function ActivityFeedClient() {
>
progress_activity
</span>
Loading
{t("loading")}
</span>
) : (
<span className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
refresh
</span>
Refresh
{t("refresh")}
</span>
)}
</button>
@@ -114,7 +111,7 @@ export default function ActivityFeedClient() {
>
progress_activity
</span>
<span className="text-sm">Loading activity</span>
<span className="text-sm">{t("loadingActivity")}</span>
</div>
) : (
<ActivityFeed entries={filtered} referenceNowMs={referenceNowMs.current} />

View File

@@ -3,14 +3,7 @@
import { useTranslations } from "next-intl";
export type EventCategory =
| "all"
| "providers"
| "combos"
| "apikeys"
| "settings"
| "quota"
| "auth"
| "system";
"all" | "providers" | "combos" | "apikeys" | "settings" | "quota" | "auth" | "system";
interface EventTypeFilterProps {
value: EventCategory;
@@ -60,11 +53,7 @@ export default function EventTypeFilter({ value, onChange }: EventTypeFilterProp
};
return (
<div
className="flex flex-wrap gap-2"
role="group"
aria-label="Filter by event type"
>
<div className="flex flex-wrap gap-2" role="group" aria-label={t("filterAria")}>
{CATEGORIES.map((cat) => (
<button
key={cat}

View File

@@ -1,8 +1,9 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import { SkillsConceptCard } from "@/shared/components/SkillsConceptCard";
import { matchesSearch } from "@/shared/utils/turkishText";
import { CoverageBar } from "./components/CoverageBar";
import { McpA2aLinksBar } from "./components/McpA2aLinksBar";
import { SkillCard } from "./components/SkillCard";
@@ -108,7 +109,7 @@ export function AgentSkillsPageClient(): JSX.Element {
setLoadingPreview(false);
}
},
[markdownCache],
[markdownCache]
);
// ── Debounced preview load (200ms) ───────────────────────────────────────
@@ -120,12 +121,9 @@ export function AgentSkillsPageClient(): JSX.Element {
return () => clearTimeout(timer);
}, [selectedId, loadPreview]);
const handleSelectCard = useCallback(
(id: string) => {
setSelectedId(id);
},
[],
);
const handleSelectCard = useCallback((id: string) => {
setSelectedId(id);
}, []);
const handleRefreshPreview = useCallback(() => {
if (!selectedId) return;
@@ -164,22 +162,34 @@ export function AgentSkillsPageClient(): JSX.Element {
}, [t]);
// ── Filtering + search ────────────────────────────────────────────────────
const filteredSkills = catalog.filter((s) => {
const localizedCatalog = useMemo(
() =>
catalog.map((skill) => {
const nameKey = `catalog.${skill.id}.name`;
const descriptionKey = `catalog.${skill.id}.description`;
return {
...skill,
name: t.has(nameKey) ? t(nameKey) : skill.name,
description: t.has(descriptionKey) ? t(descriptionKey) : skill.description,
};
}),
[catalog, t]
);
const filteredSkills = localizedCatalog.filter((s) => {
if (filter !== "all" && s.category !== filter) return false;
if (searchTerm.trim()) {
const q = searchTerm.toLowerCase();
return (
s.name.toLowerCase().includes(q) ||
s.description.toLowerCase().includes(q) ||
s.id.toLowerCase().includes(q)
matchesSearch(s.name, searchTerm) ||
matchesSearch(s.description, searchTerm) ||
matchesSearch(s.id, searchTerm)
);
}
return true;
});
const selectedMarkdown = selectedId ? markdownCache.get(selectedId) ?? null : null;
const coverageTotal =
coverage !== null ? coverage.api.have + coverage.cli.have : null;
const selectedMarkdown = selectedId ? (markdownCache.get(selectedId) ?? null) : null;
const coverageTotal = coverage !== null ? coverage.api.have + coverage.cli.have : null;
const showGenerateButton = coverageTotal !== null && coverageTotal < 42;
return (
@@ -211,11 +221,7 @@ export function AgentSkillsPageClient(): JSX.Element {
</button>
)}
</div>
{coverage ? (
<CoverageBar coverage={coverage} />
) : (
<CoverageBarSkeleton />
)}
{coverage ? <CoverageBar coverage={coverage} /> : <CoverageBarSkeleton />}
</div>
{/* MCP + A2A links */}
@@ -264,10 +270,7 @@ export function AgentSkillsPageClient(): JSX.Element {
{/* Two-column grid: left = skill cards, right = preview */}
<div className="grid grid-cols-12 gap-4" data-testid="skills-grid">
{/* Left: skill cards list (col-span 7) */}
<div
className="col-span-12 lg:col-span-7 flex flex-col gap-2"
data-testid="skills-list"
>
<div className="col-span-12 lg:col-span-7 flex flex-col gap-2" data-testid="skills-list">
{loadingCatalog ? (
Array.from({ length: 6 }).map((_, i) => <SkillCardSkeleton key={i} />)
) : filteredSkills.length === 0 ? (

View File

@@ -9,7 +9,7 @@ function useOrigin(): string {
return useSyncExternalStore(
() => () => {}, // no external subscription needed
() => (typeof window !== "undefined" ? window.location.origin : ""),
() => "", // server snapshot
() => "" // server snapshot
);
}
@@ -78,14 +78,9 @@ export function McpA2aLinksBar(): JSX.Element {
label={t("mcpUrl")}
url={mcpUrl}
icon="electrical_services"
prompt="Add this MCP endpoint to your agent to give it 37 OmniRoute tools."
/>
<LinkCard
label={t("a2aLink")}
url={a2aUrl}
icon="hub"
prompt="Register this Agent Card with your orchestrator to enable A2A task delegation."
prompt={t("mcpPrompt")}
/>
<LinkCard label={t("a2aLink")} url={a2aUrl} icon="hub" prompt={t("a2aPrompt")} />
</div>
);
}

View File

@@ -62,9 +62,7 @@ export function SkillPreviewPane({
className="flex flex-col items-center justify-center h-full min-h-[300px] rounded-xl border border-dashed border-border bg-bg-subtle/30 p-8 text-center"
data-testid="skill-preview-empty"
>
<span className="material-symbols-outlined text-[32px] text-text-muted mb-3">
article
</span>
<span className="material-symbols-outlined text-[32px] text-text-muted mb-3">article</span>
<p className="text-sm text-text-muted">{t("previewEmpty")}</p>
</div>
);
@@ -86,7 +84,7 @@ export function SkillPreviewPane({
onClick={onRefresh}
disabled={loading}
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors disabled:opacity-50"
aria-label="Refresh"
aria-label={t("refresh")}
>
<span
className={`material-symbols-outlined text-[14px] ${loading ? "animate-spin" : ""}`}

View File

@@ -64,40 +64,16 @@ function getAutopilotVariant(state: ComboAutopilotCombo["state"]) {
return "success" as const;
}
function getAutopilotLabel(state: ComboAutopilotCombo["state"]) {
if (state === "down") return "Down";
if (state === "degraded") return "Needs attention";
return "Healthy";
}
function getIssueVariant(severity: ComboAutopilotIssue["severity"]) {
if (severity === "critical") return "error" as const;
if (severity === "warning") return "warning" as const;
return "info" as const;
}
function getFactorLabel(key: string) {
const labels: Record<string, string> = {
quota: "Quota",
health: "Health",
costInv: "Cost",
latencyInv: "Latency",
taskFit: "Task fit",
stability: "Stability",
tierPriority: "Tier",
tierAffinity: "Tier fit",
specificityMatch: "Specificity",
contextAffinity: "Context",
resetWindowAffinity: "Reset window",
};
return labels[key] ?? key;
}
function getTrendMeta(trend: ComboHealthMetrics["quotaHealth"]["providers"][number]["trend"]) {
if (trend === "improving") {
return {
icon: "trending_up",
label: "Improving",
variant: "success" as const,
};
}
@@ -105,14 +81,12 @@ function getTrendMeta(trend: ComboHealthMetrics["quotaHealth"]["providers"][numb
if (trend === "declining") {
return {
icon: "trending_down",
label: "Declining",
variant: "warning" as const,
};
}
return {
icon: "trending_flat",
label: "Stable",
variant: "default" as const,
};
}
@@ -157,6 +131,7 @@ function DistributionBar({ label, value, meta }: { label: string; value: number;
}
function ComboForecastPanel({ forecast }: { forecast: ComboForecastMetrics }) {
const t = useTranslations("analytics");
const nodeMap = useProviderNodeMap();
const topTargets = useMemo(
() =>
@@ -170,17 +145,19 @@ function ComboForecastPanel({ forecast }: { forecast: ComboForecastMetrics }) {
<div className="border-t border-black/5 px-6 py-5 dark:border-white/5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-sm font-semibold text-text-main">Cost & quota forecast</div>
<div className="mt-1 text-xs text-text-muted">
Linear projection from historical combo traffic and quota snapshots.
<div className="text-sm font-semibold text-text-main">
{t("comboHealthForecastTitle")}
</div>
<div className="mt-1 text-xs text-text-muted">{t("comboHealthForecastDescription")}</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={getRiskVariant(forecast.quotaRisk.level)} size="sm">
{forecast.quotaRisk.level} quota risk
{t("comboHealthQuotaRisk", { level: forecast.quotaRisk.level })}
</Badge>
<Badge variant={forecast.confidence === "no_data" ? "default" : "info"} size="sm">
{forecast.confidence.replace("_", " ")} confidence
{t("comboHealthConfidence", {
level: forecast.confidence.replace("_", " "),
})}
</Badge>
</div>
</div>
@@ -188,21 +165,24 @@ function ComboForecastPanel({ forecast }: { forecast: ComboForecastMetrics }) {
<div className="mt-4 grid gap-3 md:grid-cols-3">
<MetricBlock
icon="payments"
label="Projected cost"
label={t("comboHealthProjectedCost")}
value={formatUsd(forecast.forecast.projectedCostUsd)}
subValue={`history ${formatUsd(forecast.history.costUsd)} · ${formatUsd(
forecast.history.avgDailyCostUsd
)}/day`}
subValue={t("comboHealthCostHistory", {
total: formatUsd(forecast.history.costUsd),
daily: formatUsd(forecast.history.avgDailyCostUsd),
})}
/>
<MetricBlock
icon="query_stats"
label="Projected requests"
label={t("comboHealthProjectedRequests")}
value={formatCompactNumber(forecast.forecast.projectedRequests)}
subValue={`${formatCompactNumber(forecast.history.requests)} in selected range`}
subValue={t("comboHealthRequestsInRange", {
count: formatCompactNumber(forecast.history.requests),
})}
/>
<MetricBlock
icon="battery_alert"
label="Worst projected quota"
label={t("comboHealthWorstProjectedQuota")}
value={
forecast.quotaRisk.projectedWorstRemainingPct === null
? "n/a"
@@ -210,8 +190,10 @@ function ComboForecastPanel({ forecast }: { forecast: ComboForecastMetrics }) {
}
subValue={
forecast.quotaRisk.timeToExhaustDays === null
? "No depletion estimate"
: `${forecast.quotaRisk.timeToExhaustDays.toFixed(1)}d to exhaust`
? t("comboHealthNoDepletionEstimate")
: t("comboHealthDaysToExhaust", {
days: forecast.quotaRisk.timeToExhaustDays.toFixed(1),
})
}
/>
</div>
@@ -229,7 +211,7 @@ function ComboForecastPanel({ forecast }: { forecast: ComboForecastMetrics }) {
{target.label || target.model}
</div>
<div className="mt-1 text-xs text-text-muted">
{resolveProviderName(target.provider, nodeMap)} · traffic{" "}
{resolveProviderName(target.provider, nodeMap)} · {t("comboHealthTraffic")}{" "}
{formatShare(target.trafficShare)}
</div>
</div>
@@ -239,13 +221,13 @@ function ComboForecastPanel({ forecast }: { forecast: ComboForecastMetrics }) {
</div>
<div className="mt-3 grid gap-2 text-xs text-text-muted">
<div className="flex justify-between gap-3">
<span>Projected cost</span>
<span>{t("comboHealthProjectedCost")}</span>
<span className="font-medium text-text-main">
{formatUsd(target.forecast.projectedCostUsd)}
</span>
</div>
<div className="flex justify-between gap-3">
<span>Projected quota</span>
<span>{t("comboHealthProjectedQuota")}</span>
<span className="font-medium text-text-main">
{target.quota.projectedRemainingPct === null
? "n/a"
@@ -253,7 +235,7 @@ function ComboForecastPanel({ forecast }: { forecast: ComboForecastMetrics }) {
</span>
</div>
<div className="flex justify-between gap-3">
<span>Pricing coverage</span>
<span>{t("comboHealthPricingCoverage")}</span>
<span className="font-medium text-text-main">
{formatPercent(forecast.dataQuality.pricingCoveragePct, 0)}
</span>
@@ -274,6 +256,7 @@ function ComboForecastPanel({ forecast }: { forecast: ComboForecastMetrics }) {
}
function ComboAutopilotPanel({ report }: { report: ComboAutopilotReport }) {
const t = useTranslations("analytics");
const topIssues = useMemo(
() =>
report.combos.flatMap((combo) => combo.issues.map((issue) => ({ combo, issue }))).slice(0, 5),
@@ -285,7 +268,9 @@ function ComboAutopilotPanel({ report }: { report: ComboAutopilotReport }) {
<div className="flex flex-col gap-4 border-b border-black/5 px-6 py-5 dark:border-white/5 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-base font-semibold text-text-main">Combo Health Autopilot</h3>
<h3 className="text-base font-semibold text-text-main">
{t("comboHealthAutopilotTitle")}
</h3>
<Badge
variant={
report.status === "critical"
@@ -299,30 +284,28 @@ function ComboAutopilotPanel({ report }: { report: ComboAutopilotReport }) {
{report.status}
</Badge>
</div>
<p className="mt-1 text-sm text-text-muted">
Prioritized recommendations from combo health, forecasts, quotas, and provider health.
</p>
<p className="mt-1 text-sm text-text-muted">{t("comboHealthAutopilotDescription")}</p>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:min-w-90">
<MetricBlock
icon="monitor_heart"
label="Issues"
label={t("comboHealthIssues")}
value={report.summary.issueCount.toLocaleString()}
subValue={`${report.summary.actionableCount} actionable`}
subValue={t("comboHealthActionable", { count: report.summary.actionableCount })}
/>
<MetricBlock
icon="error"
label="Down"
label={t("comboHealthDown")}
value={report.summary.downCount.toLocaleString()}
/>
<MetricBlock
icon="warning"
label="Degraded"
label={t("comboHealthDegraded")}
value={report.summary.degradedCount.toLocaleString()}
/>
<MetricBlock
icon="task_alt"
label="Healthy"
label={t("comboHealthHealthy")}
value={report.summary.healthyCount.toLocaleString()}
/>
</div>
@@ -373,7 +356,7 @@ function ComboAutopilotPanel({ report }: { report: ComboAutopilotReport }) {
</div>
) : (
<div className="rounded-lg border border-green-500/20 bg-green-500/5 p-4 text-sm text-green-700 dark:text-green-300">
No active combo health issues detected for the selected range.
{t("comboHealthNoActiveIssues")}
</div>
)}
</div>
@@ -382,6 +365,7 @@ function ComboAutopilotPanel({ report }: { report: ComboAutopilotReport }) {
}
function ComboScoringInspectorPanel({ inspector }: { inspector: ComboScoringInspectorCombo }) {
const t = useTranslations("analytics");
const nodeMap = useProviderNodeMap();
const topTargets = inspector.targets.slice(0, 3);
@@ -391,24 +375,21 @@ function ComboScoringInspectorPanel({ inspector }: { inspector: ComboScoringInsp
<div>
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-semibold text-text-main">
Intelligent scoring inspector
{t("comboHealthScoringInspector")}
</div>
<Badge variant="info" size="sm">
Read-only recompute
{t("comboHealthReadOnlyRecompute")}
</Badge>
</div>
<div className="mt-1 text-xs text-text-muted">
Factor-level explanation for target ranking using current health, forecast, and routing
heuristics.
</div>
<div className="mt-1 text-xs text-text-muted">{t("comboHealthScoringDescription")}</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="primary" size="sm">
Task: {inspector.taskType}
{t("comboHealthTask", { task: inspector.taskType })}
</Badge>
{inspector.selectedExecutionKey ? (
<Badge variant="success" size="sm">
Selected rank #1
{t("comboHealthSelectedRank")}
</Badge>
) : null}
</div>
@@ -448,7 +429,7 @@ function ComboScoringInspectorPanel({ inspector }: { inspector: ComboScoringInsp
{topFactors.map((factor) => (
<div key={factor.key} className="text-xs">
<div className="flex items-center justify-between gap-3 text-text-muted">
<span>{getFactorLabel(factor.key)}</span>
<span>{t(`comboHealthFactor.${factor.key}`)}</span>
<span className="font-medium text-text-main">
+{factor.contribution.toFixed(3)}
</span>
@@ -464,9 +445,21 @@ function ComboScoringInspectorPanel({ inspector }: { inspector: ComboScoringInsp
</div>
<div className="mt-3 flex flex-wrap gap-2 text-[11px] text-text-muted">
<span>Quota {formatPercentOrDash(target.signals.quotaRemainingPct)}</span>
<span>Latency {target.signals.avgLatencyMs ?? "n/a"}ms</span>
<span>Issues {target.signals.autopilotIssueCount}</span>
<span>
{t("comboHealthQuotaValue", {
value: formatPercentOrDash(target.signals.quotaRemainingPct),
})}
</span>
<span>
{t("comboHealthLatencyValue", {
value: target.signals.avgLatencyMs ?? "n/a",
})}
</span>
<span>
{t("comboHealthIssueCount", {
count: target.signals.autopilotIssueCount,
})}
</span>
</div>
</div>
);
@@ -474,7 +467,7 @@ function ComboScoringInspectorPanel({ inspector }: { inspector: ComboScoringInsp
</div>
) : (
<div className="mt-4 rounded-lg border border-black/5 bg-black/2 p-4 text-sm text-text-muted dark:border-white/5 dark:bg-white/2">
No inspectable targets for this combo.
{t("comboHealthNoInspectableTargets")}
</div>
)}
</div>
@@ -516,12 +509,15 @@ function ComboHealthCard({
</Badge>
{autopilot ? (
<Badge variant={getAutopilotVariant(autopilot.state)} size="sm">
{getAutopilotLabel(autopilot.state)} · {autopilot.score}
{t(`comboHealthAutopilotState.${autopilot.state}`)} · {autopilot.score}
</Badge>
) : null}
</div>
<p className="mt-2 text-sm text-text-muted">
{combo.models.length} models across {combo.quotaHealth.providers.length} providers
{t("comboHealthModelProviderCount", {
models: combo.models.length,
providers: combo.quotaHealth.providers.length,
})}
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 lg:min-w-105">
@@ -534,13 +530,15 @@ function ComboHealthCard({
icon="balance"
label={t("comboHealthUsageSkew")}
value={combo.usageSkew.giniCoefficient.toFixed(2)}
subValue="Gini coefficient"
subValue={t("comboHealthGiniCoefficient")}
/>
<MetricBlock
icon="bolt"
label={t("comboHealthSuccessRate")}
value={formatPercent(combo.performance.successRate * 100, 1)}
subValue={`${combo.performance.totalRequests.toLocaleString()} requests`}
subValue={t("comboHealthRequestCount", {
count: combo.performance.totalRequests.toLocaleString(),
})}
/>
</div>
</div>
@@ -553,7 +551,7 @@ function ComboHealthCard({
{t("comboHealthQuotaHealth")}
</div>
<div className="mt-1 text-xs text-text-muted">
Lowest remaining quota across providers with short trend signals.
{t("comboHealthQuotaHealthDescription")}
</div>
</div>
@@ -573,16 +571,18 @@ function ComboHealthCard({
{resolveProviderName(provider.provider, nodeMap)}
</div>
<div className="mt-1 text-xs text-text-muted">
Remaining quota {formatPercent(provider.remainingPct, 1)}
{t("comboHealthRemainingQuota", {
value: formatPercent(provider.remainingPct, 1),
})}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={trendMeta.variant} size="sm" icon={trendMeta.icon}>
{trendMeta.label}
{t(`comboHealthTrend.${provider.trend}`)}
</Badge>
{provider.isExhausted ? (
<Badge variant="error" size="sm">
Exhausted
{t("statusExhausted")}
</Badge>
) : null}
</div>
@@ -603,7 +603,7 @@ function ComboHealthCard({
<div>
<div className="text-sm font-semibold text-text-main">{t("comboHealthUsageSkew")}</div>
<div className="mt-1 text-xs text-text-muted">
Model request share and token share within this combo.
{t("comboHealthUsageSkewDescription")}
</div>
</div>
@@ -617,8 +617,10 @@ function ComboHealthCard({
<div className="min-w-0">
<div className="truncate text-sm font-medium text-text-main">{entry.model}</div>
<div className="mt-1 text-xs text-text-muted">
Request share {formatShare(entry.requestShare)} · Token share{" "}
{formatShare(entry.tokenShare)}
{t("comboHealthShareSummary", {
requests: formatShare(entry.requestShare),
tokens: formatShare(entry.tokenShare),
})}
</div>
</div>
<Badge size="sm">{formatShare(entry.requestShare)}</Badge>
@@ -643,9 +645,11 @@ function ComboHealthCard({
<section className="flex flex-col gap-4">
<div>
<div className="text-sm font-semibold text-text-main">Performance</div>
<div className="text-sm font-semibold text-text-main">
{t("comboHealthPerformance")}
</div>
<div className="mt-1 text-xs text-text-muted">
Reliability and throughput for routed combo traffic.
{t("comboHealthPerformanceDescription")}
</div>
</div>
@@ -676,7 +680,7 @@ function ComboHealthCard({
{t("comboHealthExecutionTargets")}
</div>
<div className="mt-1 text-xs text-text-muted">
Step-level runtime metrics and quota visibility for structured combo targets.
{t("comboHealthExecutionTargetsDescription")}
</div>
</div>
@@ -704,7 +708,7 @@ function ComboHealthCard({
</Badge>
) : null}
<Badge size="sm" variant="default">
{target.requests} req
{t("comboHealthRequestShort", { count: target.requests })}
</Badge>
</div>
</div>
@@ -728,9 +732,11 @@ function ComboHealthCard({
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-text-muted">
<span>Quota scope: {target.quotaScope}</span>
{target.quotaTrend ? <span>Trend: {target.quotaTrend}</span> : null}
{target.quotaIsExhausted ? <span>Exhausted</span> : null}
<span>{t("comboHealthQuotaScope", { scope: target.quotaScope })}</span>
{target.quotaTrend ? (
<span>{t("comboHealthTrendValue", { trend: target.quotaTrend })}</span>
) : null}
{target.quotaIsExhausted ? <span>{t("statusExhausted")}</span> : null}
</div>
</div>
))}
@@ -814,7 +820,7 @@ export default function ComboHealthTab() {
);
if (!response.ok) {
throw new Error("Failed to fetch combo health data");
throw new Error(t("comboHealthFetchFailed"));
}
const result = (await response.json()) as ComboHealthDashboardResponse;
@@ -830,7 +836,7 @@ export default function ComboHealthTab() {
if ((fetchError as Error).name === "AbortError") {
return;
}
setError(fetchError instanceof Error ? fetchError.message : "Unknown error");
setError(fetchError instanceof Error ? fetchError.message : t("unknownError"));
if (!isRetry) setData(null);
} finally {
if (!controller.signal.aborted) {
@@ -839,7 +845,7 @@ export default function ComboHealthTab() {
}
}
},
[range, horizon]
[range, horizon, t]
);
useEffect(() => {
@@ -872,9 +878,7 @@ export default function ComboHealthTab() {
<div className="flex flex-col gap-4 rounded-xl border border-black/5 bg-surface p-5 shadow-sm dark:border-white/5 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-lg font-semibold text-text-main">{t("comboHealthTitle")}</h2>
<p className="mt-1 text-sm text-text-muted">
Monitor quota pressure, skewed model usage, and delivery performance by combo.
</p>
<p className="mt-1 text-sm text-text-muted">{t("comboHealthIntro")}</p>
</div>
<div className="flex flex-col gap-3 sm:items-end">
<TimeRangeSelector value={range} onChange={setRange} />
@@ -891,7 +895,7 @@ export default function ComboHealthTab() {
: "text-text-muted hover:bg-black/5 hover:text-text-main dark:hover:bg-white/5"
)}
>
{value} forecast
{t("comboHealthForecastHorizon", { value })}
</button>
))}
</div>
@@ -946,12 +950,12 @@ export default function ComboHealthTab() {
<span className="material-symbols-outlined animate-spin text-[18px]">
progress_activity
</span>
Retrying
{t("retrying")}
</>
) : (
<>
<span className="material-symbols-outlined text-[18px]">refresh</span>
Retry
{t("retry")}
</>
)}
</button>
@@ -965,12 +969,9 @@ export default function ComboHealthTab() {
<span className="material-symbols-outlined text-[40px] text-text-muted/70">
monitor_heart
</span>
<div className="text-base font-medium text-text-main">
No combo health data available
</div>
<div className="text-base font-medium text-text-main">{t("comboHealthNoData")}</div>
<div className="max-w-md text-sm text-text-muted">
Combo quota snapshots and routed requests will appear here after traffic starts
flowing.
{t("comboHealthNoDataDescription")}
</div>
<div className="rounded-lg border border-black/5 bg-black/[0.02] p-4 dark:border-white/5 dark:bg-white/[0.02]">
<p className="text-xs font-medium text-text-main">{t("comboHealthGettingStarted")}</p>
@@ -980,20 +981,22 @@ export default function ComboHealthTab() {
check_circle
</span>
<span>
Create combos in <strong>Combos</strong> with multiple providers
{t.rich("comboHealthStepCreate", {
strong: (chunks) => <strong>{chunks}</strong>,
})}
</span>
</li>
<li className="mt-1 flex items-start gap-2">
<span className="material-symbols-outlined text-[14px] text-primary">
check_circle
</span>
<span>Send requests to combo endpoints to generate traffic data</span>
<span>{t("comboHealthStepSend")}</span>
</li>
<li className="mt-1 flex items-start gap-2">
<span className="material-symbols-outlined text-[14px] text-primary">
check_circle
</span>
<span>Health metrics will appear automatically as requests are routed</span>
<span>{t("comboHealthStepAutomatic")}</span>
</li>
</ul>
</div>
@@ -1009,7 +1012,7 @@ export default function ComboHealthTab() {
size="sm"
className={cn("text-primary", "[&_.material-symbols-outlined]:text-[16px]")}
/>
Tracking {combos.length} combos for {range}
{t("comboHealthTracking", { count: combos.length, range })}
</div>
{combos.map((combo) => (
<ComboHealthCard

View File

@@ -73,19 +73,22 @@ function ModeBar({
tokensSaved: number;
skipped?: number;
}) {
const t = useTranslations("analytics");
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
return (
<div className="flex flex-col gap-1">
<div className="flex justify-between text-sm">
<span className="font-medium text-text capitalize">{mode}</span>
<span className="text-text-muted">
{count} requests · {tokensSaved.toLocaleString()} tokens saved
{t("compressionAnalyticsModeStats", {
count,
tokens: tokensSaved.toLocaleString(),
})}
{skipped > 0 && (
// #4268: attempted-but-no-op runs (e.g. Stacked saved nothing) are
// recorded now, so this mode is visible even when count is 0.
<span className="text-text-muted/70">
{" "}
· {skipped.toLocaleString()} skipped (no-op)
{t("compressionAnalyticsSkipped", { count: skipped.toLocaleString() })}
</span>
)}
</span>
@@ -112,13 +115,17 @@ function ProviderBar({
total: number;
tokensSaved: number;
}) {
const t = useTranslations("analytics");
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
return (
<div className="flex flex-col gap-1">
<div className="flex justify-between text-sm">
<span className="font-medium text-text">{provider}</span>
<span className="text-text-muted">
{count} requests · {tokensSaved.toLocaleString()} tokens saved
{t("compressionAnalyticsModeStats", {
count,
tokens: tokensSaved.toLocaleString(),
})}
</span>
</div>
<div className="h-2 rounded-full bg-bg-muted overflow-hidden">
@@ -157,7 +164,7 @@ export default function CompressionAnalyticsTab() {
return (
<div className="flex items-center justify-center py-16 text-text-muted">
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
Loading compression analytics
{t("compressionAnalyticsLoading")}
</div>
);
}
@@ -166,11 +173,8 @@ export default function CompressionAnalyticsTab() {
return (
<div className="card p-6 text-center text-text-muted">
<span className="material-symbols-outlined text-[32px] mb-2 block">compress</span>
{error || "No compression data yet."}
<p className="text-xs mt-2">
Compression requests will appear here after the first request via /v1/chat/completions
with compression enabled.
</p>
{error || t("compressionAnalyticsNoDataYet")}
<p className="text-xs mt-2">{t("compressionAnalyticsNoDataDescription")}</p>
</div>
);
}
@@ -198,12 +202,12 @@ export default function CompressionAnalyticsTab() {
}`}
>
{range === "24h"
? "Last 24h"
? t("rangeLast24h")
: range === "7d"
? "Last 7d"
? t("rangeLast7d")
: range === "30d"
? "Last 30d"
: "All time"}
? t("rangeLast30d")
: t("rangeAllTime")}
</button>
))}
</div>
@@ -234,13 +238,15 @@ export default function CompressionAnalyticsTab() {
icon="receipt_long"
label={t("compressionAnalyticsReceipts")}
value={stats.realUsage.requestsWithReceipts.toLocaleString()}
sub={`${stats.realUsage.totalTokens.toLocaleString()} real tokens`}
sub={t("compressionAnalyticsRealTokens", {
count: stats.realUsage.totalTokens.toLocaleString(),
})}
/>
<StatCard
icon="verified"
label={t("compressionAnalyticsFallbacks")}
value={stats.validationFallbacks.toLocaleString()}
sub="validation restores"
sub={t("compressionAnalyticsValidationRestores")}
/>
</div>
@@ -248,7 +254,7 @@ export default function CompressionAnalyticsTab() {
<div className="card p-5">
<h3 className="font-semibold text-text mb-4 flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[20px]">receipt_long</span>
Real Usage Receipts
{t("compressionAnalyticsRealUsageReceipts")}
</h3>
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 text-sm">
<div>
@@ -278,7 +284,7 @@ export default function CompressionAnalyticsTab() {
</div>
</div>
<div>
<div className="text-text-muted">Sources</div>
<div className="text-text-muted">{t("compressionAnalyticsSources")}</div>
<div className="text-lg font-semibold text-text">
{Object.entries(stats.realUsage.bySource)
.map(([source, count]) => `${source}: ${count}`)
@@ -294,7 +300,7 @@ export default function CompressionAnalyticsTab() {
<div className="card p-5">
<h3 className="font-semibold text-text mb-4 flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[20px]">tune</span>
Mode Breakdown
{t("compressionAnalyticsModeBreakdown")}
</h3>
<div className="flex flex-col gap-4">
{modes.map(([mode, data]) => (
@@ -316,7 +322,7 @@ export default function CompressionAnalyticsTab() {
<div className="card p-5">
<h3 className="font-semibold text-text mb-4 flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[20px]">hub</span>
Provider Breakdown
{t("compressionAnalyticsProviderBreakdown")}
</h3>
<div className="flex flex-col gap-4">
{providers.map(([prov, data]) => (
@@ -337,7 +343,7 @@ export default function CompressionAnalyticsTab() {
<div className="card p-5">
<h3 className="font-semibold text-text mb-4 flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[20px]">show_chart</span>
Last 24 Hours (Activity)
{t("compressionAnalyticsLast24HoursActivity")}
</h3>
<div className="flex items-end gap-2 h-48">
{stats.last24h.map((entry, idx) => {
@@ -348,7 +354,11 @@ export default function CompressionAnalyticsTab() {
<div
className="w-full rounded-t-sm bg-gradient-to-b from-primary to-primary/70 transition-all hover:opacity-80 cursor-pointer group relative"
style={{ height: `${Math.max(countPct, 5)}%` }}
title={`${entry.hour}: ${entry.count} requests, ${entry.tokensSaved.toLocaleString()} tokens saved`}
title={t("compressionAnalyticsChartPoint", {
hour: entry.hour,
count: entry.count,
tokens: entry.tokensSaved.toLocaleString(),
})}
>
<div className="absolute -top-6 left-0 right-0 opacity-0 group-hover:opacity-100 transition-opacity text-xs text-text-muted whitespace-nowrap text-center">
{entry.count}
@@ -362,8 +372,12 @@ export default function CompressionAnalyticsTab() {
})}
</div>
<div className="mt-4 grid grid-cols-2 gap-2 text-xs text-text-muted">
<div>Max requests/hour: {maxCountPerHour}</div>
<div>Max tokens/hour: {maxTokensPerHour.toLocaleString()}</div>
<div>{t("compressionAnalyticsMaxRequests", { count: maxCountPerHour })}</div>
<div>
{t("compressionAnalyticsMaxTokens", {
count: maxTokensPerHour.toLocaleString(),
})}
</div>
</div>
</div>
)}
@@ -376,8 +390,9 @@ export default function CompressionAnalyticsTab() {
</span>
<p className="font-medium text-text">{t("compressionAnalyticsNoDataYet")}</p>
<p className="text-sm mt-1">
Use <code className="bg-bg-muted px-1 rounded">POST /v1/chat/completions</code> with
compression configuration to start tracking compression analytics.
{t.rich("compressionAnalyticsStartTracking", {
code: (chunks) => <code className="bg-bg-muted px-1 rounded">{chunks}</code>,
})}
</p>
</div>
)}
@@ -386,9 +401,9 @@ export default function CompressionAnalyticsTab() {
<div className="text-xs text-text-muted border border-border rounded-lg p-3 flex items-start gap-2">
<span className="material-symbols-outlined text-[16px] text-blue-500 mt-0.5">info</span>
<span>
<strong>Compression analytics:</strong> Token savings tracked per mode (off, lite,
standard, aggressive, ultra, RTK, stacked), engine, compression combo, and provider. Hover
over charts for details. Use the time selector to view different time periods.
{t.rich("compressionAnalyticsInfo", {
strong: (chunks) => <strong>{chunks}</strong>,
})}
</span>
</div>
</div>

View File

@@ -15,13 +15,6 @@ import type {
UtilizationTimeRange,
} from "@/shared/types/utilization";
const RANGE_LABELS: Record<UtilizationTimeRange, string> = {
"1h": "Last hour",
"24h": "Last 24 hours",
"7d": "Last 7 days",
"30d": "Last 30 days",
};
const PROVIDER_COLORS = [
"var(--color-primary)",
"var(--color-accent)",
@@ -189,7 +182,7 @@ export default function ProviderUtilizationTab() {
<div className="flex flex-col gap-6">
<Card
title={t("providerUtilizationTitle")}
subtitle={RANGE_LABELS[range]}
subtitle={t(`utilizationRange.${range}`)}
icon="monitoring"
action={
<div className="flex items-center gap-4">
@@ -203,7 +196,7 @@ export default function ProviderUtilizationTab() {
}`}
>
<span className="material-symbols-outlined text-[14px]">dns</span>
Global View
{t("providerUtilizationGlobalView")}
</button>
<button
onClick={() => setAggregateBy("connection")}
@@ -214,7 +207,7 @@ export default function ProviderUtilizationTab() {
}`}
>
<span className="material-symbols-outlined text-[14px]">account_tree</span>
Account Split
{t("providerUtilizationAccountSplit")}
</button>
</div>
<TimeRangeSelector value={range} onChange={setRange} />
@@ -227,7 +220,7 @@ export default function ProviderUtilizationTab() {
<span className="material-symbols-outlined mr-2 animate-spin text-[18px]">
progress_activity
</span>
Loading utilization data
{t("providerUtilizationLoading")}
</div>
) : error ? (
<div className="flex min-h-80 flex-col items-center justify-center gap-4 text-center">
@@ -249,12 +242,12 @@ export default function ProviderUtilizationTab() {
<span className="material-symbols-outlined animate-spin text-[18px]">
progress_activity
</span>
Retrying
{t("retrying")}
</>
) : (
<>
<span className="material-symbols-outlined text-[18px]">refresh</span>
Retry
{t("retry")}
</>
)}
</button>
@@ -267,7 +260,7 @@ export default function ProviderUtilizationTab() {
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-text-main">{t("providerUtilizationNoData")}</p>
<p className="max-w-md text-sm text-text-muted">
Provider quota snapshots will appear here after utilization data is collected.
{t("providerUtilizationNoDataDescription")}
</p>
</div>
<div className="rounded-lg border border-black/5 bg-black/[0.02] p-4 dark:border-white/5 dark:bg-white/[0.02]">
@@ -280,22 +273,22 @@ export default function ProviderUtilizationTab() {
check_circle
</span>
<span>
Connect providers via OAuth or API keys in <strong>Providers</strong>
{t.rich("providerUtilizationStepConnect", {
strong: (chunks) => <strong>{chunks}</strong>,
})}
</span>
</li>
<li className="mt-1 flex items-start gap-2">
<span className="material-symbols-outlined text-[14px] text-primary">
check_circle
</span>
<span>
Enable quota tracking by using the provider in a combo or direct request
</span>
<span>{t("providerUtilizationStepEnable")}</span>
</li>
<li className="mt-1 flex items-start gap-2">
<span className="material-symbols-outlined text-[14px] text-primary">
check_circle
</span>
<span>Data will appear automatically as quota snapshots are collected</span>
<span>{t("providerUtilizationStepAutomatic")}</span>
</li>
</ul>
</div>
@@ -343,7 +336,11 @@ export default function ProviderUtilizationTab() {
: "bg-success/10 text-success"
}`}
>
{point.isExhausted ? "Exhausted" : isLow ? "Low" : "Healthy"}
{point.isExhausted
? t("statusExhausted")
: isLow
? t("statusLow")
: t("statusHealthy")}
</span>
</div>
@@ -373,7 +370,7 @@ export default function ProviderUtilizationTab() {
</div>
<div className="flex items-center justify-between text-xs text-text-muted">
<span>0%</span>
<span>Remaining quota</span>
<span>{t("remainingQuota")}</span>
<span>100%</span>
</div>
</div>

View File

@@ -223,6 +223,7 @@ function ExplainabilitySkeleton() {
}
function FactorCard({ factor }: { factor: ExplanationFactor }) {
const t = useTranslations("analytics");
const contributionPct = Math.round(factor.contribution * 100);
const weightPct = Math.round(factor.weight * 100);
@@ -241,16 +242,17 @@ function FactorCard({ factor }: { factor: ExplanationFactor }) {
<div className="h-full rounded-full bg-primary" style={{ width: `${contributionPct}%` }} />
</div>
<div className="mt-2 text-xs text-text-muted">
Weight {weightPct}% · {factor.details}
{t("routeWeight", { weight: weightPct })} · {factor.details}
</div>
</div>
);
}
function TargetTimeline({ targets }: { targets: ExplainTarget[] }) {
const t = useTranslations("analytics");
const nodeMap = useProviderNodeMap();
if (targets.length === 0) {
return <div className="text-sm text-text-muted">No related target evidence persisted yet.</div>;
return <div className="text-sm text-text-muted">{t("routeNoRelatedEvidence")}</div>;
}
return (
@@ -269,16 +271,16 @@ function TargetTimeline({ targets }: { targets: ExplainTarget[] }) {
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-text-main">
{resolveProviderName(target.provider, nodeMap)} / {target.model || "unknown"}
{resolveProviderName(target.provider, nodeMap)} / {target.model || t("unknown")}
</span>
{target.outcome === "selected" ? (
<Badge variant="primary" size="sm">
Selected
{t("selected")}
</Badge>
) : null}
</div>
<div className="mt-1 text-xs text-text-muted">
{formatDate(target.timestamp)} · {target.comboStepId || "no step id"}
{formatDate(target.timestamp)} · {target.comboStepId || t("routeNoStepId")}
</div>
<div className="mt-1 text-xs text-text-muted">{target.reason}</div>
</div>
@@ -295,11 +297,14 @@ function TargetTimeline({ targets }: { targets: ExplainTarget[] }) {
);
}
function replayAlignmentLabel(alignment: NonNullable<DecisionReplay["recompute"]>["alignment"]) {
if (alignment === "matches_recomputed_top_target") return "Matches current top target";
if (alignment === "differs_from_recomputed_top_target") return "Differs from current top";
if (alignment === "runtime_target_missing_from_recompute") return "Target missing now";
return "Not combo routed";
function replayAlignmentLabel(
alignment: NonNullable<DecisionReplay["recompute"]>["alignment"],
t: ReturnType<typeof useTranslations>
) {
if (alignment === "matches_recomputed_top_target") return t("routeMatchesTopTarget");
if (alignment === "differs_from_recomputed_top_target") return t("routeDiffersFromTop");
if (alignment === "runtime_target_missing_from_recompute") return t("routeTargetMissingNow");
return t("routeNotComboRouted");
}
function replayAlignmentVariant(alignment: NonNullable<DecisionReplay["recompute"]>["alignment"]) {
@@ -310,28 +315,28 @@ function replayAlignmentVariant(alignment: NonNullable<DecisionReplay["recompute
}
function WhyThisTargetCard({ replay }: { replay: DecisionReplay | undefined }) {
const t = useTranslations("analytics");
const nodeMap = useProviderNodeMap();
if (!replay) return null;
const recompute = replay.recompute;
const candidates = recompute?.candidates ?? [];
return (
<Card
title="Why this target?"
subtitle="Exact runtime metadata plus read-only scoring replay"
icon="psychology"
>
<Card title={t("routeWhyTarget")} subtitle={t("routeWhyTargetSubtitle")} icon="psychology">
<div className="flex flex-col gap-4">
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-text-main">Exact runtime log</div>
<div className="text-sm font-semibold text-text-main">
{t("routeExactRuntimeLog")}
</div>
<div className="mt-1 truncate text-xs text-text-muted">
{resolveProviderName(replay.runtime.provider, nodeMap)} /{" "}
{replay.runtime.model || "unknown"}
{replay.runtime.model || t("unknown")}
</div>
<div className="mt-1 text-xs text-text-muted">
{formatDate(replay.runtime.timestamp)} · {replay.runtime.comboStepId || "no step"}
{formatDate(replay.runtime.timestamp)} ·{" "}
{replay.runtime.comboStepId || t("routeNoStep")}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -339,7 +344,7 @@ function WhyThisTargetCard({ replay }: { replay: DecisionReplay | undefined }) {
HTTP {replay.runtime.status || "n/a"}
</Badge>
<Badge variant="success" size="sm">
call_logs exact
{t("routeCallLogsExact")}
</Badge>
</div>
</div>
@@ -349,25 +354,27 @@ function WhyThisTargetCard({ replay }: { replay: DecisionReplay | undefined }) {
<div className="rounded-lg border border-black/5 bg-black/2 p-4 dark:border-white/5 dark:bg-white/2">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-text-main">Read-only recompute</div>
<div className="text-sm font-semibold text-text-main">
{t("routeReadOnlyRecompute")}
</div>
<div className="mt-1 text-xs text-text-muted">
{recompute.comboName} · {recompute.strategy} · {recompute.timeRange} /{" "}
{recompute.horizon}
</div>
</div>
<Badge variant={replayAlignmentVariant(recompute.alignment)} size="sm">
{replayAlignmentLabel(recompute.alignment)}
{replayAlignmentLabel(recompute.alignment, t)}
</Badge>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
<RouteMetric
icon="leaderboard"
label="Runtime rank now"
label={t("routeRuntimeRankNow")}
value={recompute.runtimeSelectedRank ? `#${recompute.runtimeSelectedRank}` : "n/a"}
/>
<RouteMetric
icon="score"
label="Runtime score now"
label={t("routeRuntimeScoreNow")}
value={
recompute.runtimeSelectedScore !== null
? `${Math.round(recompute.runtimeSelectedScore * 100)}%`
@@ -376,14 +383,14 @@ function WhyThisTargetCard({ replay }: { replay: DecisionReplay | undefined }) {
/>
<RouteMetric
icon="looks_one"
label="Would select now"
label={t("routeWouldSelectNow")}
value={recompute.recomputedSelectedExecutionKey || "n/a"}
/>
</div>
</div>
) : (
<div className="rounded-lg border border-warning/20 bg-warning/10 p-4 text-sm text-text-muted">
No combo candidate ranking can be recomputed for this request.
{t("routeNoRecomputeCandidates")}
</div>
)}
@@ -408,12 +415,12 @@ function WhyThisTargetCard({ replay }: { replay: DecisionReplay | undefined }) {
</span>
{candidate.isRuntimeSelected ? (
<Badge variant="primary" size="sm">
Runtime
{t("runtime")}
</Badge>
) : null}
{candidate.wouldSelectNow ? (
<Badge variant="success" size="sm">
Top now
{t("routeTopNow")}
</Badge>
) : null}
</div>
@@ -467,7 +474,7 @@ export default function RouteExplainabilityTab({
signal,
cache: "no-store",
});
if (!response.ok) throw new Error("Failed to fetch request logs");
if (!response.ok) throw new Error(t("routeFetchLogsFailed"));
const data = (await response.json()) as CallLogOption[];
setLogs(data);
setSelectedId((current) => {
@@ -480,35 +487,38 @@ export default function RouteExplainabilityTab({
setError(null);
} catch (fetchError) {
if ((fetchError as Error).name === "AbortError") return;
setError(fetchError instanceof Error ? fetchError.message : "Failed to fetch request logs");
setError(fetchError instanceof Error ? fetchError.message : t("routeFetchLogsFailed"));
setLogs([]);
} finally {
if (!signal?.aborted) setLogsLoading(false);
}
},
[initialRequestId]
[initialRequestId, t]
);
const fetchExplanation = useCallback(async (requestId: string, signal?: AbortSignal) => {
if (!requestId) return;
setExplanationLoading(true);
try {
const response = await fetch(`/api/usage/route-explain/${encodeURIComponent(requestId)}`, {
signal,
cache: "no-store",
});
if (!response.ok) throw new Error("Failed to explain route");
const data = (await response.json()) as RouteExplainabilityResponse;
setExplanation(data);
setError(null);
} catch (fetchError) {
if ((fetchError as Error).name === "AbortError") return;
setError(fetchError instanceof Error ? fetchError.message : "Failed to explain route");
setExplanation(null);
} finally {
if (!signal?.aborted) setExplanationLoading(false);
}
}, []);
const fetchExplanation = useCallback(
async (requestId: string, signal?: AbortSignal) => {
if (!requestId) return;
setExplanationLoading(true);
try {
const response = await fetch(`/api/usage/route-explain/${encodeURIComponent(requestId)}`, {
signal,
cache: "no-store",
});
if (!response.ok) throw new Error(t("routeExplainFailed"));
const data = (await response.json()) as RouteExplainabilityResponse;
setExplanation(data);
setError(null);
} catch (fetchError) {
if ((fetchError as Error).name === "AbortError") return;
setError(fetchError instanceof Error ? fetchError.message : t("routeExplainFailed"));
setExplanation(null);
} finally {
if (!signal?.aborted) setExplanationLoading(false);
}
},
[t]
);
useEffect(() => {
const controller = new AbortController();
@@ -569,7 +579,7 @@ export default function RouteExplainabilityTab({
{logs.map((log) => (
<option key={log.id} value={log.id}>
{formatDate(log.timestamp)} · HTTP {log.status} ·{" "}
{log.comboName || resolveProviderName(log.provider, nodeMap) || "direct"} ·{" "}
{log.comboName || resolveProviderName(log.provider, nodeMap) || t("direct")} ·{" "}
{log.model || log.requestedModel || log.id}
</option>
))}
@@ -583,7 +593,7 @@ export default function RouteExplainabilityTab({
<Card className="p-8">
<div className="flex flex-col items-center justify-center gap-3 text-center">
<span className="material-symbols-outlined text-[40px] text-error">route_off</span>
<div className="font-medium text-text-main">Unable to load route explanation</div>
<div className="font-medium text-text-main">{t("routeUnableToLoad")}</div>
<div className="text-sm text-text-muted">{error}</div>
<button
type="button"
@@ -591,7 +601,7 @@ export default function RouteExplainabilityTab({
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover"
>
<span className="material-symbols-outlined text-[18px]">refresh</span>
Retry
{t("retry")}
</button>
</div>
</Card>
@@ -601,10 +611,9 @@ export default function RouteExplainabilityTab({
<Card className="p-10">
<div className="flex flex-col items-center justify-center gap-4 text-center">
<span className="material-symbols-outlined text-[40px] text-text-muted/70">route</span>
<div className="text-base font-medium text-text-main">No request logs available</div>
<div className="text-base font-medium text-text-main">{t("routeNoRequestLogs")}</div>
<div className="max-w-md text-sm text-text-muted">
Send traffic through OmniRoute first. Route explanations are generated from persisted
structured call logs.
{t("routeNoRequestLogsDescription")}
</div>
</div>
</Card>
@@ -614,7 +623,7 @@ export default function RouteExplainabilityTab({
<div className="grid gap-6 xl:grid-cols-[0.85fr_1.15fr]">
<div className="flex flex-col gap-6">
<Card
title="Decision summary"
title={t("routeDecisionSummary")}
subtitle={selectedLog?.id || explanation.requestId}
icon="alt_route"
>
@@ -635,47 +644,53 @@ export default function RouteExplainabilityTab({
: "default"
}
>
{explanation.confidence} confidence
{t("routeConfidence", { confidence: explanation.confidence })}
</Badge>
</div>
<p className="text-sm text-text-muted">{explanation.summary}</p>
<div className="grid gap-3 sm:grid-cols-2">
<RouteMetric
icon="analytics"
label="Route score"
label={t("routeScore")}
value={`${Math.round(explanation.score * 100)}%`}
/>
<RouteMetric
icon="timer"
label="Latency"
label={t("latency")}
value={formatDuration(explanation.latencyActual)}
/>
<RouteMetric
icon="task_alt"
label="Recent success"
label={t("routeRecentSuccess")}
value={`${explanation.targetStats.successRate}%`}
/>
<RouteMetric
icon="bolt"
label="Avg target latency"
label={t("routeAvgTargetLatency")}
value={formatDuration(explanation.targetStats.avgLatencyMs)}
/>
</div>
</div>
</Card>
<Card title="Selected target" icon="my_location">
<Card title={t("routeSelectedTarget")} icon="my_location">
<div className="grid gap-3 text-sm">
{[
["Provider", resolveProviderName(explanation.selectedTarget.provider, nodeMap)],
["Model", explanation.selectedTarget.model || "n/a"],
["Account", explanation.selectedTarget.account || "n/a"],
["Connection", explanation.selectedTarget.connectionId || "n/a"],
["Combo", explanation.comboUsed || "Direct"],
["Step", explanation.selectedTarget.comboStepId || "n/a"],
[
"Tokens",
`${explanation.selectedTarget.tokensIn.toLocaleString()} in · ${explanation.selectedTarget.tokensOut.toLocaleString()} out`,
t("provider"),
resolveProviderName(explanation.selectedTarget.provider, nodeMap),
],
[t("model"), explanation.selectedTarget.model || t("notAvailable")],
[t("account"), explanation.selectedTarget.account || t("notAvailable")],
[t("connection"), explanation.selectedTarget.connectionId || t("notAvailable")],
[t("combo"), explanation.comboUsed || t("direct")],
[t("routeStep"), explanation.selectedTarget.comboStepId || t("notAvailable")],
[
t("tokens"),
t("routeTokenCounts", {
input: explanation.selectedTarget.tokensIn.toLocaleString(),
output: explanation.selectedTarget.tokensOut.toLocaleString(),
}),
],
].map(([label, value]) => (
<div
@@ -693,7 +708,7 @@ export default function RouteExplainabilityTab({
<WhyThisTargetCard replay={explanation.decisionReplay} />
<Card title="Evidence" icon="fact_check">
<Card title={t("routeEvidence")} icon="fact_check">
<div className="flex flex-col gap-2">
{explanation.evidence.map((item) => (
<div
@@ -711,11 +726,7 @@ export default function RouteExplainabilityTab({
</div>
<div className="flex flex-col gap-6">
<Card
title="Routing factors"
subtitle="Weighted signals used for this explanation"
icon="schema"
>
<Card title={t("routeFactors")} subtitle={t("routeFactorsSubtitle")} icon="schema">
<div className="grid gap-3 lg:grid-cols-2">
{explanation.decision.factors.map((factor) => (
<FactorCard key={factor.name} factor={factor} />
@@ -724,15 +735,15 @@ export default function RouteExplainabilityTab({
</Card>
<Card
title="Fallback and target timeline"
subtitle="Inferred from persisted call logs around this request"
title={t("routeFallbackTimeline")}
subtitle={t("routeFallbackTimelineSubtitle")}
icon="timeline"
>
<TargetTimeline targets={explanation.relatedTargets} />
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card title="Recommendations" icon="tips_and_updates">
<Card title={t("routeRecommendations")} icon="tips_and_updates">
<ul className="flex flex-col gap-2 text-sm text-text-muted">
{explanation.recommendations.map((item) => (
<li key={item} className="flex items-start gap-2">
@@ -745,7 +756,7 @@ export default function RouteExplainabilityTab({
</ul>
</Card>
<Card title="Limitations" icon="info">
<Card title={t("routeLimitations")} icon="info">
{explanation.limitations.length > 0 ? (
<ul className="flex flex-col gap-2 text-sm text-text-muted">
{explanation.limitations.map((item) => (
@@ -758,9 +769,7 @@ export default function RouteExplainabilityTab({
))}
</ul>
) : (
<div className="text-sm text-text-muted">
No known limitations for this explanation.
</div>
<div className="text-sm text-text-muted">{t("routeNoKnownLimitations")}</div>
)}
</Card>
</div>

View File

@@ -62,16 +62,16 @@ export default function DiversityScoreCard() {
let riskColor = "text-green-500";
let gaugeColor = "bg-green-500";
let riskLabel = "Healthy Distribution";
let riskLabel = t("diversityHealthy");
if (scorePercentage < 40) {
riskColor = "text-red-500";
gaugeColor = "bg-red-500";
riskLabel = "High Vendor Lock-in Risk";
riskLabel = t("diversityRiskHigh");
} else if (scorePercentage < 70) {
riskColor = "text-amber-500";
gaugeColor = "bg-amber-500";
riskLabel = "Moderate Distribution";
riskLabel = t("diversityRiskModerate");
}
const providerEntries = Object.entries(data.providers || {})
@@ -86,12 +86,15 @@ export default function DiversityScoreCard() {
<span className="material-symbols-outlined text-[20px] text-primary">pie_chart</span>
<h3 className="font-semibold text-text-main">{t("diversityScoreTitle")}</h3>
<span className="text-xs text-text-muted hidden sm:inline">
Provider concentration snapshot for the recent traffic window.
{t("diversityScoreDesc")}
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-text-muted hidden md:inline">
Window: {data.windowSize} reqs · Last {Math.round(data.ttlMs / 60000)} mins
{t("diversityWindow", {
count: data.windowSize,
mins: Math.round(data.ttlMs / 60000),
})}
</span>
<span
className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-medium ${
@@ -102,7 +105,7 @@ export default function DiversityScoreCard() {
: "bg-green-500/10 text-green-500"
}`}
>
Shannon entropy
{t("diversityShannonEntropy")}
</span>
</div>
</div>
@@ -133,7 +136,9 @@ export default function DiversityScoreCard() {
<span className={`text-lg font-semibold tabular-nums ${riskColor}`}>
{scorePercentage}%
</span>
<span className="text-[9px] uppercase tracking-[0.15em] text-text-muted">score</span>
<span className="text-[9px] uppercase tracking-[0.15em] text-text-muted">
{t("diversityScoreLabel")}
</span>
</div>
</div>
@@ -141,7 +146,7 @@ export default function DiversityScoreCard() {
<div className="shrink-0 text-center sm:text-left">
<div className={`text-sm font-medium ${riskColor}`}>{riskLabel}</div>
<div className="text-xs text-text-muted mt-0.5 max-w-[200px]">
Higher values mean traffic is spread across multiple providers.
{t("diversityHigherExplanation")}
</div>
</div>
@@ -151,9 +156,7 @@ export default function DiversityScoreCard() {
{/* Provider bars */}
<div className="flex-1 min-w-0 w-full">
{providerEntries.length === 0 ? (
<div className="text-sm text-text-muted text-center py-2">
No recent usage data available.
</div>
<div className="text-sm text-text-muted text-center py-2">{t("diversityNoData")}</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2">
{providerEntries.map(([provider, stat]) => (

View File

@@ -13,12 +13,7 @@ import SearchAnalyticsTab from "./SearchAnalyticsTab";
import DiversityScoreCard from "./components/DiversityScoreCard";
type AnalyticsTab =
| "overview"
| "evals"
| "search"
| "utilization"
| "combo-health"
| "route-trace";
"overview" | "evals" | "search" | "utilization" | "combo-health" | "route-trace";
const ANALYTICS_TABS: Array<{
id: AnalyticsTab;
@@ -82,7 +77,7 @@ function AnalyticsPageContent() {
<div className="flex flex-col gap-6">
<div
role="tablist"
aria-label="Analytics sections"
aria-label={t("sectionsAria")}
className="flex flex-wrap items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1"
>
{ANALYTICS_TABS.map((tab) => {

View File

@@ -259,7 +259,7 @@ export default function ApiManagerPageClient() {
fetchModels();
fetchCombos();
fetchConnections();
}, []);
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- initial dashboard load only
useEffect(() => {
if (!showAddModal || !nameError) return;
@@ -562,9 +562,8 @@ export default function ApiManagerPageClient() {
// 4. search query (case-insensitive substring on name and key)
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
list = list.filter(
(k) => k.name.toLowerCase().includes(q) || k.key.toLowerCase().includes(q)
(k) => matchesSearch(k.name, searchQuery) || matchesSearch(k.key, searchQuery)
);
}
@@ -2131,9 +2130,7 @@ const PermissionsModal = memo(function PermissionsModal({
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("maxActiveSessions")}</p>
<p className="text-xs text-text-muted">
0 = unlimited. Return 429 when this key exceeds concurrent sticky sessions.
</p>
<p className="text-xs text-text-muted">{t("maxActiveSessionsDescription")}</p>
</div>
<div className="w-32">
<Input
@@ -2152,10 +2149,8 @@ const PermissionsModal = memo(function PermissionsModal({
{/* Soft Throttle */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">Throttle Delay</p>
<p className="text-xs text-text-muted">
Add a fixed delay before requests for this key are routed. 0 = no slowdown.
</p>
<p className="text-sm font-medium text-text-main">{t("throttleDelay")}</p>
<p className="text-xs text-text-muted">{t("throttleDelayDescription")}</p>
</div>
<div className="w-36">
<Input
@@ -2644,7 +2639,7 @@ const PermissionsModal = memo(function PermissionsModal({
type="button"
onClick={() => setClaudeCodeFamiliesExpanded((prev) => !prev)}
className="inline-flex items-center gap-1 font-mono text-text-main"
title="Expand Claude Code families"
title={t("expandClaudeCodeFamilies")}
aria-expanded={claudeCodeFamiliesExpanded}
>
<span className="truncate max-w-[140px]" title={modelId}>
@@ -2658,7 +2653,7 @@ const PermissionsModal = memo(function PermissionsModal({
type="button"
onClick={() => handleToggleModel(modelId)}
className="text-text-muted hover:text-red-500 transition-colors"
title="Remove Claude Code default"
title={t("removeClaudeCodeDefault")}
>
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
@@ -2939,7 +2934,9 @@ const PermissionsModal = memo(function PermissionsModal({
{conn.name || conn.id.slice(0, 8)}
</span>
{!conn.isActive && (
<span className="text-[9px] text-red-400 shrink-0">inactive</span>
<span className="text-[9px] text-red-400 shrink-0">
{tc("inactive")}
</span>
)}
</button>
);
@@ -2955,7 +2952,7 @@ const PermissionsModal = memo(function PermissionsModal({
{allCombos.length > 0 && (
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-text-main">Allowed Combos</p>
<p className="text-sm font-medium text-text-main">{t("allowedCombos")}</p>
<div className="flex gap-1 p-0.5 bg-surface rounded-md">
<button
onClick={() => {
@@ -2968,7 +2965,7 @@ const PermissionsModal = memo(function PermissionsModal({
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
All
{tc("all")}
</button>
<button
onClick={() => setAllowAllCombos(false)}
@@ -2978,14 +2975,14 @@ const PermissionsModal = memo(function PermissionsModal({
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
Restrict
{t("restrict")}
</button>
</div>
</div>
<p className="text-xs text-text-muted">
{allowAllCombos
? "This key can use any combo."
: `Restricted to ${selectedCombos.length} combo${selectedCombos.length !== 1 ? "s" : ""}.`}
? t("allCombosAllowed")
: t("restrictedComboCount", { count: selectedCombos.length })}
</p>
{!allowAllCombos && (
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">

View File

@@ -16,15 +16,13 @@ export function BypassProviderQuotaToggle({
onToggle: () => void;
}) {
const tc = useTranslations("common");
const t = useTranslations("apiManager");
return (
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-amber-500/20 bg-amber-500/5">
<div className="flex flex-col gap-1 pr-2">
<p className="text-sm font-medium text-text-main">Bypass provider quota cutoffs</p>
<p className="text-xs text-text-muted">
Allows this key to ignore upstream provider/account cutoff policy during routing. API key
USD quotas still apply.
</p>
<p className="text-sm font-medium text-text-main">{t("bypassProviderQuota")}</p>
<p className="text-xs text-text-muted">{t("bypassProviderQuotaDescription")}</p>
</div>
<button
type="button"

View File

@@ -1,6 +1,7 @@
"use client";
import { Input } from "@/shared/components";
import { useTranslations } from "next-intl";
export function UsageLimitSettings({
enabled,
@@ -21,15 +22,13 @@ export function UsageLimitSettings({
onDailyLimitUsdChange: (value: string) => void;
onWeeklyLimitUsdChange: (value: string) => void;
}) {
const t = useTranslations("usageLimits");
return (
<div className="mt-1 rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">USD usage quota</p>
<p className="text-xs text-text-muted">
Blocks this key with a 400 API error after its local USD spend reaches the configured
daily or weekly quota.
</p>
<p className="text-sm font-medium text-text-main">{t("usdUsageQuota")}</p>
<p className="text-xs text-text-muted">{t("usdUsageQuotaDescription")}</p>
</div>
<button
type="button"
@@ -48,7 +47,7 @@ export function UsageLimitSettings({
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-text-muted mb-1 block">Daily quota (USD)</label>
<label className="text-xs text-text-muted mb-1 block">{t("dailyQuotaUsd")}</label>
<Input
type="number"
min={0}
@@ -59,7 +58,7 @@ export function UsageLimitSettings({
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Weekly quota (USD)</label>
<label className="text-xs text-text-muted mb-1 block">{t("weeklyQuotaUsd")}</label>
<Input
type="number"
min={0}
@@ -70,10 +69,7 @@ export function UsageLimitSettings({
/>
</div>
</div>
<p className="mt-2 text-[11px] text-text-muted">
Weekly quota follows the cached Claude weekly reset when available; otherwise it falls back
to a rolling 7 day window. Daily quota uses the Fortaleza calendar day.
</p>
<p className="mt-2 text-[11px] text-text-muted">{t("quotaWindowDescription")}</p>
</div>
);
}

View File

@@ -1,22 +1,23 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import { matchesSearch } from "@/shared/utils/turkishText";
import FileDetailModal from "./FileDetailModal";
import ExpirationBadge from "./components/ExpirationBadge";
function relativeTime(ts: number): string {
function relativeTime(ts: number, locale: string): string {
// ts is in seconds (Unix timestamp)
const diffMs = Date.now() - ts * 1000;
const diffSec = Math.round(diffMs / 1000);
if (diffSec < 60) return `${diffSec}s ago`;
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
if (diffSec < 60) return formatter.format(-diffSec, "second");
const diffMin = Math.round(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
if (diffMin < 60) return formatter.format(-diffMin, "minute");
const diffHr = Math.round(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
if (diffHr < 24) return formatter.format(-diffHr, "hour");
const diffDays = Math.round(diffHr / 24);
return `${diffDays}d ago`;
return formatter.format(-diffDays, "day");
}
interface FileRecord {
@@ -77,6 +78,7 @@ export default function FilesListTab({
onRefresh,
batches,
}: Readonly<FilesListTabProps>) {
const locale = useLocale();
const t = useTranslations("common");
const [searchQuery, setSearchQuery] = useState("");
const [purposeFilter, setPurposeFilter] = useState("all");
@@ -107,11 +109,11 @@ export default function FilesListTab({
const text = await response.text();
setFileContents(text);
} else {
setFileContents("Failed to load file contents");
setFileContents(t("batchFileDetailFailedToLoad"));
}
} catch (error) {
console.error("Failed to fetch file contents:", error);
setFileContents("Error loading file contents");
setFileContents(t("batchFileDetailLoadError"));
} finally {
setContentsLoading(false);
}
@@ -138,7 +140,9 @@ export default function FilesListTab({
{/* Filters */}
<div className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]">
<span className="text-sm text-[var(--color-text-muted)] self-center">
{filesTotal ? `${filesTotal} files` : "Files"}
{typeof filesTotal === "number"
? t("batchFilesCount", { count: filesTotal })
: t("batchFilesListFilesTable")}
</span>
<input
type="text"
@@ -154,7 +158,7 @@ export default function FilesListTab({
>
{purposes.map((p) => (
<option key={p} value={p}>
{p === "all" ? "All purposes" : p}
{p === "all" ? t("batchFilesAllPurposes") : t(`batchFilePurpose.${p}`)}
</option>
))}
</select>
@@ -169,10 +173,10 @@ export default function FilesListTab({
ID
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Filename
{t("batchFilesFilename")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Purpose
{t("batchFilesPurpose")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
{t("filesListSizeColumn")}
@@ -181,10 +185,10 @@ export default function FilesListTab({
{t("filesListUsedByColumn")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Created
{t("created")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Expires
{t("batchFilesExpires")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
{/* Actions */}
@@ -197,14 +201,14 @@ export default function FilesListTab({
<td colSpan={8} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
<div className="flex items-center justify-center gap-2">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-[var(--color-accent)]" />
Loading
{t("loading")}
</div>
</td>
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
No files found
{t("batchFilesNoneFound")}
</td>
</tr>
) : (
@@ -289,13 +293,15 @@ export default function FilesListTab({
)}
</td>
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
{fileCreatedAt ? relativeTime(fileCreatedAt) : "—"}
{fileCreatedAt ? relativeTime(fileCreatedAt, locale) : "—"}
</td>
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
{fileExpiresAt ? (
<ExpirationBadge expiresAt={fileExpiresAt} variant="compact" />
) : (
<span className="text-xs text-[var(--color-text-muted)]">Never</span>
<span className="text-xs text-[var(--color-text-muted)]">
{t("batchFilesNeverExpires")}
</span>
)}
</td>
{/* Actions column */}
@@ -321,7 +327,9 @@ export default function FilesListTab({
void handleDeleteFile(file);
}}
disabled={!canDelete || deletingId === file.id}
title={canDelete ? t("filesListDelete") : "File in use by active batch"}
title={
canDelete ? t("filesListDelete") : t("batchFileInUseByActiveBatch")
}
className="p-1.5 rounded text-[var(--color-text-muted)] hover:text-red-400 hover:bg-[var(--color-bg-alt)] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-[16px]">

View File

@@ -124,7 +124,7 @@ export default function UploadFileModal({ onClose, onUploaded }: Props) {
</div>
<button
onClick={onClose}
aria-label="Close"
aria-label={t("close")}
className="p-1.5 rounded-lg text-[var(--color-text-muted)] hover:bg-[var(--color-bg-alt)] transition-colors"
>
<span className="material-symbols-outlined text-[20px]">close</span>

View File

@@ -41,7 +41,7 @@ function parseCsvRow(line: string): string[] {
}
const MAPPING_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "", label: "— ignore —" },
{ value: "", label: "" },
{ value: "custom_id", label: "custom_id" },
{ value: "body.messages[0].content", label: "body.messages[0].content" },
{ value: "body.messages[0].role", label: "body.messages[0].role" },
@@ -133,7 +133,7 @@ export default function CsvMappingStep({
<p className="text-sm font-medium text-[var(--color-text)]">{t("wizardCsvMappingTitle")}</p>
{columns.length === 0 && (
<p className="text-xs text-[var(--color-text-muted)]">No columns detected in CSV header.</p>
<p className="text-xs text-[var(--color-text-muted)]">{t("wizardCsvNoColumns")}</p>
)}
<div className="flex flex-col gap-3">
@@ -150,7 +150,7 @@ export default function CsvMappingStep({
>
{MAPPING_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
{opt.value === "" ? t("wizardCsvIgnoreColumn") : opt.label}
</option>
))}
</select>
@@ -167,7 +167,7 @@ export default function CsvMappingStep({
{hasCustomId ? "check_circle" : "radio_button_unchecked"}
</span>
<span className={hasCustomId ? "text-emerald-400" : "text-[var(--color-text-muted)]"}>
custom_id mapped
{t("wizardCsvCustomIdMapped")}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
@@ -177,7 +177,7 @@ export default function CsvMappingStep({
{hasContent ? "check_circle" : "radio_button_unchecked"}
</span>
<span className={hasContent ? "text-emerald-400" : "text-[var(--color-text-muted)]"}>
Content field mapped (messages, input, or prompt)
{t("wizardCsvContentMapped")}
</span>
</div>
</div>
@@ -189,19 +189,23 @@ export default function CsvMappingStep({
onClick={handleApply}
className="self-start rounded-lg px-4 py-2 text-sm font-medium bg-[var(--color-accent)] text-white disabled:opacity-40 hover:opacity-90 transition-opacity"
>
Apply mapping
{t("wizardCsvApplyMapping")}
</button>
{/* Conversion feedback */}
{conversionResult && (
<div className="flex flex-col gap-1 text-xs">
<span className="text-emerald-400">{conversionResult.rowsParsed} rows parsed</span>
<span className="text-emerald-400">
{t("wizardCsvRowsParsed", { count: conversionResult.rowsParsed })}
</span>
{conversionResult.rowsSkipped > 0 && (
<span className="text-yellow-400">{conversionResult.rowsSkipped} rows skipped</span>
<span className="text-yellow-400">
{t("wizardCsvRowsSkipped", { count: conversionResult.rowsSkipped })}
</span>
)}
{conversionResult.errors.slice(0, 5).map((e) => (
<span key={e.row} className="text-red-400">
Row {e.row}: {e.reason}
{t("wizardCsvRowError", { row: e.row, reason: e.reason })}
</span>
))}
</div>

View File

@@ -37,7 +37,7 @@ export default function JsonlValidationStep({
sampledLines: 0,
uniqueCustomIds: 0,
duplicateCustomIds: [],
errors: [{ lineNumber: 0, reason: "Validation failed — could not parse content." }],
errors: [{ lineNumber: 0, reason: t("wizardValidationParseFailed") }],
preview: [],
byteSize: 0,
};
@@ -46,7 +46,7 @@ export default function JsonlValidationStep({
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jsonl, endpoint]);
if (loading) {
@@ -55,7 +55,7 @@ export default function JsonlValidationStep({
<span className="material-symbols-outlined text-3xl text-[var(--color-accent)] animate-spin">
progress_activity
</span>
<span className="text-sm text-[var(--color-text-muted)]">Validating</span>
<span className="text-sm text-[var(--color-text-muted)]">{t("wizardValidating")}</span>
</div>
);
}
@@ -71,8 +71,11 @@ export default function JsonlValidationStep({
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-emerald-400">{t("wizardValidationOk")}</span>
<span className="text-xs text-[var(--color-text-muted)]">
{result.totalLines} lines · {result.uniqueCustomIds} unique custom_ids ·{" "}
{t("wizardValidationFieldsOk")}
{t("wizardValidationSummary", {
lines: result.totalLines,
ids: result.uniqueCustomIds,
})}{" "}
· {t("wizardValidationFieldsOk")}
</span>
</div>
</div>
@@ -82,7 +85,7 @@ export default function JsonlValidationStep({
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-red-400">{t("wizardValidationErrors")}</span>
<span className="text-xs text-[var(--color-text-muted)]">
{result.errors.length} error{result.errors.length !== 1 ? "s" : ""} found
{t("wizardValidationErrorCount", { count: result.errors.length })}
</span>
</div>
</div>
@@ -98,7 +101,9 @@ export default function JsonlValidationStep({
{/* Duplicate IDs */}
{result.duplicateCustomIds.length > 0 && (
<div className="rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 flex flex-col gap-1">
<span className="text-xs font-medium text-red-400">Duplicate custom_ids detected:</span>
<span className="text-xs font-medium text-red-400">
{t("wizardValidationDuplicateIds")}
</span>
{result.duplicateCustomIds.slice(0, 10).map((id) => (
<span key={id} className="text-xs text-red-300 font-mono">
{id}
@@ -111,13 +116,15 @@ export default function JsonlValidationStep({
{result.errors.length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-[var(--color-text-muted)]">
Errors (first {Math.min(result.errors.length, 50)}):
{t("wizardValidationFirstErrors", {
count: Math.min(result.errors.length, 50),
})}
</span>
<div className="max-h-60 overflow-auto rounded-lg border border-[var(--color-border)] divide-y divide-[var(--color-border)]">
{result.errors.slice(0, 50).map((err) => (
<div key={`${err.lineNumber}-${err.reason}`} className="px-3 py-2 flex gap-3 text-xs">
<span className="text-[var(--color-text-muted)] min-w-[60px]">
Line {err.lineNumber}
{t("wizardValidationLine", { line: err.lineNumber })}
</span>
<span className="text-red-400 flex-1">{err.reason}</span>
{err.field && (

View File

@@ -20,12 +20,7 @@ type GenerationResult = {
audioUrl?: string;
};
type MediaModelConfig = { id: string; name: string };
type MediaProviderConfig = {
id: string;
authType: string;
supportedFormats?: string[];
models: MediaModelConfig[];
};
type MediaProviderConfig = { id: string; models: MediaModelConfig[] };
type ProviderModelGroup = {
id: string;
name: string;
@@ -45,79 +40,56 @@ function toProviderModels(registry: Record<string, MediaProviderConfig>): Provid
}));
}
function providersRequiringCredentials(registry: Record<string, MediaProviderConfig>): string[] {
return Object.entries(registry)
.filter(([, config]) => config.authType !== "none")
.map(([providerId]) => providerId);
}
const IMAGE_PROVIDER_MODELS = toProviderModels(IMAGE_PROVIDERS);
const VIDEO_PROVIDER_MODELS = toProviderModels(VIDEO_PROVIDERS);
const MUSIC_PROVIDER_MODELS = toProviderModels(MUSIC_PROVIDERS);
const SPEECH_PROVIDER_MODELS = toProviderModels(AUDIO_SPEECH_PROVIDERS);
const TRANSCRIPTION_PROVIDER_MODELS = toProviderModels(AUDIO_TRANSCRIPTION_PROVIDERS);
const IMAGE_PROVIDERS_REQUIRING_CREDENTIALS = providersRequiringCredentials(IMAGE_PROVIDERS);
const VIDEO_PROVIDERS_REQUIRING_CREDENTIALS = providersRequiringCredentials(VIDEO_PROVIDERS);
const MUSIC_PROVIDERS_REQUIRING_CREDENTIALS = providersRequiringCredentials(MUSIC_PROVIDERS);
const SPEECH_PROVIDERS_REQUIRING_CREDENTIALS =
providersRequiringCredentials(AUDIO_SPEECH_PROVIDERS);
const TRANSCRIPTION_PROVIDERS_REQUIRING_CREDENTIALS = providersRequiringCredentials(
AUDIO_TRANSCRIPTION_PROVIDERS
);
const MODALITY_CONFIG: Record<
Modality,
{
icon: string;
endpoint: string;
label: string;
placeholder?: string;
labelKey: string;
placeholderKey?: string;
color: string;
textLabel?: string;
needsCredentials: string[];
}
> = {
image: {
icon: "image",
endpoint: "/api/v1/images/generations",
label: "Image Generation",
placeholder: "A serene landscape with mountains at sunset...",
labelKey: "imageGeneration",
placeholderKey: "imagePromptPlaceholder",
color: "from-purple-500 to-pink-500",
needsCredentials: IMAGE_PROVIDERS_REQUIRING_CREDENTIALS,
},
video: {
icon: "videocam",
endpoint: "/api/v1/videos/generations",
label: "Video Generation",
placeholder: "A timelapse of a flower blooming...",
labelKey: "videoGeneration",
placeholderKey: "videoPromptPlaceholder",
color: "from-blue-500 to-cyan-500",
needsCredentials: VIDEO_PROVIDERS_REQUIRING_CREDENTIALS,
},
music: {
icon: "music_note",
endpoint: "/api/v1/music/generations",
label: "Music Generation",
placeholder: "Upbeat electronic music with synth pads...",
labelKey: "musicGeneration",
placeholderKey: "musicPromptPlaceholder",
color: "from-orange-500 to-yellow-500",
needsCredentials: MUSIC_PROVIDERS_REQUIRING_CREDENTIALS,
},
speech: {
icon: "record_voice_over",
endpoint: "/api/v1/audio/speech",
label: "Text to Speech",
placeholder: "Hello! Welcome to OmniRoute, your intelligent AI gateway...",
labelKey: "textToSpeech",
placeholderKey: "speechTextPlaceholder",
color: "from-green-500 to-teal-500",
textLabel: "Text",
needsCredentials: SPEECH_PROVIDERS_REQUIRING_CREDENTIALS,
},
transcription: {
icon: "mic",
endpoint: "/api/v1/audio/transcriptions",
label: "Transcription",
placeholder: "Upload an audio file to transcribe...",
labelKey: "transcription",
placeholderKey: "transcriptionPlaceholder",
color: "from-indigo-500 to-blue-500",
needsCredentials: TRANSCRIPTION_PROVIDERS_REQUIRING_CREDENTIALS,
},
};
@@ -339,7 +311,11 @@ function getVoiceList(providerId: string) {
}
/** Parse a human-readable error from the API error response */
function parseApiError(raw: any, statusCode: number): { message: string; isCredentials: boolean } {
function parseApiError(
raw: any,
statusCode: number,
fallbackMessage: string
): { message: string; isCredentials: boolean } {
const readErrorMessage = (value: any): string | null => {
if (!value) return null;
if (typeof value === "string") return value;
@@ -372,7 +348,7 @@ function parseApiError(raw: any, statusCode: number): { message: string; isCrede
raw?.message ||
raw?.detail ||
(typeof raw === "string" ? raw : null) ||
`Request failed (${statusCode})`;
fallbackMessage;
const isCredentials =
typeof msg === "string" &&
@@ -400,7 +376,7 @@ function formatFileSize(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function fileToDataUrl(file: File): Promise<string> {
function fileToDataUrl(file: File, errorMessage: string): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
@@ -408,23 +384,20 @@ function fileToDataUrl(file: File): Promise<string> {
resolve(reader.result);
return;
}
reject(new Error("Failed to read file"));
reject(new Error(errorMessage));
};
reader.onerror = () => reject(reader.error || new Error("Failed to read file"));
reader.onerror = () => reject(reader.error || new Error(errorMessage));
reader.readAsDataURL(file);
});
}
/** Render image result thumbnails */
function ImageResults({ data }: { data: any }) {
const t = useTranslations("media");
const images: Array<{ url?: string; b64_json?: string; revised_prompt?: string }> =
data?.data || [];
if (images.length === 0) {
return (
<p className="text-sm text-text-muted italic">
No images returned. The provider might have accepted the request but returned empty data.
</p>
);
return <p className="text-sm text-text-muted italic">{t("noImagesReturned")}</p>;
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
@@ -439,7 +412,7 @@ function ImageResults({ data }: { data: any }) {
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={img.revised_prompt || `Generated image ${i + 1}`}
alt={img.revised_prompt || t("generatedImageAlt", { index: i + 1 })}
className="w-full"
/>
<a
@@ -448,7 +421,7 @@ function ImageResults({ data }: { data: any }) {
className="absolute bottom-2 right-2 bg-black/60 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1"
>
<span className="material-symbols-outlined text-[13px]">download</span>
Save
{t("save")}
</a>
{img.revised_prompt && (
<p
@@ -589,7 +562,7 @@ export default function MediaPageClient() {
if (activeTab === "speech") {
if (!promptValue) {
setError("Please enter text to synthesize.");
setError(t("enterTextToSynthesize"));
setLoading(false);
return;
}
@@ -605,7 +578,11 @@ export default function MediaPageClient() {
});
if (!res.ok) {
const raw = await res.json().catch(() => ({}));
const { message, isCredentials } = parseApiError(raw, res.status);
const { message, isCredentials } = parseApiError(
raw,
res.status,
t("requestFailed", { status: res.status })
);
setIsCredentialsError(isCredentials);
throw new Error(message);
}
@@ -623,7 +600,7 @@ export default function MediaPageClient() {
if (activeTab === "transcription") {
if (!audioFile) {
setError("Please select an audio file to transcribe.");
setError(t("selectAudioToTranscribe"));
setLoading(false);
return;
}
@@ -633,25 +610,25 @@ export default function MediaPageClient() {
const res = await fetch(config.endpoint, { method: "POST", body: form });
if (!res.ok) {
const raw = await res.json().catch(() => ({}));
const { message, isCredentials } = parseApiError(raw, res.status);
const { message, isCredentials } = parseApiError(
raw,
res.status,
t("requestFailed", { status: res.status })
);
setIsCredentialsError(isCredentials);
throw new Error(message);
}
const data = await res.json();
// Check for noSpeechDetected flag (music, silence, etc.) — NOT a credential error
if (data?.noSpeechDetected) {
setError(
`No speech detected in the audio file. If you uploaded music or a silent file, try an audio file with spoken words. Provider: "${selectedProvider}".`
);
setError(t("noSpeechDetected", { provider: selectedProvider }));
setIsCredentialsError(false);
setLoading(false);
return;
}
// Warn if text is empty without the noSpeechDetected flag (unexpected)
if (data && typeof data.text === "string" && data.text.trim() === "") {
setError(
`Transcription returned empty text. The audio may contain no recognizable speech, or the "${selectedProvider}" API key may be invalid. Check Dashboard → Logs → Proxy for details.`
);
setError(t("emptyTranscription", { provider: selectedProvider }));
// Only mark as credential error if we can confirm it from context
setIsCredentialsError(false);
setLoading(false);
@@ -663,14 +640,14 @@ export default function MediaPageClient() {
}
if (activeTab === "image" && selectedProvider === "topaz" && !imageInputFile) {
setError("Topaz requires an input image.");
setError(t("topazRequiresImage"));
setLoading(false);
return;
}
if (!prompt.trim()) {
if (activeTab !== "image" || selectedProvider !== "topaz") {
setError("Please enter a prompt.");
setError(t("enterPrompt"));
setLoading(false);
return;
}
@@ -680,18 +657,18 @@ export default function MediaPageClient() {
model: modelId,
prompt:
promptValue ||
(activeTab === "image" && selectedProvider === "topaz" ? "Enhance this image" : ""),
(activeTab === "image" && selectedProvider === "topaz" ? t("enhanceThisImage") : ""),
...(activeTab === "image" ? { size: "1024x1024", n: 1 } : {}),
};
if (activeTab === "image" && imageInputFile) {
const imageDataUrl = await fileToDataUrl(imageInputFile);
const imageDataUrl = await fileToDataUrl(imageInputFile, t("failedToReadFile"));
payload.image_url = imageDataUrl;
payload.imageUrls = [imageDataUrl];
}
if (activeTab === "image" && imageMaskFile) {
const maskDataUrl = await fileToDataUrl(imageMaskFile);
const maskDataUrl = await fileToDataUrl(imageMaskFile, t("failedToReadFile"));
payload.mask = maskDataUrl;
payload.mask_url = maskDataUrl;
}
@@ -703,14 +680,18 @@ export default function MediaPageClient() {
});
if (!res.ok) {
const raw = await res.json().catch(() => ({}));
const { message, isCredentials } = parseApiError(raw, res.status);
const { message, isCredentials } = parseApiError(
raw,
res.status,
t("requestFailed", { status: res.status })
);
setIsCredentialsError(isCredentials);
throw new Error(message);
}
const data = await res.json();
setResult({ type: activeTab, data, timestamp: Date.now() });
} catch (err: any) {
setError(err.message || "Generation failed");
setError(err.message || t("generationFailed"));
}
setLoading(false);
};
@@ -747,7 +728,7 @@ export default function MediaPageClient() {
}`}
>
<span className="material-symbols-outlined text-[18px]">{cfg.icon}</span>
{cfg.label}
{t(cfg.labelKey)}
</button>
);
})}
@@ -759,7 +740,7 @@ export default function MediaPageClient() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Provider dropdown */}
<div>
<label className="block text-sm font-medium text-text-main mb-2">Provider</label>
<label className="block text-sm font-medium text-text-main mb-2">{t("provider")}</label>
<select
value={selectedProvider}
onChange={(e) => handleProviderChange(e.target.value)}
@@ -794,13 +775,17 @@ export default function MediaPageClient() {
{selectedProvider && !["sdwebui", "comfyui", "qwen"].includes(selectedProvider) && (
<p className="text-xs text-text-muted flex items-center gap-1.5">
<span className="material-symbols-outlined text-[14px] text-amber-500">info</span>
Requires <strong className="capitalize">{selectedProvider}</strong> API key in{" "}
<Link
href="/dashboard/providers"
className="text-primary underline underline-offset-2 hover:text-primary/80"
>
Providers
</Link>
{t.rich("credentialsRequired", {
provider: () => <strong className="capitalize">{selectedProvider}</strong>,
providers: (chunks) => (
<Link
href="/dashboard/providers"
className="text-primary underline underline-offset-2 hover:text-primary/80"
>
{chunks}
</Link>
),
})}
</p>
)}
@@ -808,7 +793,7 @@ export default function MediaPageClient() {
{activeTab === "speech" && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-main mb-2">Voice</label>
<label className="block text-sm font-medium text-text-main mb-2">{t("voice")}</label>
<select
value={speechVoice}
onChange={(e) => setSpeechVoice(e.target.value)}
@@ -822,7 +807,7 @@ export default function MediaPageClient() {
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-2">Format</label>
<label className="block text-sm font-medium text-text-main mb-2">{t("format")}</label>
<select
value={speechFormat}
onChange={(e) => setSpeechFormat(e.target.value)}
@@ -842,7 +827,7 @@ export default function MediaPageClient() {
{activeTab === "transcription" ? (
<div>
<label className="block text-sm font-medium text-text-main mb-2">
Audio / Video File
{t("audioVideoFile")}
</label>
<input
type="file"
@@ -852,7 +837,7 @@ export default function MediaPageClient() {
setFileSizeError(null);
if (file && file.size > MAX_TRANSCRIPTION_FILE_SIZE) {
setFileSizeError(
`File too large (${formatFileSize(file.size)}). Maximum allowed: 4 GB.`
t("fileTooLarge", { size: formatFileSize(file.size), max: "4 GB" })
);
setAudioFile(null);
e.target.value = "";
@@ -873,9 +858,7 @@ export default function MediaPageClient() {
{audioFile.name} ({formatFileSize(audioFile.size)})
</p>
)}
<p className="text-[10px] text-text-muted/60 mt-1">
Supports audio and video files up to 4 GB
</p>
<p className="text-[10px] text-text-muted/60 mt-1">{t("audioVideoFileHint")}</p>
</div>
) : (
<>
@@ -883,7 +866,7 @@ export default function MediaPageClient() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-main mb-2">
Source Image
{t("sourceImage")}
</label>
<input
type="file"
@@ -896,13 +879,11 @@ export default function MediaPageClient() {
{imageInputFile.name} ({formatFileSize(imageInputFile.size)})
</p>
)}
<p className="text-[10px] text-text-muted/60 mt-1">
Optional for image-to-image, editing and upscale workflows.
</p>
<p className="text-[10px] text-text-muted/60 mt-1">{t("sourceImageHint")}</p>
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-2">
Mask Image
{t("maskImage")}
</label>
<input
type="file"
@@ -915,9 +896,7 @@ export default function MediaPageClient() {
{imageMaskFile.name} ({formatFileSize(imageMaskFile.size)})
</p>
)}
<p className="text-[10px] text-text-muted/60 mt-1">
Optional. Used by inpaint-style models that support masks.
</p>
<p className="text-[10px] text-text-muted/60 mt-1">{t("maskImageHint")}</p>
</div>
</div>
)}
@@ -926,9 +905,9 @@ export default function MediaPageClient() {
<div>
<label className="block text-sm font-medium text-text-main mb-2">
{activeTab === "speech"
? "Text"
? t("text")
: activeTab === "image" && selectedProvider === "topaz"
? "Prompt (optional)"
? t("promptOptional")
: t("prompt")}
</label>
<textarea
@@ -937,8 +916,10 @@ export default function MediaPageClient() {
onChange={(e) => setPrompt(e.target.value)}
placeholder={
activeTab === "image" && selectedProvider === "topaz"
? "Optional enhancement instructions..."
: config.placeholder
? t("enhancementInstructionsPlaceholder")
: config.placeholderKey
? t(config.placeholderKey)
: undefined
}
className="w-full px-3 py-2 rounded-lg bg-surface border border-black/10 dark:border-white/10 text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30 resize-none"
/>
@@ -962,9 +943,9 @@ export default function MediaPageClient() {
progress_activity
</span>
{activeTab === "speech"
? "Synthesizing..."
? t("synthesizing")
: activeTab === "transcription"
? "Transcribing..."
? t("transcribing")
: t("generating")}
</>
) : (
@@ -977,10 +958,10 @@ export default function MediaPageClient() {
: "auto_awesome"}
</span>
{activeTab === "speech"
? "Synthesize Speech"
? t("synthesizeSpeech")
: activeTab === "transcription"
? "Transcribe Audio"
: `${t("generate")} ${config.label}`}
? t("transcribeAudio")
: t("generateModality", { modality: t(config.labelKey) })}
</>
)}
</button>
@@ -1000,7 +981,7 @@ export default function MediaPageClient() {
<p
className={`text-sm font-medium ${isCredentialsError ? "text-amber-500" : "text-red-500"}`}
>
{isCredentialsError ? "API Key Required" : t("error")}
{isCredentialsError ? t("apiKeyRequired") : t("error")}
</p>
<p className="text-sm text-text-muted mt-1 break-words">{error}</p>
{isCredentialsError && (
@@ -1009,7 +990,7 @@ export default function MediaPageClient() {
className="inline-flex items-center gap-1 mt-2 text-xs text-primary hover:underline"
>
<span className="material-symbols-outlined text-[13px]">open_in_new</span>
Configure API keys in Providers
{t("configureApiKeys")}
</Link>
)}
</div>
@@ -1040,7 +1021,7 @@ export default function MediaPageClient() {
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
<span className="material-symbols-outlined text-[16px]">download</span>
Download {result.data?.format?.toUpperCase() || "MP3"}
{t("downloadFormat", { format: result.data?.format?.toUpperCase() || "MP3" })}
</a>
</div>
) : result.type === "image" ? (
@@ -1049,13 +1030,13 @@ export default function MediaPageClient() {
<div className="space-y-3">
<div className="bg-surface rounded-lg p-4 text-sm text-text-main leading-relaxed whitespace-pre-wrap">
{result.data?.text || (
<span className="text-text-muted italic">No text returned</span>
<span className="text-text-muted italic">{t("noTextReturned")}</span>
)}
</div>
{result.data?.words && (
<details className="mt-2">
<summary className="text-xs text-text-muted cursor-pointer hover:text-text-main">
Word-level timestamps ({result.data.words.length} words)
{t("wordTimestamps", { count: result.data.words.length })}
</summary>
<pre className="bg-surface rounded mt-2 p-3 text-xs text-text-muted overflow-auto max-h-48 custom-scrollbar">
{JSON.stringify(result.data.words, null, 2)}
@@ -1089,9 +1070,11 @@ export default function MediaPageClient() {
{cfg.icon}
</span>
</div>
<span className="text-sm font-medium text-text-main">{cfg.label}</span>
<span className="text-sm font-medium text-text-main">{t(cfg.labelKey)}</span>
</div>
<p className="text-xs text-text-muted">{providerCount} providers</p>
<p className="text-xs text-text-muted">
{t("providerCount", { count: providerCount })}
</p>
<code className="block mt-2 text-xs text-primary/70 bg-primary/5 rounded px-2 py-1">
POST {cfg.endpoint}
</code>

View File

@@ -81,7 +81,7 @@ const markdownComponents: Components = {
};
export default function ChangelogViewer() {
const t = useTranslations("common");
const t = useTranslations("changelogPage");
const [markdown, setMarkdown] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
@@ -111,7 +111,7 @@ export default function ChangelogViewer() {
<span className="material-symbols-outlined animate-spin text-[32px] text-text-muted/50">
sync
</span>
<p className="text-sm text-text-muted">{t("changelogViewerLoading")}</p>
<p className="text-sm text-text-muted">{t("loading")}</p>
</div>
);
}
@@ -122,9 +122,9 @@ export default function ChangelogViewer() {
<span className="material-symbols-outlined mb-4 text-[48px] text-red-500/50">
error_outline
</span>
<p>Could not load the changelog. Please try again later.</p>
<p>{t("changelogLoadFailed")}</p>
<Button variant="secondary" className="mt-4" onClick={() => globalThis.location.reload()}>
Retry
{t("retry")}
</Button>
</div>
);
@@ -139,7 +139,7 @@ export default function ChangelogViewer() {
<a href={CHANGELOG_GITHUB_URL} target="_blank" rel="noopener noreferrer">
<Button variant="secondary" className="gap-2 text-xs">
<span className="material-symbols-outlined text-[16px]">open_in_new</span>
View Full History on GitHub
{t("viewFullHistory")}
</Button>
</a>
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components";
import {
NEWS_JSON_URL,
@@ -9,6 +10,7 @@ import {
} from "@/shared/utils/releaseNotes";
export default function NewsViewer() {
const t = useTranslations("changelogPage");
const [news, setNews] = useState<NewsAnnouncement | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
@@ -49,7 +51,7 @@ export default function NewsViewer() {
<span className="material-symbols-outlined text-[48px] text-red-500/50 mb-4">
error_outline
</span>
<p>Could not load announcements. Please try again later.</p>
<p>{t("announcementsLoadFailed")}</p>
</div>
);
}
@@ -60,7 +62,7 @@ export default function NewsViewer() {
<span className="material-symbols-outlined text-[48px] opacity-50 mb-4">
notifications_off
</span>
<p>No new announcements at this time.</p>
<p>{t("noAnnouncements")}</p>
</div>
);
}
@@ -83,7 +85,7 @@ export default function NewsViewer() {
<div className="shrink-0 md:ml-auto">
<a href={news.link} target="_blank" rel="noopener noreferrer">
<Button variant="primary" className="gap-2">
{news.linkLabel || "Learn More"}
{news.linkLabel || t("learnMore")}
<span className="material-symbols-outlined text-[18px]">arrow_forward</span>
</Button>
</a>

View File

@@ -1,11 +1,13 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Card, SegmentedControl } from "@/shared/components";
import ChangelogViewer from "./components/ChangelogViewer";
import NewsViewer from "./components/NewsViewer";
export default function ChangelogPage() {
const t = useTranslations("changelogPage");
const [activeTab, setActiveTab] = useState<"news" | "changelog">("news");
return (
@@ -14,8 +16,8 @@ export default function ChangelogPage() {
<div className="w-full sm:w-[240px]">
<SegmentedControl
options={[
{ label: "News", value: "news" },
{ label: "Changelog", value: "changelog" },
{ label: t("newsTab"), value: "news" },
{ label: t("changelogTab"), value: "changelog" },
]}
value={activeTab}
onChange={(val) => setActiveTab(val as "news" | "changelog")}

View File

@@ -26,7 +26,7 @@ function ChaosSystemPromptField({
onChange={(e) => onChange(e.target.value)}
rows={3}
className="w-full px-3 py-1.5 rounded-md border border-border bg-surface text-sm text-text-main resize-y"
placeholder="Optional: override the default chaos mode system prompt..."
placeholder={t("systemPromptPlaceholder")}
/>
</div>
);
@@ -69,7 +69,7 @@ export function ChaosBasicSettingsFields({
<span className="material-symbols-outlined text-[14px]">
{settings.enabled ? "toggle_on" : "toggle_off"}
</span>
{settings.enabled ? "Enabled" : "Disabled"}
{settings.enabled ? t("enabled") : t("disabled")}
</button>
</div>
@@ -94,10 +94,8 @@ export function ChaosBasicSettingsFields({
{/* Max Tokens */}
<div className="p-3 rounded-lg border border-border bg-surface/40">
<p className="text-sm font-medium text-text-main">Max Tokens</p>
<p className="text-xs text-text-muted mb-2">
Maximum tokens per model response. Higher values cost more and take longer.
</p>
<p className="text-sm font-medium text-text-main">{t("maxTokens")}</p>
<p className="text-xs text-text-muted mb-2">{t("maxTokensDesc")}</p>
<input
type="number"
min={256}

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
export interface ChaosProviderInfo {
id: string;
name: string;
@@ -28,6 +30,7 @@ function ChaosProviderOverrideRow({
onUpdate: UpdateOverride;
onRemove: (index: number) => void;
}) {
const t = useTranslations("chaosConfig");
return (
<div className="flex items-center gap-2 p-2 rounded-md bg-black/5 dark:bg-white/5">
{/* Provider dropdown with available options */}
@@ -35,7 +38,7 @@ function ChaosProviderOverrideRow({
<input
type="text"
list={`provider-list-${index}`}
placeholder="Provider ID (type or select)"
placeholder={t("providerIdPlaceholder")}
value={override.providerId}
onChange={(e) => onUpdate(index, "providerId", e.target.value)}
className="w-full px-2 py-1 rounded border border-border bg-surface text-xs text-text-main"
@@ -48,7 +51,7 @@ function ChaosProviderOverrideRow({
</div>
<input
type="text"
placeholder="Model ID (optional)"
placeholder={t("modelIdPlaceholder")}
value={override.modelId || ""}
onChange={(e) => onUpdate(index, "modelId", e.target.value)}
className="flex-1 px-2 py-1 rounded border border-border bg-surface text-xs text-text-main"
@@ -60,7 +63,7 @@ function ChaosProviderOverrideRow({
override.enabled ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"
}`}
>
{override.enabled ? "ON" : "OFF"}
{override.enabled ? t("on") : t("off")}
</button>
<button
type="button"
@@ -73,12 +76,17 @@ function ChaosProviderOverrideRow({
);
}
function ChaosAvailableProvidersHint({ availableProviders }: { availableProviders: ChaosProviderInfo[] }) {
function ChaosAvailableProvidersHint({
availableProviders,
}: {
availableProviders: ChaosProviderInfo[];
}) {
const t = useTranslations("chaosConfig");
if (availableProviders.length === 0) return null;
return (
<details className="text-xs text-text-muted">
<summary className="cursor-pointer hover:text-text-main">
Available providers ({availableProviders.length})
{t("availableProviders", { count: availableProviders.length })}
</summary>
<div className="mt-1 flex flex-wrap gap-1">
{availableProviders.map((p) => (
@@ -116,6 +124,7 @@ export function ChaosProviderOverridesPanel({
onUpdate: UpdateOverride;
onRemove: (index: number) => void;
}) {
const t = useTranslations("chaosConfig");
return (
<div className="p-3 rounded-lg border border-border bg-surface/40 space-y-3">
<div className="flex items-center justify-between">
@@ -134,9 +143,7 @@ export function ChaosProviderOverridesPanel({
</div>
{overrides.length === 0 && (
<p className="text-xs text-text-muted italic">
No overrides all active providers will participate with their default models
</p>
<p className="text-xs text-text-muted italic">{t("noProviderOverrides")}</p>
)}
{overrides.map((override, idx) => (

View File

@@ -109,9 +109,7 @@ export default function AntigravityToolCard({
// browser hitting a Windows server does not (#822).
const serverIsWindows = status?.isWin === true;
const canRunWithoutPassword =
serverIsWindows ||
status?.hasCachedPassword === true ||
status?.needsSudoPassword === false;
serverIsWindows || status?.hasCachedPassword === true || status?.needsSudoPassword === false;
const handleStart = () => {
if (canRunWithoutPassword) {
@@ -220,7 +218,10 @@ export default function AntigravityToolCard({
if (currentEditingAlias) {
setModelMappings((prev) => ({
...prev,
[currentEditingAlias]: { ...getMappingEntry(prev, currentEditingAlias), model: model.value },
[currentEditingAlias]: {
...getMappingEntry(prev, currentEditingAlias),
model: model.value,
},
}));
}
};
@@ -290,7 +291,7 @@ export default function AntigravityToolCard({
</Badge>
)}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">{t(`toolDescriptions.${tool.id}`)}</p>
</div>
</div>
<span
@@ -522,7 +523,9 @@ export default function AntigravityToolCard({
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={
currentEditingAlias ? getMappingEntry(modelMappings, currentEditingAlias).model || null : null
currentEditingAlias
? getMappingEntry(modelMappings, currentEditingAlias).model || null
: null
}
activeProviders={activeProviders}
modelAliases={modelAliases}

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
type CompatMode = "off" | "auto" | "always";
@@ -27,6 +28,7 @@ function isCompatMode(value: unknown): value is CompatMode {
* every Claude-format request. Cycles off → auto → always via the existing /api/settings PATCH.
*/
export default function ClaudeClassifierCompatToggle() {
const t = useTranslations("cliTools");
const [mode, setMode] = useState<CompatMode>("off");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -41,11 +43,11 @@ export default function ClaudeClassifierCompatToggle() {
setMode(isCompatMode(data?.claudeClassifierCompat) ? data.claudeClassifierCompat : "off");
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load setting");
setError(err instanceof Error ? err.message : t("classifierCompatLoadFailed"));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
load();
@@ -66,30 +68,31 @@ export default function ClaudeClassifierCompatToggle() {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (err) {
setMode(previous); // revert on failure
setError(err instanceof Error ? err.message : "Failed to save");
setError(err instanceof Error ? err.message : t("failedSave"));
} finally {
setSaving(false);
}
}, [mode]);
}, [mode, t]);
return (
<div className="rounded-lg border border-border bg-surface/40 p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h4 className="text-xs font-semibold text-text-main">Auto-permission classifier compat</h4>
<h4 className="text-xs font-semibold text-text-main">{t("classifierCompatTitle")}</h4>
<p className="text-xs text-text-muted">
Short-circuit Claude Code&apos;s <code>--permission-mode auto</code> security classifier
with a synthetic allow, so fallback routes don&apos;t fail closed. Off by default.
{t.rich("classifierCompatDescription", {
code: (chunks) => <code>{chunks}</code>,
})}
</p>
</div>
<button
type="button"
onClick={cycle}
disabled={loading || saving}
title="Cycle off → auto → always"
title={t("classifierCompatCycle")}
className={`shrink-0 rounded border px-3 py-1.5 text-xs font-medium uppercase tracking-wide transition-colors disabled:opacity-50 ${MODE_STYLES[mode]}`}
>
{mode}
{t(`classifierCompatMode.${mode}`)}
</button>
</div>
{error ? <p className="mt-2 text-xs text-red-500">{error}</p> : null}

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Toggle } from "@/shared/components";
type FlagEntry = { key: string; effectiveValue: string };
@@ -19,6 +20,7 @@ function isOn(value: string | undefined): boolean {
* sync regenerate that tool's profile files from the live catalog.
*/
export default function CliProfileAutoSyncToggles() {
const t = useTranslations("cliTools");
const [codexOn, setCodexOn] = useState(false);
const [claudeOn, setClaudeOn] = useState(false);
const [loading, setLoading] = useState(true);
@@ -36,11 +38,11 @@ export default function CliProfileAutoSyncToggles() {
setClaudeOn(isOn(flags.find((f) => f.key === CLAUDE_KEY)?.effectiveValue));
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load settings");
setError(err instanceof Error ? err.message : t("profileSyncLoadFailed"));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
load();
@@ -60,37 +62,34 @@ export default function CliProfileAutoSyncToggles() {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (err) {
apply(previous); // revert on failure
setError(err instanceof Error ? err.message : "Failed to save");
setError(err instanceof Error ? err.message : t("failedSave"));
} finally {
setSavingKey(null);
}
},
[]
[t]
);
return (
<div className="rounded-xl border border-border bg-surface/40 p-4">
<div className="mb-3">
<h3 className="text-sm font-semibold text-text-main">CLI profile auto-sync</h3>
<p className="text-xs text-text-muted">
After a provider model sync, automatically regenerate CLI tool profiles from the live
catalog. Off by default only profile files are written, never the active/default config.
</p>
<h3 className="text-sm font-semibold text-text-main">{t("profileSyncTitle")}</h3>
<p className="text-xs text-text-muted">{t("profileSyncDescription")}</p>
</div>
<div className="flex flex-col gap-3">
<Toggle
checked={codexOn}
disabled={loading || savingKey === CODEX_KEY}
onChange={(v) => persist(CODEX_KEY, v, codexOn, setCodexOn)}
label="Codex profiles"
description="Regenerate ~/.codex/*.config.toml after model discovery."
label={t("codexProfiles")}
description={t("codexProfilesDescription")}
/>
<Toggle
checked={claudeOn}
disabled={loading || savingKey === CLAUDE_KEY}
onChange={(v) => persist(CLAUDE_KEY, v, claudeOn, setClaudeOn)}
label="Claude Code profiles"
description="Regenerate ~/.claude/profiles/<name>/settings.json after model discovery."
label={t("claudeProfiles")}
description={t("claudeProfilesDescription")}
/>
</div>
{error ? <p className="mt-2 text-xs text-red-500">{error}</p> : null}

View File

@@ -606,7 +606,7 @@ openai_base_url = "${getEffectiveBaseUrl()}"
{/* Reasoning Effort */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Reasoning Effort
{t("reasoningEffort")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -616,20 +616,20 @@ openai_base_url = "${getEffectiveBaseUrl()}"
onChange={(e) => setReasoningEffort(e.target.value)}
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="xhigh">XHigh</option>
<option value="max">Max</option>
<option value="ultra">Ultra</option>
<option value="none">{t("effortNone")}</option>
<option value="low">{t("effortLow")}</option>
<option value="medium">{t("effortMedium")}</option>
<option value="high">{t("effortHigh")}</option>
<option value="xhigh">{t("effortExtraHigh")}</option>
<option value="max">{t("effortMax")}</option>
<option value="ultra">{t("effortUltra")}</option>
</select>
</div>
{/* Wire API */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Wire API
{t("wireApi")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -647,7 +647,7 @@ openai_base_url = "${getEffectiveBaseUrl()}"
<div className="h-px bg-border/50 my-2"></div>
<div className="text-[11px] text-text-muted mb-2 font-medium uppercase tracking-wider text-right">
Model Aliases ([notice.model_migrations])
{t("modelAliases")} ([notice.model_migrations])
</div>
{CODEX_DEFAULT_MODELS.map((defaultModel) => (
<div key={defaultModel} className="flex items-center gap-2 group">
@@ -673,7 +673,7 @@ openai_base_url = "${getEffectiveBaseUrl()}"
onChange={(e) =>
setModelMappings({ ...modelMappings, [defaultModel]: e.target.value })
}
placeholder={`Route ${defaultModel} to...`}
placeholder={t("routeModelPlaceholder", { model: defaultModel })}
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"
/>
{modelMappings[defaultModel] && (

View File

@@ -176,7 +176,7 @@ export default function CopilotToolCard({
<div className="flex items-center gap-3">
<div className="size-8 rounded-lg flex items-center justify-center shrink-0">
<Image
src={tool.image || "/providers/copilot.png"}
src={tool.image || "/providers/copilot.svg"}
alt={tool.name}
width={32}
height={32}
@@ -195,7 +195,7 @@ export default function CopilotToolCard({
{t("guide")}
</span>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">{t("toolDescriptions.copilot")}</p>
</div>
</div>
<span
@@ -215,12 +215,11 @@ export default function CopilotToolCard({
<div className="text-sm text-blue-700 dark:text-blue-300">
<p className="font-medium">{t("copilotConfigGenerator")}</p>
<p className="mt-1 text-xs opacity-80">
Generates the{" "}
{t("copilotGeneratorDescriptionPrefix")}{" "}
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
chatLanguageModels.json
</code>{" "}
block for VS Code GitHub Copilot using the Azure vendor pattern. Select the models
you want, then copy the JSON into your config file.
{t("copilotGeneratorDescriptionSuffix")}
</p>
</div>
</div>
@@ -229,10 +228,10 @@ export default function CopilotToolCard({
<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-lg">warning</span>
<p className="text-xs text-yellow-600 dark:text-yellow-400">
This configuration uses the Azure vendor workaround for custom model lists. Tested
with <strong>VS Code 1.109</strong> and{" "}
<strong>GitHub Copilot Chat v0.37</strong>. Future extension updates may change
this behavior.
{t.rich("copilotCompatibilityWarning", {
vscode: (chunks) => <strong>{chunks}</strong>,
copilot: (chunks) => <strong>{chunks}</strong>,
})}
</p>
</div>
@@ -273,7 +272,10 @@ export default function CopilotToolCard({
{cloudEnabled && apiKeys?.length > 0 ? "2" : "1"}
</div>
<span className="font-medium text-sm">
Select Models ({selectedModels.size}/{availableModels.length})
{t("copilotSelectModels", {
selected: selectedModels.size,
total: availableModels.length,
})}
</span>
</div>
<div className="flex gap-2">
@@ -281,13 +283,13 @@ export default function CopilotToolCard({
onClick={selectAll}
className="px-2 py-1 text-xs bg-bg-secondary hover:bg-bg-tertiary rounded border border-border transition-colors"
>
Select All
{t("selectAll")}
</button>
<button
onClick={deselectAll}
className="px-2 py-1 text-xs bg-bg-secondary hover:bg-bg-tertiary rounded border border-border transition-colors"
>
Clear
{t("clear")}
</button>
</div>
</div>
@@ -308,7 +310,7 @@ export default function CopilotToolCard({
<span className="material-symbols-outlined animate-spin text-base">
progress_activity
</span>
<span>Loading models...</span>
<span>{t("loadingModels")}</span>
</div>
) : availableModels.length === 0 && allModels.length === 0 ? (
<div className="flex items-center gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
@@ -343,7 +345,7 @@ export default function CopilotToolCard({
<span className="material-symbols-outlined text-base group-open:rotate-90 transition-transform">
chevron_right
</span>
Advanced Options
{t("advancedOptions")}
</summary>
<div className="mt-3 grid grid-cols-2 gap-3 pl-6">
<div>
@@ -384,7 +386,7 @@ export default function CopilotToolCard({
onChange={(e) => setVision(e.target.checked)}
className="rounded border-border accent-[#1F6FEB]"
/>
<span className="text-sm">Vision</span>
<span className="text-sm">{t("vision")}</span>
</label>
</div>
</details>
@@ -401,8 +403,7 @@ export default function CopilotToolCard({
{cloudEnabled && apiKeys?.length > 0 ? "3" : "2"}
</div>
<span className="font-medium text-sm">
Copy Config ({selectedModels.size} model{selectedModels.size !== 1 ? "s" : ""}
)
{t("copilotCopyConfigForModels", { count: selectedModels.size })}
</span>
</div>
<Button
@@ -430,9 +431,7 @@ export default function CopilotToolCard({
~/.config/Code/User/chatLanguageModels.json
</code>
</p>
<p className="text-xs text-text-muted mt-1">
Then reload VS Code and set the API key in the input prompt.
</p>
<p className="text-xs text-text-muted mt-1">{t("copilotReloadInstruction")}</p>
</div>
</div>
)}

View File

@@ -132,7 +132,7 @@ export default function CustomCliCard({
{translateOrFallback("custom", "Custom")}
</span>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">{t("toolDescriptions.custom")}</p>
</div>
</div>
<span

View File

@@ -24,6 +24,7 @@ export default function DefaultToolCard({
const t = useTranslations("cliTools");
const translateOrFallback = useCallback(
(key, fallback, values = undefined) => {
if (!t.has(key)) return fallback;
try {
return t(key, values);
} catch {
@@ -100,40 +101,44 @@ export default function DefaultToolCard({
// Persist and restore model selection per tool via localStorage
useEffect(() => {
const savedModel = localStorage.getItem(`omniroute-cli-model-${toolId}`);
if (savedModel) {
if (isMultiModelTool) {
try {
const parsed = JSON.parse(savedModel);
if (Array.isArray(parsed)) {
const normalized = parsed.map((value) => String(value || "").trim()).filter(Boolean);
setModelValues(normalized);
setModelValue(normalized[0] || "");
} else {
const restoreTimer = window.setTimeout(() => {
const savedModel = localStorage.getItem(`omniroute-cli-model-${toolId}`);
if (savedModel) {
if (isMultiModelTool) {
try {
const parsed = JSON.parse(savedModel);
if (Array.isArray(parsed)) {
const normalized = parsed.map((value) => String(value || "").trim()).filter(Boolean);
setModelValues(normalized);
setModelValue(normalized[0] || "");
} else {
setModelValue(savedModel);
setModelValues([savedModel]);
}
} catch {
setModelValue(savedModel);
setModelValues([savedModel]);
}
} catch {
} else {
setModelValue(savedModel);
setModelValues([savedModel]);
}
} else {
setModelValue(savedModel);
}
}
const savedKey = localStorage.getItem(`omniroute-cli-key-${toolId}`);
// (#523) localStorage may contain a masked key string from before the fix —
// match by prefix/suffix against known keys to find the id.
if (savedKey && apiKeys?.length > 0) {
const prefix = savedKey.slice(0, 8);
const suffix = savedKey.slice(-4);
const matchedKey = apiKeys.find(
(k) =>
(k.rawKey && k.rawKey.startsWith(prefix) && k.rawKey.endsWith(suffix)) ||
(k.key && k.key.startsWith(prefix) && k.key.endsWith(suffix))
);
if (matchedKey) setSelectedApiKeyId(matchedKey.id);
}
const savedKey = localStorage.getItem(`omniroute-cli-key-${toolId}`);
// (#523) localStorage may contain a masked key string from before the fix —
// match by prefix/suffix against known keys to find the id.
if (savedKey && apiKeys?.length > 0) {
const prefix = savedKey.slice(0, 8);
const suffix = savedKey.slice(-4);
const matchedKey = apiKeys.find(
(k) =>
(k.rawKey && k.rawKey.startsWith(prefix) && k.rawKey.endsWith(suffix)) ||
(k.key && k.key.startsWith(prefix) && k.key.endsWith(suffix))
);
if (matchedKey) setSelectedApiKeyId(matchedKey.id);
}
}, 0);
return () => window.clearTimeout(restoreTimer);
}, [toolId, apiKeys, isMultiModelTool]);
const handleModelChange = useCallback(
@@ -238,7 +243,7 @@ export default function DefaultToolCard({
getSelectedModelLabelMap,
replaceVars,
resolveApiKeyValue,
tool.codeBlock?.code,
tool.codeBlock,
usesOpenCodePreview,
usesQwenCodePreview,
]);

View File

@@ -486,7 +486,7 @@ export default function DroidToolCard({
onClick={() => addModel()}
disabled={!modelInput.trim() || modelList.includes(modelInput.trim())}
className="px-2 py-1.5 rounded border bg-surface border-border hover:border-primary text-xs shrink-0 disabled:opacity-50"
title="Add model"
title={t("addModel")}
>
<span className="material-symbols-outlined text-[14px]">add</span>
</button>

View File

@@ -1,26 +1,43 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Card, Button, ModelSelectModal } from "@/shared/components";
interface Role {
id: string;
label: string;
description: string;
labelKey: string;
descriptionKey: string;
}
const HERMES_ROLES: Role[] = [
{ id: "default", label: "Default (main)", description: "Primary conversation model" },
{ id: "default", labelKey: "hermesRoleDefault", descriptionKey: "hermesRoleDefaultDesc" },
{
id: "delegation",
label: "Delegation (subagents)",
description: "Orchestrator and sub-agent model",
labelKey: "hermesRoleDelegation",
descriptionKey: "hermesRoleDelegationDesc",
},
{ id: "vision", labelKey: "hermesRoleVision", descriptionKey: "hermesRoleVisionDesc" },
{
id: "compression",
labelKey: "hermesRoleCompression",
descriptionKey: "hermesRoleCompressionDesc",
},
{
id: "web_extract",
labelKey: "hermesRoleWebExtract",
descriptionKey: "hermesRoleWebExtractDesc",
},
{
id: "skills_hub",
labelKey: "hermesRoleSkillsHub",
descriptionKey: "hermesRoleSkillsHubDesc",
},
{
id: "approval",
labelKey: "hermesRoleApproval",
descriptionKey: "hermesRoleApprovalDesc",
},
{ id: "vision", label: "Vision", description: "Image and screenshot understanding" },
{ id: "compression", label: "Compression", description: "Prompt compression & summarization" },
{ id: "web_extract", label: "Web Extract", description: "Web page content extraction" },
{ id: "skills_hub", label: "Skills Hub", description: "Skills and tool-use reasoning" },
{ id: "approval", label: "Approval", description: "Safety and approval decisions" },
];
const HERMES_AGENT_ZERO_CONFIG_PROVIDERS = ["opencode"];
@@ -36,6 +53,7 @@ export default function HermesAgentToolCard({
cloudEnabled,
batchStatus,
}: any) {
const t = useTranslations("cliTools");
type RoleSelection = { model: string; provider: string };
const [selections, setSelections] = useState<Record<string, RoleSelection>>({});
@@ -60,11 +78,11 @@ export default function HermesAgentToolCard({
const diff = Date.now() - then;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days > 0) return `${days}d`;
if (days > 0) return t("daysAgoShort", { count: days });
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours > 0) return `${hours}h`;
if (hours > 0) return t("hoursAgoShort", { count: hours });
const minutes = Math.floor(diff / (1000 * 60));
return `${minutes}m`;
return t("minutesAgoShort", { count: minutes });
}
const loadCurrentConfig = useCallback(async () => {
@@ -160,7 +178,7 @@ export default function HermesAgentToolCard({
}
if (payloadSelections.length === 0) {
setMessage("Select models for roles (or ensure roles are loaded) before previewing.");
setMessage(t("hermesSelectBeforePreview"));
return;
}
@@ -181,10 +199,10 @@ export default function HermesAgentToolCard({
if (res.ok && data.yaml) {
setPreviewYaml(data.yaml);
} else {
setMessage(data.error || "Failed to generate preview");
setMessage(data.error || t("hermesPreviewFailed"));
}
} catch {
setMessage("Failed to generate preview");
setMessage(t("hermesPreviewFailed"));
} finally {
setIsPreviewLoading(false);
}
@@ -212,15 +230,15 @@ export default function HermesAgentToolCard({
const data = await res.json();
if (res.ok) {
setMessage(`Saved to ${data.configPath}`);
setMessage(t("hermesSavedTo", { path: data.configPath }));
setSelections({}); // clear pending user choices after successful save
setPreviewYaml(null); // hide any open preview after apply
await loadCurrentConfig();
} else {
setMessage(data.error || "Failed to save");
setMessage(data.error || t("failedToSave"));
}
} catch {
setMessage("Network error");
setMessage(t("networkError"));
} finally {
setIsSaving(false);
}
@@ -289,10 +307,12 @@ export default function HermesAgentToolCard({
{firstSetupAt && (
<span
className="text-[10px] text-text-muted flex items-center gap-0.5 font-normal"
title={`First set up via OmniRoute on ${new Date(firstSetupAt).toLocaleDateString()}`}
title={t("hermesFirstSetupTitle", {
date: new Date(firstSetupAt).toLocaleDateString(),
})}
>
<span className="material-symbols-outlined text-[11px]">schedule</span>
{formatTimeSince(firstSetupAt)} since setup
{t("hermesSinceSetup", { time: formatTimeSince(firstSetupAt) })}
</span>
)}
</h3>
@@ -300,13 +320,14 @@ export default function HermesAgentToolCard({
Object.keys(selections).length > 0 ||
Object.keys(batchStatus?.hermesAgentRoles || {}).length > 0) && (
<span className="text-[10px] px-1.5 py-px rounded bg-emerald-500/10 text-emerald-600">
{configuredRolesCount}/{HERMES_ROLES.length} roles
{t("hermesConfiguredRoles", {
configured: configuredRolesCount,
total: HERMES_ROLES.length,
})}
</span>
)}
</div>
<p className="text-xs text-text-muted truncate">
{tool?.description || "Advanced multi-role terminal agent (by Nousresearch)"}
</p>
<p className="text-xs text-text-muted truncate">{t("toolDescriptions.hermes-agent")}</p>
</div>
</div>
<span
@@ -328,14 +349,14 @@ export default function HermesAgentToolCard({
loading={isLoading}
>
<span className="material-symbols-outlined text-[14px] mr-1">refresh</span>
Refresh all
{t("refreshAll")}
</Button>
</div>
{/* Quick apply row — consistent small action pills */}
{activeProviders?.[0]?.models?.length > 0 && (
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="text-text-muted">Quick apply same model to all roles:</span>
<span className="text-text-muted">{t("hermesQuickApply")}</span>
{activeProviders[0].models.slice(0, 6).map((m: any) => {
const modelValue = typeof m === "string" ? m : m?.value || m?.name;
if (!modelValue) return null;
@@ -344,7 +365,7 @@ export default function HermesAgentToolCard({
key={modelValue}
onClick={() => applyToAll(modelValue)}
className="px-2 py-0.5 rounded border border-border bg-surface hover:bg-bg-secondary text-text-main transition-colors"
title={`Apply ${modelValue} to every role`}
title={t("hermesApplyModelToAll", { model: modelValue })}
>
{modelValue}
</button>
@@ -365,12 +386,17 @@ export default function HermesAgentToolCard({
// Badge logic per user's spec:
// - If user has selected something in this session (pending): show as via OmniRoute
// - Else if current from disk: show real provider name + "(not OmniRoute)" or "OmniRoute"
let badge: { label: string; pending: boolean } | null = null;
let badge: { label: string; pending: boolean; outsideOmniRoute: boolean } | null =
null;
if (sel) {
// pending change made via the Select modal / quick apply → will be routed via OmniRoute
const prov = sel.provider || "OmniRoute";
badge = { label: `${prov} (via OmniRoute)`, pending: true };
badge = {
label: t("hermesViaOmniRoute", { provider: prov }),
pending: true,
outsideOmniRoute: false,
};
} else if (current) {
const isOmni =
current?.provider === "omniroute" ||
@@ -378,10 +404,14 @@ export default function HermesAgentToolCard({
(current?.base_url || "").includes("localhost");
if (isOmni) {
badge = { label: "OmniRoute", pending: false };
badge = { label: "OmniRoute", pending: false, outsideOmniRoute: false };
} else {
const realProvider = current.provider || "Other";
badge = { label: `${realProvider} (not OmniRoute)`, pending: false };
const realProvider = current.provider || t("other");
badge = {
label: t("hermesNotOmniRoute", { provider: realProvider }),
pending: false,
outsideOmniRoute: true,
};
}
}
@@ -389,9 +419,9 @@ export default function HermesAgentToolCard({
<div key={role.id} className="flex items-start justify-between gap-3 py-1">
{/* Left: role label + subtitle (now has room so long descriptions stay on one line) */}
<div className="min-w-0 pr-3">
<div className="font-medium text-sm text-text-main">{role.label}</div>
<div className="font-medium text-sm text-text-main">{t(role.labelKey)}</div>
<div className="text-[10px] leading-tight text-text-muted">
{role.description}
{t(role.descriptionKey)}
</div>
</div>
@@ -411,7 +441,7 @@ export default function HermesAgentToolCard({
{badge && (
<div
className={`text-[10px] px-1.5 py-px rounded shrink-0 ${
badge.label.includes("not OmniRoute")
badge.outsideOmniRoute
? "bg-amber-500/10 text-amber-600"
: "bg-emerald-500/10 text-emerald-600"
}`}
@@ -427,7 +457,7 @@ export default function HermesAgentToolCard({
onClick={() => setModalRole(role.id)}
disabled={isLoadingAny}
>
Select
{t("select")}
</Button>
{sel && (
@@ -442,9 +472,9 @@ export default function HermesAgentToolCard({
});
}}
disabled={isLoadingAny}
title="Remove this role from pending changes"
title={t("hermesRemovePendingRole")}
>
Clear
{t("clear")}
</Button>
)}
</div>
@@ -471,7 +501,7 @@ export default function HermesAgentToolCard({
loading={isSaving}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
Apply to Hermes Agent
{t("hermesApply")}
</Button>
<Button
@@ -486,13 +516,12 @@ export default function HermesAgentToolCard({
loading={isPreviewLoading}
>
<span className="material-symbols-outlined text-[14px] mr-1">visibility</span>
Preview
{t("preview")}
</Button>
{Object.keys(selections).length > 0 && (
<span className="text-xs text-text-muted ml-1">
{Object.keys(selections).length} role
{Object.keys(selections).length === 1 ? "" : "s"} will be updated
{t("hermesRolesWillUpdate", { count: Object.keys(selections).length })}
</span>
)}
@@ -505,7 +534,7 @@ export default function HermesAgentToolCard({
{previewYaml && (
<div className="mt-2">
<div className="text-[10px] font-medium text-text-muted mb-1.5 flex items-center gap-1.5">
<span>Preview will write to ~/.hermes/config.yaml</span>
<span>{t("hermesPreviewPath")}</span>
</div>
<pre className="p-4 bg-bg-secondary rounded-lg border border-border overflow-auto max-h-80 text-xs">
<code className="font-mono whitespace-pre text-text-main">{previewYaml}</code>
@@ -514,7 +543,7 @@ export default function HermesAgentToolCard({
)}
<p className="text-xs text-text-muted -mt-2">
Saves the selected models for each role into <code>~/.hermes/config.yaml</code>.
{t("hermesSaveDescription")} <code>~/.hermes/config.yaml</code>.
</p>
</div>
)}

View File

@@ -282,7 +282,7 @@ export default function OpenClawToolCard({
<div className="flex items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image
src="/providers/openclaw.png"
src="/providers/openclaw.svg"
alt={tool.name}
width={32}
height={32}
@@ -345,11 +345,7 @@ export default function OpenClawToolCard({
deployments where the CLI lives on the user's machine,
not on the server. Upstream report: #579.
*/}
<Button
variant="ghost"
size="sm"
onClick={() => setShowManualConfigModal(true)}
>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>
{t("manualConfig")}
</Button>

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from "react";
import { Card, Button, Input, Badge } from "@/shared/components";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
// ── Types ────────────────────────────────────────────────────────────────────
@@ -38,7 +38,7 @@ const CLOUD_AGENTS = [
id: "jules",
name: "Jules",
provider: "Google",
description: "Google's autonomous coding agent",
descriptionKey: "agentDescriptions.jules",
icon: "smart_toy",
iconBg: "bg-yellow-500/10",
iconColor: "text-yellow-600",
@@ -47,7 +47,7 @@ const CLOUD_AGENTS = [
id: "devin",
name: "Devin",
provider: "Cognition",
description: "Cognition's AI software engineer",
descriptionKey: "agentDescriptions.devin",
icon: "psychology",
iconBg: "bg-blue-500/10",
iconColor: "text-blue-600",
@@ -56,7 +56,7 @@ const CLOUD_AGENTS = [
id: "codex-cloud",
name: "Codex Cloud",
provider: "OpenAI",
description: "OpenAI's cloud-based coding agent",
descriptionKey: "agentDescriptions.codexCloud",
icon: "cloud",
iconBg: "bg-emerald-500/10",
iconColor: "text-emerald-600",
@@ -65,7 +65,7 @@ const CLOUD_AGENTS = [
id: "cursor-cloud",
name: "Cursor Cloud",
provider: "Cursor",
description: "Cursor's Background / Cloud Agents (official API key)",
descriptionKey: "agentDescriptions.cursorCloud",
icon: "cloud",
iconBg: "bg-slate-500/10",
iconColor: "text-slate-600",
@@ -101,6 +101,7 @@ function formatDuration(start: string, end: string) {
// ── Component ────────────────────────────────────────────────────────────────
export default function CloudAgentsPage() {
const locale = useLocale();
const [activeTab, setActiveTab] = useState<TabId>("tasks");
const t = useTranslations("cloudAgents");
@@ -137,12 +138,15 @@ export default function CloudAgentsPage() {
// ── Load settings from localStorage ──────────────────────────────────────
useEffect(() => {
try {
const stored = localStorage.getItem("omniroute-cloud-agents-settings");
if (stored) setSettings(JSON.parse(stored));
} catch {
// ignore
}
const loadSettings = setTimeout(() => {
try {
const stored = localStorage.getItem("omniroute-cloud-agents-settings");
if (stored) setSettings(JSON.parse(stored));
} catch {
// Ignore malformed or unavailable local storage.
}
}, 0);
return () => clearTimeout(loadSettings);
}, []);
const updateSetting = (key: keyof typeof settings, value: boolean) => {
@@ -180,7 +184,8 @@ export default function CloudAgentsPage() {
}, []);
useEffect(() => {
fetchTasks();
const initialFetch = setTimeout(() => void fetchTasks(), 0);
return () => clearTimeout(initialFetch);
}, [fetchTasks]);
// ── Auto-poll when tasks are running/queued ──────────────────────────────
@@ -212,7 +217,9 @@ export default function CloudAgentsPage() {
// ── Tab mount effects ────────────────────────────────────────────────────
useEffect(() => {
if (activeTab === "agents") fetchAgentHealth();
if (activeTab !== "agents") return;
const healthFetch = setTimeout(() => void fetchAgentHealth(), 0);
return () => clearTimeout(healthFetch);
}, [activeTab, fetchAgentHealth]);
// ── Filtered tasks ───────────────────────────────────────────────────────
@@ -496,7 +503,7 @@ export default function CloudAgentsPage() {
onChange={(e) => setNewTask({ ...newTask, autoCreatePr: e.target.checked })}
className="h-4 w-4 rounded border-border/60"
/>
Auto-create PR
{t("settingAutoPR")}
</label>
</div>
<div className="flex justify-end">
@@ -581,7 +588,7 @@ export default function CloudAgentsPage() {
{task.prompt || t("untitledTask")}
</p>
<p className="text-xs text-text-muted">
{agent.name} · {new Date(task.createdAt).toLocaleString()}
{agent.name} · {new Date(task.createdAt).toLocaleString(locale)}
</p>
</div>
</div>
@@ -609,7 +616,7 @@ export default function CloudAgentsPage() {
<div>
<p className="font-medium">{getAgentInfo(selectedTask.providerId).name}</p>
<p className="text-xs text-text-muted">
{t("created")}: {new Date(selectedTask.createdAt).toLocaleString()}
{t("created")}: {new Date(selectedTask.createdAt).toLocaleString(locale)}
</p>
</div>
</div>
@@ -668,7 +675,9 @@ export default function CloudAgentsPage() {
: "bg-surface/40 text-text-main"
}`}
>
<span className="font-medium capitalize">{activity.type}: </span>
<span className="font-medium">
{t(`activityTypes.${activity.type}`)}:{" "}
</span>
{activity.content}
</div>
))}
@@ -788,7 +797,7 @@ export default function CloudAgentsPage() {
</div>
{/* Description */}
<p className="text-sm text-text-muted">{agent.description}</p>
<p className="text-sm text-text-muted">{t(agent.descriptionKey)}</p>
{/* Connection status */}
<div className="flex items-center gap-2">

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import Card from "@/shared/components/Card";
import { CardSkeleton } from "@/shared/components/Loading";
@@ -89,8 +90,8 @@ function fmtDate(value: string | null | undefined): string {
return date.toLocaleString();
}
function shortId(value: string | null | undefined, max = 10): string {
if (!value) return "dynamic";
function shortId(value: string | null | undefined, fallback: string, max = 10): string {
if (!value) return fallback;
return value.length > max ? `${value.slice(0, max)}` : value;
}
@@ -104,11 +105,28 @@ function metricValue(label: string, value: string, hint?: string) {
);
}
function stateLabel(state: ComboControlCenterSummary["healthState"]): string {
if (state === "healthy") return "Healthy";
if (state === "warning") return "Needs attention";
if (state === "critical") return "Critical";
return "Idle";
function stateLabel(
state: ComboControlCenterSummary["healthState"],
t: ReturnType<typeof useTranslations>
): string {
return t(`state.${state}`);
}
function healthReasonLabel(reason: string, t: ReturnType<typeof useTranslations>): string {
const keyByReason: Record<string, string> = {
"No recent combo traffic": "noRecentTraffic",
"Low success rate": "lowSuccessRate",
"Success rate below target": "successBelowTarget",
"High fallback rate": "highFallbackRate",
"Elevated fallback rate": "elevatedFallbackRate",
"At least one quota is exhausted": "quotaExhausted",
"Quota is nearly exhausted": "quotaNearlyExhausted",
"Quota is getting low": "quotaGettingLow",
"Traffic distribution is highly skewed": "trafficHighlySkewed",
"Combo looks healthy": "comboHealthy",
};
const key = keyByReason[reason];
return key ? t(`healthReason.${key}`) : reason;
}
function targetHealthTone(target: ComboControlCenterTarget | ComboControlCenterTargetHealth) {
@@ -124,6 +142,7 @@ function targetHealthTone(target: ComboControlCenterTarget | ComboControlCenterT
}
function TargetConfiguredRow({ target }: { target: ComboControlCenterTarget }) {
const t = useTranslations("comboControl");
return (
<div className="rounded-xl border border-border bg-surface p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
@@ -133,31 +152,31 @@ function TargetConfiguredRow({ target }: { target: ComboControlCenterTarget }) {
{target.index + 1}
</span>
<span className="rounded-full border border-border bg-bg-subtle px-2 py-0.5 text-[11px] uppercase tracking-wide text-text-muted">
{target.kind === "combo-ref" ? "Nested combo" : "Model target"}
{target.kind === "combo-ref" ? t("nestedCombo") : t("modelTarget")}
</span>
{target.weight > 0 && (
<span className="rounded-full border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300">
{target.weight}% weight
{t("weight", { value: target.weight })}
</span>
)}
</div>
<p className="mt-2 truncate font-mono text-sm text-text-main">{target.label}</p>
<p className="mt-1 text-xs text-text-muted">
{target.provider ? getProviderDisplayName(target.provider) : "Combo reference"} ·
account {shortId(target.connectionId)}
{target.provider ? getProviderDisplayName(target.provider) : t("comboReference")} ·{" "}
{t("accountShort", { id: shortId(target.connectionId, t("dynamic")) })}
</p>
</div>
<div className={`rounded-lg border px-3 py-2 text-xs ${targetHealthTone(target)}`}>
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span>Requests</span>
<span>{t("requests")}</span>
<span className="text-right font-semibold">{target.health?.requests ?? 0}</span>
<span>Success</span>
<span>{t("success")}</span>
<span className="text-right font-semibold">
{fmtPercent(target.health?.successRate)}
</span>
<span>Latency</span>
<span>{t("latency")}</span>
<span className="text-right font-semibold">{fmtMs(target.health?.avgLatencyMs)}</span>
<span>Quota</span>
<span>{t("quota")}</span>
<span className="text-right font-semibold">
{fmtPercent(target.health?.quotaRemainingPct)}
</span>
@@ -169,19 +188,27 @@ function TargetConfiguredRow({ target }: { target: ComboControlCenterTarget }) {
}
function ResolvedTargetRow({ target }: { target: ComboControlCenterTargetHealth }) {
const t = useTranslations("comboControl");
return (
<div className="rounded-xl border border-border bg-surface p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<p className="truncate font-mono text-sm text-text-main">{target.model || "unknown"}</p>
<p className="truncate font-mono text-sm text-text-main">
{target.model || t("unknown")}
</p>
<p className="mt-1 text-xs text-text-muted">
{target.provider ? getProviderDisplayName(target.provider) : "unknown provider"} ·
account {shortId(target.connectionId)} · key {shortId(target.executionKey)}
{target.provider ? getProviderDisplayName(target.provider) : t("unknownProvider")} ·{" "}
{t("accountShort", { id: shortId(target.connectionId, t("dynamic")) })} ·{" "}
{t("keyShort", { id: shortId(target.executionKey, t("dynamic")) })}
</p>
</div>
<div className={`rounded-lg border px-3 py-2 text-xs ${targetHealthTone(target)}`}>
{target.requests ?? 0} req · {fmtPercent(target.successRate)} success ·{" "}
{fmtMs(target.avgLatencyMs)} · quota {fmtPercent(target.quotaRemainingPct)}
{t("resolvedTargetMetrics", {
requests: target.requests ?? 0,
success: fmtPercent(target.successRate),
latency: fmtMs(target.avgLatencyMs),
quota: fmtPercent(target.quotaRemainingPct),
})}
</div>
</div>
</div>
@@ -189,6 +216,7 @@ function ResolvedTargetRow({ target }: { target: ComboControlCenterTargetHealth
}
function RecentLogRow({ log }: { log: CallLogEntry }) {
const t = useTranslations("comboControl");
const ok = typeof log.status === "number" && log.status >= 200 && log.status < 400;
return (
<div className="rounded-lg border border-border bg-surface px-3 py-2">
@@ -196,11 +224,13 @@ function RecentLogRow({ log }: { log: CallLogEntry }) {
<div className="min-w-0">
<p className="truncate text-sm text-text-main">
<span className={ok ? "text-emerald-400" : "text-red-400"}>{log.status || "—"}</span>{" "}
{log.model || "unknown model"}
{log.model || t("unknownModel")}
</p>
<p className="text-xs text-text-muted">
{fmtDate(log.timestamp)} · {log.provider || "unknown provider"} · step{" "}
{shortId(log.comboStepId || log.comboExecutionKey)}
{fmtDate(log.timestamp)} · {log.provider || t("unknownProvider")} ·{" "}
{t("stepShort", {
id: shortId(log.comboStepId || log.comboExecutionKey, t("dynamic")),
})}
</p>
</div>
<div className="text-xs text-text-muted">{fmtMs(log.duration)}</div>
@@ -211,6 +241,7 @@ function RecentLogRow({ log }: { log: CallLogEntry }) {
}
export default function ComboControlCenterClient({ comboId }: { comboId: string }) {
const t = useTranslations("comboControl");
const [combo, setCombo] = useState<ComboControlCenterCombo | null>(null);
const [metrics, setMetrics] = useState<ComboControlCenterMetrics | null>(null);
const [health, setHealth] = useState<ComboControlCenterHealth | null>(null);
@@ -241,11 +272,11 @@ export default function ComboControlCenterClient({ comboId }: { comboId: string
setLogs(toArray<CallLogEntry>(logsData));
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load combo control center");
setError(err instanceof Error ? err.message : t("loadFailed"));
} finally {
setLoading(false);
}
}, [comboId, range]);
}, [comboId, range, t]);
useEffect(() => {
void load();
@@ -276,10 +307,10 @@ export default function ComboControlCenterClient({ comboId }: { comboId: string
return (
<div className="space-y-4">
<Link href="/dashboard/combos" className="text-sm text-primary hover:underline">
Back to Combos
{t("backToCombos")}
</Link>
<Card className="border border-red-500/20 bg-red-500/10 p-6">
<h1 className="text-lg font-semibold text-red-300">Combo Control Center unavailable</h1>
<h1 className="text-lg font-semibold text-red-300">{t("unavailable")}</h1>
<p className="mt-2 text-sm text-red-200">{error}</p>
</Card>
</div>
@@ -293,22 +324,23 @@ export default function ComboControlCenterClient({ comboId }: { comboId: string
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<Link href="/dashboard/combos" className="text-sm text-primary hover:underline">
Back to Combos
{t("backToCombos")}
</Link>
<div className="mt-3 flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold text-text-main">Combo Control Center</h1>
<h1 className="text-2xl font-semibold text-text-main">{t("title")}</h1>
<span
className={`rounded-full border px-3 py-1 text-xs font-medium ${STATE_STYLES[summary.healthState]}`}
>
{stateLabel(summary.healthState)}
{stateLabel(summary.healthState, t)}
</span>
<span className="rounded-full border border-border bg-bg-subtle px-3 py-1 text-xs text-text-muted">
{summary.isActive ? "Active" : "Disabled"}
{summary.isActive ? t("active") : t("disabled")}
</span>
</div>
<p className="mt-2 max-w-3xl text-sm text-text-muted">
Central read-only view for routing behavior, health, quota, runtime metrics and recent
decisions for <code className="font-mono text-text-main">{combo.name}</code>.
{t.rich("description", {
combo: () => <code className="font-mono text-text-main">{combo.name}</code>,
})}
</p>
</div>
<div className="flex flex-wrap gap-2">
@@ -317,35 +349,33 @@ export default function ComboControlCenterClient({ comboId }: { comboId: string
onClick={() => void load()}
className="rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main transition-colors hover:bg-surface/80"
>
Refresh
{t("refresh")}
</button>
<Link
href="/dashboard/combos"
className="rounded-lg border border-primary/30 bg-primary/10 px-3 py-2 text-sm text-primary transition-colors hover:bg-primary/20"
>
Edit in Combos
{t("editInCombos")}
</Link>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{metricValue("Requests", String(summary.totalRequests), `${range} window`)}
{metricValue("Success", fmtPercent(summary.successRate), "runtime/health blend")}
{metricValue("Latency", fmtMs(summary.avgLatencyMs), "average response time")}
{metricValue(t("requests"), String(summary.totalRequests), t("rangeWindow", { range }))}
{metricValue(t("success"), fmtPercent(summary.successRate), t("runtimeHealthBlend"))}
{metricValue(t("latency"), fmtMs(summary.avgLatencyMs), t("averageResponseTime"))}
{metricValue(
"Worst quota",
t("worstQuota"),
fmtPercent(summary.worstQuotaRemainingPct),
"provider/account telemetry"
t("providerAccountTelemetry")
)}
</div>
<Card className="p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 className="text-lg font-semibold text-text-main">Overview</h2>
<p className="mt-1 text-sm text-text-muted">
Strategy, runtime status and control links for this combo.
</p>
<h2 className="text-lg font-semibold text-text-main">{t("overview")}</h2>
<p className="mt-1 text-sm text-text-muted">{t("overviewDescription")}</p>
</div>
<div className="flex flex-wrap gap-1 rounded-xl border border-border bg-bg-subtle p-1">
{TIME_RANGES.map((item) => (
@@ -367,30 +397,33 @@ export default function ComboControlCenterClient({ comboId }: { comboId: string
<div className="mt-4 grid gap-3 md:grid-cols-3">
<div className="rounded-xl border border-border bg-bg-subtle p-3">
<p className="text-xs uppercase tracking-wide text-text-muted">Strategy</p>
<p className="text-xs uppercase tracking-wide text-text-muted">{t("strategy")}</p>
<p className="mt-1 font-semibold text-text-main">{summary.strategy}</p>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-3">
<p className="text-xs uppercase tracking-wide text-text-muted">Targets</p>
<p className="text-xs uppercase tracking-wide text-text-muted">{t("targets")}</p>
<p className="mt-1 font-semibold text-text-main">
{summary.targetCount} configured · {resolvedTargets.length} resolved
{t("targetCounts", {
configured: summary.targetCount,
resolved: resolvedTargets.length,
})}
</p>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-3">
<p className="text-xs uppercase tracking-wide text-text-muted">Providers</p>
<p className="text-xs uppercase tracking-wide text-text-muted">{t("providers")}</p>
<p className="mt-1 font-semibold text-text-main">{summary.providerCount}</p>
</div>
</div>
<div className="mt-4 rounded-xl border border-border bg-surface p-3">
<p className="text-sm font-medium text-text-main">Health reasons</p>
<p className="text-sm font-medium text-text-main">{t("healthReasons")}</p>
<div className="mt-2 flex flex-wrap gap-2">
{summary.healthReasons.map((reason) => (
<span
key={reason}
className="rounded-full border border-border bg-bg-subtle px-2 py-1 text-xs text-text-muted"
>
{reason}
{healthReasonLabel(reason, t)}
</span>
))}
</div>
@@ -401,15 +434,13 @@ export default function ComboControlCenterClient({ comboId }: { comboId: string
<Card className="p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-text-main">Configured targets</h2>
<p className="mt-1 text-sm text-text-muted">
The saved combo steps, enriched with matching health data when available.
</p>
<h2 className="text-lg font-semibold text-text-main">{t("configuredTargets")}</h2>
<p className="mt-1 text-sm text-text-muted">{t("configuredTargetsDescription")}</p>
</div>
</div>
<div className="mt-4 space-y-3">
{configuredTargets.length === 0 ? (
<p className="text-sm text-text-muted">No targets configured.</p>
<p className="text-sm text-text-muted">{t("noConfiguredTargets")}</p>
) : (
configuredTargets.map((target) => (
<TargetConfiguredRow key={target.id} target={target} />
@@ -419,11 +450,11 @@ export default function ComboControlCenterClient({ comboId }: { comboId: string
</Card>
<Card className="p-5">
<h2 className="text-lg font-semibold text-text-main">Runtime config</h2>
<p className="mt-1 text-sm text-text-muted">Selected advanced settings for this combo.</p>
<h2 className="text-lg font-semibold text-text-main">{t("runtimeConfig")}</h2>
<p className="mt-1 text-sm text-text-muted">{t("runtimeConfigDescription")}</p>
<div className="mt-4 space-y-2">
{Object.keys(runtimeConfig).length === 0 ? (
<p className="text-sm text-text-muted">No custom runtime config.</p>
<p className="text-sm text-text-muted">{t("noRuntimeConfig")}</p>
) : (
Object.entries(runtimeConfig).map(([key, value]) => (
<div
@@ -442,13 +473,11 @@ export default function ComboControlCenterClient({ comboId }: { comboId: string
</div>
<Card className="p-5">
<h2 className="text-lg font-semibold text-text-main">Resolved runtime targets</h2>
<p className="mt-1 text-sm text-text-muted">
Flattened targets after nested combo resolution and target-level metrics.
</p>
<h2 className="text-lg font-semibold text-text-main">{t("resolvedTargets")}</h2>
<p className="mt-1 text-sm text-text-muted">{t("resolvedTargetsDescription")}</p>
<div className="mt-4 space-y-3">
{resolvedTargets.length === 0 ? (
<p className="text-sm text-text-muted">No resolved target health yet.</p>
<p className="text-sm text-text-muted">{t("noResolvedTargetHealth")}</p>
) : (
resolvedTargets.map((target) => (
<ResolvedTargetRow
@@ -462,10 +491,10 @@ export default function ComboControlCenterClient({ comboId }: { comboId: string
<div className="grid gap-6 xl:grid-cols-2">
<Card className="p-5">
<h2 className="text-lg font-semibold text-text-main">Quota and distribution</h2>
<h2 className="text-lg font-semibold text-text-main">{t("quotaDistribution")}</h2>
<div className="mt-4 space-y-3">
{(health?.quotaHealth?.providers || []).length === 0 ? (
<p className="text-sm text-text-muted">No quota snapshots for this combo window.</p>
<p className="text-sm text-text-muted">{t("noQuotaSnapshots")}</p>
) : (
health?.quotaHealth?.providers?.map((provider) => (
<div
@@ -484,19 +513,18 @@ export default function ComboControlCenterClient({ comboId }: { comboId: string
))
)}
<div className="rounded-lg border border-border bg-bg-subtle px-3 py-2 text-sm text-text-muted">
Usage skew: <span className="text-text-main">{summary.usageSkew.toFixed(2)}</span>
{t("usageSkew")}:{" "}
<span className="text-text-main">{summary.usageSkew.toFixed(2)}</span>
</div>
</div>
</Card>
<Card className="p-5">
<h2 className="text-lg font-semibold text-text-main">Recent routing decisions</h2>
<p className="mt-1 text-sm text-text-muted">
Recent call logs filtered by this combo name. Open Analytics for full explainability.
</p>
<h2 className="text-lg font-semibold text-text-main">{t("recentDecisions")}</h2>
<p className="mt-1 text-sm text-text-muted">{t("recentDecisionsDescription")}</p>
<div className="mt-4 space-y-2">
{logs.length === 0 ? (
<p className="text-sm text-text-muted">No recent combo call logs found.</p>
<p className="text-sm text-text-muted">{t("noRecentLogs")}</p>
) : (
logs.map((log) => (
<RecentLogRow key={log.id || `${log.timestamp}-${log.model}`} log={log} />
@@ -507,15 +535,15 @@ export default function ComboControlCenterClient({ comboId }: { comboId: string
</div>
<Card className="p-5">
<h2 className="text-lg font-semibold text-text-main">Quick links</h2>
<h2 className="text-lg font-semibold text-text-main">{t("quickLinks")}</h2>
<div className="mt-4 flex flex-wrap gap-2">
{[
["Combo Health", "/dashboard/analytics/combo-health"],
["Call Logs", "/dashboard/logs"],
["Costs", "/dashboard/costs"],
["Quota", "/dashboard/quota"],
["Playground", "/dashboard/playground"],
["Providers", "/dashboard/providers"],
[t("comboHealth"), "/dashboard/analytics/combo-health"],
[t("callLogs"), "/dashboard/logs"],
[t("costs"), "/dashboard/costs"],
[t("quota"), "/dashboard/quota"],
[t("playground"), "/dashboard/playground"],
[t("providers"), "/dashboard/providers"],
].map(([label, href]) => (
<Link
key={href}

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
type ModelEntry = {
weight?: number;
[key: string]: unknown;
@@ -35,6 +37,7 @@ export function effectiveSharePercent(weight: number, total: number): number {
}
export default function WeightTotalBar({ models }: WeightTotalBarProps) {
const t = useTranslations("combos");
const total = models.reduce((sum, m) => sum + (m.weight || 0), 0);
const isValid = total === 100;
@@ -65,10 +68,7 @@ export default function WeightTotalBar({ models }: WeightTotalBarProps) {
{m.weight}%
{/* #6147 — show the *effective* routing share when weights don't sum to 100 */}
{total > 0 && total !== 100 && (
<span
className="text-text-muted/70"
title="Effective routing share (weight ÷ total)"
>
<span className="text-text-muted/70" title={t("effectiveRoutingShare")}>
{" → "}
{Math.round(effectiveSharePercent(m.weight, total))}%
</span>
@@ -87,4 +87,4 @@ export default function WeightTotalBar({ models }: WeightTotalBarProps) {
</div>
</div>
);
}
}

View File

@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import type { NodeTypes } from "@xyflow/react";
import { useTranslations } from "next-intl";
import { FlowCanvas } from "@/shared/components/flow/FlowCanvas";
import {
comboRunToFlow,
@@ -43,6 +44,7 @@ interface FleetOverviewProps {
}
function FleetOverview({ comboEvents }: FleetOverviewProps) {
const t = useTranslations("combos");
// `now` must advance, or the 60s rolling window freezes at mount and aging
// events are never re-classified out of "active". A low-frequency tick rolls it.
const [now, setNow] = useState(() => Date.now());
@@ -61,8 +63,8 @@ function FleetOverview({ comboEvents }: FleetOverviewProps) {
return (
<div className="flex flex-col items-center justify-center h-full gap-2 text-muted">
<span className="text-2xl opacity-40"></span>
<p className="text-sm">No providers observed yet.</p>
<p className="text-xs opacity-60">Fleet data arrives via live combo events.</p>
<p className="text-sm">{t("liveNoProviders")}</p>
<p className="text-xs opacity-60">{t("liveFleetDataHint")}</p>
</div>
);
}
@@ -72,7 +74,7 @@ function FleetOverview({ comboEvents }: FleetOverviewProps) {
{sets.active.size > 0 && (
<div>
<div className="text-[10px] font-semibold uppercase tracking-wide text-muted mb-1.5">
Active ({sets.active.size})
{t("liveActiveCount", { count: sets.active.size })}
</div>
<div className="flex flex-wrap gap-1.5">
{[...sets.active].map((p) => (
@@ -90,7 +92,7 @@ function FleetOverview({ comboEvents }: FleetOverviewProps) {
{sets.error.size > 0 && (
<div>
<div className="text-[10px] font-semibold uppercase tracking-wide text-muted mb-1.5">
Errors ({sets.error.size})
{t("liveErrorCount", { count: sets.error.size })}
</div>
<div className="flex flex-wrap gap-1.5">
{[...sets.error].map((p) => (
@@ -108,7 +110,7 @@ function FleetOverview({ comboEvents }: FleetOverviewProps) {
{sets.last.size > 0 && (
<div>
<div className="text-[10px] font-semibold uppercase tracking-wide text-muted mb-1.5">
Inactive ({sets.last.size})
{t("liveInactiveCount", { count: sets.last.size })}
</div>
<div className="flex flex-wrap gap-1.5">
{[...sets.last].map((p) => (
@@ -133,14 +135,15 @@ function FleetOverview({ comboEvents }: FleetOverviewProps) {
// ── Empty state ───────────────────────────────────────────────────────────
function EmptyState() {
const t = useTranslations("combos");
return (
<div
className="flex flex-col items-center justify-center h-full gap-3 text-muted"
data-testid="combo-live-studio-empty"
>
<span className="text-3xl opacity-40"></span>
<p className="text-sm">No combo run available.</p>
<p className="text-xs opacity-60">Live data arrives via the WS combo channel.</p>
<p className="text-sm">{t("liveNoRun")}</p>
<p className="text-xs opacity-60">{t("liveDataHint")}</p>
</div>
);
}
@@ -148,13 +151,14 @@ function EmptyState() {
// ── Disconnected banner ───────────────────────────────────────────────────
function DisconnectedBanner() {
const t = useTranslations("combos");
return (
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-border bg-bg/80 text-xs text-muted shrink-0"
data-testid="combo-disconnected-banner"
>
<span className="text-amber-500 font-semibold"></span>
<span>Live disabled WebSocket disconnected. Showing last known state.</span>
<span>{t("liveDisconnected")}</span>
</div>
);
}
@@ -224,6 +228,7 @@ export function ComboLiveStudio({
providerHealth,
connectionHealth,
}: ComboLiveStudioProps) {
const t = useTranslations("combos");
const [mode, setMode] = useState<"single" | "fleet">("single");
const [selectedCombo, setSelectedCombo] = useState<string>("");
@@ -285,10 +290,10 @@ export function ComboLiveStudio({
className="text-xs border border-border rounded px-2 py-1 bg-bg text-muted"
value={selectedCombo}
onChange={(e) => setSelectedCombo(e.target.value)}
aria-label="Select combo"
aria-label={t("liveSelectCombo")}
data-testid="combo-selector"
>
<option value=""> select combo </option>
<option value="">{t("liveSelectComboPlaceholder")}</option>
{comboOptions.map((name) => (
<option key={name} value={name}>
{name}
@@ -309,7 +314,7 @@ export function ComboLiveStudio({
</span>
)}
<span className="text-xs text-muted">
{displayRun.targets.length} target{displayRun.targets.length !== 1 ? "s" : ""}
{t("liveTargetCount", { count: displayRun.targets.length })}
</span>
<span
className="text-xs font-bold"
@@ -339,7 +344,7 @@ export function ComboLiveStudio({
onClick={() => setMode("single")}
data-testid="mode-single"
>
Single
{t("liveSingle")}
</button>
<button
className="px-2.5 py-1 transition-colors"
@@ -350,7 +355,7 @@ export function ComboLiveStudio({
onClick={() => setMode("fleet")}
data-testid="mode-fleet"
>
Fleet
{t("liveFleet")}
</button>
</div>
</div>

View File

@@ -3903,8 +3903,16 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
}
className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-surface-1 focus:border-primary focus:outline-none"
>
<option value="flatten">Flatten nested combos</option>
<option value="execute">Execute nested combos as targets</option>
<option value="flatten">
{getI18nOrFallback(t, "nestedComboFlatten", "Flatten nested combos")}
</option>
<option value="execute">
{getI18nOrFallback(
t,
"nestedComboExecute",
"Execute nested combos as targets"
)}
</option>
</select>
</div>
{/* #6168: per-combo session-stickiness override (tri-state so it can

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import Button from "@/shared/components/Button";
import Card from "@/shared/components/Card";
import Badge from "@/shared/components/Badge";
@@ -42,19 +43,20 @@ interface Combo {
// ── Status helpers ───────────────────────────────────────────────────────────
function StatusTag({ status }: { status: TargetSimulation["status"] }) {
const t = useTranslations("combos");
const map: Record<
TargetSimulation["status"],
{ label: string; variant: "success" | "error" | "warning" | "info" }
{ labelKey: string; variant: "success" | "error" | "warning" | "info" }
> = {
available: { label: "Available", variant: "success" },
no_quota: { label: "No Quota", variant: "error" },
degraded: { label: "Degraded", variant: "warning" },
error: { label: "Error", variant: "error" },
unknown: { label: "Unknown", variant: "info" },
available: { labelKey: "playgroundStatusAvailable", variant: "success" },
no_quota: { labelKey: "playgroundStatusNoQuota", variant: "error" },
degraded: { labelKey: "playgroundStatusDegraded", variant: "warning" },
error: { labelKey: "playgroundStatusError", variant: "error" },
unknown: { labelKey: "playgroundStatusUnknown", variant: "info" },
};
return (
<Badge variant={map[status].variant} size="sm">
{map[status].label}
{t(map[status].labelKey)}
</Badge>
);
}
@@ -62,6 +64,7 @@ function StatusTag({ status }: { status: TargetSimulation["status"] }) {
// ── Component ────────────────────────────────────────────────────────────────
export default function ComboPlaygroundClient() {
const t = useTranslations("combos");
const [combos, setCombos] = useState<Combo[]>([]);
const [selectedComboId, setSelectedComboId] = useState("");
const [promptTokens, setPromptTokens] = useState(500);
@@ -99,34 +102,32 @@ export default function ComboPlaygroundClient() {
else setResult(data);
} catch {
setResult({
comboName: "Error",
comboName: t("playgroundStatusError"),
strategy: "-",
targets: [],
totalEstimatedCost: 0,
totalEstimatedLatencyMs: 0,
warnings: [],
errors: ["Network error during simulation"],
errors: [t("playgroundNetworkError")],
});
} finally {
setLoading(false);
}
}, [selectedComboId, promptTokens]);
}, [selectedComboId, promptTokens, t]);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Combo Playground</h1>
<p className="text-sm text-text-muted mt-1">
Simulate how requests will be routed through your combos
</p>
<h1 className="text-xl font-bold">{t("playgroundTitle")}</h1>
<p className="text-sm text-text-muted mt-1">{t("playgroundDescription")}</p>
</div>
</div>
{/* Configuration Panel */}
<Card>
<div className="p-4 space-y-4">
<h2 className="text-sm font-semibold">Configuration</h2>
<h2 className="text-sm font-semibold">{t("playgroundConfiguration")}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Combo Selector */}
@@ -137,10 +138,12 @@ export default function ComboPlaygroundClient() {
value={selectedComboId}
onChange={(e) => setSelectedComboId(e.target.value)}
>
{combos.length === 0 && <option value="">No combos configured</option>}
{combos.length === 0 && (
<option value="">{t("playgroundNoCombosConfigured")}</option>
)}
{combos.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.strategy}, {c.isActive ? "active" : "inactive"})
{c.name} ({c.strategy}, {c.isActive ? t("active") : t("inactive")})
</option>
))}
</select>
@@ -149,7 +152,7 @@ export default function ComboPlaygroundClient() {
{/* Prompt Tokens */}
<div>
<label className="block text-sm font-medium mb-1">
Estimated Prompt Tokens: <strong>{promptTokens}</strong>
{t("playgroundEstimatedPromptTokens")}: <strong>{promptTokens}</strong>
</label>
<input
type="range"
@@ -168,7 +171,7 @@ export default function ComboPlaygroundClient() {
</div>
<Button onClick={simulate} disabled={loading || combos.length === 0}>
{loading ? "Simulating..." : "Simulate Route"}
{loading ? t("playgroundSimulating") : t("playgroundSimulateRoute")}
</Button>
</div>
</Card>
@@ -180,23 +183,25 @@ export default function ComboPlaygroundClient() {
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold">Routing Path</h2>
<h2 className="text-sm font-semibold">{t("playgroundRoutingPath")}</h2>
<div className="flex items-center gap-3 text-sm">
<span className="text-text-muted">
Strategy: <strong>{result.strategy}</strong>
{t("playgroundStrategy")}: <strong>{result.strategy}</strong>
</span>
<span className="text-text-muted">
Est. Cost: <strong>${result.totalEstimatedCost.toFixed(6)}</strong>
{t("playgroundEstimatedCost")}:{" "}
<strong>${result.totalEstimatedCost.toFixed(6)}</strong>
</span>
<span className="text-text-muted">
Est. Latency: <strong>{result.totalEstimatedLatencyMs.toFixed(0)}ms</strong>
{t("playgroundEstimatedLatency")}:{" "}
<strong>{result.totalEstimatedLatencyMs.toFixed(0)}ms</strong>
</span>
</div>
</div>
{/* Visual Cascade */}
<div className="space-y-0">
{result.targets.map((t, i) => (
{result.targets.map((target, i) => (
<div key={i}>
{/* Arrow between targets */}
{i > 0 && (
@@ -216,10 +221,12 @@ export default function ComboPlaygroundClient() {
/>
</svg>
{result.strategy === "priority" && (
<span className="text-[10px]">fallback</span>
<span className="text-[10px]">{t("playgroundFallback")}</span>
)}
{result.strategy === "weighted" && (
<span className="text-[10px]">weight {t.rank}</span>
<span className="text-[10px]">
{t("playgroundWeight", { value: target.rank })}
</span>
)}
</div>
</div>
@@ -228,11 +235,11 @@ export default function ComboPlaygroundClient() {
{/* Target Card */}
<div
className={`border rounded-lg p-3 ${
t.status === "available"
target.status === "available"
? "border-green-500/30 bg-green-500/5"
: t.status === "error"
: target.status === "error"
? "border-red-500/30 bg-red-500/5"
: t.status === "unknown"
: target.status === "unknown"
? "border-yellow-500/30 bg-yellow-500/5"
: "border-border bg-surface/50"
}`}
@@ -240,22 +247,24 @@ export default function ComboPlaygroundClient() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary/10 text-primary text-xs font-bold">
{t.rank}
{target.rank}
</div>
<div>
<div className="font-medium text-sm">{t.provider}</div>
<div className="text-xs text-text-muted font-mono">{t.model}</div>
<div className="font-medium text-sm">{target.provider}</div>
<div className="text-xs text-text-muted font-mono">{target.model}</div>
</div>
</div>
<div className="flex items-center gap-3">
<StatusTag status={t.status} />
<StatusTag status={target.status} />
<span className="text-xs text-text-muted">
${t.estimatedCost.toFixed(6)}
${target.estimatedCost.toFixed(6)}
</span>
<span className="text-xs text-text-muted">{t.estimatedLatencyMs}ms</span>
{t.contextWindow && (
<span className="text-xs text-text-muted">
{target.estimatedLatencyMs}ms
</span>
{target.contextWindow && (
<span className="text-xs text-text-muted">
{(t.contextWindow / 1000).toFixed(0)}K ctx
{(target.contextWindow / 1000).toFixed(0)}K ctx
</span>
)}
</div>
@@ -272,7 +281,7 @@ export default function ComboPlaygroundClient() {
<Card>
<div className="p-4 space-y-2">
<h3 className="text-sm font-semibold text-yellow-600 dark:text-yellow-400">
Warnings ({result.warnings.length})
{t("playgroundWarningCount", { count: result.warnings.length })}
</h3>
{result.warnings.map((w, i) => (
<p key={i} className="text-sm text-text-muted flex items-start gap-2">
@@ -288,7 +297,7 @@ export default function ComboPlaygroundClient() {
<Card>
<div className="p-4 space-y-2">
<h3 className="text-sm font-semibold text-red-600 dark:text-red-400">
Errors ({result.errors.length})
{t("playgroundErrorCount", { count: result.errors.length })}
</h3>
{result.errors.map((e, i) => (
<p key={i} className="text-sm text-red-500 flex items-start gap-2">
@@ -307,7 +316,9 @@ export default function ComboPlaygroundClient() {
<Card>
<div className="p-8 text-center">
<p className="text-text-muted">
Select a combo and click <strong>Simulate Route</strong> to see the routing path.
{t.rich("playgroundEmptyHint", {
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p>
</div>
</Card>
@@ -317,9 +328,9 @@ export default function ComboPlaygroundClient() {
<Card>
<div className="p-8 text-center">
<p className="text-text-muted">
No combos configured yet.{" "}
{t("playgroundNoCombosYet")}{" "}
<Link href="/dashboard/combos" className="text-primary hover:underline">
Create one first
{t("playgroundCreateFirst")}
</Link>
.
</p>

View File

@@ -1,17 +1,33 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
interface Row { engine: string; meanSavingsPercent: number; meanRetention: number; totalCompressedTokens: number; }
interface VerifyResult { id: string; verdict: string | null; usdCost: number; skippedCapped: boolean; }
export interface CompareViewProps { text: string; }
interface Row {
engine: string;
meanSavingsPercent: number;
meanRetention: number;
totalCompressedTokens: number;
}
interface VerifyResult {
id: string;
verdict: string | null;
usdCost: number;
skippedCapped: boolean;
}
export interface CompareViewProps {
text: string;
}
async function runFidelityCheck(
rows: Row[], text: string, opts: { provider: string; judgeModel: string; capUsd: number }
rows: Row[],
text: string,
opts: { provider: string; judgeModel: string; capUsd: number }
): Promise<{ verdicts: Record<string, VerifyResult>; spent: number; capped: boolean } | null> {
const items = await Promise.all(
rows.map(async (r) => {
const res = await fetch("/api/compression/preview", {
method: "POST", headers: { "Content-Type": "application/json" },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: text }], engineId: r.engine }),
});
const d = await res.json();
@@ -19,51 +35,145 @@ async function runFidelityCheck(
})
);
const vres = await fetch("/api/compression/compare/verify", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items, provider: opts.provider, judgeModel: opts.judgeModel, costCapUsd: opts.capUsd }),
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items,
provider: opts.provider,
judgeModel: opts.judgeModel,
costCapUsd: opts.capUsd,
}),
});
const vdata = await vres.json();
if (vres.ok && Array.isArray(vdata.results)) {
const map: Record<string, VerifyResult> = {};
for (const v of vdata.results) map[v.id] = v;
return { verdicts: map, spent: typeof vdata.totalUsd === "number" ? vdata.totalUsd : 0, capped: Boolean(vdata.capped) };
return {
verdicts: map,
spent: typeof vdata.totalUsd === "number" ? vdata.totalUsd : 0,
capped: Boolean(vdata.capped),
};
}
return null;
}
interface VerifyControlsProps {
provider: string; onProvider: (v: string) => void;
judgeModel: string; onJudgeModel: (v: string) => void;
capUsd: number; onCapUsd: (v: number) => void;
verifying: boolean; onVerify: () => void;
spent: number | null; capped: boolean;
provider: string;
onProvider: (v: string) => void;
judgeModel: string;
onJudgeModel: (v: string) => void;
capUsd: number;
onCapUsd: (v: number) => void;
verifying: boolean;
onVerify: () => void;
spent: number | null;
capped: boolean;
}
function VerifyControls({ provider, onProvider, judgeModel, onJudgeModel, capUsd, onCapUsd, verifying, onVerify, spent, capped }: VerifyControlsProps) {
function VerifyControls({
provider,
onProvider,
judgeModel,
onJudgeModel,
capUsd,
onCapUsd,
verifying,
onVerify,
spent,
capped,
}: VerifyControlsProps) {
const t = useTranslations("compressionStudio");
return (
<>
<label className="text-[10px]">provider
<input className="ml-1 w-24 rounded border px-1 text-xs" value={provider} onChange={(e) => onProvider(e.target.value)} />
<label className="text-[10px]">
{t("provider")}
<input
className="ml-1 w-24 rounded border px-1 text-xs"
value={provider}
onChange={(e) => onProvider(e.target.value)}
/>
</label>
<label className="text-[10px]">juiz (modelo)
<input data-testid="verify-model" className="ml-1 w-32 rounded border px-1 text-xs" value={judgeModel} onChange={(e) => onJudgeModel(e.target.value)} placeholder="ex: claude-haiku" />
<label className="text-[10px]">
{t("judgeModel")}
<input
data-testid="verify-model"
className="ml-1 w-32 rounded border px-1 text-xs"
value={judgeModel}
onChange={(e) => onJudgeModel(e.target.value)}
placeholder={t("judgeModelPlaceholder")}
/>
</label>
<label className="text-[10px]">cap USD
<input type="number" step="0.01" min="0" className="ml-1 w-16 rounded border px-1 text-xs" value={capUsd} onChange={(e) => onCapUsd(Number(e.target.value))} />
<label className="text-[10px]">
{t("maxCostUsd")}
<input
type="number"
step="0.01"
min="0"
className="ml-1 w-16 rounded border px-1 text-xs"
value={capUsd}
onChange={(e) => onCapUsd(Number(e.target.value))}
/>
</label>
<button data-testid="verify-all" onClick={onVerify} disabled={verifying || !judgeModel}
title={!judgeModel ? "informe o modelo-juiz" : undefined}
className="rounded bg-purple-500/30 px-3 py-1 text-sm disabled:opacity-40">
{verifying ? "Verificando..." : "⚖ Verificar todas"}
<button
data-testid="verify-all"
onClick={onVerify}
disabled={verifying || !judgeModel}
title={!judgeModel ? t("enterJudgeModel") : undefined}
className="rounded bg-purple-500/30 px-3 py-1 text-sm disabled:opacity-40"
>
{verifying ? t("verifying") : `${t("verifyAll")}`}
</button>
{spent !== null && (
<span className="text-[10px] opacity-70">gasto ${spent.toFixed(3)} / ${capUsd.toFixed(2)}{capped ? " · cap atingido" : ""}</span>
<span className="text-[10px] opacity-70">
{t("spent", { spent: spent.toFixed(3), cap: capUsd.toFixed(2) })}
{capped ? ` · ${t("capReached")}` : ""}
</span>
)}
</>
);
}
function ComparisonTable({
rows,
verdicts,
}: {
rows: Row[];
verdicts: Record<string, VerifyResult>;
}) {
const t = useTranslations("compressionStudio");
return (
<table className="w-full text-xs">
<thead>
<tr className="text-left opacity-60">
<th>{t("engine")}</th>
<th>{t("savings")}</th>
<th>{t("retention")}</th>
<th>{t("outputTokensShort")}</th>
<th>{t("fidelity")}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const verdict = verdicts[row.engine];
return (
<tr key={row.engine} data-testid="compare-row" className="border-b">
<td className="font-semibold">{row.engine}</td>
<td>{row.meanSavingsPercent.toFixed(0)}%</td>
<td>{Math.round(row.meanRetention * 100)}%</td>
<td>{row.totalCompressedTokens}</td>
<td data-testid="verify-verdict">
{verdict ? (verdict.skippedCapped ? "—(cap)" : (verdict.verdict ?? "?")) : ""}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
export function CompareView({ text }: CompareViewProps) {
const t = useTranslations("compressionStudio");
const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(false);
const [verdicts, setVerdicts] = useState<Record<string, VerifyResult>>({});
@@ -80,12 +190,15 @@ export function CompareView({ text }: CompareViewProps) {
setSpent(null);
try {
const res = await fetch("/api/compression/compare", {
method: "POST", headers: { "Content-Type": "application/json" },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: text }] }),
});
const data = await res.json();
if (res.ok) setRows(Array.isArray(data.rows) ? data.rows : []);
} finally { setLoading(false); }
} finally {
setLoading(false);
}
};
const verifyAll = async () => {
@@ -93,45 +206,43 @@ export function CompareView({ text }: CompareViewProps) {
setVerifying(true);
try {
const out = await runFidelityCheck(rows, text, { provider, judgeModel, capUsd });
if (out) { setVerdicts(out.verdicts); setSpent(out.spent); setCapped(out.capped); }
} finally { setVerifying(false); }
if (out) {
setVerdicts(out.verdicts);
setSpent(out.spent);
setCapped(out.capped);
}
} finally {
setVerifying(false);
}
};
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-end gap-2">
<button data-testid="compare-load" onClick={load} disabled={loading} className="rounded bg-blue-500/30 px-3 py-1 text-sm">
{loading ? "Rodando..." : "Carregar A/B"}
<button
data-testid="compare-load"
onClick={load}
disabled={loading}
className="rounded bg-blue-500/30 px-3 py-1 text-sm"
>
{loading ? t("running") : t("loadAb")}
</button>
{rows.length > 0 && (
<VerifyControls
provider={provider} onProvider={setProvider}
judgeModel={judgeModel} onJudgeModel={setJudgeModel}
capUsd={capUsd} onCapUsd={setCapUsd}
verifying={verifying} onVerify={verifyAll}
spent={spent} capped={capped}
provider={provider}
onProvider={setProvider}
judgeModel={judgeModel}
onJudgeModel={setJudgeModel}
capUsd={capUsd}
onCapUsd={setCapUsd}
verifying={verifying}
onVerify={verifyAll}
spent={spent}
capped={capped}
/>
)}
</div>
<table className="w-full text-xs">
<thead>
<tr className="text-left opacity-60"><th>Engine</th><th>Savings</th><th>Retention</th><th>Out tok</th><th>Fidelity</th></tr>
</thead>
<tbody>
{rows.map((r) => {
const v = verdicts[r.engine];
return (
<tr key={r.engine} data-testid="compare-row" className="border-b">
<td className="font-semibold">{r.engine}</td>
<td>{r.meanSavingsPercent.toFixed(0)}%</td>
<td>{Math.round(r.meanRetention * 100)}%</td>
<td>{r.totalCompressedTokens}</td>
<td data-testid="verify-verdict">{v ? (v.skippedCapped ? "—(cap)" : v.verdict ?? "?") : ""}</td>
</tr>
);
})}
</tbody>
</table>
<ComparisonTable rows={rows} verdicts={verdicts} />
</div>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import type { NodeTypes } from "@xyflow/react";
import { FlowCanvas } from "@/shared/components/flow/FlowCanvas";
import { EngineNode } from "./nodes/EngineNode";
@@ -91,14 +92,15 @@ function ViewButton({
// ── Empty state ───────────────────────────────────────────────────────────
function EmptyState() {
const t = useTranslations("compressionStudio");
return (
<div
className="flex flex-col items-center justify-center h-full gap-3 text-muted"
data-testid="compression-cockpit-empty"
>
<span className="text-3xl opacity-40"></span>
<p className="text-sm">No compression run available.</p>
<p className="text-xs opacity-60">Live data arrives via the WS compression channel.</p>
<p className="text-sm">{t("noRun")}</p>
<p className="text-xs opacity-60">{t("liveDataHint")}</p>
</div>
);
}
@@ -129,6 +131,7 @@ export interface CompressionCockpitProps {
* component WS-free and unit-testable with a static run.
*/
export function CompressionCockpit({ run: runProp }: CompressionCockpitProps) {
const t = useTranslations("compressionStudio");
// Canvas (A2, ReactFlow) is the default; Waterfall (A1) is a plain-div list view of the same run.
const [view, setView] = useState<CockpitView>("canvas");
@@ -193,29 +196,29 @@ export function CompressionCockpit({ run: runProp }: CompressionCockpitProps) {
/>
<div className="ml-auto flex items-center gap-2">
{/* View toggle: ReactFlow canvas (A2) ↔ waterfall list (A1) */}
<div className="flex items-center gap-1" role="group" aria-label="Cockpit view">
<div className="flex items-center gap-1" role="group" aria-label={t("cockpitView")}>
<ViewButton
label="Canvas"
label={t("canvas")}
testId="cockpit-view-canvas"
active={view === "canvas"}
onClick={() => setView("canvas")}
/>
<ViewButton
label="Waterfall"
label={t("waterfall")}
testId="cockpit-view-waterfall"
active={view === "waterfall"}
onClick={() => setView("waterfall")}
/>
</div>
<span className="text-[11px] text-muted">
{fmt(run.originalTokens)} {fmt(run.compressedTokens)} tok
{fmt(run.originalTokens)} {fmt(run.compressedTokens)} {t("tokenShort")}
</span>
<span className="text-xs font-bold" style={{ color: "#22c55e" }}>
{run.savingsPercent.toFixed(1)}%
</span>
{isComplete && view === "canvas" && (
<span className="text-[10px] px-1.5 py-0.5 rounded border border-border text-muted">
replay done
{t("replayDone")}
</span>
)}
</div>
@@ -244,14 +247,14 @@ export function CompressionCockpit({ run: runProp }: CompressionCockpitProps) {
<button
onClick={isPlaying ? pause : play}
className="flex items-center gap-1.5 px-2.5 py-1 rounded border border-border text-xs hover:bg-border/30 transition-colors"
aria-label={isPlaying ? "Pause replay" : "Play replay"}
aria-label={isPlaying ? t("pauseReplay") : t("playReplay")}
>
{isPlaying ? "⏸ Pause" : "▷ Replay"}
{isPlaying ? `${t("pause")}` : `${t("replay")}`}
</button>
<button
onClick={reset}
className="px-2 py-1 rounded border border-border text-xs text-muted hover:bg-border/30 transition-colors"
aria-label="Reset replay"
aria-label={t("resetReplay")}
>
</button>
@@ -262,7 +265,10 @@ export function CompressionCockpit({ run: runProp }: CompressionCockpitProps) {
</div>
{displayRun && currentFrame && (
<span className="ml-auto text-[11px] text-muted">
step {displayRun?.steps.length ?? 0}/{run.steps.length}
{t("stepProgress", {
current: displayRun?.steps.length ?? 0,
total: run.steps.length,
})}
</span>
)}
</div>

View File

@@ -1,22 +1,44 @@
"use client";
import { useTranslations } from "next-intl";
import type { DiffSegment } from "./compressionFlowModel";
export interface DiffPaneProps { segments: DiffSegment[]; preservedBlocks: Array<{ kind: string; preview: string }>; }
const SEG_CLASS: Record<DiffSegment["type"], string> = { same: "opacity-90", removed: "bg-red-500/20 line-through", added: "bg-green-500/20" };
export interface DiffPaneProps {
segments: DiffSegment[];
preservedBlocks: Array<{ kind: string; preview: string }>;
}
const SEG_CLASS: Record<DiffSegment["type"], string> = {
same: "opacity-90",
removed: "bg-red-500/20 line-through",
added: "bg-green-500/20",
};
export function DiffPane({ segments, preservedBlocks }: DiffPaneProps) {
const t = useTranslations("compressionStudio");
return (
<div data-testid="diff-pane" className="font-mono text-xs leading-relaxed">
<div className="mb-2 flex gap-2 text-[10px]">
<span className="rounded bg-blue-500/20 px-2 py-0.5">inline</span>
<button type="button" disabled title="split view em breve" className="rounded px-2 py-0.5 opacity-40">
split (em breve)
<span className="rounded bg-blue-500/20 px-2 py-0.5">{t("inlineView")}</span>
<button
type="button"
disabled
title={t("splitComingSoon")}
className="rounded px-2 py-0.5 opacity-40"
>
{t("splitComingSoon")}
</button>
</div>
<div className="whitespace-pre-wrap">
{segments.map((seg, i) => (<span key={i} data-testid={`diff-${seg.type}`} className={SEG_CLASS[seg.type]}>{seg.text}</span>))}
{segments.map((seg, i) => (
<span key={i} data-testid={`diff-${seg.type}`} className={SEG_CLASS[seg.type]}>
{seg.text}
</span>
))}
</div>
{preservedBlocks.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1" data-testid="diff-preserved">
{preservedBlocks.map((b, i) => (<span key={i} className="rounded border border-purple-500/50 px-1 text-[10px]">{b.kind}: {b.preview.slice(0, 40)}</span>))}
{preservedBlocks.map((b, i) => (
<span key={i} className="rounded border border-purple-500/50 px-1 text-[10px]">
{b.kind}: {b.preview.slice(0, 40)}
</span>
))}
</div>
)}
</div>

View File

@@ -1,9 +1,11 @@
"use client";
import { useTranslations } from "next-intl";
import type { EncoderComparison } from "./compressionFlowModel";
const fmt = (n: number) => n.toLocaleString("en-US");
export function EncoderComparisonTable({ comparison }: { comparison: EncoderComparison }) {
const t = useTranslations("compressionStudio");
if (!comparison || comparison.arraysCompared === 0) return null;
const rows: Array<{
key: "gcf" | "toon" | "json";
@@ -18,17 +20,17 @@ export function EncoderComparisonTable({ comparison }: { comparison: EncoderComp
return (
<section data-testid="encoder-comparison" className="rounded border p-2 text-xs">
<header className="mb-1 font-semibold">
Encoder A/B {comparison.arraysCompared} array(s){" "}
{t("encoderComparison", { count: comparison.arraysCompared })}{" "}
<span data-testid="encoder-winner" className="font-mono">
vencedor: {comparison.winner}
{t("encoderWinner", { winner: comparison.winner })}
</span>
</header>
<table className="w-full font-mono">
<thead>
<tr className="text-left text-muted-foreground">
<th>encoder</th>
<th>bytes</th>
<th>tokens (cl100k)</th>
<th>{t("encoder")}</th>
<th>{t("bytes")}</th>
<th>{t("tokensCl100k")}</th>
</tr>
</thead>
<tbody>

View File

@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { usePreviewCompression, type Lane, type PreviewBatch } from "@/hooks/usePreviewCompression";
import { WaterfallInspector } from "./WaterfallInspector";
import { DiffPane } from "./DiffPane";
@@ -14,10 +15,10 @@ export interface PlayViewProps {
laneEngines?: readonly string[];
}
function laneStatus(l: Lane): string {
function laneStatus(l: Lane, t: ReturnType<typeof useTranslations>): string {
const rejected = l.run?.steps?.find((s) => s.rejected);
if (rejected) return `rejeitado: ${rejected.rejectReason ?? ""}`;
return l.error ? "⚠ erro" : l.run ? `${l.run.savingsPercent}%` : "—";
if (rejected) return `${t("laneRejected", { reason: rejected.rejectReason ?? "" })}`;
return l.error ? `${t("error")}` : l.run ? `${l.run.savingsPercent}%` : "—";
}
function resolveActiveDiff(batch: PreviewBatch | null, selectedLane: string | null) {
@@ -26,6 +27,7 @@ function resolveActiveDiff(batch: PreviewBatch | null, selectedLane: string | nu
}
function LaneList({ lanes, onSelect }: { lanes: Lane[]; onSelect: (e: string) => void }) {
const t = useTranslations("compressionStudio");
return (
<>
{lanes.map((l) => (
@@ -36,7 +38,7 @@ function LaneList({ lanes, onSelect }: { lanes: Lane[]; onSelect: (e: string) =>
className="flex w-full items-center justify-between border-b py-1 text-left font-mono text-xs"
>
<span>{l.engine}</span>
<span>{laneStatus(l)}</span>
<span>{laneStatus(l, t)}</span>
</button>
))}
</>
@@ -44,6 +46,7 @@ function LaneList({ lanes, onSelect }: { lanes: Lane[]; onSelect: (e: string) =>
}
export function PlayView({ text, onText, laneEngines = LANE_ENGINES }: PlayViewProps) {
const t = useTranslations("compressionStudio");
const [active, setActive] = useState<string[]>(["rtk", "caveman"]);
const [fuzzyDedup, setFuzzyDedup] = useState(false);
const [selectedLane, setSelectedLane] = useState<string | null>(null);
@@ -99,7 +102,7 @@ export function PlayView({ text, onText, laneEngines = LANE_ENGINES }: PlayViewP
{batch?.combined && (
<section data-testid="play-combined">
<header className="text-xs font-semibold">
Fluxo combinado {active.join(" → ")}{" "}
{t("combinedFlow")} {active.join(" → ")}{" "}
<QuantumLockBadge stats={batch.combined.quantumLock} />
</header>
<WaterfallInspector run={batch.combined} />
@@ -107,7 +110,7 @@ export function PlayView({ text, onText, laneEngines = LANE_ENGINES }: PlayViewP
</section>
)}
<section>
<header className="text-xs font-semibold">Cada camada sozinha</header>
<header className="text-xs font-semibold">{t("eachLayer")}</header>
<LaneList lanes={batch?.lanes ?? []} onSelect={setSelectedLane} />
</section>
{(() => {
@@ -119,14 +122,16 @@ export function PlayView({ text, onText, laneEngines = LANE_ENGINES }: PlayViewP
})()}
{activeDiff && (
<section>
<header className="text-xs font-semibold">Diff {selectedLane ?? "combinado"}</header>
<header className="text-xs font-semibold">
{t("diff")} {selectedLane ?? t("combined")}
</header>
<DiffPane segments={activeDiff} preservedBlocks={[]} />
</section>
)}
{batch?.heatmap && (
<section data-testid="play-heatmap">
<header className="text-xs font-semibold">
Saliency heatmap {batch.heatmap.mode}
{t("saliencyHeatmap")} {batch.heatmap.mode}
</header>
<SaliencyHeatmap heatmap={batch.heatmap} />
</section>

View File

@@ -1,36 +1,148 @@
"use client";
export const LANE_ENGINES = ["session-dedup", "ccr", "lite", "rtk", "ionizer", "headroom", "caveman", "aggressive", "ultra"] as const;
export interface PlaygroundInputProps { text: string; onText: (t: string) => void; active: string[]; onToggleActive: (engine: string) => void; onRun: () => void; loading: boolean; fidelityGate: boolean; onToggleFidelity: () => void; fuzzyDedup: boolean; onToggleFuzzy: () => void; riskGate: boolean; onToggleRisk: () => void; quantumLock: boolean; onToggleQuantum: () => void; heatmap: "ultra" | "universal" | false; onToggleHeatmap: () => void; }
export function PlaygroundInput({ text, onText, active, onToggleActive, onRun, loading, fidelityGate, onToggleFidelity, fuzzyDedup, onToggleFuzzy, riskGate, onToggleRisk, quantumLock, onToggleQuantum, heatmap, onToggleHeatmap }: PlaygroundInputProps) {
import type { ReactNode } from "react";
import { useTranslations } from "next-intl";
export const LANE_ENGINES = [
"session-dedup",
"ccr",
"lite",
"rtk",
"ionizer",
"headroom",
"caveman",
"aggressive",
"ultra",
] as const;
export interface PlaygroundInputProps {
text: string;
onText: (t: string) => void;
active: string[];
onToggleActive: (engine: string) => void;
onRun: () => void;
loading: boolean;
fidelityGate: boolean;
onToggleFidelity: () => void;
fuzzyDedup: boolean;
onToggleFuzzy: () => void;
riskGate: boolean;
onToggleRisk: () => void;
quantumLock: boolean;
onToggleQuantum: () => void;
heatmap: "ultra" | "universal" | false;
onToggleHeatmap: () => void;
}
function EngineOptions({
active,
onToggleActive,
}: {
active: string[];
onToggleActive: (engine: string) => void;
}) {
const t = useTranslations("compressionStudio");
return (
<div className="flex flex-col gap-3">
<textarea data-testid="play-input" className="min-h-[160px] w-full rounded border p-2 font-mono text-xs" value={text} onChange={(e) => onText(e.target.value)} placeholder="Cole prompt / tool-output / contexto..." />
<div>
<div className="text-[10px] uppercase opacity-60">Ativos no fluxo combinado</div>
{LANE_ENGINES.map((e) => (<label key={e} className="flex items-center gap-2 text-sm"><input type="checkbox" checked={active.includes(e)} onChange={() => onToggleActive(e)} />{e}</label>))}
<label className="flex items-center gap-2 text-sm opacity-50"><input type="checkbox" disabled /> llmlingua <span className="text-[10px]">(requer modelo ONNX)</span></label>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" data-testid="fidelity-toggle" checked={fidelityGate} onChange={onToggleFidelity} />
Verificar fidelidade (rejeitar camada que corromper)
<div>
<div className="text-[10px] uppercase opacity-60">{t("activeCombined")}</div>
{LANE_ENGINES.map((engine) => (
<label key={engine} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={active.includes(engine)}
onChange={() => onToggleActive(engine)}
/>
{engine}
</label>
))}
<label className="flex items-center gap-2 text-sm opacity-50">
<input type="checkbox" disabled /> llmlingua{" "}
<span className="text-[10px]">({t("requiresOnnx")})</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" data-testid="fuzzy-toggle" checked={fuzzyDedup} onChange={onToggleFuzzy} />
Fuzzy dedup (near-duplicate CCR)
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" data-testid="risk-toggle" checked={riskGate} onChange={onToggleRisk} />
Protect sensitive content (risk-gate)
</label>
<label className="flex items-center gap-1 text-xs">
<input type="checkbox" data-testid="quantum-toggle" checked={quantumLock} onChange={onToggleQuantum} />
QuantumLock (stabilize cache prefix)
</label>
<label className="flex items-center gap-1 text-xs">
<input type="checkbox" data-testid="heatmap-toggle" checked={Boolean(heatmap)} onChange={onToggleHeatmap} />
Saliency heatmap {heatmap ? `(${heatmap})` : ""}
</label>
<button data-testid="play-run" className="rounded bg-blue-500/30 py-2 font-semibold" onClick={onRun} disabled={loading}>{loading ? "Rodando..." : "▶ Run"}</button>
</div>
);
}
function ToggleOption({
testId,
checked,
onChange,
children,
className = "flex items-center gap-2 text-sm",
}: {
testId: string;
checked: boolean;
onChange: () => void;
children: ReactNode;
className?: string;
}) {
return (
<label className={className}>
<input type="checkbox" data-testid={testId} checked={checked} onChange={onChange} />
{children}
</label>
);
}
export function PlaygroundInput({
text,
onText,
active,
onToggleActive,
onRun,
loading,
fidelityGate,
onToggleFidelity,
fuzzyDedup,
onToggleFuzzy,
riskGate,
onToggleRisk,
quantumLock,
onToggleQuantum,
heatmap,
onToggleHeatmap,
}: PlaygroundInputProps) {
const t = useTranslations("compressionStudio");
return (
<div className="flex flex-col gap-3">
<textarea
data-testid="play-input"
className="min-h-[160px] w-full rounded border p-2 font-mono text-xs"
value={text}
onChange={(e) => onText(e.target.value)}
placeholder={t("inputPlaceholder")}
/>
<EngineOptions active={active} onToggleActive={onToggleActive} />
<ToggleOption testId="fidelity-toggle" checked={fidelityGate} onChange={onToggleFidelity}>
{t("verifyFidelity")}
</ToggleOption>
<ToggleOption testId="fuzzy-toggle" checked={fuzzyDedup} onChange={onToggleFuzzy}>
{t("fuzzyDedup")}
</ToggleOption>
<ToggleOption testId="risk-toggle" checked={riskGate} onChange={onToggleRisk}>
{t("protectSensitive")}
</ToggleOption>
<ToggleOption
testId="quantum-toggle"
checked={quantumLock}
onChange={onToggleQuantum}
className="flex items-center gap-1 text-xs"
>
{t("quantumLock")}
</ToggleOption>
<ToggleOption
testId="heatmap-toggle"
checked={Boolean(heatmap)}
onChange={onToggleHeatmap}
className="flex items-center gap-1 text-xs"
>
{t("saliencyHeatmap")} {heatmap ? `(${heatmap})` : ""}
</ToggleOption>
<button
data-testid="play-run"
className="rounded bg-blue-500/30 py-2 font-semibold"
onClick={onRun}
disabled={loading}
>
{loading ? t("running") : `${t("run")}`}
</button>
</div>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import type { CompressionRunModel, CompressionEngineStep } from "./compressionFlowModel";
import { useTranslations } from "next-intl";
// ── Helpers ───────────────────────────────────────────────────────────────
@@ -22,6 +23,7 @@ function fmt(n: number): string {
// ── Row ───────────────────────────────────────────────────────────────────
function StepRow({ step, maxTokens }: { step: CompressionEngineStep; maxTokens: number }) {
const t = useTranslations("compressionStudio");
const skipped = step.originalTokens === step.compressedTokens;
const color = skipped ? "#6b7280" : savingsColor(step.savingsPercent);
const barWidthIn = maxTokens > 0 ? (step.originalTokens / maxTokens) * 100 : 100;
@@ -38,7 +40,9 @@ function StepRow({ step, maxTokens }: { step: CompressionEngineStep; maxTokens:
{step.engine}
</span>
{skipped && (
<span className="ml-1.5 text-[10px] text-muted bg-muted/10 px-1 rounded">skip</span>
<span className="ml-1.5 text-[10px] text-muted bg-muted/10 px-1 rounded">
{t("skipped")}
</span>
)}
</div>
@@ -103,19 +107,22 @@ export interface WaterfallInspectorProps {
* Plain divs — no ReactFlow.
*/
export function WaterfallInspector({ run, className = "" }: WaterfallInspectorProps) {
const t = useTranslations("compressionStudio");
const maxTokens = run.originalTokens;
return (
<div className={`flex flex-col ${className}`} data-testid="waterfall-inspector">
{/* Header — INPUT */}
<div className="flex items-center gap-3 py-2 border-b border-border font-semibold text-xs text-muted">
<span className="w-28 shrink-0 text-primary"> INPUT</span>
<span className="w-28 shrink-0 text-primary"> {t("input")}</span>
<div className="flex-1">
<div className="h-2 rounded-full bg-muted/30 overflow-hidden">
<div className="h-full w-full rounded-full bg-muted/50" />
</div>
</div>
<span className="w-36 shrink-0 text-right">{fmt(run.originalTokens)} tokens</span>
<span className="w-36 shrink-0 text-right">
{t("tokenCount", { count: run.originalTokens })}
</span>
</div>
{/* Steps */}
@@ -125,7 +132,7 @@ export function WaterfallInspector({ run, className = "" }: WaterfallInspectorPr
{/* Footer — OUTPUT */}
<div className="flex items-center gap-3 py-2 border-t border-border mt-1">
<span className="w-28 shrink-0 text-xs font-semibold text-green-500"> OUTPUT</span>
<span className="w-28 shrink-0 text-xs font-semibold text-green-500"> {t("output")}</span>
<div className="flex-1">
<div className="h-2 rounded-full bg-border/30 overflow-hidden">
<div
@@ -136,7 +143,7 @@ export function WaterfallInspector({ run, className = "" }: WaterfallInspectorPr
</div>
<div className="w-36 shrink-0 text-right">
<div className="text-xs font-semibold text-green-500">
{fmt(run.compressedTokens)} tokens
{t("tokenCount", { count: run.compressedTokens })}
</div>
<div
className="text-[11px] font-bold"
@@ -151,7 +158,7 @@ export function WaterfallInspector({ run, className = "" }: WaterfallInspectorPr
{/* Summary bar */}
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-border/10 mt-2 text-[11px] text-muted">
<span>
{fmt(run.originalTokens)} {fmt(run.compressedTokens)} tok
{fmt(run.originalTokens)} {fmt(run.compressedTokens)} {t("tokenShort")}
</span>
<span className="text-green-500 font-bold">{run.savingsPercent.toFixed(1)}%</span>
{run.comboId && <span className="font-mono opacity-70">{run.comboId}</span>}

View File

@@ -1,15 +1,25 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { PlayView } from "./PlayView";
import { CompareView } from "./CompareView";
export default function CompressionStudioPage() {
const t = useTranslations("compressionStudio");
const [tab, setTab] = useState<"play" | "compare">("play");
const [text, setText] = useState("");
return (
<div className="flex h-[calc(100dvh-6rem)] min-h-[480px] flex-col p-4">
<div className="mb-3 flex gap-2">
<button data-testid="tab-play" aria-pressed={tab === "play"} onClick={() => setTab("play")}>Play</button>
<button data-testid="tab-compare" aria-pressed={tab === "compare"} onClick={() => setTab("compare")}>Compare</button>
<button data-testid="tab-play" aria-pressed={tab === "play"} onClick={() => setTab("play")}>
{t("playTab")}
</button>
<button
data-testid="tab-compare"
aria-pressed={tab === "compare"}
onClick={() => setTab("compare")}
>
{t("compareTab")}
</button>
</div>
<div className="min-h-0 flex-1">
{tab === "play" ? <PlayView text={text} onText={setText} /> : <CompareView text={text} />}

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
interface Summary {
totalRuns: number;
@@ -23,6 +23,7 @@ const EMPTY: Summary = {
export default function CompressionStylesTile() {
const t = useTranslations("settings");
const locale = useLocale();
const [summary, setSummary] = useState<Summary>(EMPTY);
useEffect(() => {
@@ -43,18 +44,20 @@ export default function CompressionStylesTile() {
>
<p className="text-sm font-medium text-text-main">{t("compressionStylesTileTitle")}</p>
<p className="mt-1 text-2xl font-semibold text-text-main">
{summary.totalTokensSaved.toLocaleString("en-US", { useGrouping: false })}
{summary.totalTokensSaved.toLocaleString(locale, { useGrouping: false })}
</p>
<p className="text-xs text-text-muted">
{t("compressionStylesTileSummary", {
tokens: summary.totalTokensSaved,
runs: summary.runsWithStyles,
})}
</p>
<p className="text-xs text-text-muted">tokens saved · {summary.runsWithStyles} runs styled</p>
<div className="mt-2 flex flex-wrap gap-1">
{styles.length === 0 ? (
<span className="text-xs text-text-muted">No styled runs yet.</span>
<span className="text-xs text-text-muted">{t("compressionStylesTileEmpty")}</span>
) : (
styles.map(([id, count]) => (
<span
key={id}
className="rounded bg-border/30 px-2 py-0.5 text-xs text-text-main"
>
<span key={id} className="rounded bg-border/30 px-2 py-0.5 text-xs text-text-main">
{id} · {count}
</span>
))

View File

@@ -216,10 +216,7 @@ export default function CavemanContextPageClient() {
{!masterEnabled && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300 flex items-start gap-2">
<span className="material-symbols-outlined text-[18px]">info</span>
<p>
Token Saver master switch is OFF these settings will not affect requests until you
turn it on from Compression Settings or change it here.
</p>
<p>{t("masterDisabledWarning")}</p>
</div>
)}

View File

@@ -2,12 +2,8 @@
// Combos screen = Compression Hub (top) + named-combos manager (below).
//
// IMPORTANT (hydration): no `useTranslations` here. The earlier combos redesign
// failed to hydrate on the production build and the only structural difference from
// the engine pages was a page-level `useTranslations`. Strings are literal English,
// matching `EngineConfigPage` / `CompressionHub`, both of which hydrate cleanly.
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { STACKED_PIPELINE_ENGINE_INTENSITIES } from "@/shared/validation/compressionConfigSchemas";
import { CompressionPipelineEditor } from "@/shared/components/compression/CompressionPipelineEditor";
import { ComboCompressionModeSelect } from "@/shared/components/compression/ComboCompressionModeSelect";
@@ -41,6 +37,7 @@ const EMPTY_PIPELINE: PipelineStep[] = [
const ENGINE_INTENSITIES: Record<string, readonly string[]> = STACKED_PIPELINE_ENGINE_INTENSITIES;
function NamedCombosManager() {
const t = useTranslations("contextCombos");
const [combos, setCombos] = useState<CompressionCombo[]>([]);
const [routingCombos, setRoutingCombos] = useState<RoutingCombo[]>([]);
const [languagePacks, setLanguagePacks] = useState<LanguagePack[]>([]);
@@ -118,11 +115,11 @@ function NamedCombosManager() {
const saveCombo = async () => {
const trimmed = name.trim();
if (!trimmed) {
setError("Enter a combo name before saving.");
setError(t("nameRequired"));
return;
}
if (pipeline.length === 0) {
setError("Add at least one pipeline step before saving.");
setError(t("pipelineRequired"));
return;
}
setError(null);
@@ -146,7 +143,7 @@ function NamedCombosManager() {
);
if (!res.ok) {
const body = await res.json().catch(() => null);
setError(body?.error || `Failed to save combo (HTTP ${res.status}).`);
setError(body?.error || t("saveComboFailed", { status: res.status }));
return;
}
const combo = await res.json();
@@ -163,7 +160,7 @@ function NamedCombosManager() {
};
const deleteCombo = async (combo: CompressionCombo) => {
if (!confirm(`Delete combo "${combo.name}"?`)) return;
if (!confirm(t("deleteNamedConfirm", { name: combo.name }))) return;
const res = await fetch(`/api/context/combos/${combo.id}`, { method: "DELETE" });
if (res.ok) refresh();
};
@@ -185,10 +182,8 @@ function NamedCombosManager() {
return (
<div className="flex flex-col gap-4">
<div>
<h2 className="text-lg font-semibold text-text-main">Named combos</h2>
<p className="text-sm text-text-muted">
Save different pipelines and assign them to specific routing combos.
</p>
<h2 className="text-lg font-semibold text-text-main">{t("namedCombos")}</h2>
<p className="text-sm text-text-muted">{t("namedCombosDescription")}</p>
</div>
<section className="rounded-lg border border-border bg-surface p-4">
@@ -196,13 +191,13 @@ function NamedCombosManager() {
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Combo name"
placeholder={t("comboNamePlaceholder")}
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-main"
/>
<input
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Description"
placeholder={t("descriptionPlaceholder")}
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-main"
/>
</div>
@@ -217,7 +212,7 @@ function NamedCombosManager() {
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
<div>
<h3 className="mb-2 text-sm font-semibold text-text-main">Language packs</h3>
<h3 className="mb-2 text-sm font-semibold text-text-main">{t("languagePacks")}</h3>
<div className="space-y-2 text-sm text-text-main">
{languagePacks.map((pack) => (
<label key={pack.language} className="flex items-center justify-between gap-2">
@@ -235,30 +230,30 @@ function NamedCombosManager() {
</div>
</div>
<div className="space-y-2 text-sm text-text-main">
<h3 className="text-sm font-semibold text-text-main">Output mode</h3>
<h3 className="text-sm font-semibold text-text-main">{t("outputMode")}</h3>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={outputMode}
onChange={(event) => setOutputMode(event.target.checked)}
/>
Enabled
{t("enabled")}
</label>
<select
value={outputModeIntensity}
onChange={(event) => setOutputModeIntensity(event.target.value)}
className="w-full rounded-lg border border-border bg-bg px-3 py-2 text-sm"
>
<option value="lite">lite</option>
<option value="full">full</option>
<option value="ultra">ultra</option>
<option value="lite">{t("intensityLite")}</option>
<option value="full">{t("intensityFull")}</option>
<option value="ultra">{t("intensityUltra")}</option>
</select>
</div>
<div>
<h3 className="mb-2 text-sm font-semibold text-text-main">Assign to routing</h3>
<h3 className="mb-2 text-sm font-semibold text-text-main">{t("assignToRouting")}</h3>
<div className="max-h-44 space-y-2 overflow-auto text-sm text-text-main">
{routingCombos.length === 0 ? (
<p className="text-xs text-text-muted">No routing combos available.</p>
<p className="text-xs text-text-muted">{t("noAssignments")}</p>
) : (
routingCombos.map((combo) => {
const id = combo.id ?? combo.name ?? "";
@@ -299,14 +294,14 @@ function NamedCombosManager() {
disabled={saving}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white"
>
{editingId ? "Save" : "Create combo"}
{editingId ? t("save") : t("createCombo")}
</button>
{editingId && (
<button
onClick={resetForm}
className="rounded-lg border border-border px-4 py-2 text-sm text-text-main"
>
Cancel
{t("cancel")}
</button>
)}
</div>
@@ -325,7 +320,7 @@ function NamedCombosManager() {
data-testid={`active-badge-${combo.id}`}
className="rounded-full bg-green-500/10 px-2 py-1 text-xs font-medium text-green-500"
>
Active
{t("active")}
</span>
)}
</div>
@@ -341,21 +336,21 @@ function NamedCombosManager() {
))}
</div>
<p className="mt-3 text-xs text-text-muted">
Language packs: {combo.languagePacks.join(", ")}
{t("languagePacksList", { packs: combo.languagePacks.join(", ") })}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<button
onClick={() => editCombo(combo)}
className="rounded-lg border border-border px-3 py-1.5 text-xs text-text-main"
>
Edit
{t("editCombo")}
</button>
{!combo.isDefault && (
<button
onClick={() => deleteCombo(combo)}
className="rounded-lg border border-danger/40 px-3 py-1.5 text-xs text-danger"
>
Delete
{t("deleteCombo")}
</button>
)}
</div>

View File

@@ -2,18 +2,13 @@
// Compression Hub — the single place to understand and control compression.
//
// IMPORTANT (hydration): this component deliberately does NOT use `useTranslations`.
// The previous combos redesign failed to hydrate on the production build; the only
// structural difference from the engine pages (which hydrate fine) was a page-level
// `useTranslations("contextCombos")`. To stay on the proven-good path, strings here
// remain literal English text, exactly like `EngineConfigPage`.
//
// Phase 2: this Hub is now a thin overview. The master toggle, mode selector, and the
// reorderable per-layer pipeline live in the panel at /dashboard/context/settings and
// in the named-combo editor. Here we expose a single active-profile selector
// (Default-from-panel | a named combo) + a read-only preview.
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -67,6 +62,7 @@ function Toggle({
// ── Main component ──────────────────────────────────────────────────────────────
export default function CompressionHub() {
const t = useTranslations("contextCombos");
const [settings, setSettings] = useState<CompressionSettings | null>(null);
const [combos, setCombos] = useState<NamedCombo[]>([]);
const [loading, setLoading] = useState(true);
@@ -124,21 +120,21 @@ export default function CompressionHub() {
});
if (!res.ok) {
setSettings(settings); // revert
setError("Failed to save settings.");
setError(t("saveSettingsFailed"));
}
} catch {
setSettings(settings);
setError("Failed to save settings.");
setError(t("saveSettingsFailed"));
}
},
[settings]
[settings, t]
);
// ── Derived state ─────────────────────────────────────────────────────────────
if (loading) {
return (
<div className="flex items-center justify-center p-10 text-sm text-text-muted">
Loading...
{t("loading")}
</div>
);
}
@@ -157,10 +153,8 @@ export default function CompressionHub() {
hub
</span>
<div>
<h1 className="text-xl font-bold text-text-main">Compression Hub</h1>
<p className="text-sm text-text-muted">
Pick which compression profile runs globally.
</p>
<h1 className="text-xl font-bold text-text-main">{t("hubTitle")}</h1>
<p className="text-sm text-text-muted">{t("hubDescription")}</p>
</div>
</div>
<button
@@ -168,7 +162,7 @@ export default function CompressionHub() {
onClick={() => setExplainerOpen((v) => !v)}
className="shrink-0 rounded-lg border border-border px-3 py-1.5 text-xs text-text-main hover:bg-bg"
>
{explainerOpen ? "Hide explanation" : "How it works"}
{explainerOpen ? t("hideExplanation") : t("howItWorks")}
</button>
</div>
@@ -180,25 +174,30 @@ export default function CompressionHub() {
{explainerOpen && (
<div className="rounded-lg border border-border bg-bg p-4 text-sm text-text-muted">
<p className="mb-2">
Compression reduces <strong className="text-text-main">tokens and cost</strong> by
rewriting history before it is sent to the provider while preserving meaning.
{t.rich("explanationIntro", {
strong: (chunks) => <strong className="text-text-main">{chunks}</strong>,
})}
</p>
<ol className="ml-4 list-decimal space-y-1.5">
<li>
<strong className="text-text-main">Active profile</strong>: chooses which compression
profile runs globally the panel-derived Default or one of your saved named combos.
{t.rich("explanationActiveProfile", {
strong: (chunks) => <strong className="text-text-main">{chunks}</strong>,
})}
</li>
<li>
<strong className="text-text-main">Default (from panel)</strong>: derived from the
master switch and per-engine toggles you configure in Compression Settings.
{t.rich("explanationDefault", {
strong: (chunks) => <strong className="text-text-main">{chunks}</strong>,
})}
</li>
<li>
<strong className="text-text-main">Named combos</strong>: saved pipelines you build in
the named-combo editor. Selecting one makes it the active profile for every request.
{t.rich("explanationNamedCombos", {
strong: (chunks) => <strong className="text-text-main">{chunks}</strong>,
})}
</li>
<li>
<strong className="text-text-main">Preview</strong>: shows which engines the active
profile runs, in order.
{t.rich("explanationPreview", {
strong: (chunks) => <strong className="text-text-main">{chunks}</strong>,
})}
</li>
</ol>
</div>
@@ -208,11 +207,9 @@ export default function CompressionHub() {
<div className="flex flex-col gap-3 rounded-lg border border-border bg-bg p-4">
<div className="flex flex-col gap-1">
<label htmlFor="active-profile" className="text-sm font-semibold text-text-main">
Active profile
{t("activeProfile")}
</label>
<p className="text-xs text-text-muted">
Pick which compression profile runs globally the panel-derived Default or a saved named combo.
</p>
<p className="text-xs text-text-muted">{t("activeProfileDescription")}</p>
</div>
<select
id="active-profile"
@@ -221,7 +218,7 @@ export default function CompressionHub() {
onChange={(e) => saveSettings({ activeComboId: e.target.value || null })}
className="rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main"
>
<option value="">Default (from panel)</option>
<option value="">{t("defaultFromPanel")}</option>
{combos.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
@@ -234,13 +231,13 @@ export default function CompressionHub() {
>
{activeCombo ? (
<span>
Runs: <span className="font-mono text-text-main">{activePipelineText}</span>
{t("runs")} <span className="font-mono text-text-main">{activePipelineText}</span>
</span>
) : (
<span>
Default configured in{" "}
{t("defaultConfiguredPrefix")}{" "}
<a href="/dashboard/context/settings" className="underline hover:text-text-main">
Compression Settings
{t("compressionSettings")}
</a>
.
</span>
@@ -250,27 +247,23 @@ export default function CompressionHub() {
{/* ── Provider-delegated compression ── */}
<div className="flex flex-col gap-3">
<h2 className="text-sm font-semibold text-text-main">Provider-delegated compression</h2>
<h2 className="text-sm font-semibold text-text-main">{t("providerDelegated")}</h2>
<div className="flex items-center gap-3 rounded-lg border border-border bg-bg p-4">
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-text-main">Context Editing (Claude)</p>
<p className="text-xs text-text-muted">
Lets the provider clear old tool-use blocks server-side, without rewriting the message.
</p>
<p className="text-sm font-semibold text-text-main">{t("contextEditingClaude")}</p>
<p className="text-xs text-text-muted">{t("contextEditingDescription")}</p>
</div>
<Toggle
checked={!!settings?.contextEditing?.enabled}
onChange={() =>
saveSettings({ contextEditing: { enabled: !settings?.contextEditing?.enabled } })
}
ariaLabel="Context Editing"
ariaLabel={t("contextEditingAria")}
/>
</div>
<div className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/5 px-3 py-2 text-xs text-amber-500">
<span className="material-symbols-outlined text-[16px]">info</span>
<span>
Currently available for Claude (Anthropic) only. It is a delegated mode: the provider clears old tool-use blocks server-side we do not rewrite the message. Does not affect other providers.
</span>
<span>{t("contextEditingNote")}</span>
</div>
</div>
</section>

View File

@@ -5,13 +5,12 @@
// a real before→after (dense text vs the rendered PNG page), the fail-closed gate
// flow, and the enable control (wired to /api/settings/compression, preview engine).
//
// Engine-facing copy is hardcoded English (matches the catalog convention — engine
// text stays deterministic, not i18n). The sample in ./sampleData.ts is a REAL render
// from the omniglyph package, not a mockup.
// The sample in ./sampleData.ts is a REAL render from the omniglyph package, not a mockup.
//
// Card/Toggle are imported from their direct module paths (not the @/shared/components
// barrel) — the barrel pulls a Node-only module that hangs vitest/jsdom.
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import Card from "@/shared/components/Card";
import Toggle from "@/shared/components/Toggle";
import { SAMPLE_BEFORE_TEXT, SAMPLE_PAGE_PNG_DATA_URI, SAMPLE_METRICS } from "./sampleData";
@@ -24,67 +23,56 @@ type EngineMap = Record<string, { enabled: boolean; level?: string }>;
/** The measured fail-closed gate chain, in evaluation order. Every no-op is telemetered
* as `skip:<reason>`; the engine only fires when all pass. */
const GATES: ReadonlyArray<{ label: string; pass: string; why: string }> = [
const GATES = [
{
label: "Model",
pass: "claude-fable-5",
why: "Only Fable 5 reads dense pages at 100% (measured, n=30). GPT-5.5 and Gemini 2.5-flash are blocked.",
id: "model",
},
{
label: "Transport",
pass: "direct Anthropic",
why: "Aggregators resample images and destroy legibility — only the direct route is authoritative.",
id: "transport",
},
{
label: "Format",
pass: "native Claude",
why: "The body must be Claude-format (never a system role inside messages).",
id: "format",
},
{
label: "Profitable",
pass: "dense enough",
why: "The exact 28px-patch cost gate decides per request; small or sparse text passes through untouched.",
id: "profitable",
},
];
] as const;
const ECONOMICS: ReadonlyArray<{ value: string; label: string }> = [
{ value: "~10×", label: "fewer tokens on the converted block" },
{ value: "5970%", label: "end-to-end savings (measured)" },
{ value: "1456", label: "image tokens for a 1568×728 page (~28k chars)" },
{ value: "100%", label: "reading accuracy on Fable 5 (n=30)" },
];
const ECONOMICS = [
{ value: "~10×", id: "fewerTokens" },
{ value: "5970%", id: "savings" },
{ value: "1456", id: "imageTokens" },
{ value: "100%", id: "accuracy" },
] as const;
// Section components are split out of the page component so each function stays
// under the complexity gate's 80-line cap; the page composes them.
function PageHeader() {
const t = useTranslations("omniglyph");
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold">OmniGlyph</h1>
<span className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
Preview
{t("preview")}
</span>
</div>
<p className="max-w-2xl text-sm text-text-muted">
Context-as-image compression. Renders the system prompt, tool docs and dense history as
compact PNG pages that Claude Fable 5 reads instead of the text image tokens are billed by
dimensions, not characters, so the converted block costs ~10× less. Direct Anthropic route
only.
</p>
<p className="max-w-2xl text-sm text-text-muted">{t("description")}</p>
</div>
);
}
function EconomicsCard() {
const t = useTranslations("omniglyph");
return (
<Card className="p-6">
<h2 className="mb-4 text-lg font-semibold">The economics</h2>
<h2 className="mb-4 text-lg font-semibold">{t("economicsTitle")}</h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{ECONOMICS.map((e) => (
<div key={e.label} className="flex flex-col gap-1">
<div key={e.id} className="flex flex-col gap-1">
<span className="text-2xl font-semibold tabular-nums">{e.value}</span>
<span className="text-xs text-text-muted">{e.label}</span>
<span className="text-xs text-text-muted">{t(`economics.${e.id}`)}</span>
</div>
))}
</div>
@@ -93,21 +81,22 @@ function EconomicsCard() {
}
function BeforeAfterCard() {
const t = useTranslations("omniglyph");
return (
<Card className="p-6">
<div className="mb-1 flex items-baseline justify-between">
<h2 className="text-lg font-semibold">Before after</h2>
<h2 className="text-lg font-semibold">{t("beforeAfterTitle")}</h2>
<span className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
{SAMPLE_METRICS.savingsPct}% tokens on this block
{t("blockSavings", { percent: SAMPLE_METRICS.savingsPct })}
</span>
</div>
<p className="mb-4 text-xs text-text-muted">
A real render of {SAMPLE_METRICS.beforeChars} characters of dense tool docs not a mockup.
{t("realRender", { characters: SAMPLE_METRICS.beforeChars })}
</p>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-text-muted">
Text · {SAMPLE_METRICS.textTokens} tokens
{t("textTokens", { tokens: SAMPLE_METRICS.textTokens })}
</span>
<pre className="max-h-60 overflow-auto rounded border border-black/10 bg-black/5 p-3 text-[11px] leading-relaxed dark:border-white/10 dark:bg-white/5">
{SAMPLE_BEFORE_TEXT}
@@ -115,13 +104,16 @@ function BeforeAfterCard() {
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-text-muted">
Rendered page · {SAMPLE_METRICS.imageTokens} tokens
{t("renderedTokens", { tokens: SAMPLE_METRICS.imageTokens })}
</span>
<div className="overflow-auto rounded border border-black/10 bg-white p-3 dark:border-white/10">
{/* eslint-disable-next-line @next/next/no-img-element -- data URI, no loader needed */}
<img
src={SAMPLE_PAGE_PNG_DATA_URI}
alt={`Rendered dense page, ${SAMPLE_METRICS.pageWidth}×${SAMPLE_METRICS.pageHeight}px`}
alt={t("renderedImageAlt", {
width: SAMPLE_METRICS.pageWidth,
height: SAMPLE_METRICS.pageHeight,
})}
width={SAMPLE_METRICS.pageWidth}
height={SAMPLE_METRICS.pageHeight}
className="max-w-full"
@@ -134,24 +126,29 @@ function BeforeAfterCard() {
}
function GatesCard() {
const t = useTranslations("omniglyph");
return (
<Card className="p-6">
<h2 className="mb-1 text-lg font-semibold">When it fires</h2>
<h2 className="mb-1 text-lg font-semibold">{t("gatesTitle")}</h2>
<p className="mb-4 text-xs text-text-muted">
Fail-closed: every gate must pass, or the request passes through untouched (each skip is
telemetered as <code>skip:&lt;reason&gt;</code>).
{t.rich("gatesDescription", {
code: (chunks) => <code>{chunks}</code>,
})}
</p>
<ol className="flex flex-col gap-3">
{GATES.map((g, i) => (
<li key={g.label} className="flex gap-3">
<li key={g.id} className="flex gap-3">
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-black/5 text-xs font-semibold tabular-nums dark:bg-white/10">
{i + 1}
</span>
<div className="flex flex-col">
<span className="text-sm font-medium">
{g.label} <span className="text-emerald-600 dark:text-emerald-400">{g.pass}</span>
{t(`gates.${g.id}.label`)} {" "}
<span className="text-emerald-600 dark:text-emerald-400">
{t(`gates.${g.id}.pass`)}
</span>
</span>
<span className="text-xs text-text-muted">{g.why}</span>
<span className="text-xs text-text-muted">{t(`gates.${g.id}.why`)}</span>
</div>
</li>
))}
@@ -166,21 +163,22 @@ function EnableCard(props: {
status: "" | "saved" | "error";
onToggle: (next: boolean) => void;
}) {
const t = useTranslations("omniglyph");
return (
<Card className="p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<h2 className="text-lg font-semibold">Enable the engine</h2>
<h2 className="text-lg font-semibold">{t("enableTitle")}</h2>
<p className="max-w-xl text-sm text-text-muted">
Runs last in the stack (after RTK/Caveman clean the text, OmniGlyph images the residual)
and also standalone via the <code>omniglyph</code> mode. Preview off by default until
the end-to-end validation lands.
{t.rich("enableDescription", {
code: (chunks) => <code>{chunks}</code>,
})}
</p>
<span className="mt-1 h-4 text-xs text-text-muted" aria-live="polite">
{props.status === "saved"
? "Saved."
? t("saved")
: props.status === "error"
? "Could not save."
? t("saveFailed")
: ""}
</span>
</div>
@@ -190,7 +188,7 @@ function EnableCard(props: {
checked={props.enabled}
onChange={props.onToggle}
disabled={props.disabled}
ariaLabel="Enable the OmniGlyph engine"
ariaLabel={t("enableAria")}
/>
</span>
</div>

View File

@@ -7,10 +7,6 @@
// toggle (its own endpoint / separate store), a read-only derived-pipeline preview,
// and the general settings (auto-trigger tokens + preserve-system-prompt).
//
// Engine rows use the catalog label/description (hardcoded English) directly — NOT
// i18n — so they stay deterministic. Human-facing chrome (master, general) keeps the
// app's i18n via useTranslations("settings").
import Link from "next/link";
import { useEffect, useState } from "react";
import { useTranslations, useLocale } from "next-intl";
@@ -33,7 +29,7 @@ import {
DEFAULT_CONTEXT_BUDGET,
type ContextBudgetConfig,
} from "../../../../../../open-sse/services/compression/adaptiveCompression/types.ts";
import { formatAdaptiveTarget } from "./adaptiveTargetLabel.ts";
import { getAdaptiveTargetSummary } from "./adaptiveTargetLabel.ts";
type CavemanIntensity = "lite" | "full" | "ultra";
@@ -124,6 +120,26 @@ function LiveZoneToggle({
);
}
function AdaptiveTargetPreview({ contextBudget }: { contextBudget?: ContextBudgetConfig }) {
const t = useTranslations("settings");
const target = getAdaptiveTargetSummary(contextBudget ?? DEFAULT_CONTEXT_BUDGET, 200000);
return (
<div
data-testid="adaptive-target-preview"
className="mb-4 rounded-md border border-border/60 bg-bg-subtle px-3 py-2 text-xs text-text-muted"
>
{target.enabled
? t("compressionAdaptiveTarget", {
mode: target.mode,
policy: target.policy,
target: target.target,
contextLimit: target.contextLimit,
})
: t("compressionAdaptiveOff")}
</div>
);
}
export default function CompressionPanel() {
const t = useTranslations("settings");
// D-A6/§7: locale-gated styles (e.g. terse-cjk → zh) are only OFFERED under their locale.
@@ -238,11 +254,12 @@ export default function CompressionPanel() {
const derived = deriveDefaultPlan(config.engines, config.enabled);
const derivedText =
derived.mode === "off"
? "off"
? t("compressionDerivedOff")
: derived.stackedPipeline.length > 0
? `runs: ${derived.stackedPipeline.map((s) => s.engine).join(" → ")}`
: `mode: ${derived.mode}`;
? t("compressionDerivedRuns", {
pipeline: derived.stackedPipeline.map((s) => s.engine).join(" → "),
})
: t("compressionDerivedMode", { mode: derived.mode });
if (loading) {
return (
<Card className="p-6">
@@ -305,16 +322,12 @@ export default function CompressionPanel() {
data-testid="derived-pipeline-preview"
className="mb-4 rounded-md border border-border/60 bg-bg-subtle px-3 py-2 text-xs text-text-muted"
>
<span className="font-medium text-text-main">Effective pipeline:</span> {derivedText}
<span className="font-medium text-text-main">{t("compressionEffectivePipeline")}</span>{" "}
{derivedText}
</div>
{/* Adaptive context-budget — read-only computed target (Phase 4C, D-C1 transparency) */}
<div
data-testid="adaptive-target-preview"
className="mb-4 rounded-md border border-border/60 bg-bg-subtle px-3 py-2 text-xs text-text-muted"
>
{formatAdaptiveTarget(config.contextBudget ?? DEFAULT_CONTEXT_BUDGET, 200000)}
</div>
<AdaptiveTargetPreview contextBudget={config.contextBudget} />
{/* Engine grid */}
<div className={`divide-y divide-border ${config.enabled ? "" : "opacity-60"}`}>
@@ -323,6 +336,8 @@ export default function CompressionPanel() {
const engine = config.engines[id] ?? { enabled: false };
const levels = meta.levels;
const level = engine.level ?? levels?.[0] ?? "";
const engineLabel = t(`compressionEngine.${id}.label`);
const engineDescription = t(`compressionEngine.${id}.description`);
return (
<div
key={id}
@@ -331,7 +346,7 @@ export default function CompressionPanel() {
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-text-main">
{meta.label}
{engineLabel}
<Link
href={`/dashboard/context/${id}`}
className="rounded border border-border bg-bg-subtle px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-text-muted hover:border-primary/40 hover:text-primary"
@@ -339,7 +354,7 @@ export default function CompressionPanel() {
{id}
</Link>
</div>
<p className="mt-0.5 text-xs text-text-muted">{meta.description}</p>
<p className="mt-0.5 text-xs text-text-muted">{engineDescription}</p>
<EngineGuidanceDetail
id={id}
guidance={meta.guidance}
@@ -357,7 +372,7 @@ export default function CompressionPanel() {
>
{levels.map((lvl) => (
<option key={lvl} value={lvl}>
{lvl}
{t(`compressionLevel.${lvl}`)}
</option>
))}
</select>
@@ -368,7 +383,7 @@ export default function CompressionPanel() {
checked={engine.enabled}
onChange={(enabled) => setEngine(id, { enabled })}
disabled={!config.enabled || saving}
ariaLabel={meta.label}
ariaLabel={engineLabel}
/>
</span>
</div>
@@ -384,7 +399,7 @@ export default function CompressionPanel() {
{t("compressionSettingsOutputStyles")}
</p>
<p className="mt-0.5 text-xs text-text-muted">
Inject response-shaping instructions without rewriting provider output. Combine freely.
{t("compressionOutputStylesDescription")}
</p>
</div>
{OUTPUT_STYLE_IDS.filter((id) => {
@@ -393,6 +408,8 @@ export default function CompressionPanel() {
}).map((id) => {
const meta = outputStyleMeta(id);
const sel = config.outputStyles?.find((s) => s.id === id);
const styleLabel = t(`compressionOutputStyle.${id}.label`);
const styleDescription = t(`compressionOutputStyle.${id}.description`);
return (
<div
key={id}
@@ -400,8 +417,8 @@ export default function CompressionPanel() {
className="flex items-center justify-between gap-2"
>
<div className="min-w-0">
<p className="text-sm text-text-main">{meta.label}</p>
{meta.description && <p className="text-xs text-text-muted">{meta.description}</p>}
<p className="text-sm text-text-main">{styleLabel}</p>
{meta.description && <p className="text-xs text-text-muted">{styleDescription}</p>}
</div>
<div className="flex shrink-0 items-center gap-2">
<select
@@ -415,7 +432,7 @@ export default function CompressionPanel() {
>
{CAVEMAN_OUTPUT_LEVELS.map((lvl) => (
<option key={lvl} value={lvl}>
{lvl}
{t(`compressionLevel.${lvl}`)}
</option>
))}
</select>
@@ -425,7 +442,7 @@ export default function CompressionPanel() {
checked={Boolean(sel)}
onChange={(enabled) => setOutputStyle(id, { enabled })}
disabled={saving}
ariaLabel={meta.label}
ariaLabel={styleLabel}
/>
</span>
</div>
@@ -474,9 +491,7 @@ export default function CompressionPanel() {
<div className="flex flex-col gap-2 border-t border-border/30 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-text-main">{t("mcpAccessibilityTitle")}</p>
<p className="mt-0.5 text-xs text-text-muted">
Scopes MCP tool outputs (separate store).
</p>
<p className="mt-0.5 text-xs text-text-muted">{t("mcpAccessibilityDescription")}</p>
</div>
<span data-testid="mcp-accessibility-toggle">
<Toggle

View File

@@ -15,3 +15,27 @@ export function formatAdaptiveTarget(
const target = computeTarget(config.policy, representativeModelContextLimit, null, config);
return `Adaptive (${config.mode}, policy: ${config.policy}) — target ≈ ${target.toLocaleString()} tokens (for a ${representativeModelContextLimit.toLocaleString()}-token window)`;
}
export type AdaptiveTargetSummary =
| { enabled: false }
| {
enabled: true;
mode: ContextBudgetConfig["mode"];
policy: ContextBudgetConfig["policy"];
target: number;
contextLimit: number;
};
export function getAdaptiveTargetSummary(
config: ContextBudgetConfig,
representativeModelContextLimit: number
): AdaptiveTargetSummary {
if (config.mode === "off") return { enabled: false };
return {
enabled: true,
mode: config.mode,
policy: config.policy,
target: computeTarget(config.policy, representativeModelContextLimit, null, config),
contextLimit: representativeModelContextLimit,
};
}

View File

@@ -155,6 +155,24 @@ const CHART_COLORS = [
"#ec4899",
];
const SHORT_WEEKDAY_INDEX: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
function formatWeekdayLabel(day: string, locale: string): string {
const index = SHORT_WEEKDAY_INDEX[day.slice(0, 3)];
if (index === undefined) return day;
return new Intl.DateTimeFormat(locale, { weekday: "short" }).format(
new Date(Date.UTC(2024, 0, 7 + index))
);
}
export function createCurrencyFormatter(locale: string) {
return new Intl.NumberFormat(locale, {
style: "currency",
@@ -875,14 +893,19 @@ export default function CostOverviewTab() {
<div className="grid grid-cols-1 xl:grid-cols-[1fr_1.5fr] gap-4">
<WeeklyPatternCard
title={t("weeklyUsagePattern")}
rows={analytics?.weeklyPattern || []}
rows={(analytics?.weeklyPattern || []).map((row) => ({
...row,
day: formatWeekdayLabel(row.day, locale),
}))}
locale={locale}
tokensLabel={t("tokens")}
/>
<ActivityHeatmap
title={t("activityHeatmap")}
activityMap={analytics?.activityMap || {}}
lessLabel={t("less")}
moreLabel={t("more")}
tokensLabel={t("tokens")}
locale={locale}
/>
</div>
@@ -1102,12 +1125,14 @@ function ActivityHeatmap({
activityMap,
lessLabel,
moreLabel,
tokensLabel,
locale,
}: {
title: string;
activityMap: Record<string, number>;
lessLabel: string;
moreLabel: string;
tokensLabel: string;
locale: string;
}) {
const days: Array<{ date: string; value: number }> = [];
@@ -1151,7 +1176,7 @@ function ActivityHeatmap({
className={`w-2.75 h-2.75 rounded-xs ${getIntensity(day.value)}`}
title={`${day.date}: ${
day.value > 0
? `${new Intl.NumberFormat(locale).format(day.value)} tokens`
? `${new Intl.NumberFormat(locale).format(day.value)} ${tokensLabel}`
: "No activity"
}`}
/>

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
export interface ApiKeyUsageLimitPayload {
@@ -46,16 +47,18 @@ function parseUsdInput(value: string): number | null {
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function formatResetHint(resetAtIso: string | null): string {
if (!resetAtIso) return "fallback: rolling 7 days";
function formatResetHint(resetAtIso: string | null, t: ReturnType<typeof useTranslations>): string {
if (!resetAtIso) return t("fallbackRollingDays", { days: 7 });
const resetMs = Date.parse(resetAtIso);
if (!Number.isFinite(resetMs)) return "fallback: rolling 7 days";
if (!Number.isFinite(resetMs)) return t("fallbackRollingDays", { days: 7 });
const deltaMs = resetMs - Date.now();
if (deltaMs <= 0) return "reset due now";
if (deltaMs <= 0) return t("resetDueNow");
const hourMs = 60 * 60 * 1000;
const dayMs = 24 * hourMs;
if (deltaMs < dayMs) return `resets in ${Math.max(1, Math.ceil(deltaMs / hourMs))}h`;
return `resets in ${Math.max(1, Math.ceil(deltaMs / dayMs))}d`;
if (deltaMs < dayMs) {
return t("resetsInHours", { count: Math.max(1, Math.ceil(deltaMs / hourMs)) });
}
return t("resetsInDays", { count: Math.max(1, Math.ceil(deltaMs / dayMs)) });
}
function UsageQuotaMetric({ label, value }: { label: string; value: string }) {
@@ -78,6 +81,7 @@ export function ApiKeyUsageLimitCard({
locale: string;
onSave: (next: ApiKeyUsageLimitSavePayload) => Promise<void>;
}) {
const t = useTranslations("usageLimits");
const [enabled, setEnabled] = useState(false);
const [dailyLimit, setDailyLimit] = useState("");
const [weeklyLimit, setWeeklyLimit] = useState("");
@@ -113,7 +117,7 @@ export function ApiKeyUsageLimitCard({
weeklyUsageLimitUsd: parseUsdInput(weeklyLimit),
});
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "Failed to save usage limits");
setError(saveError instanceof Error ? saveError.message : t("saveFailed"));
} finally {
setSaving(false);
}
@@ -125,17 +129,14 @@ export function ApiKeyUsageLimitCard({
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-emerald-400 text-lg">paid</span>
<h3 className="text-sm font-semibold text-text-main">API key USD quota</h3>
<h3 className="text-sm font-semibold text-text-main">{t("apiKeyUsdQuota")}</h3>
{payload?.key.name && (
<span className="truncate rounded bg-surface px-2 py-0.5 text-xs text-text-muted">
{payload.key.name}
</span>
)}
</div>
<p className="mt-1 text-xs text-text-muted">
When enabled, @@om-usage returns daily quota, weekly quota, daily spend, and weekly
spend in USD. Weekly follows the cached Claude reset when available.
</p>
<p className="mt-1 text-xs text-text-muted">{t("apiKeyUsdQuotaDescription")}</p>
</div>
<button
type="button"
@@ -150,22 +151,22 @@ export function ApiKeyUsageLimitCard({
} ${loading || !payload ? "opacity-50" : ""}`}
>
<span className="material-symbols-outlined text-[14px]">paid</span>
{enabled ? "Enabled" : "Disabled"}
{enabled ? t("enabled") : t("disabled")}
</button>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<UsageQuotaMetric
label="Daily spend"
label={t("dailySpend")}
value={loading || !status ? "..." : formatter.format(status.dailySpentUsd)}
/>
<UsageQuotaMetric
label="Weekly spend"
label={t("weeklySpend")}
value={loading || !status ? "..." : formatter.format(status.weeklySpentUsd)}
/>
<div className="rounded-lg border border-border/20 bg-surface/20 px-4 py-3">
<label className="text-xs uppercase tracking-wide text-text-muted font-semibold">
Daily quota
{t("dailyQuota")}
</label>
<input
type="number"
@@ -179,7 +180,7 @@ export function ApiKeyUsageLimitCard({
</div>
<div className="rounded-lg border border-border/20 bg-surface/20 px-4 py-3">
<label className="text-xs uppercase tracking-wide text-text-muted font-semibold">
Weekly quota
{t("weeklyQuota")}
</label>
<input
type="number"
@@ -191,7 +192,7 @@ export function ApiKeyUsageLimitCard({
placeholder="0.00"
/>
<p className="mt-1 text-[10px] text-text-muted">
{formatResetHint(status?.weeklyResetAtIso ?? null)}
{formatResetHint(status?.weeklyResetAtIso ?? null, t)}
</p>
</div>
</div>
@@ -207,7 +208,7 @@ export function ApiKeyUsageLimitCard({
<span className="material-symbols-outlined text-[14px]">
{saving ? "hourglass_empty" : "save"}
</span>
{saving ? "Saving..." : "Save quota"}
{saving ? t("saving") : t("saveQuota")}
</button>
</div>
</Card>

View File

@@ -182,10 +182,12 @@ export function WeeklyPatternCard({
title,
rows,
locale,
tokensLabel,
}: {
title: string;
rows: Array<{ day: string; avgTokens: number; totalTokens: number }>;
locale: string;
tokensLabel: string;
}) {
const chartData = rows.map((row) => ({
day: row.day,
@@ -219,7 +221,7 @@ export function WeeklyPatternCard({
/>
<Tooltip
formatter={(value: number) =>
`${new Intl.NumberFormat(locale).format(value || 0)} tokens`
`${new Intl.NumberFormat(locale).format(value || 0)} ${tokensLabel}`
}
contentStyle={{
background: "var(--surface)",

View File

@@ -41,11 +41,11 @@ export default function AllocationTable({ allocations, usage, keyLabels }: Alloc
<table className="w-full text-[11px]">
<thead>
<tr className="text-[10px] uppercase tracking-wide text-text-muted border-b border-border/40">
<th className="text-left py-1 pr-2 font-semibold">API Key</th>
<th className="text-right py-1 pr-2 font-semibold">Weight</th>
<th className="text-left py-1 pr-2 font-semibold">{t("apiKeyColumn")}</th>
<th className="text-right py-1 pr-2 font-semibold">{t("weightColumn")}</th>
<th className="text-right py-1 pr-2 font-semibold">{t("realConsumedColumn")}</th>
<th className="text-right py-1 pr-2 font-semibold">{t("deficitColumn")}</th>
<th className="text-right py-1 font-semibold">Policy</th>
<th className="text-right py-1 font-semibold">{t("policy")}</th>
</tr>
</thead>
<tbody>
@@ -88,17 +88,22 @@ export default function AllocationTable({ allocations, usage, keyLabels }: Alloc
{deficit !== null ? (
<span
className={
deficit > 0 ? "text-red-400" : deficit < 0 ? "text-emerald-400" : "text-text-muted"
deficit > 0
? "text-red-400"
: deficit < 0
? "text-emerald-400"
: "text-text-muted"
}
>
{deficit > 0 ? "+" : ""}{deficit.toLocaleString()}
{deficit > 0 ? "+" : ""}
{deficit.toLocaleString()}
</span>
) : (
<span className="text-text-muted"></span>
)}
{fairShare !== null && (
<span className="text-[9px] text-text-muted ml-1">
(fair: {fairShare.toLocaleString()})
({t("fairShareShort")}: {fairShare.toLocaleString()})
</span>
)}
</td>
@@ -112,7 +117,7 @@ export default function AllocationTable({ allocations, usage, keyLabels }: Alloc
: "bg-emerald-500/10 text-emerald-400"
}`}
>
{alloc.policy}
{t(`policy${alloc.policy[0].toUpperCase()}${alloc.policy.slice(1)}`)}
</span>
</td>
</tr>

View File

@@ -24,7 +24,13 @@ import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { maskEmailLikeValue } from "@/shared/utils/maskEmail";
import { getKnownPlan } from "@/lib/quota/planRegistry";
import { quotaModelName } from "@/lib/quota/quotaModelNaming";
import type { Policy, PoolAllocation, QuotaDimension, QuotaUnit, QuotaWindow } from "@/lib/quota/dimensions";
import type {
Policy,
PoolAllocation,
QuotaDimension,
QuotaUnit,
QuotaWindow,
} from "@/lib/quota/dimensions";
import type { QuotaPool } from "@/lib/db/quotaPools";
// ────────────────────────────────────────────────────────────────────────────
@@ -346,8 +352,7 @@ export default function PoolWizard({
const availableKeys = apiKeys.filter((k) => !allocations.some((a) => a.apiKeyId === k.id));
const keyLabel = (id: string) =>
apiKeys.find((k) => k.id === id)?.name || id.slice(0, 12) + "…";
const keyLabel = (id: string) => apiKeys.find((k) => k.id === id)?.name || id.slice(0, 12) + "…";
const addKey = (id: string) => {
setAllocations((prev) => {
@@ -362,9 +367,7 @@ export default function PoolWizard({
const updateWeight = (id: string, value: number) => {
setAllocations((prev) =>
prev.map((a) =>
a.apiKeyId === id ? { ...a, weight: Math.max(0, Math.min(100, value)) } : a
)
prev.map((a) => (a.apiKeyId === id ? { ...a, weight: Math.max(0, Math.min(100, value)) } : a))
);
};
@@ -397,15 +400,17 @@ export default function PoolWizard({
if (connectionIds.length === 0 || !name) return [];
const MAX_PER_PROVIDER = 3;
return connectionIds.map((cid) => {
const conn = connections.find((c) => c.id === cid);
if (!conn) return null;
const allModels = getPreviewModels(conn.provider);
const names = allModels.slice(0, MAX_PER_PROVIDER).map((m) =>
quotaModelName(name, conn.provider, m)
);
return { provider: conn.provider, names, totalModels: allModels.length };
}).filter(Boolean) as Array<{ provider: string; names: string[]; totalModels: number }>;
return connectionIds
.map((cid) => {
const conn = connections.find((c) => c.id === cid);
if (!conn) return null;
const allModels = getPreviewModels(conn.provider);
const names = allModels
.slice(0, MAX_PER_PROVIDER)
.map((m) => quotaModelName(name, conn.provider, m));
return { provider: conn.provider, names, totalModels: allModels.length };
})
.filter(Boolean) as Array<{ provider: string; names: string[]; totalModels: number }>;
}, [connectionIds, connections, poolName]);
// Flat list (for legacy single-provider path, kept for step-3 rendering simplicity)
@@ -502,8 +507,7 @@ export default function PoolWizard({
if (!editPatchRes.ok) {
const errBody = await editPatchRes.json().catch(() => null);
throw new Error(
errBody?.error?.message ||
`PATCH /api/quota/pools failed: HTTP ${editPatchRes.status}`
errBody?.error?.message || `PATCH /api/quota/pools failed: HTTP ${editPatchRes.status}`
);
}
@@ -537,7 +541,12 @@ export default function PoolWizard({
if (!open) return null;
return (
<Modal isOpen onClose={onClose} title={editPool ? t("editPoolTitle") : t("wizardTitle")} size="lg">
<Modal
isOpen
onClose={onClose}
title={editPool ? t("editPoolTitle") : t("wizardTitle")}
size="lg"
>
<div className="flex flex-col" style={{ minHeight: 420 }}>
<Stepper currentStep={step} />
@@ -631,7 +640,9 @@ export default function PoolWizard({
type="text"
value={poolName}
onChange={(e) => setPoolName(e.target.value)}
placeholder={selectedConn ? selectedConn.provider : t("wizardPoolNamePlaceholder")}
placeholder={
selectedConn ? selectedConn.provider : t("wizardPoolNamePlaceholder")
}
className="w-full px-3 py-2 rounded border border-border bg-bg-base text-sm"
/>
</div>
@@ -675,7 +686,11 @@ export default function PoolWizard({
: "border-border text-text-muted hover:text-text-main"
}`}
>
{p === "hard" ? t("policyHard") : p === "soft" ? t("policySoft") : t("policyBurst")}
{p === "hard"
? t("policyHard")
: p === "soft"
? t("policySoft")
: t("policyBurst")}
</button>
))}
</div>
@@ -845,7 +860,7 @@ export default function PoolWizard({
value={a.weight}
onChange={(e) => updateWeight(a.apiKeyId, Number(e.target.value))}
className="px-2 py-1 rounded border border-border bg-bg-base text-sm text-right tabular-nums"
title="Weight %"
title={t("weightPercent")}
/>
<input
type="number"

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
import { useDisplayBaseUrl } from "@/shared/hooks";
@@ -62,7 +62,7 @@ export default function ApiEndpointsTab() {
className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-500 border border-blue-500/30"
title={t("badgeLoopbackTooltip")}
>
LOCAL
{t("badgeLocal")}
</span>
)}
{ep.alwaysProtected && (
@@ -70,7 +70,7 @@ export default function ApiEndpointsTab() {
className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-red-500/15 text-red-500 border border-red-500/30"
title={t("badgeAlwaysProtectedTooltip")}
>
PROTECTED
{t("badgeProtected")}
</span>
)}
{ep.internal && (
@@ -78,7 +78,7 @@ export default function ApiEndpointsTab() {
className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-gray-500/15 text-gray-400 border border-gray-500/30"
title={t("badgeInternalTooltip")}
>
INTERNAL
{t("badgeInternal")}
</span>
)}
</div>
@@ -109,7 +109,7 @@ export default function ApiEndpointsTab() {
const [useManualKey, setUseManualKey] = useState(false);
const selectedApiKey = availableApiKeys.find((apiKey) => apiKey.id === selectedApiKeyId) || null;
const loadCatalog = async () => {
const loadCatalog = useCallback(async () => {
try {
const res = await fetch("/api/openapi/spec");
if (res.ok) {
@@ -120,13 +120,13 @@ export default function ApiEndpointsTab() {
const message =
body && typeof body.error === "string"
? body.error
: `API catalog request failed with HTTP ${res.status}`;
: t("catalogLoadFailed", { status: res.status });
return { data: null, error: message };
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load API catalog";
const message = error instanceof Error ? error.message : t("catalogLoadFailedGeneric");
return { data: null, error: message };
}
};
}, [t]);
useEffect(() => {
let cancelled = false;
@@ -140,7 +140,7 @@ export default function ApiEndpointsTab() {
return () => {
cancelled = true;
};
}, []);
}, [loadCatalog]);
// Load API keys for Try It functionality. The list endpoint returns masked
// keys; the selected key is revealed only when sending a Try It request.
@@ -148,7 +148,7 @@ export default function ApiEndpointsTab() {
let cancelled = false;
fetch("/api/keys?limit=100", { credentials: "same-origin" })
.then(async (res) => {
if (!res.ok) throw new Error(`Failed to load API keys (${res.status})`);
if (!res.ok) throw new Error(t("apiKeysLoadFailed", { status: res.status }));
return res.json();
})
.then((data) => {
@@ -166,13 +166,15 @@ export default function ApiEndpointsTab() {
.catch((error) => {
if (!cancelled) {
setAvailableApiKeys([]);
setApiKeyLoadError(error instanceof Error ? error.message : "Failed to load API keys");
setApiKeyLoadError(
error instanceof Error ? error.message : t("apiKeysLoadFailedGeneric")
);
}
});
return () => {
cancelled = true;
};
}, []);
}, [t]);
// Filter endpoints
const filteredEndpoints = useMemo(() => {
@@ -245,13 +247,13 @@ export default function ApiEndpointsTab() {
if (!res.ok) {
throw new Error(
res.status === 403
? "API key reveal is disabled (ALLOW_API_KEY_REVEAL). Change it in the feature flag page or paste an API key manually."
: `Failed to reveal API key (${res.status})`
? t("apiKeyRevealDisabled")
: t("apiKeyRevealFailed", { status: res.status })
);
}
const data = await res.json();
if (!data?.key || typeof data.key !== "string") {
throw new Error("API key reveal returned an invalid response");
throw new Error(t("apiKeyRevealInvalid"));
}
setRevealedApiKeys((current) => ({ ...current, [selectedApiKey.id]: data.key }));
return data.key;
@@ -274,7 +276,7 @@ export default function ApiEndpointsTab() {
if (apiKeyForRequest) {
headers["Authorization"] = `Bearer ${apiKeyForRequest}`;
} else {
throw new Error("API key is required for this endpoint.");
throw new Error(t("apiKeyRequired"));
}
}
@@ -291,12 +293,12 @@ export default function ApiEndpointsTab() {
if (res.ok) setTryResult(await res.json());
else {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Request failed (${res.status})`);
throw new Error(err.error || t("requestFailed", { status: res.status }));
}
} catch (err: any) {
setTryResult({
status: 0,
statusText: "Error",
statusText: t("errorStatus"),
headers: {},
body: { error: err.message },
latencyMs: 0,
@@ -333,7 +335,10 @@ export default function ApiEndpointsTab() {
</span>
</div>
<p className="text-xs text-text-muted mt-0.5">
{catalog.endpoints.length} endpoints across {allTags.length} categories
{t("catalogStats", {
endpoints: catalog.endpoints.length,
categories: allTags.length,
})}
</p>
</div>
</div>
@@ -375,7 +380,7 @@ export default function ApiEndpointsTab() {
{t("apiEndpointsCatalogUnavailable")}
</h3>
<p className="text-xs text-text-muted mt-1">
{catalogError || "The OpenAPI specification could not be loaded."}
{catalogError || t("catalogUnavailableDescription")}
</p>
<a
href="/api/openapi/spec"
@@ -385,7 +390,7 @@ export default function ApiEndpointsTab() {
bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
Open JSON response
{t("openJsonResponse")}
</a>
</div>
</div>
@@ -421,7 +426,7 @@ export default function ApiEndpointsTab() {
: "bg-black/5 dark:bg-white/5 text-text-muted hover:text-text-main"
}`}
>
All
{t("all")}
</button>
{allTags.slice(0, 8).map((tag) => (
<button
@@ -439,7 +444,7 @@ export default function ApiEndpointsTab() {
))}
{allTags.length > 8 && (
<span className="px-2 py-1 text-[10px] text-text-muted">
+{allTags.length - 8} more
{t("more", { count: allTags.length - 8 })}
</span>
)}
</div>
@@ -475,7 +480,7 @@ export default function ApiEndpointsTab() {
? "bg-amber-500/10 text-amber-500"
: "bg-black/5 dark:bg-white/5 text-text-muted hover:text-text-main"
}`}
title="Show/hide internal routes (hidden by default)"
title={t("showInternalTooltip")}
>
{showInternal ? t("hideInternal") : t("showInternal")}
</button>
@@ -553,7 +558,7 @@ export default function ApiEndpointsTab() {
<span className="material-symbols-outlined text-[12px] text-amber-500">
lock
</span>
Bearer Auth
{t("bearerAuth")}
</span>
)}
{ep.requestBody && (
@@ -561,11 +566,11 @@ export default function ApiEndpointsTab() {
<span className="material-symbols-outlined text-[12px]">
description
</span>
Request Body
{t("requestBody")}
</span>
)}
<span className="flex items-center gap-1">
Responses: {ep.responses.join(", ")}
{t("responses")}: {ep.responses.join(", ")}
</span>
</div>
</div>
@@ -585,14 +590,14 @@ export default function ApiEndpointsTab() {
<span className="material-symbols-outlined text-[12px]">
{isTrying ? "close" : "play_arrow"}
</span>
{isTrying ? "Close" : "Try It"}
{isTrying ? t("close") : t("tryIt")}
</button>
</div>
{/* curl example */}
<div className="rounded-lg bg-black/5 dark:bg-black/30 p-3">
<p className="text-[9px] font-semibold text-text-muted uppercase tracking-wider mb-1">
Example
{t("example")}
</p>
<code className="text-[11px] font-mono text-text-main break-all">
curl -X {ep.method} {baseUrl}
@@ -611,14 +616,14 @@ export default function ApiEndpointsTab() {
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[9px] font-semibold text-text-muted uppercase tracking-wider">
API Key
{t("apiKey")}
</label>
<button
type="button"
onClick={() => setUseManualKey(!useManualKey)}
className="text-[9px] text-primary hover:underline"
>
{useManualKey ? "Switch to Selection" : "Enter Manually"}
{useManualKey ? t("switchToSelection") : t("enterManually")}
</button>
</div>
@@ -627,7 +632,7 @@ export default function ApiEndpointsTab() {
type="password"
value={manualApiKey}
onChange={(e) => setManualApiKey(e.target.value)}
placeholder="Paste your API key here"
placeholder={t("pasteApiKey")}
className="w-full px-3 py-2 text-xs font-mono rounded-lg border border-black/10
dark:border-white/10 bg-white dark:bg-black/30 focus:outline-none
focus:ring-1 focus:ring-primary"
@@ -648,8 +653,7 @@ export default function ApiEndpointsTab() {
</select>
) : (
<p className="text-[11px] text-amber-500">
{apiKeyLoadError ||
"No active API keys found. Toggle manual entry to paste one."}
{apiKeyLoadError || t("noActiveApiKeys")}
</p>
)}
</div>
@@ -657,7 +661,7 @@ export default function ApiEndpointsTab() {
{ep.method !== "GET" && (
<div>
<label className="text-[9px] font-semibold text-text-muted uppercase tracking-wider">
Request Body (JSON)
{t("requestBodyJson")}
</label>
<textarea
value={tryBody}
@@ -679,7 +683,7 @@ export default function ApiEndpointsTab() {
<span className="material-symbols-outlined text-[14px]">
{trying ? "hourglass_empty" : "send"}
</span>
{trying ? "Sending..." : "Send Request"}
{trying ? t("sending") : t("sendRequest")}
</button>
{tryResult && (
@@ -733,7 +737,7 @@ export default function ApiEndpointsTab() {
data_object
</span>
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
Data Schemas
{t("dataSchemas")}
</h3>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-black/5 dark:bg-white/5 text-text-muted">
{catalog.schemas.length}

View File

@@ -110,11 +110,11 @@ type EndpointTunnelVisibility = {
type EndpointTab = "apis" | "mcp" | "a2a" | "context-sources";
const ENDPOINT_TABS: Array<{ value: EndpointTab; label: string; icon: string }> = [
{ value: "apis", label: "APIs", icon: "api" },
{ value: "mcp", label: "MCP", icon: "extension" },
{ value: "a2a", label: "A2A", icon: "hub" },
{ value: "context-sources", label: "Context Sources", icon: "database" },
const ENDPOINT_TABS: Array<{ value: EndpointTab; labelKey: string; icon: string }> = [
{ value: "apis", labelKey: "tabApis", icon: "api" },
{ value: "mcp", labelKey: "tabMcp", icon: "extension" },
{ value: "a2a", labelKey: "tabA2a", icon: "hub" },
{ value: "context-sources", labelKey: "tabContextSources", icon: "database" },
];
const DEFAULT_TUNNEL_VISIBILITY: EndpointTunnelVisibility = {
@@ -1113,9 +1113,9 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
const activeUrls = [
...activeTunnelUrls,
...(cloudEnabled && cloudEndpointNew
? [{ label: "Cloud", url: cloudEndpointNew, key: "active_cloud" }]
? [{ label: t("activeCloud"), url: cloudEndpointNew, key: "active_cloud" }]
: []),
{ label: "Local", url: localApiUrl, key: "active_local" },
{ label: t("activeLocal"), url: localApiUrl, key: "active_local" },
].filter(
(candidate, index, candidates) =>
candidates.findIndex((other) => other.url === candidate.url) === index
@@ -1248,10 +1248,10 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
return (
<div className="flex flex-col gap-8">
<SegmentedControl
options={ENDPOINT_TABS}
options={ENDPOINT_TABS.map((tab) => ({ ...tab, label: t(tab.labelKey) }))}
value={activeEndpointTab}
onChange={(value) => setActiveEndpointTab(value as EndpointTab)}
aria-label="Endpoint sections"
aria-label={t("endpointSections")}
className="w-fit"
/>
@@ -1300,7 +1300,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
{activeUrls.length > 0 && (
<div className="mb-4 rounded-lg border border-primary/20 bg-primary/5 p-3">
<p className="text-[10px] font-semibold text-primary uppercase tracking-wider mb-2">
Active Endpoints
{t("activeEndpoints")}
</p>
<div className="flex flex-col gap-1.5">
{activeUrls.map(({ label, url, key }) => (
@@ -1341,7 +1341,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
<button
key={url}
onClick={() => void copy(url, `lan_${url}`)}
title={`Copy ${url}`}
title={t("copyUrlTitle", { url })}
className="inline-flex items-center gap-0.5 text-[10px] text-text-muted hover:text-text transition-colors"
>
<code className="font-mono">{url.replace(/^https?:\/\//, "")}</code>
@@ -1354,7 +1354,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
</div>
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/10 border border-green-500/30 text-green-400 shrink-0">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />
Running
{t("statusRunning")}
</span>
<button
onClick={() => void copy(localApiUrl, "endpoint_url")}
@@ -1373,11 +1373,14 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
network_node
</span>
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-wider">
Tunnels
{t("tunnels")}
</span>
<div className="flex-1 h-px bg-border/50" />
<span className="text-[10px] text-text-muted">
{activeTunnelCount} / {visibleTunnelCount} active
{t("activeTunnelCount", {
active: activeTunnelCount,
total: visibleTunnelCount,
})}
</span>
</div>
@@ -1399,7 +1402,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
<span
className={`w-1.5 h-1.5 rounded-full shrink-0 ${cloudEnabled ? "bg-green-400 animate-pulse" : "bg-text-muted"}`}
/>
{cloudEnabled ? "Active" : "Disabled"}
{cloudEnabled ? tc("active") : tc("disabled")}
</span>
{cloudEnabled ? (
<Button
@@ -1425,7 +1428,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
</Button>
) : (
<span className="text-xs text-text-muted shrink-0 px-2 py-1 rounded border border-border/70 bg-surface">
Not configured
{tc("notConfigured")}
</span>
)}
</div>

View File

@@ -63,7 +63,7 @@ export default function ObsidianSourceCard() {
const handleSaveToken = async () => {
if (!token.trim()) {
setMessage({ type: "error", text: "Please enter an Obsidian API token" });
setMessage({ type: "error", text: t("obsidianEnterToken") });
return;
}
setBusy(true);
@@ -83,11 +83,14 @@ export default function ObsidianSourceCard() {
setConnected(true);
setMessage({ type: "success", text: data.message });
} else {
setMessage({ type: "error", text: data.error ?? "Failed to connect" });
setMessage({ type: "error", text: data.error ?? t("obsidianConnectFailed") });
setConnected(false);
}
} catch (err) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "Connection failed" });
setMessage({
type: "error",
text: err instanceof Error ? err.message : t("obsidianConnectionFailed"),
});
} finally {
setBusy(false);
}
@@ -105,10 +108,13 @@ export default function ObsidianSourceCard() {
setBaseUrl(DEFAULT_URL);
setMessage({ type: "success", text: data.message });
} else {
setMessage({ type: "error", text: data.error ?? "Failed to disconnect" });
setMessage({ type: "error", text: data.error ?? t("obsidianDisconnectFailed") });
}
} catch (err) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "Disconnect failed" });
setMessage({
type: "error",
text: err instanceof Error ? err.message : t("obsidianDisconnectFailed"),
});
} finally {
setBusy(false);
}
@@ -116,7 +122,7 @@ export default function ObsidianSourceCard() {
const handleEnableWebdav = async () => {
if (!vaultPath.trim()) {
setMessage({ type: "error", text: "Please enter the vault directory path" });
setMessage({ type: "error", text: t("obsidianEnterVaultPath") });
return;
}
setWebdavBusy(true);
@@ -132,12 +138,15 @@ export default function ObsidianSourceCard() {
setWebdavEnabled(true);
setWebdavUsername(data.username);
setWebdavPassword(data.password);
setMessage({ type: "success", text: "WebDAV sync enabled. Configure your mobile device below." });
setMessage({ type: "success", text: t("obsidianWebdavEnabledMessage") });
} else {
setMessage({ type: "error", text: data.error ?? "Failed to enable WebDAV" });
setMessage({ type: "error", text: data.error ?? t("obsidianEnableWebdavFailed") });
}
} catch (err) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "Failed to enable WebDAV" });
setMessage({
type: "error",
text: err instanceof Error ? err.message : t("obsidianEnableWebdavFailed"),
});
} finally {
setWebdavBusy(false);
}
@@ -153,12 +162,15 @@ export default function ObsidianSourceCard() {
setWebdavEnabled(false);
setWebdavUsername(null);
setWebdavPassword(null);
setMessage({ type: "success", text: "WebDAV sync disabled" });
setMessage({ type: "success", text: t("obsidianWebdavDisabledMessage") });
} else {
setMessage({ type: "error", text: data.error ?? "Failed to disable WebDAV" });
setMessage({ type: "error", text: data.error ?? t("obsidianDisableWebdavFailed") });
}
} catch (err) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "Failed to disable WebDAV" });
setMessage({
type: "error",
text: err instanceof Error ? err.message : t("obsidianDisableWebdavFailed"),
});
} finally {
setWebdavBusy(false);
}
@@ -180,25 +192,32 @@ export default function ObsidianSourceCard() {
className="w-full flex items-center gap-3 text-left"
>
<div className="flex items-center justify-center size-10 rounded-lg bg-purple-500/10 shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#C084FC">
<path d="M19.355 18.538a68.967 68.959 0 0 0 1.858-2.954.81.81 0 0 0-.062-.9c-.516-.685-1.504-2.075-2.042-3.362-.553-1.321-.636-3.375-.64-4.377a1.707 1.707 0 0 0-.358-1.05l-3.198-4.064a3.744 3.744 0 0 1-.076.543c-.106.503-.307 1.004-.536 1.5-.134.29-.29.6-.446.914l-.31.626c-.516 1.068-.997 2.227-1.132 3.59-.124 1.26.046 2.73.815 4.481.128.011.257.025.386.044a6.363 6.363 0 0 1 3.326 1.505c.916.79 1.744 1.922 2.415 3.5zM8.199 22.569c.073.012.146.02.22.02.78.024 2.095.092 3.16.29.87.16 2.593.64 4.01 1.055 1.083.316 2.198-.548 2.355-1.664.114-.814.33-1.735.725-2.58l-.01.005c-.67-1.87-1.522-3.078-2.416-3.849a5.295 5.295 0 0 0-2.778-1.257c-1.54-.216-2.952.19-3.84.45.532 2.218.368 4.829-1.425 7.531zM5.533 9.938c-.023.1-.056.197-.098.29L2.82 16.059a1.602 1.602 0 0 0 .313 1.772l4.116 4.24c2.103-3.101 1.796-6.02.836-8.3-.728-1.73-1.832-3.081-2.55-3.831zM9.32 14.01c.615-.183 1.606-.465 2.745-.534-.683-1.725-.848-3.233-.716-4.577.154-1.552.7-2.847 1.235-3.95.113-.235.223-.454.328-.664.149-.297.288-.577.419-.86.217-.47.379-.885.46-1.27.08-.38.08-.72-.014-1.043-.095-.325-.297-.675-.68-1.06a1.6 1.6 0 0 0-1.475.36l-4.95 4.452a1.602 1.602 0 0 0-.513.952l-.427 2.83c.672.59 2.328 2.316 3.335 4.711.09.21.175.43.253.653z"/>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="#C084FC"
>
<path d="M19.355 18.538a68.967 68.959 0 0 0 1.858-2.954.81.81 0 0 0-.062-.9c-.516-.685-1.504-2.075-2.042-3.362-.553-1.321-.636-3.375-.64-4.377a1.707 1.707 0 0 0-.358-1.05l-3.198-4.064a3.744 3.744 0 0 1-.076.543c-.106.503-.307 1.004-.536 1.5-.134.29-.29.6-.446.914l-.31.626c-.516 1.068-.997 2.227-1.132 3.59-.124 1.26.046 2.73.815 4.481.128.011.257.025.386.044a6.363 6.363 0 0 1 3.326 1.505c.916.79 1.744 1.922 2.415 3.5zM8.199 22.569c.073.012.146.02.22.02.78.024 2.095.092 3.16.29.87.16 2.593.64 4.01 1.055 1.083.316 2.198-.548 2.355-1.664.114-.814.33-1.735.725-2.58l-.01.005c-.67-1.87-1.522-3.078-2.416-3.849a5.295 5.295 0 0 0-2.778-1.257c-1.54-.216-2.952.19-3.84.45.532 2.218.368 4.829-1.425 7.531zM5.533 9.938c-.023.1-.056.197-.098.29L2.82 16.059a1.602 1.602 0 0 0 .313 1.772l4.116 4.24c2.103-3.101 1.796-6.02.836-8.3-.728-1.73-1.832-3.081-2.55-3.831zM9.32 14.01c.615-.183 1.606-.465 2.745-.534-.683-1.725-.848-3.233-.716-4.577.154-1.552.7-2.847 1.235-3.95.113-.235.223-.454.328-.664.149-.297.288-.577.419-.86.217-.47.379-.885.46-1.27.08-.38.08-.72-.014-1.043-.095-.325-.297-.675-.68-1.06a1.6 1.6 0 0 0-1.475.36l-4.95 4.452a1.602 1.602 0 0 0-.513.952l-.427 2.83c.672.59 2.328 2.316 3.335 4.711.09.21.175.43.253.653z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm">Obsidian</span>
<Badge variant={connected ? "success" : "default"}>
{connected ? "Connected" : "Not connected"}
{connected ? t("obsidianConnected") : t("obsidianNotConnected")}
</Badge>
{webdavEnabled && (
<Badge variant="success" className="bg-blue-500/20 text-blue-400 border-blue-500/30">
WebDAV Sync
<Badge
variant="success"
className="bg-blue-500/20 text-blue-400 border-blue-500/30"
>
{t("obsidianWebdavSync")}
</Badge>
)}
</div>
<p className="text-xs text-text-muted mt-0.5">
Search, read, write, and manage notes in Obsidian through routed AI models
</p>
<p className="text-xs text-text-muted mt-0.5">{t("obsidianDescription")}</p>
</div>
<span
className={`material-symbols-outlined text-text-muted text-lg transition-transform ${expanded ? "rotate-180" : ""}`}
@@ -227,23 +246,23 @@ export default function ObsidianSourceCard() {
{!connected ? (
<div className="flex flex-col gap-2">
<label className="text-xs text-text-muted font-medium">
Obsidian Local REST API Token
{t("obsidianRestToken")}
</label>
<div className="flex gap-2">
<Input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Obsidian API key"
placeholder={t("obsidianApiKeyPlaceholder")}
disabled={busy}
className="font-mono text-sm flex-1"
/>
<Button onClick={handleSaveToken} loading={busy} variant="primary" size="sm">
Connect
{t("obsidianConnect")}
</Button>
</div>
<label className="text-xs text-text-muted font-medium mt-1">
Base URL (optional)
{t("obsidianBaseUrlOptional")}
</label>
<Input
type="text"
@@ -256,23 +275,18 @@ export default function ObsidianSourceCard() {
{baseUrl.includes(":27124") && (
<div className="flex items-center gap-1.5 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-2.5 py-1.5 text-[10px] text-yellow-300">
<span className="material-symbols-outlined text-[14px]">warning</span>
<span>
Port 27124 is the MCP endpoint (HTTPS, self-signed cert).
{" "}The REST API uses HTTP on port 27123.
</span>
<span>{t("obsidianPortWarning")}</span>
</div>
)}
<p className="text-[10px] text-text-muted">
Default: {DEFAULT_URL}. For remote vaults, enter the Tailscale IP +
{" "}port (e.g., http://100.x.x.x:27123). Enable the Local REST API
{" "}plugin on the machine running Obsidian.
{t("obsidianRemoteVaultHint", { defaultUrl: DEFAULT_URL })}
</p>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted flex-1">
Token configured. Obsidian tools are available via MCP.
{t("obsidianTokenConfigured")}
</span>
<Button
onClick={handleDisconnect}
@@ -281,23 +295,22 @@ export default function ObsidianSourceCard() {
size="sm"
className="border-red-500/30! text-red-400! hover:bg-red-500/10!"
>
Disconnect
{t("obsidianDisconnect")}
</Button>
</div>
<div className="border-t border-border/50 pt-3 flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted font-medium">Vault Sync (WebDAV)</span>
<span className="text-xs text-text-muted font-medium">
{t("obsidianVaultSync")}
</span>
</div>
<p className="text-[10px] text-text-muted">
Sync your vault to Obsidian mobile using WebDAV over Tailscape.
Obsidian mobile has built-in WebDAV support no plugins needed.
</p>
<p className="text-[10px] text-text-muted">{t("obsidianVaultSyncDescription")}</p>
{!webdavEnabled ? (
<div className="flex flex-col gap-2">
<label className="text-xs text-text-muted font-medium">
Vault Directory Path
{t("obsidianVaultDirectoryPath")}
</label>
<div className="flex gap-2">
<Input
@@ -314,17 +327,23 @@ export default function ObsidianSourceCard() {
variant="primary"
size="sm"
>
Enable
{t("obsidianEnable")}
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 rounded-lg border border-blue-500/30 bg-blue-500/10 px-3 py-2">
<span className="material-symbols-outlined text-[18px] text-blue-400">cloud_sync</span>
<span className="material-symbols-outlined text-[18px] text-blue-400">
cloud_sync
</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-blue-300 font-medium">WebDAV sync enabled</p>
<p className="text-[10px] text-blue-400/70 font-mono truncate">{getWebdavUrl()}</p>
<p className="text-xs text-blue-300 font-medium">
{t("obsidianWebdavEnabled")}
</p>
<p className="text-[10px] text-blue-400/70 font-mono truncate">
{getWebdavUrl()}
</p>
</div>
<Button
onClick={handleDisableWebdav}
@@ -333,18 +352,22 @@ export default function ObsidianSourceCard() {
size="sm"
className="border-red-500/30! text-red-400! hover:bg-red-500/10! shrink-0"
>
Disable
{t("obsidianDisable")}
</Button>
</div>
<div className="flex flex-col gap-2 rounded-lg border border-border/50 bg-black/10 p-3">
<p className="text-[11px] text-text-muted font-medium">Configure Obsidian Mobile</p>
<p className="text-[11px] text-text-muted font-medium">
{t("obsidianConfigureMobile")}
</p>
<p className="text-[10px] text-text-muted">
In Obsidian mobile: Settings Sync WebDAV enter the following:
{t("obsidianMobileInstructions")}
</p>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] text-text-muted font-medium">WebDAV URL</label>
<label className="text-[10px] text-text-muted font-medium">
{t("obsidianWebdavUrl")}
</label>
<div className="flex items-center gap-1.5 rounded border border-border/30 bg-black/20 px-2.5 py-1.5">
<code className="text-[10px] text-text-muted font-mono flex-1 break-all select-all">
{getWebdavUrl()}
@@ -353,7 +376,9 @@ export default function ObsidianSourceCard() {
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] text-text-muted font-medium">Username</label>
<label className="text-[10px] text-text-muted font-medium">
{t("obsidianUsername")}
</label>
<div className="flex items-center gap-1.5 rounded border border-border/30 bg-black/20 px-2.5 py-1.5">
<code className="text-[10px] text-text-muted font-mono flex-1 select-all">
{webdavUsername ?? "—"}
@@ -362,7 +387,9 @@ export default function ObsidianSourceCard() {
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] text-text-muted font-medium">Password</label>
<label className="text-[10px] text-text-muted font-medium">
{t("obsidianPassword")}
</label>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 rounded border border-border/30 bg-black/20 px-2.5 py-1.5 flex-1">
<code className="text-[10px] text-text-muted font-mono flex-1 select-all">
@@ -382,17 +409,13 @@ export default function ObsidianSourceCard() {
</div>
</div>
<p className="text-[10px] text-text-muted">
Use your Tailscale IP instead of localhost if connecting from mobile.
{" "}Both devices must be on the same Tailscale network.
</p>
<p className="text-[10px] text-text-muted">{t("obsidianTailscaleHint")}</p>
</div>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
import { getProviderDisplayName } from "@/lib/display/names";
import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap";
@@ -20,9 +21,11 @@ type AutopilotAction = {
type AutopilotIssue = {
id: string;
kind: string;
severity: "info" | "warning" | "critical";
title: string;
recommendation: string;
target: AutopilotAction["target"];
evidence?: Record<string, unknown>;
actions: AutopilotAction[];
};
@@ -85,21 +88,144 @@ function getErrorMessage(payload: unknown, fallback: string): string {
return fallback;
}
function formatConnectionEvidence(issue: AutopilotIssue): string | null {
function formatConnectionEvidence(
issue: AutopilotIssue,
t: ReturnType<typeof useTranslations>
): string | null {
const evidence = issue.evidence || {};
const parts: string[] = [];
if (typeof evidence.label === "string") parts.push(evidence.label);
if (typeof evidence.remainingMs === "number" && evidence.remainingMs > 0) {
parts.push(`remaining ${Math.ceil(evidence.remainingMs / 1000)}s`);
parts.push(t("remainingSeconds", { seconds: Math.ceil(evidence.remainingMs / 1000) }));
}
if (typeof evidence.errorCode === "string" || typeof evidence.errorCode === "number") {
parts.push(`code ${evidence.errorCode}`);
parts.push(t("errorCode", { code: String(evidence.errorCode) }));
}
if (typeof evidence.lastErrorType === "string") parts.push(evidence.lastErrorType);
return parts.length > 0 ? parts.join(" · ") : null;
}
function issueText(
issue: AutopilotIssue,
field: "title" | "recommendation",
t: ReturnType<typeof useTranslations>
) {
const label = typeof issue.evidence?.label === "string" ? issue.evidence.label : "";
const model = issue.target.model ?? "";
const status = typeof issue.evidence?.status === "string" ? issue.evidence.status : "";
const keys: Record<string, { title: string; recommendation: string }> = {
provider_circuit_open: {
title: "issue.circuitOpenTitle",
recommendation: "issue.circuitOpenRecommendation",
},
provider_circuit_half_open: {
title: "issue.circuitRecoveryTitle",
recommendation: "issue.circuitRecoveryRecommendation",
},
terminal_connection_error: {
title: "issue.terminalTitle",
recommendation: "issue.terminalRecommendation",
},
connection_cooldown: {
title: "issue.cooldownTitle",
recommendation: "issue.cooldownRecommendation",
},
stale_connection_error: {
title: "issue.staleErrorTitle",
recommendation: "issue.staleErrorRecommendation",
},
inactive_connection: {
title: "issue.inactiveTitle",
recommendation: "issue.inactiveRecommendation",
},
model_lockout: {
title: "issue.modelLockoutTitle",
recommendation: "issue.modelLockoutRecommendation",
},
quota_monitor_warning: {
title: "issue.quotaTitle",
recommendation: "issue.quotaRecommendation",
},
};
const key = keys[issue.kind]?.[field];
return key ? t(key, { label, model, status }) : issue[field];
}
function actionLabel(action: AutopilotAction, t: ReturnType<typeof useTranslations>) {
const keys: Record<string, string> = {
clear_provider_breaker: "action.resetProviderBreaker",
clear_connection_cooldown: "action.clearConnectionCooldown",
deactivate_connection: "action.disableConnection",
clear_stale_connection_error: "action.clearStaleError",
reactivate_connection: "action.reactivateConnection",
clear_model_lockout: "action.clearModelLockout",
};
return keys[action.type] ? t(keys[action.type]) : action.label;
}
function ProviderIssues({
issues,
busyAction,
onApply,
}: {
issues: AutopilotIssue[];
busyAction: string | null;
onApply: (issue: AutopilotIssue, action: AutopilotAction) => Promise<void>;
}) {
const t = useTranslations("providerHealthAutopilot");
const orderedIssues = [...issues]
.sort((left, right) => SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity])
.slice(0, 4);
return (
<div className="mt-3 space-y-2">
{orderedIssues.map((issue) => {
const evidence = formatConnectionEvidence(issue, t);
return (
<div key={issue.id} className="rounded-lg border border-border bg-surface p-3">
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span
className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${SEVERITY_STYLES[issue.severity]}`}
>
{t(`severity.${issue.severity}`)}
</span>
<p className="text-sm font-medium text-text-main">
{issueText(issue, "title", t)}
</p>
</div>
<p className="mt-1 text-xs text-text-muted">
{issueText(issue, "recommendation", t)}
</p>
{evidence && <p className="mt-1 text-xs text-text-muted">{evidence}</p>}
</div>
{issue.actions.length > 0 && (
<div className="flex flex-wrap gap-2 lg:justify-end">
{issue.actions.map((action) => {
const busy = busyAction === `${issue.id}:${action.type}`;
return (
<button
key={`${issue.id}:${action.type}`}
onClick={() => void onApply(issue, action)}
disabled={busy || Boolean(busyAction)}
className="rounded-lg border border-primary/30 bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20 disabled:opacity-50"
>
{busy ? t("applying") : actionLabel(action, t)}
</button>
);
})}
</div>
)}
</div>
</div>
);
})}
</div>
);
}
export default function ProviderHealthAutopilotCard() {
const t = useTranslations("providerHealthAutopilot");
const nodeMap = useProviderNodeMap();
const [report, setReport] = useState<AutopilotReport | null>(null);
const [loading, setLoading] = useState(true);
@@ -117,11 +243,11 @@ export default function ProviderHealthAutopilotCard() {
setReport(json);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load autopilot report");
setError(err instanceof Error ? err.message : t("loadFailed"));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
void load();
@@ -137,7 +263,12 @@ export default function ProviderHealthAutopilotCard() {
const applyAction = useCallback(
async (issue: AutopilotIssue, action: AutopilotAction) => {
if (action.requiresConfirmation && !confirm(`${action.label}?\n\n${issue.recommendation}`)) {
const localizedAction = actionLabel(action, t);
const localizedRecommendation = issueText(issue, "recommendation", t);
if (
action.requiresConfirmation &&
!confirm(`${localizedAction}?\n\n${localizedRecommendation}`)
) {
return;
}
@@ -156,15 +287,15 @@ export default function ProviderHealthAutopilotCard() {
});
const json = await res.json();
if (!res.ok) throw new Error(getErrorMessage(json, `HTTP ${res.status}`));
setMessage(`${action.label} applied.`);
setMessage(t("actionApplied", { action: localizedAction }));
await load();
} catch (err) {
setMessage(err instanceof Error ? err.message : "Autopilot action failed");
setMessage(err instanceof Error ? err.message : t("actionFailed"));
} finally {
setBusyAction(null);
}
},
[load]
[load, t]
);
return (
@@ -176,10 +307,8 @@ export default function ProviderHealthAutopilotCard() {
<span className="material-symbols-outlined text-[18px]">health_and_safety</span>
</div>
<div>
<h2 className="text-lg font-semibold text-text-main">Provider Health Autopilot</h2>
<p className="text-sm text-text-muted">
Finds unstable providers, account cooldowns, stale errors, and safe manual fixes.
</p>
<h2 className="text-lg font-semibold text-text-main">{t("title")}</h2>
<p className="text-sm text-text-muted">{t("description")}</p>
</div>
</div>
</div>
@@ -188,7 +317,7 @@ export default function ProviderHealthAutopilotCard() {
disabled={loading}
className="rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main transition-colors hover:bg-surface/80 disabled:opacity-50"
>
Refresh
{t("refresh")}
</button>
</div>
@@ -196,21 +325,23 @@ export default function ProviderHealthAutopilotCard() {
<div
className={`rounded-xl border px-3 py-2 ${STATUS_STYLES[report?.status || "healthy"]}`}
>
<p className="text-xs uppercase tracking-wide opacity-80">Status</p>
<p className="text-lg font-semibold capitalize">{report?.status || "loading"}</p>
<p className="text-xs uppercase tracking-wide opacity-80">{t("status")}</p>
<p className="text-lg font-semibold capitalize">
{t(`state.${report?.status || "loading"}`)}
</p>
</div>
<div className="rounded-xl border border-border bg-bg-subtle px-3 py-2">
<p className="text-xs uppercase tracking-wide text-text-muted">Issues</p>
<p className="text-xs uppercase tracking-wide text-text-muted">{t("issues")}</p>
<p className="text-lg font-semibold text-text-main">{report?.summary.issueCount ?? 0}</p>
</div>
<div className="rounded-xl border border-border bg-bg-subtle px-3 py-2">
<p className="text-xs uppercase tracking-wide text-text-muted">Actions</p>
<p className="text-xs uppercase tracking-wide text-text-muted">{t("actions")}</p>
<p className="text-lg font-semibold text-text-main">
{report?.summary.actionableCount ?? 0}
</p>
</div>
<div className="rounded-xl border border-border bg-bg-subtle px-3 py-2">
<p className="text-xs uppercase tracking-wide text-text-muted">Connections</p>
<p className="text-xs uppercase tracking-wide text-text-muted">{t("connections")}</p>
<p className="text-lg font-semibold text-text-main">
{report?.summary.connectionCount ?? 0}
</p>
@@ -228,11 +359,9 @@ export default function ProviderHealthAutopilotCard() {
{error}
</div>
) : loading && !report ? (
<p className="mt-4 text-sm text-text-muted">Loading provider recommendations...</p>
<p className="mt-4 text-sm text-text-muted">{t("loadingRecommendations")}</p>
) : topProviders.length === 0 ? (
<p className="mt-4 text-sm text-text-muted">
No provider health recommendations right now.
</p>
<p className="mt-4 text-sm text-text-muted">{t("noRecommendations")}</p>
) : (
<div className="mt-4 space-y-3">
{topProviders.map((provider) => (
@@ -246,10 +375,13 @@ export default function ProviderHealthAutopilotCard() {
{resolveProviderName(provider.provider, nodeMap)}
</h3>
<p className="text-xs text-text-muted">
score {(provider.score * 100).toFixed(0)}% · active{" "}
{provider.signals.connections.active}/{provider.signals.connections.total} ·{" "}
cooldown {provider.signals.connections.cooldown} · model lockouts{" "}
{provider.signals.modelLockouts}
{t("providerMetrics", {
score: (provider.score * 100).toFixed(0),
active: provider.signals.connections.active,
total: provider.signals.connections.total,
cooldown: provider.signals.connections.cooldown,
lockouts: provider.signals.modelLockouts,
})}
</p>
</div>
<span
@@ -261,56 +393,15 @@ export default function ProviderHealthAutopilotCard() {
: "border-green-500/20 bg-green-500/10 text-green-300"
}`}
>
{provider.state}
{t(`providerState.${provider.state}`)}
</span>
</div>
<div className="mt-3 space-y-2">
{[...provider.issues]
.sort(
(left, right) => SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity]
)
.slice(0, 4)
.map((issue) => (
<div key={issue.id} className="rounded-lg border border-border bg-surface p-3">
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span
className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${SEVERITY_STYLES[issue.severity]}`}
>
{issue.severity}
</span>
<p className="text-sm font-medium text-text-main">{issue.title}</p>
</div>
<p className="mt-1 text-xs text-text-muted">{issue.recommendation}</p>
{formatConnectionEvidence(issue) && (
<p className="mt-1 text-xs text-text-muted">
{formatConnectionEvidence(issue)}
</p>
)}
</div>
{issue.actions.length > 0 && (
<div className="flex flex-wrap gap-2 lg:justify-end">
{issue.actions.map((action) => {
const busy = busyAction === `${issue.id}:${action.type}`;
return (
<button
key={`${issue.id}:${action.type}`}
onClick={() => void applyAction(issue, action)}
disabled={busy || Boolean(busyAction)}
className="rounded-lg border border-primary/30 bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20 disabled:opacity-50"
>
{busy ? "Applying..." : action.label}
</button>
);
})}
</div>
)}
</div>
</div>
))}
</div>
<ProviderIssues
issues={provider.issues}
busyAction={busyAction}
onApply={applyAction}
/>
</div>
))}
</div>

View File

@@ -18,7 +18,7 @@ import { AI_PROVIDERS } from "@/shared/constants/providers";
import { getProviderDisplayName } from "@/lib/display/names";
import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap";
import { compareTr } from "@/shared/utils/turkishText";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import TelemetryCard from "./TelemetryCard";
import ProviderHealthAutopilotCard from "./ProviderHealthAutopilotCard";
import ProviderHealthMatrixCard from "./ProviderHealthMatrixCard";
@@ -57,6 +57,7 @@ const CB_STYLES = {
};
export default function HealthPage() {
const locale = useLocale();
const t = useTranslations("health");
const tc = useTranslations("common");
const tp = useTranslations("providers");
@@ -112,15 +113,20 @@ export default function HealthPage() {
}, []);
useEffect(() => {
fetchHealth();
fetchExtras();
fetchDbHealth();
const initialFetch = setTimeout(() => {
void fetchHealth();
void fetchExtras();
void fetchDbHealth();
}, 0);
const interval = setInterval(() => {
fetchHealth();
fetchExtras();
fetchDbHealth();
void fetchHealth();
void fetchExtras();
void fetchDbHealth();
}, 15000);
return () => clearInterval(interval);
return () => {
clearTimeout(initialFetch);
clearInterval(interval);
};
}, [fetchHealth, fetchExtras, fetchDbHealth]);
const handleResetHealth = async () => {
@@ -206,7 +212,7 @@ export default function HealthPage() {
<div className="flex items-center justify-end gap-3">
{lastRefresh && (
<span className="text-xs text-text-muted">
{t("updatedAt", { time: lastRefresh.toLocaleTimeString() })}
{t("updatedAt", { time: lastRefresh.toLocaleTimeString(locale) })}
</span>
)}
<button
@@ -265,30 +271,28 @@ export default function HealthPage() {
</div>
<div>
<h2 className="text-lg font-semibold text-text-main">{t("databaseHealth")}</h2>
<p className="text-sm text-text-muted">
Diagnose and repair stale quota/domain rows and broken combo references.
</p>
<p className="text-sm text-text-muted">{t("databaseHealthDescription")}</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-4">
<div className="rounded-xl border border-border bg-surface/50 p-3">
<p className="text-xs uppercase tracking-wide text-text-muted">Status</p>
<p className="text-xs uppercase tracking-wide text-text-muted">{t("status")}</p>
<p
className={`mt-1 text-sm font-medium ${
dbHealth?.isHealthy ? "text-green-400" : "text-amber-400"
}`}
>
{dbHealth?.isHealthy ? "Healthy" : "Attention needed"}
{dbHealth?.isHealthy ? t("healthy") : t("attentionNeeded")}
</p>
</div>
<div className="rounded-xl border border-border bg-surface/50 p-3">
<p className="text-xs uppercase tracking-wide text-text-muted">Issues</p>
<p className="text-xs uppercase tracking-wide text-text-muted">{t("issues")}</p>
<p className="mt-1 text-sm font-medium text-text-main">
{dbHealth?.issues?.length ?? 0}
</p>
</div>
<div className="rounded-xl border border-border bg-surface/50 p-3">
<p className="text-xs uppercase tracking-wide text-text-muted">Repairs</p>
<p className="text-xs uppercase tracking-wide text-text-muted">{t("repairs")}</p>
<p className="mt-1 text-sm font-medium text-text-main">
{dbHealth?.repairedCount ?? 0}
</p>
@@ -301,12 +305,10 @@ export default function HealthPage() {
disabled={repairingDb}
className="px-4 py-2 rounded-lg bg-primary/10 text-primary text-sm hover:bg-primary/20 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{repairingDb ? "Repairing..." : "Run Auto-Repair"}
{repairingDb ? t("repairing") : t("runAutoRepair")}
</button>
{dbHealth?.backupCreated && (
<p className="text-xs text-text-muted">
A repair backup was created before mutating.
</p>
<p className="text-xs text-text-muted">{t("repairBackupCreated")}</p>
)}
{dbHealthError && <p className="text-xs text-red-400">{dbHealthError}</p>}
</div>
@@ -420,9 +422,11 @@ export default function HealthPage() {
<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">groups</span>
Session Activity
{t("sessionActivity")}
</h2>
<span className="text-xs text-text-muted">{sessions?.activeCount ?? 0} active</span>
<span className="text-xs text-text-muted">
{t("activeCount", { count: sessions?.activeCount ?? 0 })}
</span>
</div>
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
@@ -450,13 +454,15 @@ export default function HealthPage() {
{session.sessionId}
</div>
<div className="text-xs text-text-muted mt-1">
{session.requestCount} requests
{t("requestCount", { count: session.requestCount })}
{session.connectionId ? `${session.connectionId.slice(0, 8)}` : ""}
</div>
</div>
<div className="text-right text-xs text-text-muted shrink-0">
<div>{Math.round((session.idleMs || 0) / 1000)}s idle</div>
<div>{Math.round((session.ageMs || 0) / 1000)}s age</div>
<div>
{t("idleSeconds", { count: Math.round((session.idleMs || 0) / 1000) })}
</div>
<div>{t("ageSeconds", { count: Math.round((session.ageMs || 0) / 1000) })}</div>
</div>
</div>
))}
@@ -470,31 +476,33 @@ export default function HealthPage() {
<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">radar</span>
Quota Monitors
{t("quotaMonitors")}
</h2>
<span className="text-xs text-text-muted">{quotaMonitor?.active ?? 0} active</span>
<span className="text-xs text-text-muted">
{t("activeCount", { count: quotaMonitor?.active ?? 0 })}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
<div className="text-xs text-text-muted">Alerting</div>
<div className="text-xs text-text-muted">{t("alerting")}</div>
<div className="text-2xl font-semibold text-amber-400 mt-1">
{quotaMonitor?.alerting ?? 0}
</div>
</div>
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
<div className="text-xs text-text-muted">Exhausted</div>
<div className="text-xs text-text-muted">{t("limitExhausted")}</div>
<div className="text-2xl font-semibold text-red-400 mt-1">
{quotaMonitor?.exhausted ?? 0}
</div>
</div>
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
<div className="text-xs text-text-muted">Errors</div>
<div className="text-xs text-text-muted">{t("errors")}</div>
<div className="text-2xl font-semibold text-orange-400 mt-1">
{quotaMonitor?.errors ?? 0}
</div>
</div>
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
<div className="text-xs text-text-muted">Providers</div>
<div className="text-xs text-text-muted">{t("providers")}</div>
<div className="text-2xl font-semibold text-text-main mt-1">
{Object.keys(quotaMonitor?.byProvider || {}).length}
</div>
@@ -552,20 +560,20 @@ export default function HealthPage() {
<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">healing</span>
Graceful Degradation Status
{t("gracefulDegradationStatus")}
</h2>
<div className="flex items-center gap-3 text-xs text-text-muted font-medium">
<span className="px-2 py-0.5 rounded bg-green-500/10 text-green-400">
Full: {degradation.summary.full}
{t("degradationFull")}: {degradation.summary.full}
</span>
<span className="px-2 py-0.5 rounded bg-amber-500/10 text-amber-500">
Reduced: {degradation.summary.reduced}
{t("degradationReduced")}: {degradation.summary.reduced}
</span>
<span className="px-2 py-0.5 rounded bg-orange-500/10 text-orange-500">
Minimal: {degradation.summary.minimal}
{t("degradationMinimal")}: {degradation.summary.minimal}
</span>
<span className="px-2 py-0.5 rounded bg-red-500/10 text-red-500">
Default: {degradation.summary.default}
{t("degradationDefault")}: {degradation.summary.default}
</span>
</div>
</div>
@@ -590,11 +598,11 @@ export default function HealthPage() {
return (
<div
key={feat.feature}
className={`rounded-lg p-3 border \${bg} flex flex-col gap-2`}
className={`rounded-lg p-3 border ${bg} flex flex-col gap-2`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold capitalize flex items-center gap-2 text-(--text-primary,#fff)">
<span className={`w-2 h-2 rounded-full \${dot}`}></span>
<span className={`w-2 h-2 rounded-full ${dot}`}></span>
{feat.feature}
</span>
<span className="text-xs uppercase tracking-wider font-bold opacity-70">
@@ -611,7 +619,9 @@ export default function HealthPage() {
</div>
)}
<div className="text-[10px] text-(--text-muted,#666) text-right mt-1">
Since {new Date(feat.since).toLocaleTimeString()}
{t("sinceTime", {
time: new Date(feat.since).toLocaleTimeString(locale),
})}
</div>
</div>
);
@@ -799,12 +809,14 @@ export default function HealthPage() {
? t("failures", { count: cb.failures })
: t("failuresPlural", { count: cb.failures })}
{Number(cb.retryAfterMs) > 0 && (
<span className="ml-2">· retry in {fmtMs(cb.retryAfterMs)}</span>
<span className="ml-2">
· {t("retryIn", { duration: fmtMs(cb.retryAfterMs) })}
</span>
)}
{cb.lastFailure && (
<span className="ml-2">
· {t("lastFailure")}:{" "}
{new Date(cb.lastFailure).toLocaleTimeString()}
{new Date(cb.lastFailure).toLocaleTimeString(locale)}
</span>
)}
</div>

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import { Card } from "@/shared/components";
type LeaderboardScope = "global" | "weekly" | "monthly" | "tokens_shared";
@@ -11,11 +11,11 @@ interface LeaderboardEntry {
score: number;
}
const SCOPE_LABELS: Record<LeaderboardScope, string> = {
global: "All Time",
weekly: "Weekly",
monthly: "Monthly",
tokens_shared: "Tokens Shared",
const SCOPE_LABEL_KEYS: Record<LeaderboardScope, string> = {
global: "leaderboardScopes.allTime",
weekly: "leaderboardScopes.weekly",
monthly: "leaderboardScopes.monthly",
tokens_shared: "leaderboardScopes.tokensShared",
};
const MEDAL_COLORS = [
@@ -28,6 +28,8 @@ const MEDAL_EMOJI = ["🥇", "🥈", "🥉"];
export default function LeaderboardPage() {
const t = useTranslations("common");
const tg = useTranslations("gamification");
const locale = useLocale();
const [scope, setScope] = useState<LeaderboardScope>("global");
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
const [myRank, setMyRank] = useState<number | null>(null);
@@ -35,24 +37,27 @@ export default function LeaderboardPage() {
const [error, setError] = useState("");
const eventSourceRef = useRef<EventSource | null>(null);
const fetchLeaderboard = useCallback(async (s: LeaderboardScope) => {
setLoading(true);
setError("");
try {
const res = await fetch(`/api/gamification/leaderboard?scope=${s}&limit=50`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setEntries(data.entries || []);
setMyRank(data.myRank ?? null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load leaderboard");
} finally {
setLoading(false);
}
}, []);
const fetchLeaderboard = useCallback(
async (s: LeaderboardScope) => {
try {
const res = await fetch(`/api/gamification/leaderboard?scope=${s}&limit=50`);
if (!res.ok) throw new Error(tg("leaderboardLoadFailed", { status: res.status }));
const data = await res.json();
setEntries(data.entries || []);
setMyRank(data.myRank ?? null);
} catch (err) {
setError(err instanceof Error ? err.message : tg("leaderboardLoadFailed"));
} finally {
setLoading(false);
}
},
[tg]
);
useEffect(() => {
fetchLeaderboard(scope);
const loadTimer = window.setTimeout(() => {
void fetchLeaderboard(scope);
}, 0);
// SSE real-time updates
const es = new EventSource(`/api/gamification/stream?scope=${scope}`);
@@ -80,6 +85,7 @@ export default function LeaderboardPage() {
};
return () => {
window.clearTimeout(loadTimer);
es.close();
eventSourceRef.current = null;
};
@@ -92,17 +98,21 @@ export default function LeaderboardPage() {
<div className="flex flex-col gap-6">
{/* Scope selector */}
<div className="flex items-center gap-2 flex-wrap">
{(Object.keys(SCOPE_LABELS) as LeaderboardScope[]).map((s) => (
{(Object.keys(SCOPE_LABEL_KEYS) as LeaderboardScope[]).map((s) => (
<button
key={s}
onClick={() => setScope(s)}
onClick={() => {
setLoading(true);
setError("");
setScope(s);
}}
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
scope === s
? "bg-violet-500 border-violet-500 text-white"
: "border-border text-text-muted hover:text-text-main hover:border-violet-500/50"
}`}
>
{SCOPE_LABELS[s]}
{tg(SCOPE_LABEL_KEYS[s])}
</button>
))}
</div>
@@ -116,8 +126,8 @@ export default function LeaderboardPage() {
<p className="text-3xl font-bold mt-1">#{myRank}</p>
</div>
<div className="text-right">
<p className="text-sm text-text-muted">Scope</p>
<p className="text-lg font-semibold">{SCOPE_LABELS[scope]}</p>
<p className="text-sm text-text-muted">{tg("scope")}</p>
<p className="text-lg font-semibold">{tg(SCOPE_LABEL_KEYS[scope])}</p>
</div>
</div>
</Card>
@@ -145,9 +155,11 @@ export default function LeaderboardPage() {
<p className="text-sm text-text-muted truncate">
{entry.apiKeyId.slice(0, 8)}...
</p>
<p className="text-2xl font-bold mt-1">{entry.score.toLocaleString()}</p>
<p className="text-2xl font-bold mt-1">
{entry.score.toLocaleString(locale)}
</p>
<p className="text-xs text-text-muted">
{scope === "tokens_shared" ? "tokens shared" : "points"}
{scope === "tokens_shared" ? tg("tokensShared") : tg("points")}
</p>
</div>
<div className="text-5xl font-black text-text-muted/20">{idx + 1}</div>
@@ -164,9 +176,9 @@ export default function LeaderboardPage() {
<table className="w-full">
<thead>
<tr className="text-left text-sm text-text-muted border-b border-border">
<th className="pb-3 font-medium w-16">Rank</th>
<th className="pb-3 font-medium">Name</th>
<th className="pb-3 font-medium text-right">Score</th>
<th className="pb-3 font-medium w-16">{tg("rank")}</th>
<th className="pb-3 font-medium">{tg("name")}</th>
<th className="pb-3 font-medium text-right">{tg("score")}</th>
</tr>
</thead>
<tbody>
@@ -178,7 +190,7 @@ export default function LeaderboardPage() {
<td className="py-3 text-text-muted font-mono">{idx + 4}</td>
<td className="py-3 font-medium">{entry.apiKeyId.slice(0, 12)}...</td>
<td className="py-3 text-right font-mono">
{entry.score.toLocaleString()}
{entry.score.toLocaleString(locale)}
</td>
</tr>
))}
@@ -190,9 +202,7 @@ export default function LeaderboardPage() {
{entries.length === 0 && !error && (
<Card>
<div className="text-center py-12 text-text-muted">
No entries yet for this scope. Start using OmniRoute to earn points!
</div>
<div className="text-center py-12 text-text-muted">{tg("leaderboardEmpty")}</div>
</Card>
)}
</>

View File

@@ -36,7 +36,11 @@ interface MediaProviderPageClientProps {
freeNote?: string;
}
function renderPlayground(kind: MediaKind, providerId: string) {
function renderPlayground(
kind: MediaKind,
providerId: string,
imageToTextCopy: { title: string; description: React.ReactNode }
) {
switch (kind) {
case "embedding":
return <EmbeddingExampleCard providerId={providerId} />;
@@ -62,15 +66,9 @@ function renderPlayground(kind: MediaKind, providerId: string) {
<div className="flex flex-col gap-2 border border-dashed border-border rounded-xl p-6">
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined text-[20px]">image_search</span>
<h3 className="text-sm font-medium">Image to Text</h3>
<h3 className="text-sm font-medium">{imageToTextCopy.title}</h3>
</div>
<p className="text-xs text-text-muted">
Inline playground for Image-to-Text will be available when{" "}
<code className="font-mono bg-bg-subtle px-1 rounded">
/api/v1/images/understanding
</code>{" "}
is implemented.
</p>
<p className="text-xs text-text-muted">{imageToTextCopy.description}</p>
</div>
);
default:
@@ -188,7 +186,7 @@ export default function MediaProviderPageClient({
<span className="text-sm font-medium flex-1 truncate">{conn.name ?? conn.id}</span>
{conn.isActive === false && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-bg-subtle border border-border text-text-muted">
disabled
{t("disabled")}
</span>
)}
</div>
@@ -198,7 +196,12 @@ export default function MediaProviderPageClient({
</div>
{/* Playground */}
{renderPlayground(activeKind, providerId)}
{renderPlayground(activeKind, providerId, {
title: t("imageToText"),
description: t.rich("imageToTextComingSoon", {
code: (chunks) => <code className="font-mono bg-bg-subtle px-1 rounded">{chunks}</code>,
}),
})}
</div>
);
}

View File

@@ -29,7 +29,7 @@ export function EmbeddingExampleCard({ providerId }: Props) {
const firstModel = models[0]?.id ?? "";
const [model, setModel] = useState<string>("");
const [inputText, setInputText] = useState<string>("Hello, world!");
const [inputText, setInputText] = useState<string>(() => t("embeddingSample"));
const [running, setRunning] = useState<boolean>(false);
const [result, setResult] = useState<{ data: unknown; latencyMs: number } | undefined>();
const [error, setError] = useState<string | null>(null);
@@ -73,7 +73,7 @@ export function EmbeddingExampleCard({ providerId }: Props) {
setResult({ data, latencyMs });
}
} catch (err) {
setError(err instanceof Error ? err.message : "Request failed");
setError(err instanceof Error ? err.message : t("requestFailed"));
} finally {
setRunning(false);
}
@@ -83,7 +83,7 @@ export function EmbeddingExampleCard({ providerId }: Props) {
return (
<PlaygroundCard
kindLabel="Embedding"
kindLabel={t("embedding")}
apiEndpoint={ENDPOINT_PATH}
onRun={handleRun}
curlSnippet={curlSnippet}
@@ -113,7 +113,7 @@ export function EmbeddingExampleCard({ providerId }: Props) {
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Hello, world!"
placeholder={t("embeddingSample")}
className="w-full rounded-md border border-border bg-bg-subtle text-sm px-2 py-1.5 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { useApiKey } from "../../providers/hooks/useApiKey";
import { useProviderModels } from "../../providers/hooks/useProviderModels";
@@ -66,7 +67,7 @@ function extractError(data: unknown): string | null {
return null;
}
function ImageResultRenderer(data: unknown) {
function ImageResultRenderer(data: unknown, altText: string) {
if (!data || typeof data !== "object") return null;
const d = data as Record<string, unknown>;
const items = Array.isArray(d.data) ? (d.data as Array<Record<string, unknown>>) : [];
@@ -81,12 +82,15 @@ function ImageResultRenderer(data: unknown) {
const src = url ?? (b64 ? `data:image/png;base64,${b64}` : null);
if (!src) return null;
return (
<img
<Image
key={i}
src={src}
alt={`Generated image ${i + 1}`}
alt={`${altText} ${i + 1}`}
width={200}
height={200}
unoptimized
className="max-w-full rounded-lg border border-border"
style={{ maxHeight: "200px" }}
style={{ maxHeight: "200px", objectFit: "contain" }}
/>
);
})}
@@ -103,7 +107,7 @@ export function ImageExampleCard({ providerId }: Props) {
const firstModel = models[0]?.id ?? "dall-e-3";
const [model, setModel] = useState<string>("");
const [prompt, setPrompt] = useState<string>("A serene landscape with mountains at sunset");
const [prompt, setPrompt] = useState<string>(() => t("imageSample"));
const [size, setSize] = useState<string>("1024x1024");
const [running, setRunning] = useState<boolean>(false);
const [result, setResult] = useState<{ data: unknown; latencyMs: number } | undefined>();
@@ -147,7 +151,7 @@ export function ImageExampleCard({ providerId }: Props) {
setResult({ data, latencyMs });
}
} catch (err) {
setError(err instanceof Error ? err.message : "Request failed");
setError(err instanceof Error ? err.message : t("requestFailed"));
} finally {
setRunning(false);
}
@@ -160,14 +164,14 @@ export function ImageExampleCard({ providerId }: Props) {
return (
<PlaygroundCard
kindLabel="Image"
kindLabel={t("image")}
apiEndpoint={ENDPOINT_PATH}
onRun={handleRun}
curlSnippet={curlSnippet}
running={running}
result={result}
error={error}
resultRenderer={ImageResultRenderer}
resultRenderer={(data) => ImageResultRenderer(data, tMedia("generatedImageAlt"))}
>
{/* Model */}
<div>
@@ -187,9 +191,7 @@ export function ImageExampleCard({ providerId }: Props) {
{/* Suggested models from HuggingFace Hub (image kind only) */}
{suggestedOnly.length > 0 && (
<div>
<label className="block text-xs text-text-muted mb-1">
{tMedia("suggestedModels")}
</label>
<label className="block text-xs text-text-muted mb-1">{tMedia("suggestedModels")}</label>
<div className="flex flex-wrap gap-1.5">
{suggestedOnly.map((m) => (
<button
@@ -231,7 +233,7 @@ export function ImageExampleCard({ providerId }: Props) {
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={2}
placeholder="A serene landscape..."
placeholder={t("imageSample")}
className="w-full rounded-md border border-border bg-bg-subtle text-sm px-2 py-1.5 text-text-main focus:outline-none focus:ring-1 focus:ring-primary resize-none"
/>
</div>

View File

@@ -248,7 +248,11 @@ export function LlmChatCard({
setMessages((prev) => {
const next = [...prev];
const last = next[next.length - 1];
next[next.length - 1] = { ...last, role: "assistant", content: `[Error: ${errMsg}]` };
next[next.length - 1] = {
...last,
role: "assistant",
content: `[${t("errorLabel")}: ${errMsg}]`,
};
return next;
});
return;
@@ -315,11 +319,15 @@ export function LlmChatCard({
// Cancelled by user — leave partial message
return;
}
const msg = err instanceof Error ? err.message : "Request failed";
const msg = err instanceof Error ? err.message : t("requestFailed");
setMessages((prev) => {
const next = [...prev];
const last = next[next.length - 1];
next[next.length - 1] = { ...last, role: "assistant", content: `[Error: ${msg}]` };
next[next.length - 1] = {
...last,
role: "assistant",
content: `[${t("errorLabel")}: ${msg}]`,
};
return next;
});
} finally {
@@ -328,7 +336,7 @@ export function LlmChatCard({
// Refocus textarea so user can keep typing
requestAnimationFrame(() => textareaRef.current?.focus());
}
}, [input, streaming, selectedKey, keys, providerId, qualifiedModel, messages]);
}, [input, streaming, selectedKey, keys, providerId, qualifiedModel, messages, t]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
@@ -403,7 +411,7 @@ export function LlmChatCard({
onChange={(e) => setSelectedKey(e.target.value)}
className="rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">(default)</option>
<option value="">{t("defaultKey")}</option>
{keys.map((k) => (
<option key={k.id} value={k.key}>
{k.name ?? k.id}
@@ -419,7 +427,7 @@ export function LlmChatCard({
onClick={handleClear}
className="text-xs text-text-muted hover:text-text-primary transition-colors"
>
Clear
{t("clear")}
</button>
)}
</div>
@@ -442,17 +450,17 @@ export function LlmChatCard({
<div className="size-10 rounded-full bg-accent/10 flex items-center justify-center">
<span className="material-symbols-outlined text-accent text-[22px]">forum</span>
</div>
<p className="text-sm text-text-muted">Send a message to start the conversation</p>
<p className="text-[11px] text-text-muted/70">
Shift+Enter for newline · Enter to send
</p>
<p className="text-sm text-text-muted">{t("emptyConversation")}</p>
<p className="text-[11px] text-text-muted/70">{t("sendHint")}</p>
</div>
) : (
<div className="mx-auto flex max-w-3xl flex-col divide-y divide-border/60">
{messages.map((msg, i) => {
const isUser = msg.role === "user";
const isError =
!isUser && typeof msg.content === "string" && msg.content.startsWith("[Error");
!isUser &&
typeof msg.content === "string" &&
msg.content.startsWith(`[${t("errorLabel")}`);
return (
<div
key={i}
@@ -483,7 +491,7 @@ export function LlmChatCard({
<div className="flex flex-col gap-1 min-w-0 flex-1">
<div className="flex items-baseline gap-2 min-w-0">
<span className="text-[10px] uppercase tracking-wider text-text-muted font-medium shrink-0">
{isUser ? "You" : isError ? "Error" : "Assistant"}
{isUser ? t("you") : isError ? t("errorLabel") : t("assistant")}
</span>
{!isUser && !isError && msg.model && (
<span
@@ -528,7 +536,7 @@ export function LlmChatCard({
<button
type="button"
onClick={handleStop}
title="Stop"
title={t("stop")}
className="size-8 flex items-center justify-center rounded-md border border-red-500/30 bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors shrink-0"
>
<span className="material-symbols-outlined text-[18px]">stop</span>

View File

@@ -29,7 +29,7 @@ export function MusicExampleCard({ providerId }: Props) {
const firstModel = models[0]?.id ?? "";
const [model, setModel] = useState<string>("");
const [prompt, setPrompt] = useState<string>("Upbeat jazz piano with light percussion");
const [prompt, setPrompt] = useState<string>(() => t("musicSample"));
const [duration, setDuration] = useState<number>(10);
const [running, setRunning] = useState<boolean>(false);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
@@ -90,7 +90,7 @@ export function MusicExampleCard({ providerId }: Props) {
setAudioUrl(url);
setLatencyMs(elapsed);
} else {
setError("No audio URL in response: " + JSON.stringify(data));
setError(t("noAudioUrl", { response: JSON.stringify(data) }));
}
} else {
const blob = await res.blob();
@@ -99,7 +99,7 @@ export function MusicExampleCard({ providerId }: Props) {
setLatencyMs(elapsed);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Request failed");
setError(err instanceof Error ? err.message : t("requestFailed"));
} finally {
setRunning(false);
}
@@ -110,7 +110,7 @@ export function MusicExampleCard({ providerId }: Props) {
const renderAudio = () => (
<div className="p-3">
<audio controls src={audioUrl!} className="w-full">
Your browser does not support audio.
{t("browserAudioUnsupported")}
</audio>
</div>
);
@@ -119,7 +119,7 @@ export function MusicExampleCard({ providerId }: Props) {
return (
<PlaygroundCard
kindLabel="Music"
kindLabel={t("music")}
apiEndpoint={ENDPOINT_PATH}
onRun={handleRun}
curlSnippet={curlSnippet}
@@ -165,7 +165,7 @@ export function MusicExampleCard({ providerId }: Props) {
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={2}
placeholder="Upbeat jazz piano with light percussion"
placeholder={t("musicSample")}
className="w-full rounded-md border border-border bg-bg-subtle text-sm px-2 py-1.5 text-text-main focus:outline-none focus:ring-1 focus:ring-primary resize-none"
/>
</div>

View File

@@ -77,7 +77,7 @@ export function OcrExampleCard({ providerId }: Props) {
setResult({ data, latencyMs });
}
} catch (err) {
setError(err instanceof Error ? err.message : "Request failed");
setError(err instanceof Error ? err.message : t("requestFailed"));
} finally {
setRunning(false);
}
@@ -112,7 +112,7 @@ export function OcrExampleCard({ providerId }: Props) {
</div>
{/* Document URL */}
<div>
<label className="block text-xs text-text-muted mb-1">Document URL</label>
<label className="block text-xs text-text-muted mb-1">{t("documentUrl")}</label>
<input
type="text"
value={documentUrl}

View File

@@ -84,7 +84,7 @@ export function SttExampleCard({ providerId }: Props) {
const selected = e.target.files?.[0] ?? null;
setFileError(null);
if (selected && selected.size > MAX_FILE_SIZE_BYTES) {
setFileError("File too large — max 25 MB");
setFileError(t("fileTooLarge25Mb"));
setFile(null);
return;
}
@@ -93,7 +93,7 @@ export function SttExampleCard({ providerId }: Props) {
const handleRun = async () => {
if (!file) {
setError("Please select an audio file first.");
setError(t("selectAudioFirst"));
return;
}
setRunning(true);
@@ -122,7 +122,7 @@ export function SttExampleCard({ providerId }: Props) {
setResult({ data, latencyMs });
}
} catch (err) {
setError(err instanceof Error ? err.message : "Request failed");
setError(err instanceof Error ? err.message : t("requestFailed"));
} finally {
setRunning(false);
}
@@ -132,7 +132,7 @@ export function SttExampleCard({ providerId }: Props) {
return (
<PlaygroundCard
kindLabel="Speech to Text"
kindLabel={t("speechToText")}
apiEndpoint={ENDPOINT_PATH}
onRun={handleRun}
curlSnippet={curlSnippet}
@@ -166,7 +166,7 @@ export function SttExampleCard({ providerId }: Props) {
className="inline-flex items-center gap-1.5 text-xs rounded-md border border-border bg-bg-subtle px-3 py-1.5 text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">upload_file</span>
{file ? file.name : "Choose file"}
{file ? file.name : t("chooseFile")}
</button>
{file && (
<span className="text-xs text-text-muted">{Math.round(file.size / 1024)}KB</span>
@@ -180,7 +180,7 @@ export function SttExampleCard({ providerId }: Props) {
onChange={handleFileChange}
/>
{fileError && <p className="text-xs text-red-400 mt-1">{fileError}</p>}
<p className="text-xs text-text-muted mt-1">mp3, wav, m4a, ogg, flac max 25 MB</p>
<p className="text-xs text-text-muted mt-1">{t("audioFormats25Mb")}</p>
</div>
</PlaygroundCard>
);

View File

@@ -33,7 +33,7 @@ export function TtsExampleCard({ providerId }: Props) {
const [model, setModel] = useState<string>("");
const [voice, setVoice] = useState<string>("alloy");
const [speed, setSpeed] = useState<string>("1.0");
const [inputText, setInputText] = useState<string>("Hello, this is a text-to-speech test.");
const [inputText, setInputText] = useState<string>(() => t("ttsSample"));
const [running, setRunning] = useState<boolean>(false);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [audioSize, setAudioSize] = useState<number | null>(null);
@@ -92,7 +92,7 @@ export function TtsExampleCard({ providerId }: Props) {
setAudioSize(blob.size);
setLatencyMs(elapsed);
} catch (err) {
setError(err instanceof Error ? err.message : "Request failed");
setError(err instanceof Error ? err.message : t("requestFailed"));
} finally {
setRunning(false);
}
@@ -113,7 +113,7 @@ export function TtsExampleCard({ providerId }: Props) {
const renderAudio = () => (
<div className="p-3 flex flex-col gap-2">
<audio controls src={audioUrl!} className="w-full">
Your browser does not support audio.
{t("browserAudioUnsupported")}
</audio>
<div className="flex items-center gap-2">
{audioSize !== null && (
@@ -135,7 +135,7 @@ export function TtsExampleCard({ providerId }: Props) {
return (
<PlaygroundCard
kindLabel="Text to Speech"
kindLabel={t("textToSpeech")}
apiEndpoint={ENDPOINT_PATH}
onRun={handleRun}
curlSnippet={curlSnippet}
@@ -197,7 +197,7 @@ export function TtsExampleCard({ providerId }: Props) {
value={inputText}
onChange={(e) => setInputText(e.target.value)}
rows={2}
placeholder="Hello, this is a text-to-speech test."
placeholder={t("ttsSample")}
className="w-full rounded-md border border-border bg-bg-subtle text-sm px-2 py-1.5 text-text-main focus:outline-none focus:ring-1 focus:ring-primary resize-none"
/>
</div>

View File

@@ -22,7 +22,7 @@ function extractError(data: unknown): string | null {
return null;
}
function VideoResultRenderer(data: unknown) {
function VideoResultRenderer(data: unknown, unsupportedText: string) {
if (!data || typeof data !== "object") {
return <pre className="text-xs p-3 text-text-main">{JSON.stringify(data, null, 2)}</pre>;
}
@@ -38,7 +38,7 @@ function VideoResultRenderer(data: unknown) {
return (
<div className="p-3">
<video controls src={videoUrl} className="max-w-full rounded-lg border border-border">
Your browser does not support video.
{unsupportedText}
</video>
</div>
);
@@ -53,7 +53,7 @@ export function VideoExampleCard({ providerId }: Props) {
const firstModel = models[0]?.id ?? "";
const [model, setModel] = useState<string>("");
const [prompt, setPrompt] = useState<string>("A time-lapse of clouds over a mountain range");
const [prompt, setPrompt] = useState<string>(() => t("videoSample"));
const [running, setRunning] = useState<boolean>(false);
const [result, setResult] = useState<{ data: unknown; latencyMs: number } | undefined>();
const [error, setError] = useState<string | null>(null);
@@ -96,7 +96,7 @@ export function VideoExampleCard({ providerId }: Props) {
setResult({ data, latencyMs });
}
} catch (err) {
setError(err instanceof Error ? err.message : "Request failed");
setError(err instanceof Error ? err.message : t("requestFailed"));
} finally {
setRunning(false);
}
@@ -106,14 +106,14 @@ export function VideoExampleCard({ providerId }: Props) {
return (
<PlaygroundCard
kindLabel="Video"
kindLabel={t("video")}
apiEndpoint={ENDPOINT_PATH}
onRun={handleRun}
curlSnippet={curlSnippet}
running={running}
result={result}
error={error}
resultRenderer={VideoResultRenderer}
resultRenderer={(data) => VideoResultRenderer(data, t("browserVideoUnsupported"))}
>
{/* Model */}
<div>
@@ -137,7 +137,7 @@ export function VideoExampleCard({ providerId }: Props) {
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={2}
placeholder="A time-lapse of clouds over a mountain range"
placeholder={t("videoSample")}
className="w-full rounded-md border border-border bg-bg-subtle text-sm px-2 py-1.5 text-text-main focus:outline-none focus:ring-1 focus:ring-primary resize-none"
/>
</div>

View File

@@ -80,7 +80,7 @@ export function WebFetchExampleCard({ providerId }: Props) {
setResult({ data, latencyMs });
}
} catch (err) {
setError(err instanceof Error ? err.message : "Request failed");
setError(err instanceof Error ? err.message : t("requestFailed"));
} finally {
setRunning(false);
}
@@ -88,7 +88,7 @@ export function WebFetchExampleCard({ providerId }: Props) {
return (
<PlaygroundCard
kindLabel="Web Fetch"
kindLabel={t("webFetch")}
apiEndpoint={ENDPOINT_PATH}
onRun={handleRun}
curlSnippet={curlSnippet}

View File

@@ -21,7 +21,7 @@ function extractError(data: unknown): string | null {
return null;
}
function SearchResultRenderer(data: unknown) {
function SearchResultRenderer(data: unknown, fallbackTitle: (number: number) => string) {
if (!data || typeof data !== "object") {
return <pre className="text-xs p-3 text-text-main">{JSON.stringify(data, null, 2)}</pre>;
}
@@ -33,7 +33,7 @@ function SearchResultRenderer(data: unknown) {
return (
<div className="flex flex-col divide-y divide-border">
{(results as Array<Record<string, unknown>>).map((item, i) => {
const title = typeof item.title === "string" ? item.title : `Result ${i + 1}`;
const title = typeof item.title === "string" ? item.title : fallbackTitle(i + 1);
const url = typeof item.url === "string" ? item.url : null;
const snippet =
typeof item.snippet === "string"
@@ -66,7 +66,7 @@ export function WebSearchExampleCard({ providerId }: Props) {
const t = useTranslations("miniPlayground");
const { apiKey } = useApiKey();
const [query, setQuery] = useState<string>("What is OmniRoute AI gateway?");
const [query, setQuery] = useState<string>(() => t("webSearchSample"));
const [numResults, setNumResults] = useState<number>(5);
const [running, setRunning] = useState<boolean>(false);
const [result, setResult] = useState<{ data: unknown; latencyMs: number } | undefined>();
@@ -109,7 +109,7 @@ export function WebSearchExampleCard({ providerId }: Props) {
setResult({ data, latencyMs });
}
} catch (err) {
setError(err instanceof Error ? err.message : "Request failed");
setError(err instanceof Error ? err.message : t("requestFailed"));
} finally {
setRunning(false);
}
@@ -117,14 +117,16 @@ export function WebSearchExampleCard({ providerId }: Props) {
return (
<PlaygroundCard
kindLabel="Web Search"
kindLabel={t("webSearch")}
apiEndpoint={ENDPOINT_PATH}
onRun={handleRun}
curlSnippet={curlSnippet}
running={running}
result={result}
error={error}
resultRenderer={SearchResultRenderer}
resultRenderer={(data) =>
SearchResultRenderer(data, (number) => t("searchResultFallback", { number }))
}
>
{/* Query */}
<div>
@@ -133,7 +135,7 @@ export function WebSearchExampleCard({ providerId }: Props) {
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="What is OmniRoute AI gateway?"
placeholder={t("webSearchSample")}
className="w-full rounded-md border border-border bg-bg-subtle text-sm px-2 py-1.5 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import type { OmniSkill } from "./OmniSkillCard";
interface SkillDetail extends OmniSkill {
@@ -36,6 +36,7 @@ export function SkillInspectorPane({
onSetMode,
onUninstall,
}: SkillInspectorPaneProps): JSX.Element {
const locale = useLocale();
const t = useTranslations("skills");
const [activeTab, setActiveTab] = useState<InspectorTab>("schema");
const [detail, setDetail] = useState<SkillDetail | null>(null);
@@ -93,10 +94,8 @@ export function SkillInspectorPane({
if (!selectedSkillId || !skill) {
return (
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-text-muted text-sm text-center p-6">
<span className="material-symbols-outlined text-[40px] mb-3 opacity-30">
manage_search
</span>
<span>Selecione uma skill à esquerda para inspecionar.</span>
<span className="material-symbols-outlined text-[40px] mb-3 opacity-30">manage_search</span>
<span>{t("selectSkillToInspect")}</span>
</div>
);
}
@@ -104,8 +103,8 @@ export function SkillInspectorPane({
const effectiveMode = skill.mode || (skill.enabled ? "on" : "off");
const tabs: { id: InspectorTab; label: string }[] = [
{ id: "schema", label: "Schema" },
{ id: "handler", label: "Handler" },
{ id: "schema", label: t("schemaTab") },
{ id: "handler", label: t("handlerTab") },
{ id: "executions", label: t("executionsTab") },
{ id: "sandbox", label: t("sandboxTab") },
];
@@ -162,7 +161,7 @@ export function SkillInspectorPane({
<div className="space-y-3">
<div>
<p className="text-xs font-medium text-text-muted uppercase tracking-wide mb-1.5">
Input Schema
{t("inputSchema")}
</p>
<pre className="text-xs bg-surface/40 rounded-lg p-3 overflow-auto max-h-[200px] text-text-main font-mono whitespace-pre-wrap">
{JSON.stringify(detail?.schema?.input ?? {}, null, 2) || "{}"}
@@ -170,7 +169,7 @@ export function SkillInspectorPane({
</div>
<div>
<p className="text-xs font-medium text-text-muted uppercase tracking-wide mb-1.5">
Output Schema
{t("outputSchema")}
</p>
<pre className="text-xs bg-surface/40 rounded-lg p-3 overflow-auto max-h-[200px] text-text-main font-mono whitespace-pre-wrap">
{JSON.stringify(detail?.schema?.output ?? {}, null, 2) || "{}"}
@@ -182,10 +181,10 @@ export function SkillInspectorPane({
{activeTab === "handler" && (
<div>
<p className="text-xs font-medium text-text-muted uppercase tracking-wide mb-1.5">
Handler Code
{t("handlerCode")}
</p>
<pre className="text-xs bg-surface/40 rounded-lg p-3 overflow-auto max-h-[400px] text-text-main font-mono whitespace-pre-wrap">
{detail?.handler ?? "// Handler not available"}
{detail?.handler ?? `// ${t("handlerUnavailable")}`}
</pre>
</div>
)}
@@ -223,7 +222,7 @@ export function SkillInspectorPane({
</td>
<td className="py-2 text-text-muted">{exec.duration}ms</td>
<td className="py-2 text-text-muted">
{new Date(exec.createdAt).toLocaleString()}
{new Date(exec.createdAt).toLocaleString(locale)}
</td>
</tr>
))}
@@ -256,7 +255,7 @@ export function SkillInspectorPane({
</div>
))}
<button className="w-full mt-3 px-3 py-2 text-xs font-medium rounded-lg border border-border text-text-muted hover:text-text-main hover:border-violet-500/50 transition-colors">
Run test (placeholder)
{t("runTestPlaceholder")}
</button>
</div>
)}
@@ -268,7 +267,7 @@ export function SkillInspectorPane({
<button
key={mode}
onClick={() => onSetMode(skill.id, mode)}
aria-label={`Set mode ${mode}`}
aria-label={t("setModeAria", { mode })}
className={`flex-1 text-xs px-2 py-1.5 rounded border transition-colors ${
effectiveMode === mode
? mode === "on"
@@ -284,7 +283,7 @@ export function SkillInspectorPane({
))}
<button
onClick={() => onUninstall(skill.id)}
aria-label="Uninstall skill"
aria-label={t("uninstallSkill")}
className="flex-1 text-xs px-2 py-1.5 rounded border border-border text-red-400 hover:bg-red-500/10 transition-colors"
>
{t("delete")}

View File

@@ -3,6 +3,7 @@
// src/app/(dashboard)/dashboard/playground/components/CompareColumn.tsx
import type { StreamMetrics } from "@/shared/schemas/playground";
import { useTranslations } from "next-intl";
import MarkdownMessage from "./MarkdownMessage";
import ProviderMetrics from "./ProviderMetrics";
@@ -28,6 +29,7 @@ interface CompareColumnProps {
* Shows the model name, streaming response (via MarkdownMessage), and ProviderMetrics.
*/
export default function CompareColumn({ column, onCancel, onRemove }: CompareColumnProps) {
const t = useTranslations("playground");
const { id, model, status, metrics, response, errorMessage } = column;
return (
@@ -46,13 +48,10 @@ export default function CompareColumn({ column, onCancel, onRemove }: CompareCol
? "bg-destructive"
: "bg-text-muted/30"
}`}
aria-label={`Status: ${status}`}
aria-label={t("statusLabel", { status: t(`status.${status}`) })}
/>
<span
className="text-xs font-medium text-text-main truncate"
title={model}
>
{model || <span className="text-text-muted italic">No model</span>}
<span className="text-xs font-medium text-text-main truncate" title={model}>
{model || <span className="text-text-muted italic">{t("noModel")}</span>}
</span>
</div>
@@ -61,16 +60,16 @@ export default function CompareColumn({ column, onCancel, onRemove }: CompareCol
<button
onClick={() => onCancel(id)}
className="text-[10px] px-1.5 py-0.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
aria-label="Cancel stream"
aria-label={t("cancelStream")}
>
Cancel
{t("cancel")}
</button>
)}
<button
onClick={() => onRemove(id)}
className="p-0.5 rounded text-text-muted hover:text-destructive transition-colors"
title="Remove column"
aria-label={`Remove column for ${model}`}
title={t("removeColumn")}
aria-label={t("removeModelColumn", { model: model || t("noModel") })}
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
@@ -86,23 +85,19 @@ export default function CompareColumn({ column, onCancel, onRemove }: CompareCol
{/* Response content */}
<div className="flex-1 overflow-y-auto px-3 py-3 text-sm">
{status === "idle" && (
<p className="text-text-muted text-xs italic">
Ready to run.
</p>
)}
{status === "idle" && <p className="text-text-muted text-xs italic">{t("readyToRun")}</p>}
{status === "error" && (
<div className="text-destructive text-xs bg-destructive/10 rounded p-2">
<span className="font-medium">Error: </span>
{errorMessage ?? "Unknown error occurred."}
<span className="font-medium">{t("errorLabel")}: </span>
{errorMessage ?? t("unknownError")}
</div>
)}
{status === "streaming" && response === "" && (
<div className="flex items-center gap-2 text-text-muted text-xs">
<span className="inline-block w-1.5 h-4 bg-primary/60 animate-pulse rounded-sm" />
Waiting for response
{t("waitingForResponse")}
</div>
)}

View File

@@ -3,6 +3,7 @@
// src/app/(dashboard)/dashboard/playground/components/ImprovePromptButton.tsx
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useImprovePrompt } from "../hooks/useImprovePrompt";
import type { ConfigState } from "./StudioConfigPane";
@@ -19,7 +20,11 @@ interface ImprovePromptButtonProps {
*
* D8: uses the model configured in the Config pane (never overrides with cheap model).
*/
export default function ImprovePromptButton({ configState, setConfigState }: ImprovePromptButtonProps) {
export default function ImprovePromptButton({
configState,
setConfigState,
}: ImprovePromptButtonProps) {
const t = useTranslations("playground");
const { loading, error, improve } = useImprovePrompt();
const [confirmOpen, setConfirmOpen] = useState(false);
const [improveError, setImproveError] = useState<string | null>(null);
@@ -30,7 +35,7 @@ export default function ImprovePromptButton({ configState, setConfigState }: Imp
const model = configState.model.trim();
if (!model) {
setImproveError("Please set a model in the Config pane first.");
setImproveError(t("setModelInConfigFirst"));
return;
}
@@ -40,7 +45,7 @@ export default function ImprovePromptButton({ configState, setConfigState }: Imp
});
if (result == null) {
setImproveError(error ?? "Improve prompt failed.");
setImproveError(error ?? t("improvePromptFailed"));
return;
}
@@ -66,16 +71,14 @@ export default function ImprovePromptButton({ configState, setConfigState }: Imp
}}
disabled={isDisabled}
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-40 disabled:cursor-not-allowed self-start"
aria-label="Improve prompt using AI"
title={!configState.model.trim() ? "Set a model first" : "Improve your prompt with AI"}
aria-label={t("improvePromptAria")}
title={!configState.model.trim() ? t("setModelFirst") : t("improvePromptTitle")}
>
<span className="text-[13px]"></span>
{loading ? "Improving…" : "Improve prompt"}
{loading ? t("improvingPrompt") : t("improvePrompt")}
</button>
{improveError && (
<p className="text-[11px] text-destructive">{improveError}</p>
)}
{improveError && <p className="text-[11px] text-destructive">{improveError}</p>}
</div>
{/* Quota confirmation modal */}
@@ -85,7 +88,7 @@ export default function ImprovePromptButton({ configState, setConfigState }: Imp
onClick={() => setConfirmOpen(false)}
role="dialog"
aria-modal="true"
aria-label="Confirm improve prompt"
aria-label={t("confirmImprovePrompt")}
>
<div
className="bg-surface border border-border rounded-xl p-5 w-80 shadow-2xl"
@@ -94,16 +97,16 @@ export default function ImprovePromptButton({ configState, setConfigState }: Imp
<div className="flex items-start gap-3 mb-4">
<span className="text-[24px] shrink-0"></span>
<div>
<h3 className="text-sm font-semibold text-text-main mb-1">
Improve prompt
</h3>
<h3 className="text-sm font-semibold text-text-main mb-1">{t("improvePrompt")}</h3>
<p className="text-xs text-text-muted">
This will send your current system prompt to{" "}
<code className="font-mono text-primary">{configState.model}</code>
{" "}to generate an improved version.
{t.rich("improvePromptDescription", {
model: () => (
<code className="font-mono text-primary">{configState.model}</code>
),
})}
</p>
<p className="text-xs text-text-muted mt-1.5 font-medium">
This action will consume model quota.
{t("improveQuotaWarning")}
</p>
</div>
</div>
@@ -113,14 +116,14 @@ export default function ImprovePromptButton({ configState, setConfigState }: Imp
onClick={() => setConfirmOpen(false)}
className="text-xs px-3 py-1.5 rounded border border-border text-text-muted hover:text-text-main transition-colors"
>
Cancel
{t("cancel")}
</button>
<button
onClick={() => void handleConfirm()}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors"
>
<span className="text-[12px]"></span>
Improve
{t("improveConfirm")}
</button>
</div>
</div>

View File

@@ -2,6 +2,8 @@
// src/app/(dashboard)/dashboard/playground/components/ParamSliders.tsx
import { useTranslations } from "next-intl";
export interface PlaygroundParams {
temperature: number;
max_tokens: number;
@@ -80,6 +82,7 @@ function SliderRow({ label, value, min, max, step, onChange }: SliderRowProps) {
* Props: { params, setParams }
*/
export default function ParamSliders({ params, setParams }: ParamSlidersProps) {
const t = useTranslations("playground");
function update<K extends keyof PlaygroundParams>(key: K, value: PlaygroundParams[K]) {
setParams({ ...params, [key]: value });
}
@@ -87,7 +90,7 @@ export default function ParamSliders({ params, setParams }: ParamSlidersProps) {
return (
<div className="space-y-3">
<SliderRow
label="Temperature"
label={t("temperature")}
value={params.temperature}
min={0}
max={2}
@@ -96,7 +99,7 @@ export default function ParamSliders({ params, setParams }: ParamSlidersProps) {
/>
<SliderRow
label="Max tokens"
label={t("maxTokens")}
value={params.max_tokens}
min={1}
max={32768}
@@ -105,7 +108,7 @@ export default function ParamSliders({ params, setParams }: ParamSlidersProps) {
/>
<SliderRow
label="Top-p"
label={t("topP")}
value={params.top_p}
min={0}
max={1}
@@ -114,7 +117,7 @@ export default function ParamSliders({ params, setParams }: ParamSlidersProps) {
/>
<SliderRow
label="Presence penalty"
label={t("presencePenalty")}
value={params.presence_penalty}
min={-2}
max={2}
@@ -123,7 +126,7 @@ export default function ParamSliders({ params, setParams }: ParamSlidersProps) {
/>
<SliderRow
label="Frequency penalty"
label={t("frequencyPenalty")}
value={params.frequency_penalty}
min={-2}
max={2}
@@ -133,11 +136,11 @@ export default function ParamSliders({ params, setParams }: ParamSlidersProps) {
{/* Seed input */}
<div className="flex flex-col gap-1">
<label className="text-xs text-text-muted font-medium">Seed</label>
<label className="text-xs text-text-muted font-medium">{t("seed")}</label>
<input
type="number"
value={params.seed ?? ""}
placeholder="Random (leave empty)"
placeholder={t("seedPlaceholder")}
onChange={(e) => {
const raw = e.target.value;
update("seed", raw === "" ? null : parseInt(raw, 10));
@@ -148,11 +151,11 @@ export default function ParamSliders({ params, setParams }: ParamSlidersProps) {
{/* Stop sequences */}
<div className="flex flex-col gap-1">
<label className="text-xs text-text-muted font-medium">Stop sequences</label>
<label className="text-xs text-text-muted font-medium">{t("stopSequences")}</label>
<input
type="text"
value={params.stop}
placeholder='e.g. "\n\n" or "END"'
placeholder={t("stopSequencesPlaceholder")}
onChange={(e) => update("stop", e.target.value)}
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary"
/>
@@ -160,7 +163,7 @@ export default function ParamSliders({ params, setParams }: ParamSlidersProps) {
{/* JSON mode toggle */}
<div className="flex items-center justify-between">
<label className="text-xs text-text-muted font-medium">JSON mode</label>
<label className="text-xs text-text-muted font-medium">{t("jsonMode")}</label>
<button
type="button"
role="switch"

View File

@@ -3,6 +3,7 @@
// src/app/(dashboard)/dashboard/playground/components/ProviderMetrics.tsx
import type { StreamMetrics } from "@/shared/schemas/playground";
import { useTranslations } from "next-intl";
interface ProviderMetricsProps {
metrics: StreamMetrics;
@@ -32,28 +33,25 @@ function formatCost(usd: number | null): string {
* to the last. Labeled "(estimated)" as required by D13.
*/
export default function ProviderMetrics({ metrics }: ProviderMetricsProps) {
const t = useTranslations("playground");
const { ttftMs, tps, tokensIn, tokensOut, costUsd } = metrics;
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-text-muted font-mono">
{/* TTFT */}
<span title="Time to first token (client-side estimate)">
TTFT {formatMs(ttftMs)}
</span>
<span title={t("ttftTitle")}>TTFT {formatMs(ttftMs)}</span>
{/* TPS */}
<span title="Tokens per second (client-side estimate)">
· {formatTps(tps)}
</span>
<span title={t("tpsTitle")}>· {formatTps(tps)}</span>
{/* Token counts */}
<span title="Prompt tokens ↑ / Completion tokens ↓">
<span title={t("tokenCountsTitle")}>
· {tokensIn} {tokensOut}
</span>
{/* Cost */}
<span title="Estimated cost (not guaranteed accurate)">
· {formatCost(costUsd)} <span className="opacity-60">(estimated)</span>
<span title={t("estimatedCostTitle")}>
· {formatCost(costUsd)} <span className="opacity-60">{t("costEstimated")}</span>
</span>
</div>
);

View File

@@ -8,6 +8,7 @@
import type { PlaygroundParams } from "./ParamSliders";
import type { ReasoningControlSpec } from "./reasoningControlUtils";
import { useTranslations } from "next-intl";
interface ReasoningControlsProps {
spec: ReasoningControlSpec;
@@ -21,6 +22,7 @@ function effortLabel(value: string): string {
}
export default function ReasoningControls({ spec, params, setParams }: ReasoningControlsProps) {
const t = useTranslations("playground");
if (!spec.show) return null;
function update<K extends keyof PlaygroundParams>(key: K, value: PlaygroundParams[K]) {
@@ -30,17 +32,17 @@ export default function ReasoningControls({ spec, params, setParams }: Reasoning
return (
<div className="flex flex-col gap-3">
<span className="text-xs font-medium text-text-muted uppercase tracking-wider">
Reasoning
{t("reasoningLabel")}
</span>
{/* Thinking toggle */}
<div className="flex items-center justify-between">
<label className="text-xs text-text-muted font-medium">Thinking</label>
<label className="text-xs text-text-muted font-medium">{t("thinking")}</label>
<button
type="button"
role="switch"
aria-checked={params.thinking}
aria-label="Thinking"
aria-label={t("thinking")}
onClick={() => update("thinking", !params.thinking)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 ${
params.thinking ? "bg-primary" : "bg-neutral-300 dark:bg-neutral-600"
@@ -56,13 +58,13 @@ export default function ReasoningControls({ spec, params, setParams }: Reasoning
{/* Effort selector */}
<div className="flex flex-col gap-1">
<label className="text-xs text-text-muted font-medium">Effort</label>
<label className="text-xs text-text-muted font-medium">{t("effort")}</label>
<select
value={params.effort}
onChange={(e) => update("effort", e.target.value)}
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
>
<option value="">Default</option>
<option value="">{t("effortDefault")}</option>
{spec.effortOptions.map((opt) => (
<option key={opt} value={opt}>
{effortLabel(opt)}

View File

@@ -3,6 +3,7 @@
// src/app/(dashboard)/dashboard/playground/components/StructuredOutputEditor.tsx
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useStructuredOutput } from "../hooks/useStructuredOutput";
import type { StructuredOutputSchemaInput } from "../hooks/useStructuredOutput";
@@ -29,11 +30,10 @@ const DEFAULT_SCHEMA: StructuredOutputSchemaInput = {
* Schema validation is client-side via Zod StructuredOutputSchema (D9).
*/
export default function StructuredOutputEditor({ structuredOutput }: StructuredOutputEditorProps) {
const t = useTranslations("playground");
const { enabled, schema, error, setEnabled, setSchema } = structuredOutput;
const [schemaRaw, setSchemaRaw] = useState(
JSON.stringify(schema ?? DEFAULT_SCHEMA, null, 2),
);
const [schemaRaw, setSchemaRaw] = useState(JSON.stringify(schema ?? DEFAULT_SCHEMA, null, 2));
const [nameField, setNameField] = useState(schema?.name ?? "my_schema");
const [parseError, setParseError] = useState<string | null>(null);
@@ -42,7 +42,7 @@ export default function StructuredOutputEditor({ structuredOutput }: StructuredO
try {
parsed = JSON.parse(schemaRaw);
} catch {
setParseError("Invalid JSON");
setParseError(t("invalidJson"));
return;
}
setParseError(null);
@@ -62,10 +62,8 @@ export default function StructuredOutputEditor({ structuredOutput }: StructuredO
{/* Toggle */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-text-main">JSON mode</span>
<span className="text-[11px] text-text-muted">
Forces response_format: json_schema
</span>
<span className="text-xs font-medium text-text-main">{t("jsonMode")}</span>
<span className="text-[11px] text-text-muted">{t("jsonModeDescription")}</span>
</div>
<button
role="switch"
@@ -74,7 +72,7 @@ export default function StructuredOutputEditor({ structuredOutput }: StructuredO
className={`relative inline-flex w-10 h-5 rounded-full transition-colors ${
enabled ? "bg-primary" : "bg-text-muted/30"
}`}
aria-label={enabled ? "Disable JSON mode" : "Enable JSON mode"}
aria-label={enabled ? t("disableJsonMode") : t("enableJsonMode")}
>
<span
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
@@ -90,7 +88,7 @@ export default function StructuredOutputEditor({ structuredOutput }: StructuredO
{/* Schema name */}
<div className="flex flex-col gap-1">
<label className="text-[10px] text-text-muted uppercase tracking-wider">
Schema name
{t("schemaName")}
</label>
<input
type="text"
@@ -104,29 +102,25 @@ export default function StructuredOutputEditor({ structuredOutput }: StructuredO
{/* Schema JSON textarea */}
<div className="flex flex-col gap-1">
<label className="text-[10px] text-text-muted uppercase tracking-wider">
JSON schema
{t("jsonSchema")}
</label>
<textarea
value={schemaRaw}
onChange={(e) => setSchemaRaw(e.target.value)}
rows={8}
className="text-xs font-mono bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
aria-label="JSON schema editor"
aria-label={t("jsonSchemaEditor")}
/>
</div>
{/* Errors */}
{(parseError ?? error) && (
<p className="text-xs text-destructive">
{parseError ?? error}
</p>
<p className="text-xs text-destructive">{parseError ?? error}</p>
)}
{/* Status — show when schema is set and no errors */}
{schema != null && !parseError && !error && (
<p className="text-xs text-green-600 dark:text-green-400">
Schema validated
</p>
<p className="text-xs text-green-600 dark:text-green-400"> {t("schemaValidated")}</p>
)}
{/* Validate button */}
@@ -134,7 +128,7 @@ export default function StructuredOutputEditor({ structuredOutput }: StructuredO
onClick={handleValidate}
className="text-xs px-3 py-1.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors self-start"
>
Validate schema
{t("validateSchema")}
</button>
</div>
)}

View File

@@ -18,10 +18,7 @@ import {
} from "@/shared/constants/providers";
import { filterModelsByQuery, pickDefaultModel, resolveModelFilterKey } from "./modelSelection";
import ReasoningControls from "./ReasoningControls";
import {
resolveReasoningControls,
type ReasoningControlSpec,
} from "./reasoningControlUtils";
import { resolveReasoningControls, type ReasoningControlSpec } from "./reasoningControlUtils";
export interface ConfigState {
endpoint: PlaygroundEndpoint;
@@ -41,20 +38,20 @@ interface StudioConfigPaneProps {
setConfigState: (s: ConfigState) => void;
}
const ENDPOINT_OPTIONS: Array<{ value: PlaygroundEndpoint; label: string }> = [
{ value: "chat.completions", label: "Chat completions" },
{ value: "responses", label: "Responses" },
{ value: "completions", label: "Completions" },
{ value: "embeddings", label: "Embeddings" },
{ value: "images", label: "Images" },
{ value: "audio.transcriptions", label: "Audio transcriptions" },
{ value: "audio.speech", label: "Audio speech" },
{ value: "video", label: "Video" },
{ value: "music", label: "Music" },
{ value: "moderations", label: "Moderations" },
{ value: "rerank", label: "Rerank" },
{ value: "search", label: "Search" },
{ value: "web.fetch", label: "Web fetch" },
const ENDPOINT_OPTIONS: Array<{ value: PlaygroundEndpoint; labelKey: string }> = [
{ value: "chat.completions", labelKey: "chat" },
{ value: "responses", labelKey: "responses" },
{ value: "completions", labelKey: "completions" },
{ value: "embeddings", labelKey: "embeddings" },
{ value: "images", labelKey: "images" },
{ value: "audio.transcriptions", labelKey: "transcription" },
{ value: "audio.speech", labelKey: "speech" },
{ value: "video", labelKey: "video" },
{ value: "music", labelKey: "music" },
{ value: "moderations", labelKey: "moderations" },
{ value: "rerank", labelKey: "rerank" },
{ value: "search", labelKey: "search" },
{ value: "web.fetch", labelKey: "webFetch" },
];
/**
@@ -65,6 +62,7 @@ const ENDPOINT_OPTIONS: Array<{ value: PlaygroundEndpoint; label: string }> = [
*/
export default function StudioConfigPane({ configState, setConfigState }: StudioConfigPaneProps) {
const t = useTranslations("common");
const tp = useTranslations("playground");
const [collapsed, setCollapsed] = useState(false);
// #4086: search/filter query for the Model dropdown — flat provider catalogs (e.g.
// 50+ OpenRouter models) made the plain <select> unusable without scrolling.
@@ -93,8 +91,11 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
selectedProviderOption?.modelPrefix,
isCompatibleConnectionId
);
const { availableModels, modelCapabilities, loading: loadingModels } =
useAvailableModels(modelFilterKey);
const {
availableModels,
modelCapabilities,
loading: loadingModels,
} = useAvailableModels(modelFilterKey);
// #4086: filter the dropdown by the search query, but always keep the currently selected
// model in the list even when it doesn't match — otherwise typing a query would silently
@@ -142,8 +143,8 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
<button
onClick={() => setCollapsed(false)}
className="mt-2 p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Expand config pane"
aria-label="Expand config pane"
title={tp("expandConfig")}
aria-label={tp("expandConfig")}
>
<span className="material-symbols-outlined text-[18px]">settings</span>
</button>
@@ -154,18 +155,18 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
return (
<aside
className="w-72 shrink-0 border-l border-border bg-bg-alt flex flex-col overflow-y-auto"
aria-label="Config pane"
aria-label={tp("configPane")}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
Config
{tp("configPane")}
</span>
<button
onClick={() => setCollapsed(true)}
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Collapse config pane"
aria-label="Collapse config pane"
title={tp("collapseConfig")}
aria-label={tp("collapseConfig")}
>
<span className="material-symbols-outlined text-[16px]">chevron_right</span>
</button>
@@ -178,7 +179,7 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
{/* Endpoint */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-text-muted uppercase tracking-wider">
Endpoint
{tp("endpointLabel")}
</label>
<select
value={configState.endpoint}
@@ -187,7 +188,7 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
>
{ENDPOINT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label} {endpointToPath(opt.value)}
{tp(`endpointOptions.${opt.labelKey}`)} {endpointToPath(opt.value)}
</option>
))}
</select>
@@ -196,7 +197,7 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
{/* Provider */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-text-muted uppercase tracking-wider">
Provider
{tp("provider")}
</label>
<select
value={provider}
@@ -208,7 +209,7 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
disabled={loadingProviders}
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
>
<option value="">Auto</option>
<option value="">{tp("autoProvider")}</option>
{providerOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
@@ -220,7 +221,7 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
{/* Model */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-text-muted uppercase tracking-wider">
Model
{tp("model")}
</label>
{availableModels.length > 0 ? (
<>
@@ -252,7 +253,7 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
type="text"
value={configState.model}
onChange={(e) => update("model", e.target.value)}
placeholder="e.g. openai/gpt-4o"
placeholder={tp("modelPlaceholder")}
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
)}
@@ -261,12 +262,12 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
{/* System prompt */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-text-muted uppercase tracking-wider">
System prompt
{tp("systemPrompt")}
</label>
<textarea
value={configState.systemPrompt}
onChange={(e) => update("systemPrompt", e.target.value)}
placeholder="You are a helpful assistant."
placeholder={tp("systemPromptPlaceholder")}
rows={4}
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
/>
@@ -277,7 +278,7 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
{/* Param sliders */}
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-text-muted uppercase tracking-wider">
Parameters
{tp("parametersLabel")}
</span>
<ParamSliders params={configState.params} setParams={(p) => update("params", p)} />
</div>

View File

@@ -3,6 +3,7 @@
// src/app/(dashboard)/dashboard/playground/components/ToolsBuilder.tsx
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useToolsBuilder } from "../hooks/useToolsBuilder";
import type { ToolDefinition } from "@/lib/playground/codeExport";
@@ -23,6 +24,7 @@ const EMPTY_TOOL: { name: string; description: string; parametersRaw: string } =
* Validation uses Zod ToolDefinitionSchema (via useToolsBuilder).
*/
export default function ToolsBuilder({ toolsBuilder }: ToolsBuilderProps) {
const t = useTranslations("playground");
const { tools, errors, add, remove, update } = toolsBuilder;
// Form state for the "Add tool" form
@@ -31,14 +33,18 @@ export default function ToolsBuilder({ toolsBuilder }: ToolsBuilderProps) {
// Per-tool editing state: index → draft values
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [editDraft, setEditDraft] = useState<{ name: string; description: string; parametersRaw: string } | null>(null);
const [editDraft, setEditDraft] = useState<{
name: string;
description: string;
parametersRaw: string;
} | null>(null);
function handleAdd() {
let parsed: unknown;
try {
parsed = JSON.parse(form.parametersRaw);
} catch {
setFormError("Parameters must be valid JSON");
setFormError(t("toolParamsInvalid"));
return;
}
@@ -108,26 +114,21 @@ export default function ToolsBuilder({ toolsBuilder }: ToolsBuilderProps) {
{tools.length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-text-muted uppercase tracking-wider">
Tools ({tools.length})
{t("toolsCount", { count: tools.length })}
</span>
{tools.map((tool, idx) => {
const isEditing = editingIndex === idx;
const toolError = errors.get(idx);
return (
<div
key={idx}
className="border border-border rounded-lg overflow-hidden"
>
<div key={idx} className="border border-border rounded-lg overflow-hidden">
{/* Tool header */}
<div className="flex items-center justify-between px-3 py-2 bg-bg-alt">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[14px] text-text-muted">
function
</span>
<code className="text-xs font-mono text-text-main">
{tool.function.name}
</code>
<code className="text-xs font-mono text-text-main">{tool.function.name}</code>
{tool.function.description && (
<span className="text-[11px] text-text-muted truncate max-w-[200px]">
{tool.function.description}
@@ -139,7 +140,7 @@ export default function ToolsBuilder({ toolsBuilder }: ToolsBuilderProps) {
<button
onClick={() => startEdit(idx)}
className="p-1 rounded text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
aria-label={`Edit tool ${tool.function.name}`}
aria-label={t("editTool", { name: tool.function.name })}
>
<span className="material-symbols-outlined text-[14px]">edit</span>
</button>
@@ -147,7 +148,7 @@ export default function ToolsBuilder({ toolsBuilder }: ToolsBuilderProps) {
<button
onClick={() => remove(idx)}
className="p-1 rounded text-text-muted hover:text-destructive transition-colors"
aria-label={`Remove tool ${tool.function.name}`}
aria-label={t("removeTool", { name: tool.function.name })}
>
<span className="material-symbols-outlined text-[14px]">delete</span>
</button>
@@ -161,38 +162,38 @@ export default function ToolsBuilder({ toolsBuilder }: ToolsBuilderProps) {
type="text"
value={editDraft.name}
onChange={(e) => setEditDraft({ ...editDraft, name: e.target.value })}
placeholder="Function name"
placeholder={t("toolNamePlaceholder")}
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
<input
type="text"
value={editDraft.description}
onChange={(e) => setEditDraft({ ...editDraft, description: e.target.value })}
placeholder="Description (optional)"
placeholder={t("toolDescPlaceholder")}
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
<textarea
value={editDraft.parametersRaw}
onChange={(e) => setEditDraft({ ...editDraft, parametersRaw: e.target.value })}
onChange={(e) =>
setEditDraft({ ...editDraft, parametersRaw: e.target.value })
}
rows={6}
className="text-xs font-mono bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
aria-label="JSON schema for parameters"
aria-label={t("toolParamsJsonSchema")}
/>
{toolError && (
<p className="text-xs text-destructive">{toolError}</p>
)}
{toolError && <p className="text-xs text-destructive">{toolError}</p>}
<div className="flex items-center gap-2">
<button
onClick={() => handleUpdate(idx)}
className="text-xs px-2.5 py-1 rounded bg-primary text-white hover:bg-primary/90 transition-colors"
>
Save
{t("save")}
</button>
<button
onClick={cancelEdit}
className="text-xs px-2.5 py-1 rounded border border-border text-text-muted hover:text-text-main transition-colors"
>
Cancel
{t("cancel")}
</button>
</div>
</div>
@@ -206,14 +207,14 @@ export default function ToolsBuilder({ toolsBuilder }: ToolsBuilderProps) {
{/* Add tool form */}
<div className="border border-border rounded-lg p-3 flex flex-col gap-2 bg-surface">
<span className="text-xs font-medium text-text-muted uppercase tracking-wider">
Add tool
{t("addTool")}
</span>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="Function name *"
placeholder={t("toolNameRequiredPlaceholder")}
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
@@ -221,32 +222,30 @@ export default function ToolsBuilder({ toolsBuilder }: ToolsBuilderProps) {
type="text"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Description (optional)"
placeholder={t("toolDescPlaceholder")}
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-text-muted uppercase tracking-wider">
Parameters (JSON schema)
{t("toolParamsLabel")}
</label>
<textarea
value={form.parametersRaw}
onChange={(e) => setForm({ ...form, parametersRaw: e.target.value })}
rows={6}
className="text-xs font-mono bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
aria-label="JSON schema for parameters"
aria-label={t("toolParamsJsonSchema")}
/>
</div>
{formError && (
<p className="text-xs text-destructive">{formError}</p>
)}
{formError && <p className="text-xs text-destructive">{formError}</p>}
<button
onClick={handleAdd}
className="text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors self-start"
>
+ Add tool
+ {t("addTool")}
</button>
</div>
</div>

View File

@@ -3,6 +3,7 @@
// src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab.tsx
import { useState, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import MarkdownMessage from "../MarkdownMessage";
import TokenCostCounter from "../TokenCostCounter";
import { useStreamMetrics } from "../../hooks/useStreamMetrics";
@@ -30,6 +31,7 @@ interface ChatTabProps {
* - Regenerate button
*/
export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps) {
const t = useTranslations("playground");
const pricing = getModelPricing(configState.model);
const streamMetrics = useStreamMetrics(pricing ?? undefined);
@@ -53,10 +55,14 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
if (configState.systemPrompt.trim()) {
out.push({ role: "system", content: configState.systemPrompt });
}
out.push(...chatMessages.filter((m) => m.role !== "system").map((m) => ({
role: m.role,
content: m.content,
})));
out.push(
...chatMessages
.filter((m) => m.role !== "system")
.map((m) => ({
role: m.role,
content: m.content,
}))
);
return out;
}
@@ -92,130 +98,132 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
}
const doSend = async (chatMessages: Message[], appendIndex?: number) => {
if (!configState.model) {
setError("Set a model in the config pane.");
if (!configState.model) {
setError(t("setModelInConfigFirst"));
return;
}
setLoading(true);
setError(null);
setResponseStatus(null);
const controller = new AbortController();
abortRef.current = controller;
const startTime = Date.now();
streamMetrics.start();
// If regenerating, replace the last assistant message; otherwise append
const targetIndex = appendIndex ?? chatMessages.length;
setMessages((prev) => {
const next = [...prev];
if (appendIndex !== undefined && next[appendIndex]?.role === "assistant") {
next[appendIndex] = { role: "assistant", content: "" };
} else {
next.push({ role: "assistant", content: "" });
}
return next;
});
try {
const fetchHeaders: Record<string, string> = { "Content-Type": "application/json" };
const res = await fetch("/api/v1/chat/completions", {
method: "POST",
headers: fetchHeaders,
body: JSON.stringify(buildRequestBody(chatMessages)),
signal: controller.signal,
});
setResponseStatus(res.status);
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
const errMsg: string =
(errData as { error?: { message?: string } }).error?.message ||
t("httpError", { status: res.status });
setError(errMsg);
setMessages((prev) => prev.slice(0, targetIndex));
setLoading(false);
setResponseDuration(Date.now() - startTime);
streamMetrics.reset();
return;
}
setLoading(true);
setError(null);
setResponseStatus(null);
let firstChunk = true;
const reader = res.body?.getReader();
const decoder = new TextDecoder();
let assistantResponse = "";
let usageData: { prompt_tokens?: number; completion_tokens?: number } | undefined;
const controller = new AbortController();
abortRef.current = controller;
const startTime = Date.now();
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
streamMetrics.start();
if (firstChunk) {
streamMetrics.onFirstChunk();
firstChunk = false;
}
// If regenerating, replace the last assistant message; otherwise append
const targetIndex = appendIndex ?? chatMessages.length;
setMessages((prev) => {
const next = [...prev];
if (appendIndex !== undefined && next[appendIndex]?.role === "assistant") {
next[appendIndex] = { role: "assistant", content: "" };
} else {
next.push({ role: "assistant", content: "" });
}
return next;
});
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n");
try {
const fetchHeaders: Record<string, string> = { "Content-Type": "application/json" };
const res = await fetch("/api/v1/chat/completions", {
method: "POST",
headers: fetchHeaders,
body: JSON.stringify(buildRequestBody(chatMessages)),
signal: controller.signal,
});
setResponseStatus(res.status);
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
const errMsg: string = (errData as { error?: { message?: string } }).error?.message || `Error ${res.status}`;
setError(errMsg);
setMessages((prev) => prev.slice(0, targetIndex));
setLoading(false);
setResponseDuration(Date.now() - startTime);
streamMetrics.reset();
return;
}
let firstChunk = true;
const reader = res.body?.getReader();
const decoder = new TextDecoder();
let assistantResponse = "";
let usageData: { prompt_tokens?: number; completion_tokens?: number } | undefined;
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (firstChunk) {
streamMetrics.onFirstChunk();
firstChunk = false;
}
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n");
for (const line of lines) {
if (line === "data: [DONE]") continue;
if (line.startsWith("data: ")) {
try {
const parsed = JSON.parse(line.slice(6)) as {
choices?: Array<{ delta?: { content?: string } }>;
usage?: { prompt_tokens?: number; completion_tokens?: number };
};
const delta = parsed.choices?.[0]?.delta?.content ?? "";
if (delta) {
assistantResponse += delta;
streamMetrics.onChunk(1);
setMessages((prev) => {
const next = [...prev];
const idx = appendIndex !== undefined ? appendIndex : next.length - 1;
next[idx] = { ...next[idx], content: assistantResponse };
return next;
});
}
if (parsed.usage) {
usageData = parsed.usage;
}
} catch {
// ignore parse errors for partial chunks
for (const line of lines) {
if (line === "data: [DONE]") continue;
if (line.startsWith("data: ")) {
try {
const parsed = JSON.parse(line.slice(6)) as {
choices?: Array<{ delta?: { content?: string } }>;
usage?: { prompt_tokens?: number; completion_tokens?: number };
};
const delta = parsed.choices?.[0]?.delta?.content ?? "";
if (delta) {
assistantResponse += delta;
streamMetrics.onChunk(1);
setMessages((prev) => {
const next = [...prev];
const idx = appendIndex !== undefined ? appendIndex : next.length - 1;
next[idx] = { ...next[idx], content: assistantResponse };
return next;
});
}
if (parsed.usage) {
usageData = parsed.usage;
}
} catch {
// ignore parse errors for partial chunks
}
}
}
}
streamMetrics.finish(usageData);
// metrics state will update asynchronously; snapshot from refs via computeMetrics directly
const metricsSnapshot = streamMetrics.metrics;
// Attach metrics to the assistant message
setMessages((prev) => {
const next = [...prev];
const idx = appendIndex !== undefined ? appendIndex : next.length - 1;
next[idx] = { ...next[idx], metrics: metricsSnapshot };
return next;
});
onMetricsUpdate?.(metricsSnapshot);
} catch (err: unknown) {
const e = err as { name?: string; message?: string };
if (e.name === "AbortError") {
setError("Request cancelled");
} else {
setError(e.message ?? "Network error");
}
streamMetrics.reset();
}
setResponseDuration(Date.now() - startTime);
setLoading(false);
streamMetrics.finish(usageData);
// metrics state will update asynchronously; snapshot from refs via computeMetrics directly
const metricsSnapshot = streamMetrics.metrics;
// Attach metrics to the assistant message
setMessages((prev) => {
const next = [...prev];
const idx = appendIndex !== undefined ? appendIndex : next.length - 1;
next[idx] = { ...next[idx], metrics: metricsSnapshot };
return next;
});
onMetricsUpdate?.(metricsSnapshot);
} catch (err: unknown) {
const e = err as { name?: string; message?: string };
if (e.name === "AbortError") {
setError(t("requestCancelled"));
} else {
setError(e.message ?? t("networkError"));
}
streamMetrics.reset();
}
setResponseDuration(Date.now() - startTime);
setLoading(false);
};
const handleSend = async () => {
@@ -271,11 +279,13 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-bg-alt text-xs text-text-muted">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]">chat</span>
<span className="font-medium">Chat</span>
<span className="font-medium">{t("tabChat")}</span>
{responseStatus !== null && (
<span
className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
responseStatus < 400 ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" : "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
responseStatus < 400
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
}`}
>
{responseStatus}
@@ -288,16 +298,16 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
<button
onClick={() => void handleRegenerate()}
className="flex items-center gap-1 text-xs text-text-muted hover:text-text-main transition-colors"
title="Regenerate last response"
title={t("regenerateLastResponse")}
>
<span className="material-symbols-outlined text-[14px]">refresh</span>
Regenerate
{t("regenerate")}
</button>
)}
<button
onClick={handleClear}
className="p-1 rounded hover:bg-red-500/10 text-text-muted hover:text-red-500 transition-colors"
title="Clear chat"
title={t("clearChat")}
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
@@ -310,9 +320,9 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
<div className="flex items-center justify-center h-full text-text-muted text-sm">
<div className="text-center space-y-2">
<span className="material-symbols-outlined text-[48px] text-text-muted/30">chat</span>
<p>Start a conversation type a message below</p>
<p>{t("startConversation")}</p>
{!configState.model && (
<p className="text-amber-500 text-xs">Set a model in the config pane first</p>
<p className="text-amber-500 text-xs">{t("setModelInConfigFirst")}</p>
)}
</div>
</div>
@@ -328,7 +338,7 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
}`}
>
<span className="text-[10px] text-text-muted uppercase mb-1 px-1">
{msg.role}
{t(`role.${msg.role}`)}
</span>
<div
className={`px-4 py-2.5 rounded-2xl text-sm ${
@@ -360,12 +370,14 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
{/* Loading indicator */}
{loading && messages[messages.length - 1]?.role === "user" && (
<div className="flex flex-col max-w-[85%] mr-auto items-start">
<span className="text-[10px] text-text-muted uppercase mb-1 px-1">assistant</span>
<span className="text-[10px] text-text-muted uppercase mb-1 px-1">
{t("role.assistant")}
</span>
<div className="px-4 py-2 rounded-2xl text-sm bg-bg-alt border border-border rounded-tl-sm text-text-muted flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] animate-spin">
progress_activity
</span>
Generating...
{t("generating")}
</div>
</div>
)}
@@ -385,7 +397,7 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (Enter to send, Shift+Enter for newline)"
placeholder={t("typeMessageWithShortcut")}
className="flex-1 min-h-[44px] max-h-[120px] bg-surface border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary resize-y"
rows={1}
disabled={loading}
@@ -396,7 +408,7 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border text-sm text-text-muted hover:text-text-main hover:bg-black/5 transition-colors shrink-0"
>
<span className="material-symbols-outlined text-[16px]">stop</span>
Stop
{t("stop")}
</button>
) : (
<button
@@ -405,7 +417,7 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-primary/90 transition-colors shrink-0"
>
<span className="material-symbols-outlined text-[16px]">send</span>
Send
{t("send")}
</button>
)}
</div>

View File

@@ -3,6 +3,7 @@
// src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import CompareColumn, { type CompareColumnData, type ColumnStatus } from "../CompareColumn";
import type { ConfigState } from "../StudioConfigPane";
import type { StreamMetrics } from "@/shared/schemas/playground";
@@ -96,6 +97,7 @@ class ColumnMetricsTracker {
* Cmd+K (or Ctrl+K) focuses the "add column" model input.
*/
export default function CompareTab({ configState }: CompareTabProps) {
const t = useTranslations("playground");
const [columns, setColumns] = useState<ColumnState[]>(() => [
{
id: createColumnId(),
@@ -242,11 +244,11 @@ export default function CompareTab({ configState }: CompareTabProps) {
});
if (!res.ok) {
const text = await res.text().catch(() => "Unknown error");
const text = await res.text().catch(() => t("unknownError"));
throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
}
if (!res.body) throw new Error("No response body");
if (!res.body) throw new Error(t("noResponseBody"));
const reader = res.body.getReader();
const decoder = new TextDecoder();
@@ -290,8 +292,7 @@ export default function CompareTab({ configState }: CompareTabProps) {
}
const usage = parsed["usage"] as
| { prompt_tokens?: number; completion_tokens?: number }
| undefined;
{ prompt_tokens?: number; completion_tokens?: number } | undefined;
if (usage != null) {
tracker.finish(usage);
}
@@ -350,10 +351,10 @@ export default function CompareTab({ configState }: CompareTabProps) {
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Enter your prompt here…"
placeholder={t("comparePromptPlaceholder")}
rows={3}
className="w-full text-sm bg-surface border border-border rounded px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
aria-label="User prompt"
aria-label={t("userPrompt")}
/>
</div>
@@ -364,20 +365,20 @@ export default function CompareTab({ configState }: CompareTabProps) {
<button
onClick={cancelAll}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-destructive/10 text-destructive border border-destructive/30 hover:bg-destructive/20 transition-colors"
aria-label="Cancel all streams"
aria-label={t("cancelAllStreams")}
>
<span className="material-symbols-outlined text-[14px]">stop</span>
Cancel all
{t("cancelAll")}
</button>
) : (
<button
onClick={() => void runAll()}
disabled={columns.length === 0 || !prompt.trim()}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Run all columns"
aria-label={t("runAllColumns")}
>
<span className="material-symbols-outlined text-[14px]">play_arrow</span>
Run all
{t("runAll")}
</button>
)}
@@ -391,26 +392,26 @@ export default function CompareTab({ configState }: CompareTabProps) {
onKeyDown={(e) => {
if (e.key === "Enter") addColumn();
}}
placeholder="Model (Cmd+K)…"
placeholder={t("modelPlaceholderCompare")}
disabled={atColumnLimit}
className="text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main w-40 disabled:opacity-40"
aria-label="Model name for new column"
aria-label={t("newColumnModelName")}
/>
<button
onClick={addColumn}
disabled={atColumnLimit}
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title={atColumnLimit ? `Maximum ${MAX_COLUMNS} columns` : "Add column"}
aria-label="Add model column"
title={atColumnLimit ? t("maxColumnsReached", { max: MAX_COLUMNS }) : t("addColumn")}
aria-label={t("addModelColumn")}
>
<span className="material-symbols-outlined text-[14px]">add</span>
Add model
{t("addModel")}
</button>
</div>
{/* Column count indicator */}
<span className="ml-auto text-[11px] text-text-muted">
{columns.length}/{MAX_COLUMNS} columns
{t("columnCount", { count: columns.length, max: MAX_COLUMNS })}
</span>
</div>
@@ -436,9 +437,9 @@ export default function CompareTab({ configState }: CompareTabProps) {
<span className="material-symbols-outlined text-[48px] text-text-muted/30">
compare
</span>
<p className="text-sm">Add a model column to compare</p>
<p className="text-sm">{t("addModelToCompare")}</p>
<p className="text-xs text-text-muted/60">
Up to {MAX_COLUMNS} models simultaneously
{t("modelsSimultaneously", { max: MAX_COLUMNS })}
</p>
</div>
</div>

View File

@@ -1,15 +1,9 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import { Card, Badge } from "@/shared/components";
import {
xpForLevel,
calculateLevel,
cumulativeXpForLevel,
getLevelTier,
getLevelTitle,
} from "@/lib/gamification/xp";
import { cumulativeXpForLevel, getLevelTier, getLevelTitle } from "@/lib/gamification/xp";
interface UserLevel {
apiKeyId: string;
@@ -41,14 +35,50 @@ interface UserBadge {
badgeRarity?: string;
}
const TIER_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
bronze: { label: "Bronze", color: "text-amber-600", bg: "bg-amber-600/10" },
silver: { label: "Silver", color: "text-gray-300", bg: "bg-gray-300/10" },
gold: { label: "Gold", color: "text-yellow-400", bg: "bg-yellow-400/10" },
platinum: { label: "Platinum", color: "text-cyan-300", bg: "bg-cyan-300/10" },
diamond: { label: "Diamond", color: "text-violet-400", bg: "bg-violet-400/10" },
const TIER_CONFIG: Record<string, { labelKey: string; color: string; bg: string }> = {
bronze: { labelKey: "tiers.bronze", color: "text-amber-600", bg: "bg-amber-600/10" },
silver: { labelKey: "tiers.silver", color: "text-gray-300", bg: "bg-gray-300/10" },
gold: { labelKey: "tiers.gold", color: "text-yellow-400", bg: "bg-yellow-400/10" },
platinum: { labelKey: "tiers.platinum", color: "text-cyan-300", bg: "bg-cyan-300/10" },
diamond: { labelKey: "tiers.diamond", color: "text-violet-400", bg: "bg-violet-400/10" },
};
const BADGE_ICONS: Record<string, string> = {
sparkles: "auto_awesome",
zap: "bolt",
cpu: "memory",
whale: "water",
gift: "redeem",
heart: "favorite",
santa: "celebration",
trophy: "emoji_events",
compass: "explore",
languages: "translate",
blocks: "widgets",
gauge: "speed",
shield: "shield",
flame: "local_fire_department",
sword: "swords",
crown: "workspace_premium",
infinity: "all_inclusive",
rocket: "rocket_launch",
bug: "bug_report",
"git-merge": "merge",
medal: "military_tech",
question: "help",
};
function BadgeIcon({ icon, earned }: { icon: string | null; earned: boolean }) {
const materialIcon = icon ? BADGE_ICONS[icon] : null;
return materialIcon ? (
<span className="material-symbols-outlined text-[32px]" aria-hidden="true">
{materialIcon}
</span>
) : (
<span aria-hidden="true">{earned ? "🏅" : "🔒"}</span>
);
}
const RARITY_COLORS: Record<string, string> = {
common: "text-gray-400 border-gray-500/30",
uncommon: "text-green-400 border-green-500/30",
@@ -59,15 +89,17 @@ const RARITY_COLORS: Record<string, string> = {
export default function ProfilePage() {
const t = useTranslations("common");
const tg = useTranslations("gamification");
const locale = useLocale();
const [userLevel, setUserLevel] = useState<UserLevel | null>(null);
const [allBadges, setAllBadges] = useState<BadgeDef[]>([]);
const [earnedBadges, setEarnedBadges] = useState<UserBadge[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [selectedBadge, setSelectedBadge] = useState<BadgeDef | null>(null);
const [streak] = useState(0); // streak data comes from future API
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [levelRes, badgesRes, earnedRes] = await Promise.all([
fetch("/api/gamification/level"),
@@ -75,6 +107,10 @@ export default function ProfilePage() {
fetch("/api/gamification/badges/earned"),
]);
if (!levelRes.ok && !badgesRes.ok && !earnedRes.ok) {
throw new Error(tg("profileLoadFailed"));
}
if (levelRes.ok) {
const data = await levelRes.json();
setUserLevel(data.level ?? data);
@@ -87,15 +123,19 @@ export default function ProfilePage() {
const data = await earnedRes.json();
setEarnedBadges(data.badges ?? data ?? []);
}
} catch {
// silent fail
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : tg("profileLoadFailed"));
} finally {
setLoading(false);
}
}, []);
}, [tg]);
useEffect(() => {
fetchData();
const loadTimer = window.setTimeout(() => {
void fetchData();
}, 0);
return () => window.clearTimeout(loadTimer);
}, [fetchData]);
if (loading) {
@@ -116,9 +156,24 @@ export default function ProfilePage() {
const tier = getLevelTier(level);
const tierConfig = TIER_CONFIG[tier] || TIER_CONFIG.bronze;
const earnedIds = new Set(earnedBadges.map((b) => b.badgeId));
const translateBadge = (badge: BadgeDef, field: "name" | "description" | "criteria") => {
const key = `badges.${badge.id}.${field}`;
const fallback = field === "name" ? badge.name : badge.description || "";
return tg.has(key) ? tg(key) : fallback;
};
const translateRarity = (rarity: string) => {
const key = `rarities.${rarity}`;
return tg.has(key) ? tg(key) : rarity;
};
const translateCategory = (category: string) => {
const key = `categories.${category}`;
return tg.has(key) ? tg(key) : category;
};
return (
<div className="flex flex-col gap-6">
{error && <div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">{error}</div>}
{/* Level & XP Card */}
<Card>
<div className="flex flex-col md:flex-row md:items-center gap-6">
@@ -129,14 +184,16 @@ export default function ProfilePage() {
{level}
</div>
<div>
<h2 className="text-xl font-bold">{getLevelTitle(level)}</h2>
<h2 className="text-xl font-bold">
{tg(`levelTitles.${getLevelTitle(level).toLowerCase()}`)}
</h2>
<div className="flex items-center gap-2 mt-1">
<Badge className={`${tierConfig.bg} ${tierConfig.color} border-0`}>
{tierConfig.label} Tier
{tg("tierLabel", { tier: tg(tierConfig.labelKey) })}
</Badge>
{streak > 0 && (
<span className="text-sm text-orange-400 flex items-center gap-1">
🔥 {streak} day streak
🔥 {tg("dayStreak", { count: streak })}
</span>
)}
</div>
@@ -146,7 +203,7 @@ export default function ProfilePage() {
<div className="flex-1">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-text-muted">
Level {level} {level + 1}
{tg("levelProgress", { current: level, next: level + 1 })}
</span>
<span className="text-text-muted">
{xpInCurrentLevel.toLocaleString()} / {xpForNext.toLocaleString()} XP
@@ -159,7 +216,7 @@ export default function ProfilePage() {
/>
</div>
<p className="text-xs text-text-muted mt-1">
{totalXp.toLocaleString()} total XP earned
{tg("totalXpEarned", { count: totalXp.toLocaleString() })}
</p>
</div>
</div>
@@ -171,10 +228,8 @@ export default function ProfilePage() {
<div className="flex items-center gap-4">
<div className="text-5xl">🔥</div>
<div>
<p className="text-2xl font-bold">{streak} Day Streak</p>
<p className="text-sm text-text-muted">
Keep using OmniRoute daily to maintain your streak!
</p>
<p className="text-2xl font-bold">{tg("dayStreak", { count: streak })}</p>
<p className="text-sm text-text-muted">{tg("maintainStreak")}</p>
</div>
</div>
</Card>
@@ -183,14 +238,12 @@ export default function ProfilePage() {
{/* Badges Grid */}
<div>
<h3 className="text-lg font-semibold mb-4">
Badges ({earnedBadges.length} / {allBadges.length})
{tg("badgesTitle", { earned: earnedBadges.length, total: allBadges.length })}
</h3>
{allBadges.length === 0 ? (
<Card>
<div className="text-center py-12 text-text-muted">
No badges available yet. Keep using OmniRoute to unlock achievements!
</div>
<div className="text-center py-12 text-text-muted">{tg("noBadges")}</div>
</Card>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
@@ -209,16 +262,22 @@ export default function ProfilePage() {
: "border-border/50 bg-surface/50 opacity-50 grayscale hover:opacity-70"
}`}
>
<div className="text-3xl mb-2">{badge.icon || (isEarned ? "🏅" : "🔒")}</div>
<div className="text-3xl mb-2">
<BadgeIcon icon={badge.icon} earned={isEarned} />
</div>
<p className="font-semibold text-sm truncate">
{badge.hidden && !isEarned ? "???" : badge.name}
{badge.hidden && !isEarned ? "???" : translateBadge(badge, "name")}
</p>
<p className="text-xs text-text-muted mt-1 line-clamp-2">
{badge.hidden && !isEarned ? "Hidden badge" : badge.description || ""}
{badge.hidden && !isEarned
? tg("hiddenBadge")
: translateBadge(badge, "description")}
</p>
{isEarned && earnedInfo?.unlockedAt && (
<p className="text-[10px] text-text-muted mt-2">
Earned {new Date(earnedInfo.unlockedAt).toLocaleDateString()}
{tg("earnedDate", {
date: new Date(earnedInfo.unlockedAt).toLocaleDateString(locale),
})}
</p>
)}
{!isEarned && badge.criteria && (
@@ -241,56 +300,66 @@ export default function ProfilePage() {
<div className="bg-surface border border-border rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<span className="text-4xl">{selectedBadge.icon || "🏅"}</span>
<span className="text-4xl">
<BadgeIcon icon={selectedBadge.icon} earned={earnedIds.has(selectedBadge.id)} />
</span>
<div>
<h2 className="text-lg font-semibold">{selectedBadge.name}</h2>
<h2 className="text-lg font-semibold">{translateBadge(selectedBadge, "name")}</h2>
<span
className={`text-xs px-2 py-0.5 rounded ${RARITY_COLORS[selectedBadge.rarity] || RARITY_COLORS.common} bg-surface`}
>
{selectedBadge.rarity}
{translateRarity(selectedBadge.rarity)}
</span>
</div>
</div>
<button
type="button"
onClick={() => setSelectedBadge(null)}
className="text-text-muted hover:text-text-main text-xl"
aria-label={t("close")}
>
×
</button>
</div>
{selectedBadge.description && (
<p className="text-sm text-text-muted mb-4">{selectedBadge.description}</p>
<p className="text-sm text-text-muted mb-4">
{translateBadge(selectedBadge, "description")}
</p>
)}
{selectedBadge.category && (
<p className="text-xs text-text-muted mb-2">
Category: <span className="text-text-main">{selectedBadge.category}</span>
{tg("category")}:{" "}
<span className="text-text-main">{translateCategory(selectedBadge.category)}</span>
</p>
)}
{selectedBadge.criteria && (
<div className="p-3 rounded-lg bg-surface/50 border border-border/50">
<p className="text-xs font-medium text-text-muted mb-1">{t("profileHowToEarn")}</p>
<p className="text-sm">{selectedBadge.criteria}</p>
<p className="text-sm">{translateBadge(selectedBadge, "criteria")}</p>
</div>
)}
{earnedIds.has(selectedBadge.id) && (
<div className="mt-4 p-3 rounded-lg bg-emerald-500/10 text-emerald-400 text-sm">
Earned on{" "}
{new Date(
earnedBadges.find((b) => b.badgeId === selectedBadge.id)?.unlockedAt || ""
).toLocaleDateString()}
{" "}
{tg("earnedOn", {
date: new Date(
earnedBadges.find((b) => b.badgeId === selectedBadge.id)?.unlockedAt || ""
).toLocaleDateString(locale),
})}
</div>
)}
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={() => setSelectedBadge(null)}
className="px-4 py-2 text-sm rounded-lg border border-border text-text-muted hover:text-text-main transition-colors"
>
Close
{t("close")}
</button>
</div>
</div>

View File

@@ -8,6 +8,7 @@
*/
import { useState, useEffect, useCallback, Fragment } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap";
@@ -63,6 +64,7 @@ function successRate(successful: number, total: number): string {
}
export default function ProviderStatsPage() {
const t = useTranslations("providerStats");
const nodeMap = useProviderNodeMap();
const [data, setData] = useState<{
providers: ProviderStat[];
@@ -86,9 +88,9 @@ export default function ProviderStatsPage() {
setError(null);
setLastRefresh(new Date());
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
setError(err instanceof Error ? err.message : t("unknownError"));
}
}, []);
}, [t]);
useEffect(() => {
fetchData();
@@ -151,7 +153,7 @@ export default function ProviderStatsPage() {
<div className="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 provider stats...</p>
<p className="text-text-muted mt-4">{t("loading")}</p>
</div>
</div>
);
@@ -162,12 +164,12 @@ export default function ProviderStatsPage() {
<div>
<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 provider stats: {error}</p>
<p className="text-red-400">{t("loadFailed", { error })}</p>
<button
onClick={fetchData}
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>
@@ -180,13 +182,13 @@ export default function ProviderStatsPage() {
<div className="flex items-center justify-end gap-3">
{lastRefresh && (
<span className="text-xs text-text-muted">
Updated {lastRefresh.toLocaleTimeString()}
{t("updated", { time: lastRefresh.toLocaleTimeString() })}
</span>
)}
<button
onClick={fetchData}
className="p-2 rounded-lg bg-surface hover:bg-surface/80 text-text-muted hover:text-text-main transition-colors"
title="Refresh"
title={t("refresh")}
>
<span className="material-symbols-outlined text-[18px]">refresh</span>
</button>
@@ -199,7 +201,7 @@ export default function ProviderStatsPage() {
<div className="flex items-center justify-center size-8 rounded-lg bg-primary/10 text-primary">
<span className="material-symbols-outlined text-[18px]">analytics</span>
</div>
<span className="text-sm text-text-muted">Total Requests</span>
<span className="text-sm text-text-muted">{t("totalRequests")}</span>
</div>
<p className="text-xl font-semibold text-text-main">{formatNumber(totalRequests)}</p>
</Card>
@@ -209,7 +211,7 @@ export default function ProviderStatsPage() {
<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]">timer</span>
</div>
<span className="text-sm text-text-muted">Avg Latency</span>
<span className="text-sm text-text-muted">{t("avgLatency")}</span>
</div>
<p className="text-xl font-semibold text-text-main">{formatLatency(avgLatency)}</p>
</Card>
@@ -219,7 +221,7 @@ export default function ProviderStatsPage() {
<div className="flex items-center justify-center size-8 rounded-lg bg-green-500/10 text-green-500">
<span className="material-symbols-outlined text-[18px]">check_circle</span>
</div>
<span className="text-sm text-text-muted">Success Rate</span>
<span className="text-sm text-text-muted">{t("successRate")}</span>
</div>
<p className="text-xl font-semibold text-text-main">
{successRate(totalSuccessful, totalRequests)}
@@ -231,7 +233,7 @@ export default function ProviderStatsPage() {
<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]">dns</span>
</div>
<span className="text-sm text-text-muted">Active Providers</span>
<span className="text-sm text-text-muted">{t("activeProviders")}</span>
</div>
<p className="text-xl font-semibold text-text-main">{activeProviders}</p>
</Card>
@@ -242,58 +244,60 @@ export default function ProviderStatsPage() {
<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">table_chart</span>
Provider Breakdown
{t("providerBreakdown")}
</h2>
<span className="text-xs text-text-muted">{activeProviders} providers</span>
<span className="text-xs text-text-muted">
{t("providerCount", { count: activeProviders })}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 px-3 text-text-muted font-medium">Provider</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">{t("provider")}</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("totalRequests")}
>
Requests <SortIcon column="totalRequests" />
{t("requests")} <SortIcon column="totalRequests" />
</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("successfulRequests")}
>
Success <SortIcon column="successfulRequests" />
{t("success")} <SortIcon column="successfulRequests" />
</th>
<th className="text-right py-2 px-3 text-text-muted font-medium">Rate</th>
<th className="text-right py-2 px-3 text-text-muted font-medium">{t("rate")}</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("avgLatencyMs")}
>
Avg Latency <SortIcon column="avgLatencyMs" />
{t("avgLatency")} <SortIcon column="avgLatencyMs" />
</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("totalTokensIn")}
>
Tokens In <SortIcon column="totalTokensIn" />
{t("tokensIn")} <SortIcon column="totalTokensIn" />
</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("totalTokensOut")}
>
Tokens Out <SortIcon column="totalTokensOut" />
{t("tokensOut")} <SortIcon column="totalTokensOut" />
</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("avgTtftAfterToolMs")}
>
TTFT After Tool <SortIcon column="avgTtftAfterToolMs" />
{t("ttftAfterTool")} <SortIcon column="avgTtftAfterToolMs" />
</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("avgGapAfterToolMs")}
>
Gap After Tool <SortIcon column="avgGapAfterToolMs" />
{t("gapAfterTool")} <SortIcon column="avgGapAfterToolMs" />
</th>
<th className="py-2 px-3 w-8" />
</tr>
@@ -370,12 +374,20 @@ export default function ProviderStatsPage() {
<table className="w-full text-xs">
<thead>
<tr className="text-text-muted">
<th className="text-left py-1.5 px-6 pl-12 font-medium">Model</th>
<th className="text-right py-1.5 px-3 font-medium">Requests</th>
<th className="text-right py-1.5 px-3 font-medium">Success</th>
<th className="text-right py-1.5 px-3 font-medium">Rate</th>
<th className="text-left py-1.5 px-6 pl-12 font-medium">
{t("model")}
</th>
<th className="text-right py-1.5 px-3 font-medium">
Avg Latency
{t("requests")}
</th>
<th className="text-right py-1.5 px-3 font-medium">
{t("success")}
</th>
<th className="text-right py-1.5 px-3 font-medium">
{t("rate")}
</th>
<th className="text-right py-1.5 px-3 font-medium">
{t("avgLatency")}
</th>
<th className="px-3 w-8" />
</tr>
@@ -427,7 +439,7 @@ export default function ProviderStatsPage() {
{sortedProviders.length === 0 && (
<tr>
<td colSpan={10} className="py-8 text-center text-text-muted">
No provider data recorded yet.
{t("noProviderData")}
</td>
</tr>
)}
@@ -439,8 +451,8 @@ export default function ProviderStatsPage() {
{/* In-Memory Metrics */}
{data?.comboMetrics && Object.keys(data.comboMetrics).length > 0 && (
<Card
title="Combo Metrics"
subtitle="Per-combo latency and throughput from streaming"
title={t("comboMetrics")}
subtitle={t("comboMetricsDescription")}
icon="speed"
className="p-5"
>
@@ -449,10 +461,18 @@ export default function ProviderStatsPage() {
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 px-3 text-text-muted font-medium">Combo</th>
<th className="text-right py-2 px-3 text-text-muted font-medium">Requests</th>
<th className="text-right py-2 px-3 text-text-muted font-medium">Avg TTFT</th>
<th className="text-right py-2 px-3 text-text-muted font-medium">Avg Total</th>
<th className="text-right py-2 px-3 text-text-muted font-medium">Success</th>
<th className="text-right py-2 px-3 text-text-muted font-medium">
{t("requests")}
</th>
<th className="text-right py-2 px-3 text-text-muted font-medium">
{t("avgTtft")}
</th>
<th className="text-right py-2 px-3 text-text-muted font-medium">
{t("avgTotal")}
</th>
<th className="text-right py-2 px-3 text-text-muted font-medium">
{t("success")}
</th>
</tr>
</thead>
<tbody>
@@ -487,8 +507,8 @@ export default function ProviderStatsPage() {
{/* Telemetry Phase Breakdown */}
{data?.telemetry && Object.keys(data.telemetry).length > 0 && (
<Card
title="Request Telemetry"
subtitle="7-phase pipeline breakdown (last 5 minutes)"
title={t("requestTelemetry")}
subtitle={t("requestTelemetryDescription")}
icon="route"
className="p-5"
>

View File

@@ -173,7 +173,7 @@ export default function ConnectionsHeaderToolbar({
handleChangeCodexGlobalServiceMode(event.target.value as CodexGlobalServiceMode)
}
disabled={savingCodexGlobalServiceMode || !codexSettingsLoaded}
aria-label="Global Codex service mode"
aria-label={providerText(t, "globalCodexServiceMode", "Global Codex service mode")}
className="rounded-md border border-border bg-bg px-2 py-1 text-xs text-text-main outline-none transition-colors focus:border-primary disabled:opacity-60"
>
{codexGlobalServiceModeOptions.map((option) => (
@@ -285,7 +285,7 @@ export default function ConnectionsHeaderToolbar({
)
}
>
Connect
{providerText(t, "connect", "Connect")}
</Button>
<Button
size="sm"
@@ -293,13 +293,13 @@ export default function ConnectionsHeaderToolbar({
icon="add"
onClick={() => gateConnectionFlow(openApiKeyAddFlow)}
>
Manual API key
{providerText(t, "manualApiKey", "Manual API key")}
</Button>
</>
) : (
<>
<Button size="sm" icon="add" onClick={() => gateConnectionFlow(openPrimaryAddFlow)}>
{providerSupportsPat ? "Add PAT" : t("add")}
{providerSupportsPat ? providerText(t, "addPat", "Add PAT") : t("add")}
</Button>
{providerId === "qoder" && (
<Button
@@ -307,7 +307,7 @@ export default function ConnectionsHeaderToolbar({
variant="secondary"
onClick={() => gateConnectionFlow(onOpenOAuthModal)}
>
Experimental OAuth
{providerText(t, "experimentalOauth", "Experimental OAuth")}
</Button>
)}
{providerId === "codex" && (
@@ -337,9 +337,7 @@ export default function ConnectionsHeaderToolbar({
icon="upload_file"
onClick={() => gateConnectionFlow(onOpenImportCodex)}
>
{typeof (t as any).has === "function" && (t as any).has("importCodexAuth")
? t("importCodexAuth")
: "Import auth"}
{providerText(t, "importCodexAuth", "Import auth")}
</Button>
)}
{providerId === "claude" && (
@@ -349,9 +347,7 @@ export default function ConnectionsHeaderToolbar({
icon="upload_file"
onClick={() => gateConnectionFlow(onOpenImportClaude)}
>
{typeof (t as any).has === "function" && (t as any).has("importClaudeAuth")
? t("importClaudeAuth")
: "Import auth"}
{providerText(t, "importClaudeAuth", "Import auth")}
</Button>
)}
{providerId === "grok-cli" && (
@@ -361,7 +357,7 @@ export default function ConnectionsHeaderToolbar({
icon="upload_file"
onClick={() => gateConnectionFlow(onOpenImportGrokCli)}
>
Import auth
{providerText(t, "importGrokAuth", "Import auth")}
</Button>
)}
</>

View File

@@ -364,13 +364,13 @@ export default function ModelCompatPopover({
setParamDirty(true);
}}
onBlur={() => saveModelParamFilters()}
placeholder="thinking, … (comma-separated)"
placeholder={t("compatBlockedParamsPlaceholder")}
disabled={disabled}
className="mb-1 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] font-mono text-text-main placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
/>
<p className="text-[10px] text-text-muted">
{t("compatBlockedParamsHint") ?? "Blocked params (stripped from requests)"}
{paramSaving && " ● saving…"}
{paramSaving && `${t("compatSaving")}`}
</p>
</div>
<div>
@@ -382,7 +382,7 @@ export default function ModelCompatPopover({
setParamDirty(true);
}}
onBlur={() => saveModelParamFilters()}
placeholder="reasoning, … (comma-separated)"
placeholder={t("compatAllowedParamsPlaceholder")}
disabled={disabled}
className="mb-1 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] font-mono text-text-main placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
/>

View File

@@ -1,10 +1,10 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { NoAuthAccountCard, NoAuthProviderCard } from "@/shared/components";
import { getProviderAlias, supportsNoAuthProviderProxy } from "@/shared/constants/providers";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
const ACCOUNT_PROVIDER_NAMES: Record<string, string> = {
mimocode: "MiMoCode",
@@ -25,6 +25,7 @@ export default function NoAuthProviderControls({
providerProxy,
onConfigureProviderProxy,
}: NoAuthProviderControlsProps) {
const noAuthT = useTranslations("noAuthProvider");
const notify = useNotificationStore();
const t = useTranslations("providers");
const [blockedProviders, setBlockedProviders] = useState<string[]>([]);
@@ -74,18 +75,22 @@ export default function NoAuthProviderControls({
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.error?.message || data?.error || "Failed to update provider");
throw new Error(data?.error?.message || data?.error || noAuthT("updateProviderFailed"));
}
setBlockedProviders(Array.isArray(data.blockedProviders) ? data.blockedProviders : next);
notify.success(`${providerName} ${nextEnabled ? "enabled" : "disabled"}`);
notify.success(
nextEnabled
? noAuthT("providerEnabled", { provider: providerName })
: noAuthT("providerDisabled", { provider: providerName })
);
} catch (error) {
setBlockedProviders(previous);
notify.error(error instanceof Error ? error.message : "Failed to update provider");
notify.error(error instanceof Error ? error.message : noAuthT("updateProviderFailed"));
} finally {
setSavingEnabled(false);
}
},
[blockedProviders, notify, providerAlias, providerId, providerName]
[blockedProviders, noAuthT, notify, providerAlias, providerId, providerName]
);
const accountProviderName = ACCOUNT_PROVIDER_NAMES[providerId];

View File

@@ -325,14 +325,14 @@ export default function ProviderParamFilterSection({
label={t("paramFiltersBlockedLabel")}
hint={t("paramFiltersBlockedHint")}
value={blockText}
placeholder="thinking, reasoning_budget, … (comma-separated)"
placeholder={t("paramFiltersBlockedPlaceholder")}
onChange={setBlockText}
/>
<ParamListField
label={t("paramFiltersAllowedLabel")}
hint={t("paramFiltersAllowedHint")}
value={allowText}
placeholder="reasoning, … (comma-separated)"
placeholder={t("paramFiltersAllowedPlaceholder")}
onChange={setAllowText}
/>
<AutoLearnToggle t={t} checked={autoLearn} onChange={setAutoLearn} />

View File

@@ -1,14 +1,20 @@
"use client";
import { useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Button, Card } from "@/shared/components";
type ZedImportCardProps = {
fetchConnections: () => Promise<void>;
notify: { success: (msg: string) => void; error: (msg: string) => void; info: (msg: string) => void };
notify: {
success: (msg: string) => void;
error: (msg: string) => void;
info: (msg: string) => void;
};
};
export default function ZedImportCard({ fetchConnections, notify }: ZedImportCardProps) {
const t = useTranslations("providers");
const [importingZed, setImportingZed] = useState(false);
const [showZedManual, setShowZedManual] = useState(false);
const [zedManualProvider, setZedManualProvider] = useState("openai");
@@ -25,28 +31,29 @@ export default function ZedImportCard({ fetchConnections, notify }: ZedImportCar
if (data.zedDockerEnvironment) {
setShowZedManual(true);
}
notify.error(data.error || "Zed import failed");
notify.error(data.error || t("zedImportFailed"));
} else if (!data.count) {
const found = data.credentials?.length ?? 0;
if (found === 0) {
notify.info("No Zed credentials found in keychain");
notify.info(t("zedNoCredentials"));
} else {
notify.info(
`Found ${found} keychain credential(s), but none matched supported providers`
);
notify.info(t("zedUnsupportedCredentials", { count: found }));
}
} else {
notify.success(
`Imported ${data.count} credential(s) from Zed for ${data.providers?.length ?? 0} provider(s)`
t("zedImportSuccess", {
credentials: data.count,
providers: data.providers?.length ?? 0,
})
);
await fetchConnections();
}
} catch (e: any) {
notify.error(e?.message || "Zed import failed");
notify.error(e?.message || t("zedImportFailed"));
} finally {
setImportingZed(false);
}
}, [importingZed, notify, fetchConnections]);
}, [fetchConnections, importingZed, notify, t]);
const handleZedManualImport = useCallback(async () => {
if (importingZedManual || !zedManualToken.trim()) return;
@@ -59,18 +66,18 @@ export default function ZedImportCard({ fetchConnections, notify }: ZedImportCar
});
const data = await res.json();
if (!res.ok || !data.success) {
notify.error(data.error?.message ?? data.error ?? "Manual import failed");
notify.error(data.error?.message ?? data.error ?? t("zedManualImportFailed"));
} else {
notify.success(`Imported ${zedManualProvider} token from Zed`);
notify.success(t("zedManualImportSuccess", { provider: zedManualProvider }));
setZedManualToken("");
await fetchConnections();
}
} catch (e: any) {
notify.error(e?.message || "Manual import failed");
notify.error(e?.message || t("zedManualImportFailed"));
} finally {
setImportingZedManual(false);
}
}, [importingZedManual, zedManualProvider, zedManualToken, notify, fetchConnections]);
}, [fetchConnections, importingZedManual, notify, t, zedManualProvider, zedManualToken]);
return (
<>
@@ -79,13 +86,9 @@ export default function ZedImportCard({ fetchConnections, notify }: ZedImportCar
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="material-symbols-outlined text-[20px]">download</span>
Import from Zed Keychain
{t("zedImportTitle")}
</h2>
<p className="text-sm text-text-muted mt-1">
Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) that
Zed IDE stored in the OS keychain and import them as connections. Requires Zed IDE
installed on this machine.
</p>
<p className="text-sm text-text-muted mt-1">{t("zedImportDescription")}</p>
</div>
<Button
size="sm"
@@ -94,7 +97,7 @@ export default function ZedImportCard({ fetchConnections, notify }: ZedImportCar
onClick={handleZedImport}
disabled={importingZed}
>
{importingZed ? "Importing" : "Import from Zed"}
{importingZed ? t("zedImporting") : t("zedImportButton")}
</Button>
</div>
</Card>
@@ -106,7 +109,7 @@ export default function ZedImportCard({ fetchConnections, notify }: ZedImportCar
>
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="material-symbols-outlined text-[20px]">edit</span>
Manual Token Import
{t("zedManualTitle")}
</h2>
<span className="material-symbols-outlined text-[18px] text-text-muted">
{showZedManual ? "expand_less" : "expand_more"}
@@ -115,10 +118,9 @@ export default function ZedImportCard({ fetchConnections, notify }: ZedImportCar
{showZedManual && (
<div className="flex flex-col gap-3 mt-1">
<p className="text-sm text-text-muted">
Use this when OmniRoute runs in Docker or the keychain is unavailable. Paste the
API key that Zed stored under{" "}
<code className="font-mono text-xs">~/.config/zed/settings.json</code> or copy
it from the Zed AI settings panel.
{t.rich("zedManualDescription", {
path: (chunks) => <code className="font-mono text-xs">{chunks}</code>,
})}
</p>
<div className="flex gap-2 flex-col sm:flex-row">
<select
@@ -137,7 +139,7 @@ export default function ZedImportCard({ fetchConnections, notify }: ZedImportCar
<input
type="password"
className="input input-sm flex-1"
placeholder="Paste API key…"
placeholder={t("zedPasteApiKey")}
value={zedManualToken}
onChange={(e) => setZedManualToken(e.target.value)}
/>
@@ -148,7 +150,7 @@ export default function ZedImportCard({ fetchConnections, notify }: ZedImportCar
onClick={handleZedManualImport}
disabled={importingZedManual || !zedManualToken.trim()}
>
{importingZedManual ? "Saving" : "Import"}
{importingZedManual ? t("zedSaving") : t("zedImportAction")}
</Button>
</div>
</div>

View File

@@ -950,7 +950,7 @@ export default function EditConnectionModal({
min={0}
value={formData.rpm}
onChange={(e) => setFormData({ ...formData, rpm: e.target.value })}
placeholder="Inherit"
placeholder={t("inherit")}
hint={t("rateLimitOverridesRpmHint")}
/>
<Input
@@ -959,7 +959,7 @@ export default function EditConnectionModal({
min={0}
value={formData.tpm}
onChange={(e) => setFormData({ ...formData, tpm: e.target.value })}
placeholder="Inherit"
placeholder={t("inherit")}
hint={t("rateLimitOverridesTpmHint")}
/>
<Input
@@ -968,7 +968,7 @@ export default function EditConnectionModal({
min={0}
value={formData.tpd}
onChange={(e) => setFormData({ ...formData, tpd: e.target.value })}
placeholder="Inherit"
placeholder={t("inherit")}
hint={t("rateLimitOverridesTpdHint")}
/>
<Input
@@ -977,7 +977,7 @@ export default function EditConnectionModal({
min={0}
value={formData.minTime}
onChange={(e) => setFormData({ ...formData, minTime: e.target.value })}
placeholder="Inherit"
placeholder={t("inherit")}
hint={t("rateLimitOverridesMinTimeHint")}
/>
<Input
@@ -988,7 +988,7 @@ export default function EditConnectionModal({
onChange={(e) =>
setFormData({ ...formData, rateLimitMaxConcurrent: e.target.value })
}
placeholder="Inherit"
placeholder={t("inherit")}
hint={t("rateLimitOverridesMaxConcurrentHint")}
/>
</div>

View File

@@ -202,7 +202,7 @@ export function ImportClaudeAuthModal({ onClose, onSuccess }: ImportClaudeAuthMo
newEntries.push({
name: file.name,
json: null,
parseError: "Not valid JSON",
parseError: t("claudeImportInvalidJson"),
email: null,
});
}
@@ -225,18 +225,30 @@ export function ImportClaudeAuthModal({ onClose, onSuccess }: ImportClaudeAuthMo
if (Array.isArray(arr)) {
setBulkEntries(
arr.map((item, i) => ({
name: `entry ${i + 1}`,
name: t("claudeImportEntryName", { number: i + 1 }),
json: item,
parseError: null,
email: null,
}))
);
} else {
setBulkEntries([{ name: "entry 1", json: arr, parseError: null, email: null }]);
setBulkEntries([
{
name: t("claudeImportEntryName", { number: 1 }),
json: arr,
parseError: null,
email: null,
},
]);
}
} catch {
setBulkEntries([
{ name: "parse error", json: null, parseError: "Invalid JSON", email: null },
{
name: t("claudeImportParseError"),
json: null,
parseError: t("claudeImportInvalidJson"),
email: null,
},
]);
}
};
@@ -289,7 +301,7 @@ export function ImportClaudeAuthModal({ onClose, onSuccess }: ImportClaudeAuthMo
try {
const validEntries = bulkEntries.filter((e) => e.json !== null);
if (validEntries.length === 0) {
notify.error("No valid entries to import");
notify.error(t("claudeImportNoValidEntries"));
return;
}
const res = await fetch("/api/providers/claude-auth/import-bulk", {
@@ -462,7 +474,7 @@ export function ImportClaudeAuthModal({ onClose, onSuccess }: ImportClaudeAuthMo
type="text"
value={singleName}
onChange={(e) => setSingleName(e.target.value)}
placeholder="My Claude account"
placeholder={t("providerDetailMyClaudeAccountPlaceholder")}
className="w-full rounded border border-border bg-bg-subtle px-2 py-1.5 text-xs text-text-main"
/>
</div>
@@ -692,7 +704,7 @@ export function ApplyClaudeAuthModal({
<code className="block rounded bg-sidebar px-2 py-1.5 text-xs font-mono text-text-main">
~/.claude/.credentials.json
</code>
<p className="mt-1 text-xs text-text-muted">Path is auto-detected per OS (Linux/Mac).</p>
<p className="mt-1 text-xs text-text-muted">{t("providerDetailPathAutoDetected")}</p>
</div>
<div>
<div className="text-xs uppercase text-text-muted mb-1">{backupLabel}</div>

View File

@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useNotificationStore } from "@/store/notificationStore";
import { Button, Modal } from "@/shared/components";
@@ -41,6 +42,7 @@ export default function ImportGrokCliAuthModal({
onSuccess,
}: ImportGrokCliAuthModalProps) {
const notify = useNotificationStore();
const t = useTranslations("providers");
const [tab, setTab] = useState<"upload" | "paste">("upload");
const [parsedJson, setParsedJson] = useState<unknown>(null);
const [detectedEmail, setDetectedEmail] = useState<string | null>(null);
@@ -58,9 +60,7 @@ export default function ImportGrokCliAuthModal({
setHasRefreshToken(false);
const result = parseGrokJson(json);
if (!result.valid) {
setParseError(
"Not a valid Grok Build auth.json. Expected an object with a key containing a JWT."
);
setParseError(t("grokInvalidAuth"));
return;
}
setDetectedEmail(result.email);
@@ -76,7 +76,7 @@ export default function ImportGrokCliAuthModal({
try {
handlePreview(JSON.parse(ev.target?.result as string));
} catch {
setParseError("Could not parse JSON");
setParseError(t("grokParseFailed"));
}
};
reader.readAsText(file);
@@ -94,7 +94,7 @@ export default function ImportGrokCliAuthModal({
try {
handlePreview(JSON.parse(text));
} catch {
setParseError("Could not parse JSON");
setParseError(t("grokParseFailed"));
setParsedJson(null);
}
}
@@ -111,24 +111,26 @@ export default function ImportGrokCliAuthModal({
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data.error || "Failed to import");
setError(data.error || t("grokImportFailed"));
return;
}
notify.success("Grok Build connection imported successfully");
notify.success(t("grokImportSuccess"));
onSuccess();
} catch {
setError("Failed to import Grok Build auth");
setError(t("grokImportFailed"));
} finally {
setLoading(false);
}
}
return (
<Modal isOpen onClose={onClose} title="Import Grok Build Auth" size="md">
<Modal isOpen onClose={onClose} title={t("grokImportTitle")} size="md">
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">
Import your Grok Build <code>~/.grok/auth.json</code> file. You can get it by running{" "}
<code>grok login</code> in your terminal.
{t.rich("grokImportDescription", {
authPath: (chunks) => <code>{chunks}</code>,
command: (chunks) => <code>{chunks}</code>,
})}
</p>
{/* Tab toggle */}
@@ -137,13 +139,13 @@ export default function ImportGrokCliAuthModal({
className={`text-sm px-3 py-1 rounded-t ${tab === "upload" ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
onClick={() => setTab("upload")}
>
Upload file
{t("grokUploadFile")}
</button>
<button
className={`text-sm px-3 py-1 rounded-t ${tab === "paste" ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
onClick={() => setTab("paste")}
>
Paste JSON
{t("grokPasteJson")}
</button>
</div>
@@ -179,22 +181,19 @@ export default function ImportGrokCliAuthModal({
<div className="flex flex-col gap-3">
<div className="bg-green-500/10 border border-green-500/20 rounded-md p-3">
<p className="text-sm text-green-400">
Valid Grok Build token detected{detectedEmail ? ` (${detectedEmail})` : ""}
{t("grokValidToken")}
{detectedEmail ? ` (${detectedEmail})` : ""}
</p>
{hasRefreshToken && (
<p className="text-xs text-green-500 mt-1">
Refresh token included automatic token renewal enabled
</p>
<p className="text-xs text-green-500 mt-1">{t("grokRefreshIncluded")}</p>
)}
{!hasRefreshToken && (
<p className="text-xs text-amber-400 mt-1">
No refresh token found you will need to re-import when the token expires
</p>
<p className="text-xs text-amber-400 mt-1">{t("grokRefreshMissing")}</p>
)}
</div>
<input
type="text"
placeholder="Connection name (optional)"
placeholder={t("grokConnectionName")}
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full p-2 text-sm bg-input border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
@@ -208,10 +207,10 @@ export default function ImportGrokCliAuthModal({
{/* Buttons */}
<div className="flex gap-2">
<Button onClick={handleSubmit} fullWidth disabled={!parsedJson || loading}>
{loading ? "Saving" : "Save Connection"}
{loading ? t("grokSaving") : t("grokSaveConnection")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
{t("cancel")}
</Button>
</div>
</div>

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