mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" : ""}`}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]) => (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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's <code>--permission-mode auto</code> security classifier
|
||||
with a synthetic allow, so fallback routes don'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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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] && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: "59–70%", label: "end-to-end savings (measured)" },
|
||||
{ value: "1456", label: "image tokens for a 1568×728 page (~28k chars)" },
|
||||
{ value: "100%", label: "reading accuracy on Fable 5 (n=30)" },
|
||||
];
|
||||
const ECONOMICS = [
|
||||
{ value: "~10×", id: "fewerTokens" },
|
||||
{ value: "59–70%", id: "savings" },
|
||||
{ value: "1456", id: "imageTokens" },
|
||||
{ value: "100%", id: "accuracy" },
|
||||
] as const;
|
||||
|
||||
// Section components are split out of the page component so each function stays
|
||||
// under the complexity gate's 80-line cap; the page composes them.
|
||||
|
||||
function PageHeader() {
|
||||
const t = useTranslations("omniglyph");
|
||||
return (
|
||||
<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:<reason></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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user