From 4012bac41da5753c2a977bcdea34d352fcc299e2 Mon Sep 17 00:00:00 2001 From: nguyenha935 Date: Tue, 21 Jul 2026 23:41:02 +0700 Subject: [PATCH] fix(i18n): preserve remaining Vietnamese localization (#7935) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: nguyenha935 --- config/quality/eslint-suppressions.json | 28 - config/quality/file-size-baseline.json | 19 +- .../dashboard/activity/ActivityFeedClient.tsx | 15 +- .../activity/components/EventTypeFilter.tsx | 15 +- .../agent-skills/AgentSkillsPageClient.tsx | 53 +- .../components/McpA2aLinksBar.tsx | 11 +- .../components/SkillPreviewPane.tsx | 6 +- .../dashboard/analytics/ComboHealthTab.tsx | 209 +- .../analytics/CompressionAnalyticsTab.tsx | 73 +- .../analytics/ProviderUtilizationTab.tsx | 39 +- .../analytics/RouteExplainabilityTab.tsx | 177 +- .../components/DiversityScoreCard.tsx | 25 +- .../(dashboard)/dashboard/analytics/page.tsx | 9 +- .../api-manager/ApiManagerPageClient.tsx | 33 +- .../components/BypassProviderQuotaToggle.tsx | 8 +- .../components/UsageLimitSettings.tsx | 18 +- .../dashboard/batch/FilesListTab.tsx | 46 +- .../batch/components/UploadFileModal.tsx | 2 +- .../components/wizard/CsvMappingStep.tsx | 22 +- .../components/wizard/JsonlValidationStep.tsx | 25 +- .../dashboard/cache/media/MediaPageClient.tsx | 203 +- .../changelog/components/ChangelogViewer.tsx | 10 +- .../changelog/components/NewsViewer.tsx | 8 +- .../(dashboard)/dashboard/changelog/page.tsx | 6 +- .../components/ChaosBasicSettingsFields.tsx | 10 +- .../ChaosProviderOverridesPanel.tsx | 23 +- .../components/AntigravityToolCard.tsx | 15 +- .../ClaudeClassifierCompatToggle.tsx | 21 +- .../components/CliProfileAutoSyncToggles.tsx | 25 +- .../cli-code/components/CodexToolCard.tsx | 22 +- .../cli-code/components/CopilotToolCard.tsx | 39 +- .../cli-code/components/CustomCliCard.tsx | 2 +- .../cli-code/components/DefaultToolCard.tsx | 63 +- .../cli-code/components/DroidToolCard.tsx | 2 +- .../components/HermesAgentToolCard.tsx | 119 +- .../cli-code/components/OpenClawToolCard.tsx | 8 +- .../dashboard/cloud-agents/page.tsx | 45 +- .../combos/ComboControlCenterClient.tsx | 184 +- .../dashboard/combos/WeightTotalBar.tsx | 10 +- .../dashboard/combos/live/ComboLiveStudio.tsx | 31 +- src/app/(dashboard)/dashboard/combos/page.tsx | 12 +- .../playground/ComboPlaygroundClient.tsx | 95 +- .../compression/studio/CompareView.tsx | 221 +- .../compression/studio/CompressionCockpit.tsx | 28 +- .../dashboard/compression/studio/DiffPane.tsx | 36 +- .../studio/EncoderComparisonTable.tsx | 12 +- .../dashboard/compression/studio/PlayView.tsx | 21 +- .../compression/studio/PlaygroundInput.tsx | 172 +- .../compression/studio/WaterfallInspector.tsx | 19 +- .../dashboard/compression/studio/page.tsx | 14 +- .../context/CompressionStylesTile.tsx | 19 +- .../caveman/CavemanContextPageClient.tsx | 5 +- .../combos/CompressionCombosPageClient.tsx | 53 +- .../context/combos/CompressionHub.tsx | 77 +- .../omniglyph/OmniglyphContextPageClient.tsx | 102 +- .../context/settings/CompressionPanel.tsx | 71 +- .../context/settings/adaptiveTargetLabel.ts | 24 + .../dashboard/costs/CostOverviewTab.tsx | 29 +- .../costs/components/ApiKeyUsageLimitCard.tsx | 39 +- .../dashboard/costs/components/CostCharts.tsx | 4 +- .../components/AllocationTable.tsx | 19 +- .../quota-share/components/PoolWizard.tsx | 57 +- .../dashboard/endpoint/ApiEndpointsTab.tsx | 78 +- .../dashboard/endpoint/EndpointPageClient.tsx | 35 +- .../components/ObsidianSourceCard.tsx | 131 +- .../health/ProviderHealthAutopilotCard.tsx | 239 +- src/app/(dashboard)/dashboard/health/page.tsx | 94 +- .../dashboard/leaderboard/page.tsx | 82 +- .../[kind]/[id]/MediaProviderPageClient.tsx | 25 +- .../components/EmbeddingExampleCard.tsx | 8 +- .../components/ImageExampleCard.tsx | 26 +- .../components/LlmChatCard.tsx | 34 +- .../components/MusicExampleCard.tsx | 12 +- .../components/OcrExampleCard.tsx | 4 +- .../components/SttExampleCard.tsx | 12 +- .../components/TtsExampleCard.tsx | 10 +- .../components/VideoExampleCard.tsx | 14 +- .../components/WebFetchExampleCard.tsx | 4 +- .../components/WebSearchExampleCard.tsx | 16 +- .../components/SkillInspectorPane.tsx | 29 +- .../playground/components/CompareColumn.tsx | 31 +- .../components/ImprovePromptButton.tsx | 41 +- .../playground/components/ParamSliders.tsx | 23 +- .../playground/components/ProviderMetrics.tsx | 16 +- .../components/ReasoningControls.tsx | 12 +- .../components/StructuredOutputEditor.tsx | 32 +- .../components/StudioConfigPane.tsx | 71 +- .../playground/components/ToolsBuilder.tsx | 59 +- .../playground/components/tabs/ChatTab.tsx | 266 +- .../playground/components/tabs/CompareTab.tsx | 37 +- .../(dashboard)/dashboard/profile/page.tsx | 163 +- .../dashboard/provider-stats/page.tsx | 92 +- .../components/ConnectionsHeaderToolbar.tsx | 20 +- .../[id]/components/ModelCompatPopover.tsx | 6 +- .../components/NoAuthProviderControls.tsx | 15 +- .../components/ProviderParamFilterSection.tsx | 4 +- .../[id]/components/ZedImportCard.tsx | 56 +- .../components/modals/EditConnectionModal.tsx | 10 +- .../modals/ImportClaudeAuthModal.tsx | 26 +- .../modals/ImportGrokCliAuthModal.tsx | 45 +- .../onboarding/ProviderOnboardingWizard.tsx | 58 +- .../onboarding/providerOnboardingCatalog.ts | 2 - .../services/components/ApiKeyField.tsx | 36 +- .../services/components/AutoStartToggle.tsx | 6 +- .../components/CliproxyConnectionPanel.tsx | 65 +- .../components/CliproxyModelMappingEditor.tsx | 54 +- .../components/NinerouterEmbedFrame.tsx | 8 +- .../components/NinerouterInstallWizard.tsx | 30 +- .../components/NinerouterModelList.tsx | 71 +- .../NinerouterProviderExposureCard.tsx | 33 +- .../components/ServiceLifecycleButtons.tsx | 12 +- .../services/components/ServiceLogsPanel.tsx | 19 +- .../services/components/ServiceStatusCard.tsx | 17 +- .../services/hooks/useServiceLogs.ts | 6 +- .../dashboard/providers/services/page.tsx | 9 +- .../services/tabs/BifrostServiceTab.tsx | 5 +- .../services/tabs/CliproxyServiceTab.tsx | 5 +- .../providers/services/tabs/MuxServiceTab.tsx | 2 +- .../services/tabs/NinerouterServiceTab.tsx | 5 +- .../dashboard/relay/RelayProxyClient.tsx | 116 +- .../runtime/components/ModelCooldownsCard.tsx | 34 +- .../search-tools/components/ResultsPanel.tsx | 4 +- .../search-tools/components/ScrapeResult.tsx | 35 +- .../components/SearchToolsConfigPane.tsx | 50 +- .../components/tabs/CompareTab.tsx | 64 +- .../components/tabs/SearchTab.tsx | 32 +- .../settings/components/AppearanceTab.tsx | 48 +- .../settings/components/ComboDefaultsTab.tsx | 8 +- .../DatabaseBackupRetentionCard.tsx | 23 +- .../settings/components/FeatureFlagCard.tsx | 31 +- .../settings/components/FeatureFlagsGrid.tsx | 107 +- .../settings/components/ModelLockoutCard.tsx | 141 +- .../settings/components/PricingTab.tsx | 41 +- .../components/ProxyRegistryManager.tsx | 11 +- .../settings/components/SecurityTab.tsx | 17 +- .../settings/components/SystemStorageTab.tsx | 107 +- .../settings/components/proxy/FreePoolTab.tsx | 18 +- .../components/proxy/SourceToggleBar.tsx | 6 +- src/app/(dashboard)/dashboard/tokens/page.tsx | 130 +- .../agent-bridge/AgentBridgePageClient.tsx | 36 +- .../components/ModelSelectorModal.tsx | 29 +- .../agent-bridge/components/SetupWizard.tsx | 66 +- .../components/CustomHostsManager.tsx | 20 +- .../components/DetailsPanel.tsx | 29 +- .../components/HttpProxySnippetCard.tsx | 6 +- .../components/RequestRow.tsx | 12 +- .../components/RequestStreamingList.tsx | 14 +- .../components/TopBarControls.tsx | 11 +- .../components/chat/ChatBubble.tsx | 26 +- .../components/session/SessionPicker.tsx | 33 +- .../components/shared/AnnotationField.tsx | 6 +- .../components/shared/HeaderTable.tsx | 10 +- .../components/shared/SseEventList.tsx | 6 +- .../components/tabs/RequestBodyTab.tsx | 10 +- .../components/tabs/ResponseBodyTab.tsx | 16 +- .../translator/TranslatorPageClient.tsx | 21 +- .../advanced/CompressionPreviewAccordion.tsx | 64 +- .../components/advanced/PipelineView.tsx | 35 +- .../components/advanced/RawJsonPanel.tsx | 32 +- .../advanced/StreamTransformerAccordion.tsx | 5 +- .../dashboard/usage/components/BudgetTab.tsx | 150 +- .../usage/components/FreeBudgetCard.tsx | 201 +- .../ProviderLimits/ProviderUsdCostModal.tsx | 56 +- .../parts/QuotaCardExpanded.tsx | 12 +- src/app/globals.css | 2 +- src/app/layout.tsx | 5 +- src/i18n/messages/en.json | 2643 ++++++- src/i18n/messages/vi.json | 6198 ++++++++++++----- src/shared/components/Breadcrumbs.tsx | 113 +- src/shared/components/Button.tsx | 5 +- src/shared/components/CloudSyncStatus.tsx | 24 +- src/shared/components/ColumnToggle.tsx | 6 +- src/shared/components/ConsoleLogViewer.tsx | 44 +- src/shared/components/EmptyState.tsx | 9 +- src/shared/components/FilterBar.tsx | 10 +- src/shared/components/Header.tsx | 9 +- src/shared/components/Input.tsx | 4 +- src/shared/components/Modal.tsx | 39 +- src/shared/components/NoAuthAccountCard.tsx | 70 +- src/shared/components/NoAuthProviderCard.tsx | 9 +- src/shared/components/NotificationToast.tsx | 4 +- src/shared/components/ProviderIcon.tsx | 54 +- src/shared/components/Select.tsx | 8 +- src/shared/components/Sidebar.tsx | 2 +- src/shared/components/ThemeToggle.tsx | 9 +- src/shared/components/UsageAnalytics.tsx | 19 +- src/shared/components/analytics/charts.tsx | 77 +- .../components/analytics/rechartsDonuts.tsx | 12 +- src/shared/components/cli/CliToolCard.tsx | 19 +- .../compression/CompressionPipelineEditor.tsx | 19 +- .../compression/EngineConfigPage.tsx | 130 +- src/shared/constants/cliTools.ts | 20 +- .../constants/sidebarVisibility/sections.ts | 12 + .../unit/cli-catalog-display-contract.test.ts | 35 + .../dashboard-localization-contract.test.ts | 199 + tests/unit/dashboard-shell-tabs.test.ts | 28 +- .../gamification-display-contract.test.ts | 51 + tests/unit/i18n-vi-completeness.test.ts | 15 + .../ui/CompressionHub-patch-only.test.tsx | 22 +- .../ui/compression-pipeline-editor.test.tsx | 23 +- tests/unit/ui/compressionCockpit.test.tsx | 11 +- .../compressionHub-active-selector.test.tsx | 29 +- .../compressionHub-context-editing.test.tsx | 11 +- tests/unit/ui/compressionHub.test.tsx | 100 +- tests/unit/ui/compressionPanel.test.tsx | 35 +- tests/unit/ui/compressionStylesPanel.test.tsx | 35 +- tests/unit/ui/compressionStylesTile.test.tsx | 15 +- 207 files changed, 12021 insertions(+), 5221 deletions(-) create mode 100644 tests/unit/cli-catalog-display-contract.test.ts create mode 100644 tests/unit/dashboard-localization-contract.test.ts create mode 100644 tests/unit/gamification-display-contract.test.ts diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 4d3304c608..789ad579cc 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -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 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 7fae35196e..fde4c223d2 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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 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, diff --git a/src/app/(dashboard)/dashboard/activity/ActivityFeedClient.tsx b/src/app/(dashboard)/dashboard/activity/ActivityFeedClient.tsx index fd301f54f4..7b3b70187c 100644 --- a/src/app/(dashboard)/dashboard/activity/ActivityFeedClient.tsx +++ b/src/app/(dashboard)/dashboard/activity/ActivityFeedClient.tsx @@ -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 ? ( @@ -78,14 +75,14 @@ export default function ActivityFeedClient() { > progress_activity - Loading + {t("loading")} ) : ( - Refresh + {t("refresh")} )} @@ -114,7 +111,7 @@ export default function ActivityFeedClient() { > progress_activity - Loading activity… + {t("loadingActivity")} ) : ( diff --git a/src/app/(dashboard)/dashboard/activity/components/EventTypeFilter.tsx b/src/app/(dashboard)/dashboard/activity/components/EventTypeFilter.tsx index 080e3ae36b..10e80037bb 100644 --- a/src/app/(dashboard)/dashboard/activity/components/EventTypeFilter.tsx +++ b/src/app/(dashboard)/dashboard/activity/components/EventTypeFilter.tsx @@ -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 ( -
+
{CATEGORIES.map((cat) => ( )}
- {coverage ? ( - - ) : ( - - )} + {coverage ? : }
{/* MCP + A2A links */} @@ -264,10 +270,7 @@ export function AgentSkillsPageClient(): JSX.Element { {/* Two-column grid: left = skill cards, right = preview */}
{/* Left: skill cards list (col-span 7) */} -
+
{loadingCatalog ? ( Array.from({ length: 6 }).map((_, i) => ) ) : filteredSkills.length === 0 ? ( diff --git a/src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx b/src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx index cddb9cb6dd..d615e6be58 100644 --- a/src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx +++ b/src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx @@ -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." - /> - +
); } diff --git a/src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx b/src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx index e73ff67157..0e78ecc192 100644 --- a/src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx +++ b/src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx @@ -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" > - - article - + article

{t("previewEmpty")}

); @@ -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")} > = { - 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 }) {
-
Cost & quota forecast
-
- Linear projection from historical combo traffic and quota snapshots. +
+ {t("comboHealthForecastTitle")}
+
{t("comboHealthForecastDescription")}
- {forecast.quotaRisk.level} quota risk + {t("comboHealthQuotaRisk", { level: forecast.quotaRisk.level })} - {forecast.confidence.replace("_", " ")} confidence + {t("comboHealthConfidence", { + level: forecast.confidence.replace("_", " "), + })}
@@ -188,21 +165,24 @@ function ComboForecastPanel({ forecast }: { forecast: ComboForecastMetrics }) {
@@ -229,7 +211,7 @@ function ComboForecastPanel({ forecast }: { forecast: ComboForecastMetrics }) { {target.label || target.model}
- {resolveProviderName(target.provider, nodeMap)} · traffic{" "} + {resolveProviderName(target.provider, nodeMap)} · {t("comboHealthTraffic")}{" "} {formatShare(target.trafficShare)}
@@ -239,13 +221,13 @@ function ComboForecastPanel({ forecast }: { forecast: ComboForecastMetrics }) {
- Projected cost + {t("comboHealthProjectedCost")} {formatUsd(target.forecast.projectedCostUsd)}
- Projected quota + {t("comboHealthProjectedQuota")} {target.quota.projectedRemainingPct === null ? "n/a" @@ -253,7 +235,7 @@ function ComboForecastPanel({ forecast }: { forecast: ComboForecastMetrics }) {
- Pricing coverage + {t("comboHealthPricingCoverage")} {formatPercent(forecast.dataQuality.pricingCoveragePct, 0)} @@ -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 }) {
-

Combo Health Autopilot

+

+ {t("comboHealthAutopilotTitle")} +

-

- Prioritized recommendations from combo health, forecasts, quotas, and provider health. -

+

{t("comboHealthAutopilotDescription")}

@@ -373,7 +356,7 @@ function ComboAutopilotPanel({ report }: { report: ComboAutopilotReport }) {
) : (
- No active combo health issues detected for the selected range. + {t("comboHealthNoActiveIssues")}
)}
@@ -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
- Intelligent scoring inspector + {t("comboHealthScoringInspector")}
- Read-only recompute + {t("comboHealthReadOnlyRecompute")}
-
- Factor-level explanation for target ranking using current health, forecast, and routing - heuristics. -
+
{t("comboHealthScoringDescription")}
- Task: {inspector.taskType} + {t("comboHealthTask", { task: inspector.taskType })} {inspector.selectedExecutionKey ? ( - Selected rank #1 + {t("comboHealthSelectedRank")} ) : null}
@@ -448,7 +429,7 @@ function ComboScoringInspectorPanel({ inspector }: { inspector: ComboScoringInsp {topFactors.map((factor) => (
- {getFactorLabel(factor.key)} + {t(`comboHealthFactor.${factor.key}`)} +{factor.contribution.toFixed(3)} @@ -464,9 +445,21 @@ function ComboScoringInspectorPanel({ inspector }: { inspector: ComboScoringInsp
- Quota {formatPercentOrDash(target.signals.quotaRemainingPct)} - Latency {target.signals.avgLatencyMs ?? "n/a"}ms - Issues {target.signals.autopilotIssueCount} + + {t("comboHealthQuotaValue", { + value: formatPercentOrDash(target.signals.quotaRemainingPct), + })} + + + {t("comboHealthLatencyValue", { + value: target.signals.avgLatencyMs ?? "n/a", + })} + + + {t("comboHealthIssueCount", { + count: target.signals.autopilotIssueCount, + })} +
); @@ -474,7 +467,7 @@ function ComboScoringInspectorPanel({ inspector }: { inspector: ComboScoringInsp
) : (
- No inspectable targets for this combo. + {t("comboHealthNoInspectableTargets")}
)} @@ -516,12 +509,15 @@ function ComboHealthCard({ {autopilot ? ( - {getAutopilotLabel(autopilot.state)} · {autopilot.score} + {t(`comboHealthAutopilotState.${autopilot.state}`)} · {autopilot.score} ) : null}

- {combo.models.length} models across {combo.quotaHealth.providers.length} providers + {t("comboHealthModelProviderCount", { + models: combo.models.length, + providers: combo.quotaHealth.providers.length, + })}

@@ -534,13 +530,15 @@ function ComboHealthCard({ icon="balance" label={t("comboHealthUsageSkew")} value={combo.usageSkew.giniCoefficient.toFixed(2)} - subValue="Gini coefficient" + subValue={t("comboHealthGiniCoefficient")} />
@@ -553,7 +551,7 @@ function ComboHealthCard({ {t("comboHealthQuotaHealth")}
- Lowest remaining quota across providers with short trend signals. + {t("comboHealthQuotaHealthDescription")}
@@ -573,16 +571,18 @@ function ComboHealthCard({ {resolveProviderName(provider.provider, nodeMap)}
- Remaining quota {formatPercent(provider.remainingPct, 1)} + {t("comboHealthRemainingQuota", { + value: formatPercent(provider.remainingPct, 1), + })}
- {trendMeta.label} + {t(`comboHealthTrend.${provider.trend}`)} {provider.isExhausted ? ( - Exhausted + {t("statusExhausted")} ) : null}
@@ -603,7 +603,7 @@ function ComboHealthCard({
{t("comboHealthUsageSkew")}
- Model request share and token share within this combo. + {t("comboHealthUsageSkewDescription")}
@@ -617,8 +617,10 @@ function ComboHealthCard({
{entry.model}
- Request share {formatShare(entry.requestShare)} · Token share{" "} - {formatShare(entry.tokenShare)} + {t("comboHealthShareSummary", { + requests: formatShare(entry.requestShare), + tokens: formatShare(entry.tokenShare), + })}
{formatShare(entry.requestShare)} @@ -643,9 +645,11 @@ function ComboHealthCard({
-
Performance
+
+ {t("comboHealthPerformance")} +
- Reliability and throughput for routed combo traffic. + {t("comboHealthPerformanceDescription")}
@@ -676,7 +680,7 @@ function ComboHealthCard({ {t("comboHealthExecutionTargets")}
- Step-level runtime metrics and quota visibility for structured combo targets. + {t("comboHealthExecutionTargetsDescription")}
@@ -704,7 +708,7 @@ function ComboHealthCard({ ) : null} - {target.requests} req + {t("comboHealthRequestShort", { count: target.requests })} @@ -728,9 +732,11 @@ function ComboHealthCard({
- Quota scope: {target.quotaScope} - {target.quotaTrend ? Trend: {target.quotaTrend} : null} - {target.quotaIsExhausted ? Exhausted : null} + {t("comboHealthQuotaScope", { scope: target.quotaScope })} + {target.quotaTrend ? ( + {t("comboHealthTrendValue", { trend: target.quotaTrend })} + ) : null} + {target.quotaIsExhausted ? {t("statusExhausted")} : null}
))} @@ -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() {

{t("comboHealthTitle")}

-

- Monitor quota pressure, skewed model usage, and delivery performance by combo. -

+

{t("comboHealthIntro")}

@@ -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 })} ))}
@@ -946,12 +950,12 @@ export default function ComboHealthTab() { progress_activity - Retrying… + {t("retrying")} ) : ( <> refresh - Retry + {t("retry")} )} @@ -965,12 +969,9 @@ export default function ComboHealthTab() { monitor_heart -
- No combo health data available -
+
{t("comboHealthNoData")}
- Combo quota snapshots and routed requests will appear here after traffic starts - flowing. + {t("comboHealthNoDataDescription")}

{t("comboHealthGettingStarted")}

@@ -980,20 +981,22 @@ export default function ComboHealthTab() { check_circle - Create combos in Combos with multiple providers + {t.rich("comboHealthStepCreate", { + strong: (chunks) => {chunks}, + })}
  • check_circle - Send requests to combo endpoints to generate traffic data + {t("comboHealthStepSend")}
  • check_circle - Health metrics will appear automatically as requests are routed + {t("comboHealthStepAutomatic")}
  • @@ -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 })}
    {combos.map((combo) => ( 0 ? Math.round((count / total) * 100) : 0; return (
    {mode} - {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. - {" "} - · {skipped.toLocaleString()} skipped (no-op) + {t("compressionAnalyticsSkipped", { count: skipped.toLocaleString() })} )} @@ -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 (
    {provider} - {count} requests · {tokensSaved.toLocaleString()} tokens saved + {t("compressionAnalyticsModeStats", { + count, + tokens: tokensSaved.toLocaleString(), + })}
    @@ -157,7 +164,7 @@ export default function CompressionAnalyticsTab() { return (
    progress_activity - Loading compression analytics… + {t("compressionAnalyticsLoading")}
    ); } @@ -166,11 +173,8 @@ export default function CompressionAnalyticsTab() { return (
    compress - {error || "No compression data yet."} -

    - Compression requests will appear here after the first request via /v1/chat/completions - with compression enabled. -

    + {error || t("compressionAnalyticsNoDataYet")} +

    {t("compressionAnalyticsNoDataDescription")}

    ); } @@ -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")} ))}
    @@ -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(), + })} />
    @@ -248,7 +254,7 @@ export default function CompressionAnalyticsTab() {

    receipt_long - Real Usage Receipts + {t("compressionAnalyticsRealUsageReceipts")}

    @@ -278,7 +284,7 @@ export default function CompressionAnalyticsTab() {
    -
    Sources
    +
    {t("compressionAnalyticsSources")}
    {Object.entries(stats.realUsage.bySource) .map(([source, count]) => `${source}: ${count}`) @@ -294,7 +300,7 @@ export default function CompressionAnalyticsTab() {

    tune - Mode Breakdown + {t("compressionAnalyticsModeBreakdown")}

    {modes.map(([mode, data]) => ( @@ -316,7 +322,7 @@ export default function CompressionAnalyticsTab() {

    hub - Provider Breakdown + {t("compressionAnalyticsProviderBreakdown")}

    {providers.map(([prov, data]) => ( @@ -337,7 +343,7 @@ export default function CompressionAnalyticsTab() {

    show_chart - Last 24 Hours (Activity) + {t("compressionAnalyticsLast24HoursActivity")}

    {stats.last24h.map((entry, idx) => { @@ -348,7 +354,11 @@ export default function CompressionAnalyticsTab() {
    {entry.count} @@ -362,8 +372,12 @@ export default function CompressionAnalyticsTab() { })}
    -
    Max requests/hour: {maxCountPerHour}
    -
    Max tokens/hour: {maxTokensPerHour.toLocaleString()}
    +
    {t("compressionAnalyticsMaxRequests", { count: maxCountPerHour })}
    +
    + {t("compressionAnalyticsMaxTokens", { + count: maxTokensPerHour.toLocaleString(), + })} +
    )} @@ -376,8 +390,9 @@ export default function CompressionAnalyticsTab() {

    {t("compressionAnalyticsNoDataYet")}

    - Use POST /v1/chat/completions with - compression configuration to start tracking compression analytics. + {t.rich("compressionAnalyticsStartTracking", { + code: (chunks) => {chunks}, + })}

    )} @@ -386,9 +401,9 @@ export default function CompressionAnalyticsTab() {
    info - Compression analytics: Token savings tracked per mode (off, lite, - standard, aggressive, ultra, RTK, stacked), engine, compression combo, and provider. Hover - over charts for details. Use the time selector to view different time periods. + {t.rich("compressionAnalyticsInfo", { + strong: (chunks) => {chunks}, + })}
    diff --git a/src/app/(dashboard)/dashboard/analytics/ProviderUtilizationTab.tsx b/src/app/(dashboard)/dashboard/analytics/ProviderUtilizationTab.tsx index 331cca2def..b8d62d96ce 100644 --- a/src/app/(dashboard)/dashboard/analytics/ProviderUtilizationTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/ProviderUtilizationTab.tsx @@ -15,13 +15,6 @@ import type { UtilizationTimeRange, } from "@/shared/types/utilization"; -const RANGE_LABELS: Record = { - "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() {
    @@ -203,7 +196,7 @@ export default function ProviderUtilizationTab() { }`} > dns - Global View + {t("providerUtilizationGlobalView")}
    @@ -227,7 +220,7 @@ export default function ProviderUtilizationTab() { progress_activity - Loading utilization data… + {t("providerUtilizationLoading")}
    ) : error ? (
    @@ -249,12 +242,12 @@ export default function ProviderUtilizationTab() { progress_activity - Retrying… + {t("retrying")} ) : ( <> refresh - Retry + {t("retry")} )} @@ -267,7 +260,7 @@ export default function ProviderUtilizationTab() {

    {t("providerUtilizationNoData")}

    - Provider quota snapshots will appear here after utilization data is collected. + {t("providerUtilizationNoDataDescription")}

    @@ -280,22 +273,22 @@ export default function ProviderUtilizationTab() { check_circle - Connect providers via OAuth or API keys in Providers + {t.rich("providerUtilizationStepConnect", { + strong: (chunks) => {chunks}, + })}
  • check_circle - - Enable quota tracking by using the provider in a combo or direct request - + {t("providerUtilizationStepEnable")}
  • check_circle - Data will appear automatically as quota snapshots are collected + {t("providerUtilizationStepAutomatic")}
  • @@ -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")}
    @@ -373,7 +370,7 @@ export default function ProviderUtilizationTab() {
    0% - Remaining quota + {t("remainingQuota")} 100%
    diff --git a/src/app/(dashboard)/dashboard/analytics/RouteExplainabilityTab.tsx b/src/app/(dashboard)/dashboard/analytics/RouteExplainabilityTab.tsx index d5ea1b397b..694b7dbdd6 100644 --- a/src/app/(dashboard)/dashboard/analytics/RouteExplainabilityTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/RouteExplainabilityTab.tsx @@ -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 }) {
    - Weight {weightPct}% · {factor.details} + {t("routeWeight", { weight: weightPct })} · {factor.details}
    ); } function TargetTimeline({ targets }: { targets: ExplainTarget[] }) { + const t = useTranslations("analytics"); const nodeMap = useProviderNodeMap(); if (targets.length === 0) { - return
    No related target evidence persisted yet.
    ; + return
    {t("routeNoRelatedEvidence")}
    ; } return ( @@ -269,16 +271,16 @@ function TargetTimeline({ targets }: { targets: ExplainTarget[] }) {
    - {resolveProviderName(target.provider, nodeMap)} / {target.model || "unknown"} + {resolveProviderName(target.provider, nodeMap)} / {target.model || t("unknown")} {target.outcome === "selected" ? ( - Selected + {t("selected")} ) : null}
    - {formatDate(target.timestamp)} · {target.comboStepId || "no step id"} + {formatDate(target.timestamp)} · {target.comboStepId || t("routeNoStepId")}
    {target.reason}
    @@ -295,11 +297,14 @@ function TargetTimeline({ targets }: { targets: ExplainTarget[] }) { ); } -function replayAlignmentLabel(alignment: NonNullable["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["alignment"], + t: ReturnType +) { + 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["alignment"]) { @@ -310,28 +315,28 @@ function replayAlignmentVariant(alignment: NonNullable +
    -
    Exact runtime log
    +
    + {t("routeExactRuntimeLog")} +
    {resolveProviderName(replay.runtime.provider, nodeMap)} /{" "} - {replay.runtime.model || "unknown"} + {replay.runtime.model || t("unknown")}
    - {formatDate(replay.runtime.timestamp)} · {replay.runtime.comboStepId || "no step"} + {formatDate(replay.runtime.timestamp)} ·{" "} + {replay.runtime.comboStepId || t("routeNoStep")}
    @@ -339,7 +344,7 @@ function WhyThisTargetCard({ replay }: { replay: DecisionReplay | undefined }) { HTTP {replay.runtime.status || "n/a"} - call_logs exact + {t("routeCallLogsExact")}
    @@ -349,25 +354,27 @@ function WhyThisTargetCard({ replay }: { replay: DecisionReplay | undefined }) {
    -
    Read-only recompute
    +
    + {t("routeReadOnlyRecompute")} +
    {recompute.comboName} · {recompute.strategy} · {recompute.timeRange} /{" "} {recompute.horizon}
    - {replayAlignmentLabel(recompute.alignment)} + {replayAlignmentLabel(recompute.alignment, t)}
    ) : (
    - No combo candidate ranking can be recomputed for this request. + {t("routeNoRecomputeCandidates")}
    )} @@ -408,12 +415,12 @@ function WhyThisTargetCard({ replay }: { replay: DecisionReplay | undefined }) { {candidate.isRuntimeSelected ? ( - Runtime + {t("runtime")} ) : null} {candidate.wouldSelectNow ? ( - Top now + {t("routeTopNow")} ) : null}
    @@ -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) => ( ))} @@ -583,7 +593,7 @@ export default function RouteExplainabilityTab({
    route_off -
    Unable to load route explanation
    +
    {t("routeUnableToLoad")}
    {error}
    @@ -601,10 +611,9 @@ export default function RouteExplainabilityTab({
    route -
    No request logs available
    +
    {t("routeNoRequestLogs")}
    - Send traffic through OmniRoute first. Route explanations are generated from persisted - structured call logs. + {t("routeNoRequestLogsDescription")}
    @@ -614,7 +623,7 @@ export default function RouteExplainabilityTab({
    @@ -635,47 +644,53 @@ export default function RouteExplainabilityTab({ : "default" } > - {explanation.confidence} confidence + {t("routeConfidence", { confidence: explanation.confidence })}

    {explanation.summary}

    - +
    {[ - ["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]) => (
    - +
    {explanation.evidence.map((item) => (
    - +
    {explanation.decision.factors.map((factor) => ( @@ -724,15 +735,15 @@ export default function RouteExplainabilityTab({
    - +
      {explanation.recommendations.map((item) => (
    • @@ -745,7 +756,7 @@ export default function RouteExplainabilityTab({
    - + {explanation.limitations.length > 0 ? (
      {explanation.limitations.map((item) => ( @@ -758,9 +769,7 @@ export default function RouteExplainabilityTab({ ))}
    ) : ( -
    - No known limitations for this explanation. -
    +
    {t("routeNoKnownLimitations")}
    )}
    diff --git a/src/app/(dashboard)/dashboard/analytics/components/DiversityScoreCard.tsx b/src/app/(dashboard)/dashboard/analytics/components/DiversityScoreCard.tsx index fc4dce9d04..b2a4d686e7 100644 --- a/src/app/(dashboard)/dashboard/analytics/components/DiversityScoreCard.tsx +++ b/src/app/(dashboard)/dashboard/analytics/components/DiversityScoreCard.tsx @@ -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() { pie_chart

    {t("diversityScoreTitle")}

    - — Provider concentration snapshot for the recent traffic window. + — {t("diversityScoreDesc")}
    - Window: {data.windowSize} reqs · Last {Math.round(data.ttlMs / 60000)} mins + {t("diversityWindow", { + count: data.windowSize, + mins: Math.round(data.ttlMs / 60000), + })} - Shannon entropy + {t("diversityShannonEntropy")}
    @@ -133,7 +136,9 @@ export default function DiversityScoreCard() { {scorePercentage}% - score + + {t("diversityScoreLabel")} +
    @@ -141,7 +146,7 @@ export default function DiversityScoreCard() {
    {riskLabel}
    - Higher values mean traffic is spread across multiple providers. + {t("diversityHigherExplanation")}
    @@ -151,9 +156,7 @@ export default function DiversityScoreCard() { {/* Provider bars */}
    {providerEntries.length === 0 ? ( -
    - No recent usage data available. -
    +
    {t("diversityNoData")}
    ) : (
    {providerEntries.map(([provider, stat]) => ( diff --git a/src/app/(dashboard)/dashboard/analytics/page.tsx b/src/app/(dashboard)/dashboard/analytics/page.tsx index 702b270e82..8f604bbc9d 100644 --- a/src/app/(dashboard)/dashboard/analytics/page.tsx +++ b/src/app/(dashboard)/dashboard/analytics/page.tsx @@ -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() {
    {ANALYTICS_TABS.map((tab) => { diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index c9ad9b8be3..c3350c35ab 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -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({

    {t("maxActiveSessions")}

    -

    - 0 = unlimited. Return 429 when this key exceeds concurrent sticky sessions. -

    +

    {t("maxActiveSessionsDescription")}

    -

    Throttle Delay

    -

    - Add a fixed delay before requests for this key are routed. 0 = no slowdown. -

    +

    {t("throttleDelay")}

    +

    {t("throttleDelayDescription")}

    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} > @@ -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")} > close @@ -2939,7 +2934,9 @@ const PermissionsModal = memo(function PermissionsModal({ {conn.name || conn.id.slice(0, 8)} {!conn.isActive && ( - inactive + + {tc("inactive")} + )} ); @@ -2955,7 +2952,7 @@ const PermissionsModal = memo(function PermissionsModal({ {allCombos.length > 0 && (
    -

    Allowed Combos

    +

    {t("allowedCombos")}

    {allowAllCombos - ? "This key can use any combo." - : `Restricted to ${selectedCombos.length} combo${selectedCombos.length !== 1 ? "s" : ""}.`} + ? t("allCombosAllowed") + : t("restrictedComboCount", { count: selectedCombos.length })}

    {!allowAllCombos && (
    diff --git a/src/app/(dashboard)/dashboard/api-manager/components/BypassProviderQuotaToggle.tsx b/src/app/(dashboard)/dashboard/api-manager/components/BypassProviderQuotaToggle.tsx index 76ccbd25d4..27dc909641 100644 --- a/src/app/(dashboard)/dashboard/api-manager/components/BypassProviderQuotaToggle.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/components/BypassProviderQuotaToggle.tsx @@ -16,15 +16,13 @@ export function BypassProviderQuotaToggle({ onToggle: () => void; }) { const tc = useTranslations("common"); + const t = useTranslations("apiManager"); return (
    -

    Bypass provider quota cutoffs

    -

    - Allows this key to ignore upstream provider/account cutoff policy during routing. API key - USD quotas still apply. -

    +

    {t("bypassProviderQuota")}

    +

    {t("bypassProviderQuotaDescription")}

    ); } diff --git a/src/app/(dashboard)/dashboard/batch/FilesListTab.tsx b/src/app/(dashboard)/dashboard/batch/FilesListTab.tsx index c8a2af5fc9..bdf00fb486 100644 --- a/src/app/(dashboard)/dashboard/batch/FilesListTab.tsx +++ b/src/app/(dashboard)/dashboard/batch/FilesListTab.tsx @@ -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) { + 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 */}
    - {filesTotal ? `${filesTotal} files` : "Files"} + {typeof filesTotal === "number" + ? t("batchFilesCount", { count: filesTotal }) + : t("batchFilesListFilesTable")} {purposes.map((p) => ( ))} @@ -169,10 +173,10 @@ export default function FilesListTab({ ID - Filename + {t("batchFilesFilename")} - Purpose + {t("batchFilesPurpose")} {t("filesListSizeColumn")} @@ -181,10 +185,10 @@ export default function FilesListTab({ {t("filesListUsedByColumn")} - Created + {t("created")} - Expires + {t("batchFilesExpires")} {/* Actions */} @@ -197,14 +201,14 @@ export default function FilesListTab({
    - Loading… + {t("loading")}
    ) : filtered.length === 0 ? ( - No files found + {t("batchFilesNoneFound")} ) : ( @@ -289,13 +293,15 @@ export default function FilesListTab({ )} - {fileCreatedAt ? relativeTime(fileCreatedAt) : "—"} + {fileCreatedAt ? relativeTime(fileCreatedAt, locale) : "—"} {fileExpiresAt ? ( ) : ( - Never + + {t("batchFilesNeverExpires")} + )} {/* 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" > diff --git a/src/app/(dashboard)/dashboard/batch/components/UploadFileModal.tsx b/src/app/(dashboard)/dashboard/batch/components/UploadFileModal.tsx index d30d7b5427..034d4c63b5 100644 --- a/src/app/(dashboard)/dashboard/batch/components/UploadFileModal.tsx +++ b/src/app/(dashboard)/dashboard/batch/components/UploadFileModal.tsx @@ -124,7 +124,7 @@ export default function UploadFileModal({ onClose, onUploaded }: Props) {
    @@ -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")} {/* Conversion feedback */} {conversionResult && (
    - {conversionResult.rowsParsed} rows parsed + + {t("wizardCsvRowsParsed", { count: conversionResult.rowsParsed })} + {conversionResult.rowsSkipped > 0 && ( - {conversionResult.rowsSkipped} rows skipped + + {t("wizardCsvRowsSkipped", { count: conversionResult.rowsSkipped })} + )} {conversionResult.errors.slice(0, 5).map((e) => ( - Row {e.row}: {e.reason} + {t("wizardCsvRowError", { row: e.row, reason: e.reason })} ))}
    diff --git a/src/app/(dashboard)/dashboard/batch/components/wizard/JsonlValidationStep.tsx b/src/app/(dashboard)/dashboard/batch/components/wizard/JsonlValidationStep.tsx index 061704a04d..1d1ad6c982 100644 --- a/src/app/(dashboard)/dashboard/batch/components/wizard/JsonlValidationStep.tsx +++ b/src/app/(dashboard)/dashboard/batch/components/wizard/JsonlValidationStep.tsx @@ -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({ progress_activity - Validating… + {t("wizardValidating")}
    ); } @@ -71,8 +71,11 @@ export default function JsonlValidationStep({
    {t("wizardValidationOk")} - {result.totalLines} lines · {result.uniqueCustomIds} unique custom_ids ·{" "} - {t("wizardValidationFieldsOk")} + {t("wizardValidationSummary", { + lines: result.totalLines, + ids: result.uniqueCustomIds, + })}{" "} + · {t("wizardValidationFieldsOk")}
    @@ -82,7 +85,7 @@ export default function JsonlValidationStep({
    {t("wizardValidationErrors")} - {result.errors.length} error{result.errors.length !== 1 ? "s" : ""} found + {t("wizardValidationErrorCount", { count: result.errors.length })}
    @@ -98,7 +101,9 @@ export default function JsonlValidationStep({ {/* Duplicate IDs */} {result.duplicateCustomIds.length > 0 && (
    - Duplicate custom_ids detected: + + {t("wizardValidationDuplicateIds")} + {result.duplicateCustomIds.slice(0, 10).map((id) => ( {id} @@ -111,13 +116,15 @@ export default function JsonlValidationStep({ {result.errors.length > 0 && (
    - Errors (first {Math.min(result.errors.length, 50)}): + {t("wizardValidationFirstErrors", { + count: Math.min(result.errors.length, 50), + })}
    {result.errors.slice(0, 50).map((err) => (
    - Line {err.lineNumber} + {t("wizardValidationLine", { line: err.lineNumber })} {err.reason} {err.field && ( diff --git a/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx b/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx index cb89cd20c3..1a8809cf38 100644 --- a/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx +++ b/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx @@ -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): Provid })); } -function providersRequiringCredentials(registry: Record): 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 { +function fileToDataUrl(file: File, errorMessage: string): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { @@ -408,23 +384,20 @@ function fileToDataUrl(file: File): Promise { 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 ( -

    - No images returned. The provider might have accepted the request but returned empty data. -

    - ); + return

    {t("noImagesReturned")}

    ; } return (
    @@ -439,7 +412,7 @@ function ImageResults({ data }: { data: any }) { {/* eslint-disable-next-line @next/next/no-img-element */} {img.revised_prompt download - Save + {t("save")} {img.revised_prompt && (

    ({})); - 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() { }`} > {cfg.icon} - {cfg.label} + {t(cfg.labelKey)} ); })} @@ -759,7 +740,7 @@ export default function MediaPageClient() {

    {/* Provider dropdown */}
    - + setSpeechVoice(e.target.value)} @@ -822,7 +807,7 @@ export default function MediaPageClient() {
    - + 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)})

    )} -

    - Supports audio and video files up to 4 GB -

    +

    {t("audioVideoFileHint")}

    ) : ( <> @@ -883,7 +866,7 @@ export default function MediaPageClient() {
    )} -

    - Optional for image-to-image, editing and upscale workflows. -

    +

    {t("sourceImageHint")}

    )} -

    - Optional. Used by inpaint-style models that support masks. -

    +

    {t("maskImageHint")}

    )} @@ -926,9 +905,9 @@ export default function MediaPageClient() {