mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +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:
@@ -13,22 +13,97 @@
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const PATH_LABELS = {
|
||||
dashboard: "Dashboard",
|
||||
providers: "Providers",
|
||||
combos: "Combos",
|
||||
settings: "Settings",
|
||||
logs: "Logs",
|
||||
"audit-log": "Audit Log",
|
||||
console: "Console",
|
||||
logger: "Logger",
|
||||
translator: "Translator",
|
||||
playground: "Playground",
|
||||
add: "Add",
|
||||
edit: "Edit",
|
||||
keys: "API Keys",
|
||||
models: "Models",
|
||||
dashboard: "dashboard",
|
||||
providers: "providers",
|
||||
combos: "combos",
|
||||
settings: "settings",
|
||||
general: "general",
|
||||
appearance: "appearance",
|
||||
ai: "ai",
|
||||
routing: "routing",
|
||||
resilience: "resilience",
|
||||
advanced: "advanced",
|
||||
"access-tokens": "accessTokens",
|
||||
"feature-flags": "featureFlags",
|
||||
logs: "logs",
|
||||
"audit-log": "auditLog",
|
||||
console: "console",
|
||||
logger: "logger",
|
||||
translator: "translator",
|
||||
playground: "playground",
|
||||
add: "add",
|
||||
edit: "edit",
|
||||
keys: "apiKeys",
|
||||
models: "models",
|
||||
"cli-code": "cliCode",
|
||||
"cli-agents": "cliAgents",
|
||||
"acp-agents": "acpAgents",
|
||||
endpoint: "endpoint",
|
||||
"api-manager": "apiManager",
|
||||
context: "context",
|
||||
compression: "compression",
|
||||
services: "services",
|
||||
analytics: "analytics",
|
||||
costs: "costs",
|
||||
health: "health",
|
||||
runtime: "runtime",
|
||||
webhooks: "webhooks",
|
||||
home: "home",
|
||||
activity: "activity",
|
||||
"agent-skills": "agentSkills",
|
||||
"combo-health": "comboHealth",
|
||||
evals: "evals",
|
||||
search: "search",
|
||||
utilization: "utilization",
|
||||
"api-endpoints": "apiEndpoints",
|
||||
audit: "audit",
|
||||
a2a: "a2a",
|
||||
mcp: "mcp",
|
||||
batch: "batch",
|
||||
files: "files",
|
||||
media: "media",
|
||||
cache: "cache",
|
||||
changelog: "changelog",
|
||||
chaos: "chaos",
|
||||
"cloud-agents": "cloudAgents",
|
||||
live: "live",
|
||||
studio: "studio",
|
||||
aggressive: "aggressive",
|
||||
caveman: "caveman",
|
||||
ccr: "ccr",
|
||||
headroom: "headroom",
|
||||
lite: "lite",
|
||||
llmlingua: "llmlingua",
|
||||
omniglyph: "omniglyph",
|
||||
rtk: "rtk",
|
||||
"session-dedup": "sessionDedup",
|
||||
ultra: "ultra",
|
||||
budget: "budget",
|
||||
pricing: "pricing",
|
||||
"quota-share": "quotaShare",
|
||||
discovery: "discovery",
|
||||
"free-provider-rankings": "freeProviderRankings",
|
||||
"free-tiers": "freeTiers",
|
||||
gamification: "gamification",
|
||||
leaderboard: "leaderboard",
|
||||
limits: "limits",
|
||||
profile: "profile",
|
||||
plugins: "plugins",
|
||||
"provider-stats": "providerStats",
|
||||
new: "new",
|
||||
quota: "quota",
|
||||
relay: "relay",
|
||||
"search-tools": "searchTools",
|
||||
security: "security",
|
||||
sidebar: "sidebar",
|
||||
tokens: "tokens",
|
||||
tools: "tools",
|
||||
"agent-bridge": "agentBridge",
|
||||
"traffic-inspector": "trafficInspector",
|
||||
usage: "usage",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -36,24 +111,26 @@ const PATH_LABELS = {
|
||||
* @param {string} segment
|
||||
* @returns {string}
|
||||
*/
|
||||
function getLabel(segment) {
|
||||
return PATH_LABELS[segment] || segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||
function getLabel(segment, t) {
|
||||
const key = PATH_LABELS[segment];
|
||||
return key ? t(key) : segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||
}
|
||||
|
||||
export default function Breadcrumbs() {
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations("breadcrumbs");
|
||||
if (!pathname || pathname === "/dashboard") return null;
|
||||
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const crumbs = segments.map((seg, idx) => ({
|
||||
label: getLabel(seg),
|
||||
label: getLabel(seg, t),
|
||||
href: "/" + segments.slice(0, idx + 1).join("/"),
|
||||
isLast: idx === segments.length - 1,
|
||||
}));
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
aria-label={t("ariaLabel")}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -9,9 +9,12 @@ const variants = {
|
||||
"bg-white dark:bg-white/10 border border-black/10 dark:border-white/10 text-text-main hover:bg-black/5 dark:hover:bg-white/5",
|
||||
outline: "border border-black/15 dark:border-white/15 text-text-main hover:bg-black/5",
|
||||
ghost: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5 hover:text-text-main",
|
||||
warning: "bg-amber-500 text-white hover:bg-amber-600 shadow-sm",
|
||||
danger: "bg-red-500 text-white hover:bg-red-600 shadow-sm",
|
||||
};
|
||||
|
||||
export type ButtonVariant = keyof typeof variants;
|
||||
|
||||
const sizes = {
|
||||
sm: "h-7 px-3 text-xs rounded-control",
|
||||
md: "h-9 px-4 text-sm rounded-control",
|
||||
@@ -20,7 +23,7 @@ const sizes = {
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children?: React.ReactNode;
|
||||
variant?: keyof typeof variants;
|
||||
variant?: ButtonVariant;
|
||||
size?: keyof typeof sizes;
|
||||
icon?: string;
|
||||
iconRight?: string;
|
||||
|
||||
@@ -12,20 +12,22 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
// #6147 — user-facing labels renamed from "Cloud …" to "Remote Settings Sync"
|
||||
// wording (this feature syncs the operator's own settings to their own remote
|
||||
// store — it is not a cloud/telemetry service). Internal state keys, the
|
||||
// `cloud_*` material icons and the cloudSync.* wiring are intentionally kept.
|
||||
const STATUS_CONFIG = {
|
||||
connected: { icon: "cloud_done", color: "text-green-500", label: "Synced" },
|
||||
syncing: { icon: "cloud_sync", color: "text-blue-400 animate-pulse", label: "Syncing..." },
|
||||
disconnected: { icon: "cloud_off", color: "text-amber-500", label: "Sync Off" },
|
||||
error: { icon: "cloud_off", color: "text-red-400", label: "Sync Error" },
|
||||
disabled: { icon: "cloud_off", color: "text-text-muted/50", label: "Disabled" },
|
||||
connected: { icon: "cloud_done", color: "text-green-500", labelKey: "synced" },
|
||||
syncing: { icon: "cloud_sync", color: "text-blue-400 animate-pulse", labelKey: "syncing" },
|
||||
disconnected: { icon: "cloud_off", color: "text-amber-500", labelKey: "off" },
|
||||
error: { icon: "cloud_off", color: "text-red-400", labelKey: "error" },
|
||||
disabled: { icon: "cloud_off", color: "text-text-muted/50", labelKey: "disabled" },
|
||||
};
|
||||
|
||||
export default function CloudSyncStatus({ collapsed = false }) {
|
||||
const t = useTranslations("cloudSyncStatus");
|
||||
const [status, setStatus] = useState("disabled");
|
||||
const [lastSync, setLastSync] = useState(null);
|
||||
const mountedRef = useRef(true);
|
||||
@@ -77,6 +79,7 @@ export default function CloudSyncStatus({ collapsed = false }) {
|
||||
if (status === "disabled") return null;
|
||||
|
||||
const config = STATUS_CONFIG[status];
|
||||
const label = t(config.labelKey);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -84,10 +87,13 @@ export default function CloudSyncStatus({ collapsed = false }) {
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-xs rounded-lg hover:bg-white/5 transition-colors cursor-pointer w-full"
|
||||
title={
|
||||
lastSync
|
||||
? `Remote settings sync ${status === "connected" ? "connected" : "disconnected"} — Last sync: ${lastSync.toLocaleTimeString()}`
|
||||
: config.label
|
||||
? t("lastSync", {
|
||||
status: status === "connected" ? t("connected") : t("disconnected"),
|
||||
time: lastSync.toLocaleTimeString(),
|
||||
})
|
||||
: label
|
||||
}
|
||||
aria-label={`Remote settings sync status: ${config.label}`}
|
||||
aria-label={t("statusLabel", { status: label })}
|
||||
>
|
||||
<span className={`material-symbols-outlined text-[16px] ${config.color}`} aria-hidden="true">
|
||||
{config.icon}
|
||||
@@ -96,7 +102,7 @@ export default function CloudSyncStatus({ collapsed = false }) {
|
||||
<span
|
||||
className={`truncate ${status === "connected" ? "text-green-500" : "text-text-muted"}`}
|
||||
>
|
||||
{config.label}
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function ColumnToggle({ columns = [], visible = {}, onToggle }) {
|
||||
const t = useTranslations("common");
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
@@ -34,7 +36,7 @@ export default function ColumnToggle({ columns = [], visible = {}, onToggle }) {
|
||||
<div ref={ref} style={{ position: "relative" }}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
title="Toggle columns"
|
||||
title={t("toggleColumns")}
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
borderRadius: "6px",
|
||||
@@ -49,7 +51,7 @@ export default function ColumnToggle({ columns = [], visible = {}, onToggle }) {
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "14px" }}>⚙️</span>
|
||||
Columns
|
||||
{t("columns")}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* Console Log Viewer — Real-time application log viewer.
|
||||
@@ -45,6 +45,7 @@ const LEVEL_BG: Record<string, string> = {
|
||||
const POLL_INTERVAL = 5000; // 5 seconds
|
||||
|
||||
export default function ConsoleLogViewer() {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("loggers");
|
||||
const tv = useTranslations("logs.consoleViewer");
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
@@ -79,9 +80,12 @@ export default function ConsoleLogViewer() {
|
||||
|
||||
// Initial fetch + polling
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
const initialFetch = setTimeout(() => void fetchLogs(), 0);
|
||||
const interval = setInterval(fetchLogs, POLL_INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
return () => {
|
||||
clearTimeout(initialFetch);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [fetchLogs]);
|
||||
|
||||
// Auto-scroll to bottom on new logs
|
||||
@@ -107,7 +111,7 @@ export default function ConsoleLogViewer() {
|
||||
const formatTime = (ts: string) => {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString("en-US", {
|
||||
return d.toLocaleTimeString(locale, {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
@@ -153,7 +157,7 @@ export default function ConsoleLogViewer() {
|
||||
<select
|
||||
value={levelFilter}
|
||||
onChange={(e) => setLevelFilter(e.target.value)}
|
||||
aria-label="Filter by log level"
|
||||
aria-label={tv("filterByLevel")}
|
||||
className="px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] focus:outline-2 focus:outline-[var(--color-accent)]"
|
||||
>
|
||||
<option value="all">{t("allLevels")}</option>
|
||||
@@ -166,17 +170,17 @@ export default function ConsoleLogViewer() {
|
||||
{/* Search */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
placeholder={tv("searchPlaceholder")}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
aria-label="Search log entries"
|
||||
aria-label={tv("searchAria")}
|
||||
className="flex-1 min-w-[200px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
|
||||
/>
|
||||
|
||||
{/* Auto-scroll toggle */}
|
||||
<button
|
||||
onClick={() => setAutoScroll(!autoScroll)}
|
||||
title={autoScroll ? "Disable auto-scroll" : "Enable auto-scroll"}
|
||||
title={autoScroll ? tv("disableAutoScroll") : tv("enableAutoScroll")}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
autoScroll
|
||||
? "bg-cyan-500/15 text-cyan-400 border-cyan-500/30"
|
||||
@@ -186,7 +190,7 @@ export default function ConsoleLogViewer() {
|
||||
<span className="material-symbols-outlined text-[16px] align-middle mr-1">
|
||||
{autoScroll ? "vertical_align_bottom" : "lock"}
|
||||
</span>
|
||||
Auto-scroll
|
||||
{tv("autoScroll")}
|
||||
</button>
|
||||
|
||||
{/* Refresh */}
|
||||
@@ -201,13 +205,13 @@ export default function ConsoleLogViewer() {
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2 ml-auto text-xs text-[var(--color-text-muted)]">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span>{filteredLogs.length} entries</span>
|
||||
<span>{tv("entryCount", { count: filteredLogs.length })}</span>
|
||||
<span className="text-[var(--color-text-muted)]/50">•</span>
|
||||
<span>Last 1h</span>
|
||||
<span>{tv("lastHour")}</span>
|
||||
{lastUpdated && (
|
||||
<>
|
||||
<span className="text-[var(--color-text-muted)]/50">•</span>
|
||||
<span>Updated {lastUpdated.toLocaleTimeString()}</span>
|
||||
<span>{tv("updatedAt", { time: lastUpdated.toLocaleTimeString(locale) })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -221,9 +225,7 @@ export default function ConsoleLogViewer() {
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px] align-middle mr-2">error</span>
|
||||
{error}
|
||||
<span className="text-xs ml-2 opacity-70">
|
||||
— Make sure the application is writing logs to file (APP_LOG_TO_FILE=true)
|
||||
</span>
|
||||
<span className="text-xs ml-2 opacity-70">— {tv("fileLoggingRequired")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -233,7 +235,7 @@ export default function ConsoleLogViewer() {
|
||||
className="rounded-xl border border-[var(--color-border)] bg-[#0d1117] overflow-auto font-mono text-xs leading-relaxed"
|
||||
style={{ maxHeight: "calc(100vh - 340px)", minHeight: "400px" }}
|
||||
role="log"
|
||||
aria-label="Application console logs"
|
||||
aria-label={tv("consoleAria")}
|
||||
aria-live="polite"
|
||||
>
|
||||
{/* Header bar */}
|
||||
@@ -241,7 +243,9 @@ export default function ConsoleLogViewer() {
|
||||
<div className="w-3 h-3 rounded-full bg-[#FF5F56]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[#FFBD2E]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[#27C93F]" />
|
||||
<span className="ml-3 text-[#8b949e] text-[11px]">OmniRoute — Application Console</span>
|
||||
<span className="ml-3 text-[#8b949e] text-[11px]">
|
||||
OmniRoute — {tv("applicationConsole")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Log entries */}
|
||||
@@ -252,9 +256,7 @@ export default function ConsoleLogViewer() {
|
||||
terminal
|
||||
</span>
|
||||
<p>{t("noLogEntries")}</p>
|
||||
<p className="text-[10px] mt-1 opacity-60">
|
||||
Ensure APP_LOG_TO_FILE=true is set in your .env file
|
||||
</p>
|
||||
<p className="text-[10px] mt-1 opacity-60">{tv("emptyFileLoggingHint")}</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredLogs.map((entry, idx) => {
|
||||
@@ -316,7 +318,7 @@ export default function ConsoleLogViewer() {
|
||||
<span className="material-symbols-outlined text-[24px] animate-spin block mb-2">
|
||||
progress_activity
|
||||
</span>
|
||||
Loading logs...
|
||||
{t("loadingLogs")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,7 @@ export default function EmptyState({
|
||||
}: EmptyStateProps) {
|
||||
const t = useTranslations("common");
|
||||
const resolvedTitle = title ?? t("nothingHere");
|
||||
const usesMaterialSymbol = /^[a-z][a-z0-9_]*$/.test(icon);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -57,7 +58,13 @@ export default function EmptyState({
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
{usesMaterialSymbol ? (
|
||||
<span className="material-symbols-outlined" style={{ fontSize: "inherit" }}>
|
||||
{icon}
|
||||
</span>
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
</div>
|
||||
<h3
|
||||
style={{
|
||||
|
||||
@@ -21,16 +21,18 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function FilterBar({
|
||||
searchValue = "",
|
||||
onSearchChange,
|
||||
placeholder = "Search...",
|
||||
placeholder,
|
||||
filters = [],
|
||||
activeFilters = {},
|
||||
onFilterChange,
|
||||
children,
|
||||
}) {
|
||||
const t = useTranslations("common");
|
||||
const [expandedFilter, setExpandedFilter] = useState(null);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
@@ -57,7 +59,7 @@ export default function FilterBar({
|
||||
type="text"
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
placeholder={placeholder || t("search")}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px 8px 32px",
|
||||
@@ -139,7 +141,7 @@ export default function FilterBar({
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
All
|
||||
{t("all")}
|
||||
</button>
|
||||
{(filter.options || []).map((opt) => (
|
||||
<button
|
||||
@@ -186,7 +188,7 @@ export default function FilterBar({
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
{t("clear")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -248,11 +248,11 @@ export default function Header({
|
||||
type="button"
|
||||
onClick={onOpenCommandPalette}
|
||||
className="hidden md:inline-flex items-center gap-2 px-2.5 py-1.5 rounded-lg border border-black/10 dark:border-white/10 bg-bg-subtle text-text-muted hover:text-text-main hover:bg-black/[0.04] dark:hover:bg-white/[0.04] transition-colors"
|
||||
title="Quick navigation (⌘K / Ctrl+K)"
|
||||
aria-label="Open quick navigation"
|
||||
title={t("quickNavigationTitle")}
|
||||
aria-label={t("openQuickNavigation")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">search</span>
|
||||
<span className="text-xs">Quick nav</span>
|
||||
<span className="text-xs">{t("quickNavigation")}</span>
|
||||
<kbd className="hidden lg:inline-flex font-mono text-[10px] px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10">
|
||||
{isMac ? "⌘K" : "Ctrl+K"}
|
||||
</kbd>
|
||||
@@ -261,7 +261,7 @@ export default function Header({
|
||||
type="button"
|
||||
onClick={onOpenCommandPalette}
|
||||
className="md:hidden p-2 rounded-lg text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
aria-label="Open quick navigation"
|
||||
aria-label={t("openQuickNavigation")}
|
||||
>
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
</button>
|
||||
@@ -275,6 +275,7 @@ export default function Header({
|
||||
onClick={handleLogout}
|
||||
className="flex items-center justify-center p-2 rounded-lg text-text-muted hover:text-red-500 hover:bg-red-500/10 transition-all"
|
||||
title={t("logout")}
|
||||
aria-label={t("logout")}
|
||||
>
|
||||
<span className="material-symbols-outlined">logout</span>
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> {
|
||||
@@ -29,6 +30,7 @@ export default function Input({
|
||||
onKeyUp: externalOnKeyUp,
|
||||
...props
|
||||
}: InputProps) {
|
||||
const t = useTranslations("common");
|
||||
const generatedId = useId();
|
||||
const inputId = externalId || generatedId;
|
||||
const errorId = error ? `${inputId}-error` : undefined;
|
||||
@@ -134,7 +136,7 @@ export default function Input({
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
keyboard_capslock
|
||||
</span>
|
||||
Caps Lock is on
|
||||
{t("capsLockOn")}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useId } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import Button from "./Button";
|
||||
import Button, { type ButtonVariant } from "./Button";
|
||||
|
||||
// #6265 — preset for content-heavy modals: caps height on the OUTERMOST dialog
|
||||
// wrapper only (single scroll owner) and keeps the inner body plain (no
|
||||
@@ -27,6 +28,18 @@ interface ModalProps {
|
||||
maxWidth?: string;
|
||||
}
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
title?: React.ReactNode;
|
||||
message: React.ReactNode;
|
||||
confirmText?: React.ReactNode;
|
||||
cancelText?: React.ReactNode;
|
||||
variant?: ButtonVariant;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -40,6 +53,7 @@ export default function Modal({
|
||||
bodyClassName,
|
||||
compactHeader = false,
|
||||
}: ModalProps) {
|
||||
const t = useTranslations("common");
|
||||
const titleId = useId();
|
||||
const dialogRef = useRef(null);
|
||||
|
||||
@@ -186,7 +200,7 @@ export default function Modal({
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
aria-label={t("close")}
|
||||
className="p-1.5 rounded-lg text-text-muted hover:bg-black/5 dark:hover:bg-white/5 transition-colors shrink-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
@@ -218,26 +232,31 @@ export function ConfirmModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title = "Confirm",
|
||||
title,
|
||||
message,
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel",
|
||||
confirmText,
|
||||
cancelText,
|
||||
variant = "danger",
|
||||
loading = false,
|
||||
}) {
|
||||
}: ConfirmModalProps) {
|
||||
const t = useTranslations("common");
|
||||
const resolvedTitle = title ?? t("confirmTitle");
|
||||
const resolvedConfirmText = confirmText ?? t("confirmAction");
|
||||
const resolvedCancelText = cancelText ?? t("cancel");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
title={resolvedTitle}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose} disabled={loading}>
|
||||
{cancelText}
|
||||
{resolvedCancelText}
|
||||
</Button>
|
||||
<Button variant={variant as any} onClick={onConfirm} loading={loading}>
|
||||
{confirmText}
|
||||
<Button variant={variant} onClick={onConfirm} loading={loading}>
|
||||
{resolvedConfirmText}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, type ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Card from "./Card";
|
||||
import Button from "./Button";
|
||||
import DistributeProxiesButton from "./DistributeProxiesButton";
|
||||
@@ -92,13 +93,16 @@ export default function NoAuthAccountCard({
|
||||
generateAccountId,
|
||||
generateApiKey,
|
||||
dataKey = "fingerprints",
|
||||
description = "Ready to use — no signup needed. Add accounts for rate-limit rotation.",
|
||||
addLabel = "Add Account",
|
||||
description,
|
||||
addLabel,
|
||||
enabled = true,
|
||||
savingEnabled = false,
|
||||
onEnabledChange,
|
||||
providerProxyControl,
|
||||
}: NoAuthAccountCardProps) {
|
||||
const t = useTranslations("noAuthProvider");
|
||||
const resolvedDescription = description || t("accountDescription");
|
||||
const resolvedAddLabel = addLabel || t("addAccount");
|
||||
const [connections, setConnections] = useState<Connection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [adding, setAdding] = useState(false);
|
||||
@@ -144,8 +148,12 @@ export default function NoAuthAccountCard({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchConnections();
|
||||
void fetchSavedProxies();
|
||||
const loadTimer = window.setTimeout(() => {
|
||||
void fetchConnections();
|
||||
void fetchSavedProxies();
|
||||
}, 0);
|
||||
|
||||
return () => window.clearTimeout(loadTimer);
|
||||
}, [fetchConnections, fetchSavedProxies]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -176,14 +184,14 @@ export default function NoAuthAccountCard({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: providerId,
|
||||
name: `${providerName} Account 1`,
|
||||
name: t("accountName", { provider: providerName, number: 1 }),
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
providerSpecificData: { [dataKey]: [accountId] },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData?.error || `Failed to create connection (${res.status})`);
|
||||
throw new Error(errData?.error || t("createConnectionFailed"));
|
||||
}
|
||||
} else {
|
||||
const updated = [...allAccountIds, accountId];
|
||||
@@ -194,7 +202,7 @@ export default function NoAuthAccountCard({
|
||||
providerSpecificData: { [dataKey]: updated },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to update connection");
|
||||
if (!res.ok) throw new Error(t("updateConnectionFailed"));
|
||||
}
|
||||
await fetchConnections();
|
||||
} catch (err) {
|
||||
@@ -302,11 +310,11 @@ export default function NoAuthAccountCard({
|
||||
if (!conn || allAccountIds.length === 0) return;
|
||||
|
||||
const proxiesRes = await fetch("/api/settings/proxies");
|
||||
if (!proxiesRes.ok) throw new Error("Failed to fetch proxies");
|
||||
if (!proxiesRes.ok) throw new Error(t("fetchProxiesFailed"));
|
||||
const proxiesData = await proxiesRes.json();
|
||||
const savedProxies = (proxiesData?.items || []).filter((p: any) => p.status === "active");
|
||||
if (savedProxies.length === 0) {
|
||||
throw new Error("No saved proxies found. Add proxies in Settings → Proxy first.");
|
||||
throw new Error(t("noSavedProxiesError"));
|
||||
}
|
||||
|
||||
// #5217 (Gap 1): distribute stores by-id references too, so editing a pool
|
||||
@@ -323,7 +331,7 @@ export default function NoAuthAccountCard({
|
||||
providerSpecificData: { accountProxies: updatedProxies },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to update connection");
|
||||
if (!res.ok) throw new Error(t("updateConnectionFailed"));
|
||||
|
||||
await fetchConnections();
|
||||
};
|
||||
@@ -336,8 +344,8 @@ export default function NoAuthAccountCard({
|
||||
<span className="material-symbols-outlined text-[20px]">lock_open</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">No authentication required</p>
|
||||
<p className="text-xs text-text-muted">{description}</p>
|
||||
<p className="text-sm font-medium">{t("title")}</p>
|
||||
<p className="text-xs text-text-muted">{resolvedDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto sm:flex-nowrap">
|
||||
@@ -354,7 +362,7 @@ export default function NoAuthAccountCard({
|
||||
<div className="border-t border-border pt-3 mt-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
Accounts ({loading ? "..." : allAccountIds.length})
|
||||
{t("accounts", { count: loading ? "..." : allAccountIds.length })}
|
||||
</span>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{!loading && allAccountIds.length > 0 && (
|
||||
@@ -365,14 +373,14 @@ export default function NoAuthAccountCard({
|
||||
/>
|
||||
)}
|
||||
<Button size="sm" icon="add" onClick={handleAddAccount} disabled={adding || !enabled}>
|
||||
{adding ? "Adding..." : addLabel}
|
||||
{adding ? t("adding") : resolvedAddLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && allAccountIds.length === 0 && (
|
||||
<p className="text-xs text-text-muted py-2">
|
||||
Using auto-generated account. Click "{addLabel}" for rate-limit rotation.
|
||||
{t("autoGeneratedAccount", { addLabel: resolvedAddLabel })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -405,9 +413,11 @@ export default function NoAuthAccountCard({
|
||||
title={
|
||||
proxy
|
||||
? `Proxy: ${proxy.type}://${proxy.host}:${proxy.port}`
|
||||
: "Configure proxy"
|
||||
: t("configureProxy")
|
||||
}
|
||||
aria-label={
|
||||
proxy ? t("proxyConfigured", { host: proxy.host }) : t("configureProxy")
|
||||
}
|
||||
aria-label={proxy ? `Proxy configured: ${proxy.host}` : "Configure proxy"}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-[16px]"
|
||||
@@ -420,7 +430,7 @@ export default function NoAuthAccountCard({
|
||||
type="button"
|
||||
onClick={() => handleRemoveAccount(id)}
|
||||
className="shrink-0 rounded p-1 text-text-muted opacity-0 transition-colors hover:bg-red-500/10 hover:text-red-500 group-hover:opacity-100"
|
||||
aria-label="Remove account"
|
||||
aria-label={t("removeAccount")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">delete</span>
|
||||
</button>
|
||||
@@ -437,7 +447,9 @@ export default function NoAuthAccountCard({
|
||||
className="w-80 max-w-full rounded-lg border border-black/10 bg-surface p-4 shadow-lg dark:border-white/10"
|
||||
>
|
||||
<p className="mb-3 text-sm font-medium">
|
||||
Proxy for Account {allAccountIds.indexOf(proxyAccountId) + 1}
|
||||
{t("proxyForAccount", {
|
||||
number: allAccountIds.indexOf(proxyAccountId) + 1,
|
||||
})}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{/* #5217 (Gap 1): pick a pre-saved Proxy Pool entry by reference,
|
||||
@@ -452,7 +464,7 @@ export default function NoAuthAccountCard({
|
||||
: "text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
Saved
|
||||
{t("saved")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -463,7 +475,7 @@ export default function NoAuthAccountCard({
|
||||
: "text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
Custom
|
||||
{t("custom")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -474,9 +486,7 @@ export default function NoAuthAccountCard({
|
||||
className="w-full rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
|
||||
>
|
||||
<option value="">
|
||||
{savedProxies.length === 0
|
||||
? "No saved proxies — add one in Settings → Proxy"
|
||||
: "Direct (no proxy)"}
|
||||
{savedProxies.length === 0 ? t("noSavedProxies") : t("directConnection")}
|
||||
</option>
|
||||
{savedProxies.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
@@ -502,14 +512,14 @@ export default function NoAuthAccountCard({
|
||||
type="text"
|
||||
value={proxyHost}
|
||||
onChange={(e) => setProxyHost(e.target.value)}
|
||||
placeholder="Host"
|
||||
placeholder={t("host")}
|
||||
className="flex-1 rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={proxyPort}
|
||||
onChange={(e) => setProxyPort(e.target.value)}
|
||||
placeholder="Port"
|
||||
placeholder={t("port")}
|
||||
className="w-16 rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
|
||||
/>
|
||||
</div>
|
||||
@@ -517,14 +527,14 @@ export default function NoAuthAccountCard({
|
||||
type="text"
|
||||
value={proxyUsername}
|
||||
onChange={(e) => setProxyUsername(e.target.value)}
|
||||
placeholder="Username (optional)"
|
||||
placeholder={t("usernameOptional")}
|
||||
className="w-full rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={proxyPassword}
|
||||
onChange={(e) => setProxyPassword(e.target.value)}
|
||||
placeholder="Password (optional)"
|
||||
placeholder={t("passwordOptional")}
|
||||
className="w-full rounded-md border border-black/10 bg-bg px-2.5 py-1.5 text-xs dark:border-white/10"
|
||||
/>
|
||||
</>
|
||||
@@ -534,14 +544,14 @@ export default function NoAuthAccountCard({
|
||||
onClick={() => setProxyAccountId(null)}
|
||||
className="rounded-md px-3 py-1.5 text-xs text-text-muted transition-colors hover:bg-black/5 hover:text-text-main dark:hover:bg-white/5"
|
||||
>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveProxy}
|
||||
disabled={savingProxy}
|
||||
className="rounded-md bg-primary/10 px-3 py-1.5 text-xs text-primary transition-colors hover:bg-primary/20 disabled:opacity-50"
|
||||
>
|
||||
{savingProxy ? "Saving..." : "Save"}
|
||||
{savingProxy ? t("saving") : t("save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { ReactNode } from "react";
|
||||
import Card from "./Card";
|
||||
import NoAuthProviderToggle from "./NoAuthProviderToggle";
|
||||
@@ -17,6 +18,8 @@ export default function NoAuthProviderCard({
|
||||
onEnabledChange,
|
||||
providerProxyControl,
|
||||
}: NoAuthProviderCardProps) {
|
||||
const t = useTranslations("noAuthProvider");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -25,10 +28,8 @@ export default function NoAuthProviderCard({
|
||||
<span className="material-symbols-outlined text-[20px]">lock_open</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">No authentication required</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
This provider is ready to use immediately — no signup or API key needed.
|
||||
</p>
|
||||
<p className="text-sm font-medium">{t("title")}</p>
|
||||
<p className="text-xs text-text-muted">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto sm:flex-nowrap">
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const ICONS = {
|
||||
success: "✓",
|
||||
@@ -67,6 +68,7 @@ const COLORS = {
|
||||
};
|
||||
|
||||
function Toast({ notification, onDismiss }) {
|
||||
const t = useTranslations("common");
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
|
||||
const handleDismiss = () => {
|
||||
@@ -142,7 +144,7 @@ function Toast({ notification, onDismiss }) {
|
||||
e.stopPropagation();
|
||||
handleDismiss();
|
||||
}}
|
||||
aria-label="Dismiss notification"
|
||||
aria-label={t("dismissNotification")}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
* 1. Theme-aware static SVGs (`THEMED_SVGS`, e.g. arena-light/dark for lmarena)
|
||||
* 2. Try /providers/{id}.svg (local SVG assets — fastest, cached separately from JS bundle)
|
||||
* 3. Try @lobehub/icons direct React components (no @lobehub/ui peer runtime)
|
||||
* 4. Fall back to thesvg.org CDN (external SVG)
|
||||
* 5. Fall back to /providers/{id}.png (legacy static assets)
|
||||
* 4. Try /providers/{id}.png (legacy static assets)
|
||||
* 5. Fall back to thesvg.org CDN (external SVG)
|
||||
* 6. Fall back to a generic AI icon
|
||||
*
|
||||
* Usage:
|
||||
@@ -284,6 +284,12 @@ const THEMED_SVGS: Record<string, { light: string; dark: string }> = {
|
||||
},
|
||||
};
|
||||
|
||||
const PROVIDER_ICON_ALIASES: Record<string, string> = {
|
||||
"opencode-go": "opencode",
|
||||
"opencode-zen": "opencode",
|
||||
"poe-web": "poe",
|
||||
};
|
||||
|
||||
const ProviderIcon = memo(function ProviderIcon({
|
||||
providerId,
|
||||
size = 24,
|
||||
@@ -296,7 +302,7 @@ const ProviderIcon = memo(function ProviderIcon({
|
||||
fallbackColor,
|
||||
}: ProviderIconProps) {
|
||||
const { isDark } = useTheme();
|
||||
const normalizedId = providerId.toLowerCase();
|
||||
const normalizedId = PROVIDER_ICON_ALIASES[providerId.toLowerCase()] || providerId.toLowerCase();
|
||||
const localSvgId = LOCAL_SVG_ALIASES[normalizedId] || normalizedId;
|
||||
const lobeIcon = getLobeProviderIcon(normalizedId, type);
|
||||
const themedSvg = THEMED_SVGS[normalizedId];
|
||||
@@ -417,27 +423,7 @@ const ProviderIcon = memo(function ProviderIcon({
|
||||
);
|
||||
}
|
||||
|
||||
// Tier 4: thesvg.org CDN — external SVG fallback for unknown providers
|
||||
if (!theSvgFailed) {
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
style={{ display: "inline-flex", alignItems: "center", ...style }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- external SVG from thesvg.org, not a static/known asset */}
|
||||
<img
|
||||
src={`https://thesvg.org/icons/${normalizedId}/default.svg`}
|
||||
alt={providerId}
|
||||
width={size}
|
||||
height={size}
|
||||
style={{ objectFit: "contain", flex: "none" }}
|
||||
onError={() => setFailedAssets((current) => ({ ...current, [theSvgKey]: true }))}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Tier 5: Local PNG — last resort before generic icon
|
||||
// Tier 4: Known local PNG — avoid a failing external request when a bundled asset exists
|
||||
if (hasPng && !pngFailed) {
|
||||
return (
|
||||
<span
|
||||
@@ -457,6 +443,26 @@ const ProviderIcon = memo(function ProviderIcon({
|
||||
);
|
||||
}
|
||||
|
||||
// Tier 5: thesvg.org CDN — external SVG fallback for unknown providers
|
||||
if (!theSvgFailed) {
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
style={{ display: "inline-flex", alignItems: "center", ...style }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- external SVG from thesvg.org, not a static/known asset */}
|
||||
<img
|
||||
src={`https://thesvg.org/icons/${normalizedId}/default.svg`}
|
||||
alt={providerId}
|
||||
width={size}
|
||||
height={size}
|
||||
style={{ objectFit: "contain", flex: "none" }}
|
||||
onError={() => setFailedAssets((current) => ({ ...current, [theSvgKey]: true }))}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Tier 6: Generic AI icon
|
||||
return (
|
||||
<span className={className} style={{ display: "inline-flex", alignItems: "center", ...style }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
interface SelectOption {
|
||||
@@ -22,7 +23,7 @@ export default function Select({
|
||||
options = [],
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select an option",
|
||||
placeholder,
|
||||
error,
|
||||
hint,
|
||||
disabled = false,
|
||||
@@ -33,6 +34,7 @@ export default function Select({
|
||||
children,
|
||||
...props
|
||||
}: SelectProps) {
|
||||
const t = useTranslations("common");
|
||||
const generatedId = useId();
|
||||
const selectId = externalId || generatedId;
|
||||
const errorId = error ? `${selectId}-error` : undefined;
|
||||
@@ -72,9 +74,9 @@ export default function Select({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{!children && placeholder && (
|
||||
{!children && (placeholder ?? t("selectOption")) && (
|
||||
<option value="" disabled className="bg-surface text-text-muted">
|
||||
{placeholder}
|
||||
{placeholder ?? t("selectOption")}
|
||||
</option>
|
||||
)}
|
||||
{!children &&
|
||||
|
||||
@@ -537,7 +537,7 @@ export default function Sidebar({
|
||||
)}
|
||||
|
||||
<nav
|
||||
aria-label="Main navigation"
|
||||
aria-label={t("mainNavigation")}
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto py-1 custom-scrollbar",
|
||||
collapsed ? "px-2 space-y-0.5" : "px-3"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "@/shared/hooks/useTheme";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
@@ -10,7 +11,9 @@ export default function ThemeToggle({
|
||||
className?: any;
|
||||
variant?: string;
|
||||
}) {
|
||||
const { theme, toggleTheme, isDark } = useTheme();
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
const t = useTranslations("header");
|
||||
const toggleLabel = isDark ? t("switchToLightMode") : t("switchToDarkMode");
|
||||
|
||||
const variants = {
|
||||
default: cn(
|
||||
@@ -36,8 +39,8 @@ export default function ThemeToggle({
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={cn(variants[variant], className)}
|
||||
aria-label={`Switch to ${isDark ? "light" : "dark"} mode`}
|
||||
title={`Switch to ${isDark ? "light" : "dark"} mode`}
|
||||
aria-label={toggleLabel}
|
||||
title={toggleLabel}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import Card from "./Card";
|
||||
import { CardSkeleton } from "./Loading";
|
||||
import { fmtCompact as fmt, fmtFull, fmtCost } from "@/shared/utils/formatting";
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
// ============================================================================
|
||||
|
||||
export default function UsageAnalytics() {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("analytics");
|
||||
const tCommon = useTranslations("common");
|
||||
const [range, setRange] = useState("30d");
|
||||
@@ -87,7 +88,8 @@ export default function UsageAnalytics() {
|
||||
}, [range, customStart, customEnd, selectedApiKeys, tCommon]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalytics();
|
||||
const timer = window.setTimeout(() => void fetchAnalytics(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [fetchAnalytics]);
|
||||
|
||||
const handleRangeSelect = useCallback((value: string) => {
|
||||
@@ -111,7 +113,7 @@ export default function UsageAnalytics() {
|
||||
if (range !== "custom" || !customStart || !customEnd) return null;
|
||||
const fmt = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(undefined, {
|
||||
return d.toLocaleDateString(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
@@ -119,7 +121,7 @@ export default function UsageAnalytics() {
|
||||
});
|
||||
};
|
||||
return `${fmt(customStart)} — ${fmt(customEnd)}`;
|
||||
}, [range, customStart, customEnd]);
|
||||
}, [range, customStart, customEnd, locale]);
|
||||
|
||||
const ranges = [
|
||||
{ value: "1d", label: t("period1D") },
|
||||
@@ -144,8 +146,13 @@ export default function UsageAnalytics() {
|
||||
const wp = analytics?.weeklyPattern || [];
|
||||
if (!wp.length) return "—";
|
||||
const max = wp.reduce((a, b) => (a.avgTokens > b.avgTokens ? a : b), wp[0]);
|
||||
return max.avgTokens > 0 ? max.day : "—";
|
||||
}, [analytics]);
|
||||
if (max.avgTokens <= 0) return "—";
|
||||
const weekdayIndex = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(max.day);
|
||||
if (weekdayIndex < 0) return max.day;
|
||||
return new Intl.DateTimeFormat(locale, { weekday: "short" }).format(
|
||||
new Date(2024, 0, 7 + weekdayIndex)
|
||||
);
|
||||
}, [analytics, locale]);
|
||||
|
||||
const providerCount = useMemo(() => {
|
||||
return (analytics?.byProvider || []).length;
|
||||
|
||||
@@ -133,7 +133,13 @@ export function CompactStatGrid({ sections }: { sections: CompactStatSection[] }
|
||||
|
||||
export function ActivityHeatmap({ activityMap }) {
|
||||
const t = useTranslations("analytics");
|
||||
const locale = useLocale();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const monthFormatter = useMemo(() => createDateFormatter(locale, { month: "short" }), [locale]);
|
||||
const weekdayFormatter = useMemo(
|
||||
() => createDateFormatter(locale, { weekday: "short" }),
|
||||
[locale]
|
||||
);
|
||||
|
||||
const cells = useMemo(() => {
|
||||
const today = new Date();
|
||||
@@ -184,27 +190,18 @@ export function ActivityHeatmap({ activityMap }) {
|
||||
if (firstDay) {
|
||||
const m = new Date(firstDay.date).getMonth();
|
||||
if (m !== lastMonth) {
|
||||
const monthNames = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
labels.push({ weekIdx, label: monthNames[m] });
|
||||
labels.push({ weekIdx, label: monthFormatter.format(new Date(2024, m, 1)) });
|
||||
lastMonth = m;
|
||||
}
|
||||
}
|
||||
});
|
||||
return labels;
|
||||
}, [weeks]);
|
||||
}, [monthFormatter, weeks]);
|
||||
|
||||
const weekdayLabels = useMemo(
|
||||
() => [1, 3, 5].map((day) => weekdayFormatter.format(new Date(2024, 0, 7 + day))),
|
||||
[weekdayFormatter]
|
||||
);
|
||||
|
||||
function getCellColor(value) {
|
||||
if (!value || value === 0) return "bg-white/[0.04]";
|
||||
@@ -222,9 +219,13 @@ export function ActivityHeatmap({ activityMap }) {
|
||||
{t("overview")}
|
||||
</h3>
|
||||
<span className="text-xs text-text-muted">
|
||||
{Object.keys(activityMap || {}).length} active days ·{" "}
|
||||
{fmt(Object.values(activityMap || {}).reduce((a: number, b: number) => a + b, 0))} tokens
|
||||
· 365 days
|
||||
{t("activitySummary", {
|
||||
active: Object.keys(activityMap || {}).length,
|
||||
tokens: fmt(
|
||||
Object.values(activityMap || {}).reduce((a: number, b: number) => a + b, 0)
|
||||
),
|
||||
days: 365,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -249,11 +250,11 @@ export function ActivityHeatmap({ activityMap }) {
|
||||
<div className="flex gap-[3px]">
|
||||
<div className="flex flex-col gap-[3px] shrink-0 text-[10px] text-text-muted pr-1 sticky left-0 z-10 bg-surface">
|
||||
<span className="h-[10px]"></span>
|
||||
<span className="h-[10px] leading-[10px]">Mon</span>
|
||||
<span className="h-[10px] leading-[10px]">{weekdayLabels[0]}</span>
|
||||
<span className="h-[10px]"></span>
|
||||
<span className="h-[10px] leading-[10px]">Wed</span>
|
||||
<span className="h-[10px] leading-[10px]">{weekdayLabels[1]}</span>
|
||||
<span className="h-[10px]"></span>
|
||||
<span className="h-[10px] leading-[10px]">Fri</span>
|
||||
<span className="h-[10px] leading-[10px]">{weekdayLabels[2]}</span>
|
||||
<span className="h-[10px]"></span>
|
||||
</div>
|
||||
|
||||
@@ -262,7 +263,11 @@ export function ActivityHeatmap({ activityMap }) {
|
||||
{week.map((day, di) => (
|
||||
<div
|
||||
key={di}
|
||||
title={day ? `${day.date}: ${fmtFull(day.value)} tokens` : ""}
|
||||
title={
|
||||
day
|
||||
? t("activityCellTitle", { date: day.date, tokens: fmtFull(day.value) })
|
||||
: ""
|
||||
}
|
||||
className={`w-[10px] h-[10px] rounded-[2px] ${day ? getCellColor(day.value) : "bg-transparent"}`}
|
||||
/>
|
||||
))}
|
||||
@@ -273,13 +278,13 @@ export function ActivityHeatmap({ activityMap }) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 mt-2 ml-6 text-[10px] text-text-muted">
|
||||
<span>Less</span>
|
||||
<span>{t("activityLess")}</span>
|
||||
<div className="w-[10px] h-[10px] rounded-[2px] bg-white/[0.04]" />
|
||||
<div className="w-[10px] h-[10px] rounded-[2px] bg-primary/20" />
|
||||
<div className="w-[10px] h-[10px] rounded-[2px] bg-primary/40" />
|
||||
<div className="w-[10px] h-[10px] rounded-[2px] bg-primary/60" />
|
||||
<div className="w-[10px] h-[10px] rounded-[2px] bg-primary/90" />
|
||||
<span>More</span>
|
||||
<span>{t("activityMore")}</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -334,7 +339,7 @@ export function ApiKeyTable({ byApiKey }) {
|
||||
return (
|
||||
<Card className="p-4 flex-1">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
API Key Breakdown
|
||||
{t("chartApiKeyBreakdown")}
|
||||
</h3>
|
||||
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
|
||||
</Card>
|
||||
@@ -345,7 +350,7 @@ export function ApiKeyTable({ byApiKey }) {
|
||||
<Card className="overflow-hidden">
|
||||
<div className="p-4 border-b border-border flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">
|
||||
API Key Breakdown
|
||||
{t("chartApiKeyBreakdown")}
|
||||
</h3>
|
||||
<input
|
||||
type="text"
|
||||
@@ -363,7 +368,8 @@ export function ApiKeyTable({ byApiKey }) {
|
||||
className="px-4 py-2.5 text-left cursor-pointer group"
|
||||
onClick={() => toggleSort("apiKeyName")}
|
||||
>
|
||||
API Key <SortIndicator active={sortBy === "apiKeyName"} sortOrder={sortOrder} />
|
||||
{t("chartApiKey")}{" "}
|
||||
<SortIndicator active={sortBy === "apiKeyName"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
@@ -444,6 +450,7 @@ export function ApiKeyTable({ byApiKey }) {
|
||||
}
|
||||
|
||||
export function MostActiveDay7d({ activityMap }) {
|
||||
const t = useTranslations("analytics");
|
||||
const locale = useLocale();
|
||||
const weekdayFormatter = useMemo(
|
||||
() => createDateFormatter(locale, { weekday: "long" }),
|
||||
@@ -485,7 +492,7 @@ export function MostActiveDay7d({ activityMap }) {
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Most Active Day
|
||||
{t("mostActiveDay")}
|
||||
</h3>
|
||||
{data ? (
|
||||
<>
|
||||
@@ -493,12 +500,12 @@ export function MostActiveDay7d({ activityMap }) {
|
||||
{data.weekday}
|
||||
</span>
|
||||
<span className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
{data.label} · {fmt(data.tokens)} tokens
|
||||
{t("datedTokenCount", { date: data.label, tokens: fmt(data.tokens) })}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
No data in the last 7 days
|
||||
{t("noDataLast7Days")}
|
||||
</span>
|
||||
)}
|
||||
</Card>
|
||||
@@ -561,7 +568,7 @@ export function WeeklySquares7d({ activityMap }) {
|
||||
style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<div
|
||||
title={`${d.dateLabel}: ${fmtFull(d.val)} tokens`}
|
||||
title={t("activityCellTitle", { date: d.dateLabel, tokens: fmtFull(d.val) })}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -750,6 +757,7 @@ function getServiceTierCostClass(serviceTier) {
|
||||
|
||||
export function ServiceTierBreakdown({ byServiceTier, summary }) {
|
||||
const t = useTranslations("costs") as TranslationFn;
|
||||
const tAnalytics = useTranslations("analytics");
|
||||
const data = useMemo(() => byServiceTier || [], [byServiceTier]);
|
||||
const totalRequests = Number(summary?.totalRequests || 0);
|
||||
const totalCost = Number(summary?.totalCost || 0);
|
||||
@@ -807,7 +815,10 @@ export function ServiceTierBreakdown({ byServiceTier, summary }) {
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-main">{tierLabel}</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
{fmtFull(tier.requests)} requests · {fmt(tier.totalTokens)} tokens
|
||||
{tAnalytics("requestTokenSummary", {
|
||||
requests: fmtFull(tier.requests),
|
||||
tokens: fmt(tier.totalTokens),
|
||||
})}
|
||||
{usageSavingsText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -103,14 +103,14 @@ export function AccountDonut({ byAccount }) {
|
||||
return (
|
||||
<Card className="p-4 flex-1">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
By Account
|
||||
{t("chartByAccount")}
|
||||
</h3>
|
||||
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return <CompactDonutCard pieData={pieData} title="By Account" formatter={fmt} />;
|
||||
return <CompactDonutCard pieData={pieData} title={t("chartByAccount")} formatter={fmt} />;
|
||||
}
|
||||
|
||||
// ── ApiKeyDonut (Recharts) ─────────────────────────────────────────────────
|
||||
@@ -123,17 +123,17 @@ export function ApiKeyDonut({ byApiKey }) {
|
||||
const pieData = useMemo(() => {
|
||||
return data.slice(0, 8).map((item, i) => ({
|
||||
name: maskApiKeyLabel(item.apiKeyName, item.apiKeyId),
|
||||
fullName: item.apiKeyName || item.apiKeyId || "unknown",
|
||||
fullName: item.apiKeyName || item.apiKeyId || t("unknownApiKey"),
|
||||
value: item.totalTokens,
|
||||
fill: getModelColor(i),
|
||||
}));
|
||||
}, [data]);
|
||||
}, [data, t]);
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
<Card className="p-4 flex-1">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
By API Key
|
||||
{t("chartByApiKey")}
|
||||
</h3>
|
||||
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
|
||||
</Card>
|
||||
@@ -143,7 +143,7 @@ export function ApiKeyDonut({ byApiKey }) {
|
||||
return (
|
||||
<CompactDonutCard
|
||||
pieData={pieData}
|
||||
title="By API Key"
|
||||
title={t("chartByApiKey")}
|
||||
formatter={fmt}
|
||||
getLegendKey={(seg, i) => `${seg.fullName}-${i}`}
|
||||
getLegendTitle={(seg) => seg.fullName}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
|
||||
import type { CliCatalogEntry } from "@/shared/schemas/cliCatalog";
|
||||
import type { ToolBatchStatus } from "@/shared/types/cliBatchStatus";
|
||||
import CliStatusBadge from "@/app/(dashboard)/dashboard/cli-code/components/CliStatusBadge";
|
||||
import { useTheme } from "@/shared/hooks/useTheme";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
export interface CliToolCardProps {
|
||||
@@ -22,19 +23,23 @@ export default function CliToolCard({
|
||||
hasActiveProviders,
|
||||
}: CliToolCardProps) {
|
||||
const t = useTranslations("cliCommon");
|
||||
const tTools = useTranslations("cliTools");
|
||||
const { isDark } = useTheme();
|
||||
const installed = batchStatus?.detection.installed ?? false;
|
||||
const configStatus = batchStatus?.config.status ?? null;
|
||||
const version = batchStatus?.detection.version ?? "not found";
|
||||
const version = batchStatus?.detection.version ?? t("card.versionNotFound");
|
||||
const endpoint = batchStatus?.config.endpoint ?? null;
|
||||
const imageSrc =
|
||||
tool.image || (isDark ? tool.imageDark || tool.imageLight : tool.imageLight || tool.imageDark);
|
||||
|
||||
const showInstallChips = !installed && tool.configType !== "guide";
|
||||
|
||||
const title = (
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Icon / image */}
|
||||
{tool.image ? (
|
||||
{imageSrc ? (
|
||||
<Image
|
||||
src={tool.image}
|
||||
src={imageSrc}
|
||||
alt={tool.name}
|
||||
width={32}
|
||||
height={32}
|
||||
@@ -58,7 +63,9 @@ export default function CliToolCard({
|
||||
{version}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted line-clamp-1 mt-0.5">{tool.description}</p>
|
||||
<p className="text-xs text-text-muted line-clamp-1 mt-0.5">
|
||||
{tTools(`toolDescriptions.${tool.id}`)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted flex-shrink-0">
|
||||
chevron_right
|
||||
@@ -124,10 +131,10 @@ export default function CliToolCard({
|
||||
{showInstallChips && (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] font-medium rounded-full bg-black/5 dark:bg-white/5 text-text-muted">
|
||||
📋 Manual config
|
||||
📋 {t("card.manualConfig")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] font-medium rounded-full bg-black/5 dark:bg-white/5 text-text-muted">
|
||||
⬇ Install
|
||||
⬇ {t("card.installGuide")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
// (parent owns the state + persistence). All mutations go through the pure
|
||||
// `compressionPipelineModel` so invariants (valid intensity, non-empty pipeline) hold.
|
||||
//
|
||||
// Hydration note: this lives under the combos screen, which deliberately uses NO
|
||||
// `useTranslations` (an earlier redesign failed to hydrate on the production build with a
|
||||
// page-level `useTranslations`). Strings are literal English to match `CompressionHub`.
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -26,6 +22,7 @@ import {
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
allowedIntensities,
|
||||
addLayer,
|
||||
@@ -57,6 +54,7 @@ function SortableRow(props: {
|
||||
onPatch: (patch: Partial<PipelineStep>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const t = useTranslations("contextCombos");
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: props.id,
|
||||
});
|
||||
@@ -74,7 +72,7 @@ function SortableRow(props: {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Drag to reorder step"
|
||||
aria-label={t("dragToReorder")}
|
||||
data-testid={`pipeline-drag-${props.index}`}
|
||||
className="cursor-grab rounded-lg border border-border px-2 py-2 text-sm text-text-muted"
|
||||
{...attributes}
|
||||
@@ -83,7 +81,7 @@ function SortableRow(props: {
|
||||
⠿
|
||||
</button>
|
||||
<select
|
||||
aria-label="Engine"
|
||||
aria-label={t("engine")}
|
||||
value={props.step.engine}
|
||||
onChange={(event) => props.onPatch({ engine: event.target.value })}
|
||||
className={SELECT_CLASS}
|
||||
@@ -95,7 +93,7 @@ function SortableRow(props: {
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Intensity"
|
||||
aria-label={t("intensity")}
|
||||
value={props.step.intensity ?? ""}
|
||||
onChange={(event) => props.onPatch({ intensity: event.target.value })}
|
||||
className={SELECT_CLASS}
|
||||
@@ -113,13 +111,14 @@ function SortableRow(props: {
|
||||
data-testid={`pipeline-remove-${props.index}`}
|
||||
className="rounded-lg border border-border px-3 py-2 text-sm text-text-main disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
{t("removeStep")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompressionPipelineEditor({ steps, onChange, engineIntensities }: Props) {
|
||||
const t = useTranslations("contextCombos");
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
@@ -141,14 +140,14 @@ export function CompressionPipelineEditor({ steps, onChange, engineIntensities }
|
||||
return (
|
||||
<div className="space-y-3" data-testid="compression-pipeline-editor">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-text-main">Pipeline</h3>
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("pipeline")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pipeline-add-step"
|
||||
onClick={() => onChange(addLayer(steps, { engine: firstEngine }, engineIntensities))}
|
||||
className="rounded-lg border border-border px-3 py-1.5 text-xs text-text-main"
|
||||
>
|
||||
Add step
|
||||
{t("addStep")}
|
||||
</button>
|
||||
</div>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import type { EngineConfigField } from "@omniroute/open-sse/services/compression/engines/types";
|
||||
import { EngineConfigForm } from "@/shared/components/compression/EngineConfigForm";
|
||||
|
||||
@@ -63,10 +64,9 @@ interface PreviewResult {
|
||||
|
||||
// ── Default preview sample ────────────────────────────────────────────────
|
||||
|
||||
const PREVIEW_SAMPLE =
|
||||
"The quick brown fox jumps over the lazy dog. " +
|
||||
"This is a sample message used to preview compression. " +
|
||||
"It contains enough text to show meaningful token savings.";
|
||||
const ENGINE_ICON_ALIASES: Record<string, string> = {
|
||||
brain: "psychology",
|
||||
};
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -79,7 +79,11 @@ function StatCard({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function renderDiffSegment(segment: PreviewDiffSegment, index: number) {
|
||||
function renderDiffSegment(
|
||||
segment: PreviewDiffSegment,
|
||||
index: number,
|
||||
translateLabel: (label: string) => string
|
||||
) {
|
||||
const label = segment.type ?? "change";
|
||||
const text =
|
||||
segment.value ??
|
||||
@@ -93,7 +97,7 @@ function renderDiffSegment(segment: PreviewDiffSegment, index: number) {
|
||||
return (
|
||||
<div key={`${label}-${index}`} className="rounded border border-border bg-background p-2">
|
||||
<span className="mr-2 rounded bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-text-muted">
|
||||
{label}
|
||||
{translateLabel(label)}
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap break-words text-text">{text}</span>
|
||||
</div>
|
||||
@@ -103,6 +107,8 @@ function renderDiffSegment(segment: PreviewDiffSegment, index: number) {
|
||||
// ── Main component ────────────────────────────────────────────────────────
|
||||
|
||||
export function EngineConfigPage({ engineId }: { engineId: string }) {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("compressionEngineConfig");
|
||||
// ── Data state ──────────────────────────────────────────────────────────
|
||||
const [engine, setEngine] = useState<EngineEntry | null>(null);
|
||||
const [configState, setConfigState] = useState<Record<string, unknown>>({});
|
||||
@@ -111,7 +117,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// ── Preview state ───────────────────────────────────────────────────────
|
||||
const [previewText, setPreviewText] = useState(PREVIEW_SAMPLE);
|
||||
const [previewText, setPreviewText] = useState(() => t("previewSample"));
|
||||
const [preview, setPreview] = useState<PreviewResult | null>(null);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
@@ -147,7 +153,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
|
||||
if (enginesData) {
|
||||
foundEngine = enginesData.engines?.find((e) => e.id === engineId) ?? null;
|
||||
} else {
|
||||
setLoadError("Failed to load engine information.");
|
||||
setLoadError(t("loadFailed"));
|
||||
}
|
||||
|
||||
// Detailed config lives in the engine's settings sub-object (when it has one);
|
||||
@@ -174,7 +180,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [engineId]);
|
||||
}, [engineId, t]);
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -200,10 +206,10 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
|
||||
body: JSON.stringify({ [subKey]: detail }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setSaveError("Failed to save configuration.");
|
||||
setSaveError(t("saveFailed"));
|
||||
}
|
||||
} catch {
|
||||
setSaveError("Failed to save configuration.");
|
||||
setSaveError(t("saveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -226,10 +232,10 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
|
||||
const data = (await res.json()) as PreviewResult;
|
||||
setPreview(data);
|
||||
} else {
|
||||
setPreviewError("Preview failed.");
|
||||
setPreviewError(t("previewFailed"));
|
||||
}
|
||||
} catch {
|
||||
setPreviewError("Preview failed.");
|
||||
setPreviewError(t("previewFailed"));
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
@@ -239,20 +245,48 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-12 text-text-muted text-sm">Loading…</div>
|
||||
<div className="flex items-center justify-center p-12 text-text-muted text-sm">
|
||||
{t("loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!engine) {
|
||||
return (
|
||||
<div className="p-6 text-sm text-text-muted">
|
||||
{loadError ?? `Engine "${engineId}" not found.`}
|
||||
{loadError ?? t("engineNotFound", { engine: engineId })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const subtitle = engine.metadata?.description ?? engine.description;
|
||||
const visibleConfigSchema = engine.configSchema.filter((field) => field.key !== "enabled");
|
||||
const engineNameKey = `engines.${engineId}.name`;
|
||||
const engineDescriptionKey = `engines.${engineId}.description`;
|
||||
const engineName = t.has(engineNameKey) ? t(engineNameKey) : engine.name;
|
||||
const rawSubtitle = engine.metadata?.description ?? engine.description;
|
||||
const subtitle = t.has(engineDescriptionKey) ? t(engineDescriptionKey) : rawSubtitle;
|
||||
const visibleConfigSchema = engine.configSchema
|
||||
.filter((field) => field.key !== "enabled")
|
||||
.map((field) => {
|
||||
const engineFieldPrefix = `engineFields.${engineId}.${field.key}`;
|
||||
const fieldPrefix = `fields.${field.key}`;
|
||||
const labelKey = t.has(`${engineFieldPrefix}.label`)
|
||||
? `${engineFieldPrefix}.label`
|
||||
: `${fieldPrefix}.label`;
|
||||
const descriptionKey = t.has(`${engineFieldPrefix}.description`)
|
||||
? `${engineFieldPrefix}.description`
|
||||
: `${fieldPrefix}.description`;
|
||||
|
||||
return {
|
||||
...field,
|
||||
label: t.has(labelKey) ? t(labelKey) : field.label,
|
||||
description:
|
||||
field.description && t.has(descriptionKey) ? t(descriptionKey) : field.description,
|
||||
options: field.options?.map((option) => {
|
||||
const optionKey = `options.${field.key}.${option.value}`;
|
||||
return { ...option, label: t.has(optionKey) ? t(optionKey) : option.label };
|
||||
}),
|
||||
};
|
||||
});
|
||||
// Only engines with a dedicated settings sub-object can persist their detail here.
|
||||
const persistable = Boolean(SETTINGS_SUBOBJECT[engineId]);
|
||||
|
||||
@@ -266,10 +300,10 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
|
||||
className="material-symbols-outlined text-[28px] leading-none text-text-muted"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{engine.icon}
|
||||
{ENGINE_ICON_ALIASES[engine.icon] || engine.icon}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="text-2xl font-bold text-text">{engine.name}</h1>
|
||||
<h1 className="text-2xl font-bold text-text">{engineName}</h1>
|
||||
</div>
|
||||
{subtitle && <p className="text-sm text-text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
@@ -283,17 +317,17 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
|
||||
{/* ── Panel pointer (on/off + level live there now) ── */}
|
||||
<div className="flex flex-col gap-1 rounded-lg border border-border bg-surface p-4">
|
||||
<p className="text-xs text-text-muted" data-testid="panel-pointer-notice">
|
||||
Turn this layer on/off and set its level in{" "}
|
||||
{t("panelPointerPrefix")}{" "}
|
||||
<a href="/dashboard/context/settings" className="underline hover:text-text">
|
||||
Compression Settings
|
||||
{t("compressionSettings")}
|
||||
</a>
|
||||
. This page edits its detailed configuration only.
|
||||
{t("panelPointerSuffix")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Config form ── */}
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border bg-surface p-4">
|
||||
<h2 className="text-sm font-semibold text-text">Configuration</h2>
|
||||
<h2 className="text-sm font-semibold text-text">{t("configuration")}</h2>
|
||||
{visibleConfigSchema.length > 0 ? (
|
||||
<EngineConfigForm
|
||||
schema={visibleConfigSchema}
|
||||
@@ -301,7 +335,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
|
||||
onChange={setConfigState}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No additional configuration.</p>
|
||||
<p className="text-sm text-text-muted">{t("noAdditionalConfiguration")}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
{persistable ? (
|
||||
@@ -310,12 +344,11 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
|
||||
disabled={saving}
|
||||
className="px-4 py-1.5 rounded bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
{saving ? t("saving") : t("save")}
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-xs text-text-muted" data-testid="no-detail-store-notice">
|
||||
This layer is configured by the global settings; there is no per-engine override to
|
||||
save here yet.
|
||||
{t("globalSettingsOnly")}
|
||||
</p>
|
||||
)}
|
||||
{saveError && <p className="text-xs text-destructive">{saveError}</p>}
|
||||
@@ -324,12 +357,12 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
|
||||
|
||||
{/* ── Live preview ── */}
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border bg-surface p-4">
|
||||
<h2 className="text-sm font-semibold text-text">Preview</h2>
|
||||
<h2 className="text-sm font-semibold text-text">{t("preview")}</h2>
|
||||
<textarea
|
||||
className="border border-border rounded px-3 py-2 text-sm text-text bg-background resize-y min-h-[80px]"
|
||||
value={previewText}
|
||||
onChange={(e) => setPreviewText(e.target.value)}
|
||||
aria-label="Preview input"
|
||||
aria-label={t("previewInput")}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
@@ -337,7 +370,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
|
||||
disabled={previewLoading}
|
||||
className="px-4 py-1.5 rounded bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{previewLoading ? "Processing..." : "Preview"}
|
||||
{previewLoading ? t("processing") : t("preview")}
|
||||
</button>
|
||||
</div>
|
||||
{previewError && <p className="text-xs text-destructive">{previewError}</p>}
|
||||
@@ -345,19 +378,22 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
|
||||
<div className="flex flex-col gap-3 pt-1 text-sm">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<span className="text-text-muted">
|
||||
Original tokens: <strong className="text-text">{preview.originalTokens}</strong>
|
||||
{t("originalTokens")}:{" "}
|
||||
<strong className="text-text">{preview.originalTokens}</strong>
|
||||
</span>
|
||||
<span className="text-text-muted">
|
||||
Compressed tokens: <strong className="text-text">{preview.compressedTokens}</strong>
|
||||
{t("compressedTokens")}:{" "}
|
||||
<strong className="text-text">{preview.compressedTokens}</strong>
|
||||
</span>
|
||||
<span className="text-text-muted">
|
||||
Savings: <strong className="text-primary">{preview.savingsPct.toFixed(1)}%</strong>
|
||||
{t("savings")}:{" "}
|
||||
<strong className="text-primary">{preview.savingsPct.toFixed(1)}%</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||||
Original
|
||||
{t("original")}
|
||||
</h3>
|
||||
<pre className="max-h-72 overflow-auto rounded border border-border bg-background p-3 whitespace-pre-wrap break-words text-text">
|
||||
{preview.original ?? ""}
|
||||
@@ -365,7 +401,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||||
Compressed
|
||||
{t("compressed")}
|
||||
</h3>
|
||||
<pre className="max-h-72 overflow-auto rounded border border-border bg-background p-3 whitespace-pre-wrap break-words text-text">
|
||||
{preview.compressed ?? ""}
|
||||
@@ -375,10 +411,15 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
|
||||
{preview.diff && preview.diff.length > 0 && (
|
||||
<div className="flex flex-col gap-2" data-testid="compression-preview-diff">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||||
Diff
|
||||
{t("diff")}
|
||||
</h3>
|
||||
<div className="flex max-h-72 flex-col gap-2 overflow-auto rounded border border-border p-2">
|
||||
{preview.diff.map(renderDiffSegment)}
|
||||
{preview.diff.map((segment, index) =>
|
||||
renderDiffSegment(segment, index, (label) => {
|
||||
const key = `diffLabels.${label}`;
|
||||
return t.has(key) ? t(key) : label;
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -388,20 +429,23 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
|
||||
|
||||
{/* ── Analytics strip ── */}
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border bg-surface p-4">
|
||||
<h2 className="text-sm font-semibold text-text">Last 7 days</h2>
|
||||
<h2 className="text-sm font-semibold text-text">{t("last7Days")}</h2>
|
||||
{analytics && analytics.runs === 0 ? (
|
||||
<p className="text-sm text-text-muted">No data yet</p>
|
||||
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
|
||||
) : analytics ? (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<StatCard label="Runs" value={analytics.runs.toLocaleString()} />
|
||||
<StatCard label="Tokens saved" value={analytics.tokensSaved.toLocaleString()} />
|
||||
<StatCard label={t("runs")} value={analytics.runs.toLocaleString(locale)} />
|
||||
<StatCard
|
||||
label="Average savings"
|
||||
label={t("tokensSaved")}
|
||||
value={analytics.tokensSaved.toLocaleString(locale)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("averageSavings")}
|
||||
value={`${analytics.avgSavingsPercent.toFixed(1)}%`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No data yet</p>
|
||||
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ export const CLI_TOOLS: Record<string, CliCatalogEntry> = {
|
||||
claude: {
|
||||
id: "claude",
|
||||
name: "Claude Code",
|
||||
icon: "terminal",
|
||||
image: "/providers/claude.svg",
|
||||
color: "#D97757",
|
||||
description: "Anthropic Claude Code CLI — ANTHROPIC_BASE_URL points to OmniRoute",
|
||||
docsUrl: "https://docs.anthropic.com/en/docs/claude-code/overview",
|
||||
@@ -71,6 +71,7 @@ export const CLI_TOOLS: Record<string, CliCatalogEntry> = {
|
||||
codex: {
|
||||
id: "codex",
|
||||
name: "OpenAI Codex CLI",
|
||||
image: "/providers/codex.svg",
|
||||
color: "#10A37F",
|
||||
description: "OpenAI Codex CLI — OpenAI-compatible base URL targets OmniRoute",
|
||||
docsUrl: "https://github.com/openai/codex",
|
||||
@@ -98,7 +99,7 @@ export const CLI_TOOLS: Record<string, CliCatalogEntry> = {
|
||||
openclaw: {
|
||||
id: "openclaw",
|
||||
name: "Open Claw",
|
||||
image: "/providers/openclaw.png",
|
||||
image: "/providers/openclaw.svg",
|
||||
color: "#FF6B35",
|
||||
description: "Open Claw — open-source multi-backend agent CLI (OSS, P. Steinberger)",
|
||||
docsUrl: "/docs?section=cli-tools&tool=openclaw",
|
||||
@@ -112,7 +113,7 @@ export const CLI_TOOLS: Record<string, CliCatalogEntry> = {
|
||||
cursor: {
|
||||
id: "cursor",
|
||||
name: "Cursor",
|
||||
image: "/providers/cursor.png",
|
||||
image: "/providers/cursor.svg",
|
||||
color: "#000000",
|
||||
// Cursor App routes via its own cloud server — local base URL not supported.
|
||||
// Use cursor-cli entry for headless/agent CLI mode with custom endpoint.
|
||||
@@ -144,6 +145,7 @@ export const CLI_TOOLS: Record<string, CliCatalogEntry> = {
|
||||
cline: {
|
||||
id: "cline",
|
||||
name: "Cline",
|
||||
image: "/providers/cline.svg",
|
||||
color: "#00D1B2",
|
||||
description: "Cline — open-source VS Code coding agent with OpenAI-compatible base URL",
|
||||
docsUrl: "https://docs.cline.bot/",
|
||||
@@ -171,7 +173,7 @@ export const CLI_TOOLS: Record<string, CliCatalogEntry> = {
|
||||
continue: {
|
||||
id: "continue",
|
||||
name: "Continue",
|
||||
image: "/providers/continue.png",
|
||||
image: "/providers/continue.svg",
|
||||
color: "#7C3AED",
|
||||
description: "Continue — open-source AI coding assistant with full provider config",
|
||||
docsUrl: "https://docs.continue.dev/",
|
||||
@@ -242,7 +244,7 @@ export const CLI_TOOLS: Record<string, CliCatalogEntry> = {
|
||||
copilot: {
|
||||
id: "copilot",
|
||||
name: "GitHub Copilot",
|
||||
image: "/providers/copilot.png",
|
||||
image: "/providers/copilot.svg",
|
||||
color: "#1F6FEB",
|
||||
// D-nota: copilot suporta COPILOT_PROVIDER_BASE_URL desde v1.0.19+
|
||||
description: "GitHub Copilot Chat — VS Code extension with COPILOT_PROVIDER_BASE_URL support",
|
||||
@@ -402,7 +404,7 @@ export const CLI_TOOLS: Record<string, CliCatalogEntry> = {
|
||||
qwen: {
|
||||
id: "qwen",
|
||||
name: "Qwen Code",
|
||||
icon: "psychology",
|
||||
image: "/providers/qwen.svg",
|
||||
color: "#10B981",
|
||||
description: "Qwen Code CLI — current V4 OpenAI-compatible model provider via OmniRoute",
|
||||
docsUrl: "https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/",
|
||||
@@ -512,7 +514,7 @@ aider --openai-api-base "{{baseUrl}}" --model "{{model}}"`,
|
||||
"cursor-cli": {
|
||||
id: "cursor-cli",
|
||||
name: "Cursor Agent CLI",
|
||||
icon: "terminal",
|
||||
image: "/providers/cursor.svg",
|
||||
color: "#000000",
|
||||
description: "Cursor Agent CLI — headless agent mode with custom provider endpoint",
|
||||
docsUrl: "https://docs.cursor.com/advanced/api",
|
||||
@@ -536,7 +538,7 @@ aider --openai-api-base "{{baseUrl}}" --model "{{model}}"`,
|
||||
roo: {
|
||||
id: "roo",
|
||||
name: "Roo Code",
|
||||
icon: "terminal",
|
||||
image: "/providers/roocode.svg",
|
||||
color: "#7C3AED",
|
||||
description: "Roo Code AI Assistant — VS Code extension with OpenAI-compatible custom base URL",
|
||||
docsUrl: "https://docs.roocode.com/",
|
||||
@@ -579,7 +581,7 @@ aider --openai-api-base "{{baseUrl}}" --model "{{model}}"`,
|
||||
"deepseek-tui": {
|
||||
id: "deepseek-tui",
|
||||
name: "DeepSeek TUI",
|
||||
icon: "terminal",
|
||||
image: "/providers/deepseek.svg",
|
||||
color: "#4F46E5",
|
||||
description: "DeepSeek TUI — Rust-based coding agent CLI with OPENAI_BASE_URL support",
|
||||
docsUrl: "https://github.com/hunterbown/deepseek-tui",
|
||||
|
||||
@@ -59,6 +59,7 @@ const OMNI_PROXY_ITEMS: readonly SidebarItemDefinition[] = [
|
||||
href: "/dashboard/combos/live",
|
||||
i18nKey: "combosLive",
|
||||
labelFallback: "Combo Studio",
|
||||
subtitleKey: "combosLiveSubtitle",
|
||||
subtitleFallback: "Live routing cascade",
|
||||
icon: "account_tree",
|
||||
},
|
||||
@@ -90,6 +91,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
|
||||
href: "/dashboard/context/settings",
|
||||
i18nKey: "contextSettings",
|
||||
labelFallback: "Compression Settings",
|
||||
subtitleKey: "contextSettingsSubtitle",
|
||||
subtitleFallback: "Global defaults",
|
||||
icon: "settings",
|
||||
},
|
||||
@@ -119,6 +121,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
|
||||
href: "/dashboard/context/headroom",
|
||||
i18nKey: "contextHeadroom",
|
||||
labelFallback: "Headroom",
|
||||
subtitleKey: "contextHeadroomSubtitle",
|
||||
subtitleFallback: "Tabular compaction",
|
||||
icon: "table_rows",
|
||||
},
|
||||
@@ -127,6 +130,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
|
||||
href: "/dashboard/context/session-dedup",
|
||||
i18nKey: "contextSessionDedup",
|
||||
labelFallback: "Session Dedup",
|
||||
subtitleKey: "contextSessionDedupSubtitle",
|
||||
subtitleFallback: "Cross-turn dedup",
|
||||
icon: "content_copy",
|
||||
},
|
||||
@@ -135,6 +139,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
|
||||
href: "/dashboard/context/ccr",
|
||||
i18nKey: "contextCcr",
|
||||
labelFallback: "CCR",
|
||||
subtitleKey: "contextCcrSubtitle",
|
||||
subtitleFallback: "Retrieve markers",
|
||||
icon: "archive",
|
||||
},
|
||||
@@ -143,6 +148,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
|
||||
href: "/dashboard/context/llmlingua",
|
||||
i18nKey: "contextLlmlingua",
|
||||
labelFallback: "LLMLingua",
|
||||
subtitleKey: "contextLlmlinguaSubtitle",
|
||||
subtitleFallback: "Semantic pruning",
|
||||
icon: "psychology",
|
||||
},
|
||||
@@ -151,6 +157,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
|
||||
href: "/dashboard/context/lite",
|
||||
i18nKey: "contextLite",
|
||||
labelFallback: "Lite",
|
||||
subtitleKey: "contextLiteSubtitle",
|
||||
subtitleFallback: "Fast whitespace cleanup",
|
||||
icon: "compress",
|
||||
},
|
||||
@@ -159,6 +166,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
|
||||
href: "/dashboard/context/aggressive",
|
||||
i18nKey: "contextAggressive",
|
||||
labelFallback: "Aggressive",
|
||||
subtitleKey: "contextAggressiveSubtitle",
|
||||
subtitleFallback: "Summary + aging",
|
||||
icon: "speed",
|
||||
},
|
||||
@@ -167,6 +175,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
|
||||
href: "/dashboard/context/ultra",
|
||||
i18nKey: "contextUltra",
|
||||
labelFallback: "Ultra",
|
||||
subtitleKey: "contextUltraSubtitle",
|
||||
subtitleFallback: "Heuristic pruning",
|
||||
icon: "bolt",
|
||||
},
|
||||
@@ -175,6 +184,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
|
||||
href: "/dashboard/context/omniglyph",
|
||||
i18nKey: "contextOmniglyph",
|
||||
labelFallback: "OmniGlyph",
|
||||
subtitleKey: "contextOmniglyphSubtitle",
|
||||
subtitleFallback: "Context-as-image",
|
||||
icon: "grain",
|
||||
},
|
||||
@@ -183,6 +193,7 @@ export const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = {
|
||||
href: "/dashboard/compression/studio",
|
||||
i18nKey: "compressionStudio",
|
||||
labelFallback: "Compression Studio",
|
||||
subtitleKey: "compressionStudioSubtitle",
|
||||
subtitleFallback: "Live engine cascade",
|
||||
icon: "monitoring",
|
||||
},
|
||||
@@ -520,6 +531,7 @@ const AGENTIC_FEATURES_ITEMS: readonly SidebarSectionChild[] = [
|
||||
href: "/dashboard/chaos",
|
||||
i18nKey: "chaosConfig",
|
||||
labelFallback: "Chaos Mode",
|
||||
subtitleKey: "chaosConfigSubtitle",
|
||||
subtitleFallback: "Multi-model parallel execution",
|
||||
icon: "blender",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user