mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(ui): v3.8.0 polish — connections border, sticky tabs, EN translations, save toasts, auto-combo catalog (#2305)
Integrated into release/v3.8.0
This commit is contained in:
85
src/app/(dashboard)/dashboard/combos/AutoComboCatalog.tsx
Normal file
85
src/app/(dashboard)/dashboard/combos/AutoComboCatalog.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
import { AUTO_COMBO_TEMPLATES } from "@/domain/assessment/types";
|
||||
|
||||
// Informational catalog of zero-config auto-routing combos.
|
||||
// Auto combos are resolved at request time by the chat handler based on the
|
||||
// currently connected providers / models — they have no row in the combos
|
||||
// table, so they were previously invisible in the UI. This panel surfaces
|
||||
// the static catalog (name, intent, categories, tiers, strategy) so users
|
||||
// can discover the auto/ prefix without reading source.
|
||||
export default function AutoComboCatalog() {
|
||||
const t = useTranslations("combos");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className="flex w-full items-start justify-between gap-3 text-left"
|
||||
aria-expanded={open}
|
||||
aria-label={open ? t("autoCatalogCollapse") : t("autoCatalogExpand")}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-xl text-primary">auto_awesome</span>
|
||||
<h2 className="text-base font-bold text-text-main">{t("autoCatalogTitle")}</h2>
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold text-primary">
|
||||
{t("autoCatalogTemplateCount", { count: AUTO_COMBO_TEMPLATES.length })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-text-muted">{t("autoCatalogDescription")}</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-base text-text-muted">
|
||||
{open ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-4 grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{AUTO_COMBO_TEMPLATES.map((tpl) => (
|
||||
<div
|
||||
key={tpl.name}
|
||||
className="rounded-lg border border-border bg-bg-subtle p-3 text-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<code className="font-mono text-sm text-text-main">{tpl.name}</code>
|
||||
<span className="rounded bg-black/5 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-text-muted dark:bg-white/5">
|
||||
{tpl.strategy}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] font-semibold text-text-main">{tpl.displayName}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{tpl.categories.map((cat) => (
|
||||
<span
|
||||
key={cat}
|
||||
className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] text-primary"
|
||||
>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
{tpl.tiers.map((tier) => (
|
||||
<span
|
||||
key={tier}
|
||||
className="rounded-full bg-black/[0.04] px-2 py-0.5 text-[10px] text-text-muted dark:bg-white/[0.04]"
|
||||
>
|
||||
{tier}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{tpl.systemMessage && (
|
||||
<p className="mt-2 text-[10px] italic text-text-muted line-clamp-2">
|
||||
{tpl.systemMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
resolveComboBuilderProviderId,
|
||||
} from "@/lib/combos/builderDraft";
|
||||
import { normalizeComboConfigMode } from "@/shared/constants/comboConfigMode";
|
||||
import AutoComboCatalog from "./AutoComboCatalog";
|
||||
import BuilderIntelligentStep from "./BuilderIntelligentStep";
|
||||
import IntelligentComboPanel from "./IntelligentComboPanel";
|
||||
import {
|
||||
@@ -950,6 +951,7 @@ export default function CombosPage() {
|
||||
<h1 className="text-2xl font-semibold">{t("title")}</h1>
|
||||
<p className="text-sm text-text-muted mt-1">{t("description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex items-center gap-2 rounded-lg border border-black/8 dark:border-white/8 bg-black/[0.02] dark:bg-white/[0.02] px-2.5 py-1.5">
|
||||
<span className="hidden lg:inline text-xs text-text-muted">
|
||||
@@ -988,6 +990,8 @@ export default function CombosPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AutoComboCatalog />
|
||||
|
||||
{showUsageGuide && (
|
||||
<ComboUsageGuide
|
||||
onHide={() => setShowUsageGuide(false)}
|
||||
|
||||
@@ -3380,7 +3380,7 @@ export default function ProviderDetailPage() {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03] border border-t-0 border-border rounded-b-lg overflow-hidden">
|
||||
{sorted.map((conn, index) => (
|
||||
<ConnectionRow
|
||||
key={conn.id}
|
||||
@@ -3511,7 +3511,7 @@ export default function ProviderDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-0">
|
||||
<div className="flex flex-col gap-0 border border-t-0 border-border rounded-b-lg overflow-hidden">
|
||||
{groupKeys.map((tag, gi) => {
|
||||
const groupConns = groupMap.get(tag)!;
|
||||
return (
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function ModelCooldownsCard() {
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
|
||||
notify.success(`Modelo reativado: ${provider}/${model}`);
|
||||
notify.success(`Model reactivated: ${provider}/${model}`);
|
||||
await load();
|
||||
} catch (error) {
|
||||
notify.error(error instanceof Error ? error.message : "Failed to clear cooldown");
|
||||
@@ -79,7 +79,7 @@ export default function ModelCooldownsCard() {
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
|
||||
notify.success("Todos os modelos em cooldown foram reativados.");
|
||||
notify.success("All models in cooldown have been reactivated.");
|
||||
await load();
|
||||
} catch (error) {
|
||||
notify.error(error instanceof Error ? error.message : "Failed to clear cooldowns");
|
||||
@@ -95,15 +95,15 @@ export default function ModelCooldownsCard() {
|
||||
<Card className="p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-text-main">Modelos em cooldown</h2>
|
||||
<h2 className="text-lg font-bold text-text-main">Models in cooldown</h2>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
Lista de modelos temporariamente isolados por falha. Quando o cooldown expira, eles
|
||||
voltam automaticamente.
|
||||
Models temporarily isolated after a failure. When the cooldown expires they come back
|
||||
automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => void load()} disabled={loading}>
|
||||
Atualizar
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -111,16 +111,16 @@ export default function ModelCooldownsCard() {
|
||||
onClick={() => void clearAll()}
|
||||
disabled={!hasItems || busyKey === "ALL"}
|
||||
>
|
||||
Reativar todos
|
||||
Reactivate all
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{loading ? (
|
||||
<p className="text-sm text-text-muted">Carregando...</p>
|
||||
<p className="text-sm text-text-muted">Loading...</p>
|
||||
) : !hasItems ? (
|
||||
<p className="text-sm text-text-muted">Nenhum modelo em cooldown no momento.</p>
|
||||
<p className="text-sm text-text-muted">No models in cooldown right now.</p>
|
||||
) : (
|
||||
sorted.map((item) => {
|
||||
const rowKey = `${item.provider}::${item.model}`;
|
||||
@@ -134,7 +134,7 @@ export default function ModelCooldownsCard() {
|
||||
{item.provider}/{item.model}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
motivo: {item.reason} • restante: {formatRemaining(item.remainingMs)}
|
||||
reason: {item.reason} • remaining: {formatRemaining(item.remainingMs)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -143,7 +143,7 @@ export default function ModelCooldownsCard() {
|
||||
onClick={() => void clearOne(item.provider, item.model)}
|
||||
disabled={busyKey === rowKey}
|
||||
>
|
||||
Reativar
|
||||
Reactivate
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -65,13 +65,13 @@ function SectionDescription({
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2 text-xs text-text-muted sm:grid-cols-3">
|
||||
<div>
|
||||
<span className="font-semibold text-text-main">Escopo:</span> {scope}
|
||||
<span className="font-semibold text-text-main">Scope:</span> {scope}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-text-main">Gatilho:</span> {trigger}
|
||||
<span className="font-semibold text-text-main">Trigger:</span> {trigger}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-text-main">Efeito:</span> {effect}
|
||||
<span className="font-semibold text-text-main">Effect:</span> {effect}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -216,12 +216,12 @@ function RequestQueueCard({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-xl text-primary">speed</span>
|
||||
<h2 className="text-lg font-bold">Fila de Requisições e Ritmo</h2>
|
||||
<h2 className="text-lg font-bold">Request Queue & Rate</h2>
|
||||
</div>
|
||||
<SectionDescription
|
||||
scope="Por fila de requisição"
|
||||
trigger="Antes de enviar para o upstream"
|
||||
effect="Enfileira requisições, limita concorrência e espaça as chamadas"
|
||||
scope="Per request queue"
|
||||
trigger="Before sending to upstream"
|
||||
effect="Queues requests, limits concurrency, and spaces calls"
|
||||
/>
|
||||
</div>
|
||||
<ActionRow
|
||||
@@ -240,29 +240,29 @@ function RequestQueueCard({
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-sm text-text-muted">
|
||||
Esta camada controla apenas enfileiramento e ritmo. Ela não grava cooldown nem abre circuit
|
||||
breaker.
|
||||
This layer only controls queueing and pacing. It does not write cooldown state nor open the
|
||||
circuit breaker.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{editing ? (
|
||||
<>
|
||||
<BooleanField
|
||||
label="Autoativar para provedores com API key"
|
||||
description="Ativa proteção de fila por padrão para conexões de API key ativas."
|
||||
label="Auto-enable for API-key providers"
|
||||
description="Enable queue protection by default for active API-key connections."
|
||||
checked={draft.autoEnableApiKeyProviders}
|
||||
onChange={(autoEnableApiKeyProviders) =>
|
||||
setDraft((prev) => ({ ...prev, autoEnableApiKeyProviders }))
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Requisições por minuto"
|
||||
label="Requests per minute"
|
||||
value={draft.requestsPerMinute}
|
||||
min={1}
|
||||
onChange={(requestsPerMinute) => setDraft((prev) => ({ ...prev, requestsPerMinute }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Tempo mínimo entre requisições"
|
||||
label="Minimum time between requests"
|
||||
value={draft.minTimeBetweenRequestsMs}
|
||||
suffix="ms"
|
||||
onChange={(minTimeBetweenRequestsMs) =>
|
||||
@@ -270,7 +270,7 @@ function RequestQueueCard({
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Requisições concorrentes"
|
||||
label="Concurrent requests"
|
||||
value={draft.concurrentRequests}
|
||||
min={1}
|
||||
onChange={(concurrentRequests) =>
|
||||
@@ -278,7 +278,7 @@ function RequestQueueCard({
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Tempo máximo de espera em fila"
|
||||
label="Maximum queue wait time"
|
||||
value={draft.maxWaitMs}
|
||||
min={1}
|
||||
suffix="ms"
|
||||
@@ -288,31 +288,31 @@ function RequestQueueCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Autoativar para provedores com API key</div>
|
||||
<div className="text-xs text-text-muted">Auto-enable for API-key providers</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.autoEnableApiKeyProviders ? "Ativado" : "Desativado"}
|
||||
{value.autoEnableApiKeyProviders ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Requisições por minuto</div>
|
||||
<div className="text-xs text-text-muted">Requests per minute</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.requestsPerMinute}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Tempo mínimo entre requisições</div>
|
||||
<div className="text-xs text-text-muted">Minimum time between requests</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{formatMs(value.minTimeBetweenRequestsMs)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Requisições concorrentes</div>
|
||||
<div className="text-xs text-text-muted">Concurrent requests</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.concurrentRequests}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Tempo máximo de espera em fila</div>
|
||||
<div className="text-xs text-text-muted">Maximum queue wait time</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{formatMs(value.maxWaitMs)}
|
||||
</div>
|
||||
@@ -347,7 +347,7 @@ function ConnectionCooldownCard({
|
||||
{editing ? (
|
||||
<>
|
||||
<NumberField
|
||||
label="Cooldown base"
|
||||
label="Base cooldown"
|
||||
value={current.baseCooldownMs}
|
||||
min={0}
|
||||
suffix="ms"
|
||||
@@ -420,7 +420,7 @@ function ConnectionCooldownCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Cooldown base</span>
|
||||
<span className="text-text-muted">Base cooldown</span>
|
||||
<span className="font-mono text-text-main">{formatMs(current.baseCooldownMs)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
@@ -533,7 +533,7 @@ function ProviderBreakerCard({
|
||||
{editing ? (
|
||||
<>
|
||||
<NumberField
|
||||
label="Limite de falhas"
|
||||
label="Failure threshold"
|
||||
value={current.failureThreshold}
|
||||
min={1}
|
||||
onChange={(failureThreshold) =>
|
||||
@@ -541,7 +541,7 @@ function ProviderBreakerCard({
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Tempo para reset"
|
||||
label="Reset timeout"
|
||||
value={current.resetTimeoutMs}
|
||||
min={1000}
|
||||
suffix="ms"
|
||||
@@ -553,11 +553,11 @@ function ProviderBreakerCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Limite de falhas</span>
|
||||
<span className="text-text-muted">Failure threshold</span>
|
||||
<span className="font-mono text-text-main">{current.failureThreshold}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Tempo para reset</span>
|
||||
<span className="text-text-muted">Reset timeout</span>
|
||||
<span className="font-mono text-text-main">{formatMs(current.resetTimeoutMs)}</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -574,12 +574,12 @@ function ProviderBreakerCard({
|
||||
<span className="material-symbols-outlined text-xl text-primary">
|
||||
electrical_services
|
||||
</span>
|
||||
<h2 className="text-lg font-bold">Circuit Breaker por Provedor</h2>
|
||||
<h2 className="text-lg font-bold">Circuit Breaker per Provider</h2>
|
||||
</div>
|
||||
<SectionDescription
|
||||
scope="Provedor inteiro"
|
||||
trigger="Falhas finais de transporte/servidor após esgotar fallback de conexão"
|
||||
effect="Bloqueia temporariamente esse provedor até o tempo de reset expirar"
|
||||
scope="Entire provider"
|
||||
trigger="Final transport/server failures after exhausting connection fallback"
|
||||
effect="Temporarily blocks this provider until the reset timeout expires"
|
||||
/>
|
||||
</div>
|
||||
<ActionRow
|
||||
@@ -598,13 +598,13 @@ function ProviderBreakerCard({
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-sm text-text-muted">
|
||||
O estado em tempo real do breaker aparece apenas na página Saúde. Rate limits 429 no escopo
|
||||
de conexão ficam no Cooldown de Conexão e não disparam o breaker de provedor.
|
||||
The live breaker state is shown only on the Health page. 429 rate limits at the connection
|
||||
scope stay in Connection Cooldown and do not trip the provider breaker.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{renderProfile("oauth", "Provedores OAuth", "lock")}
|
||||
{renderProfile("apikey", "Provedores API Key", "key")}
|
||||
{renderProfile("oauth", "OAuth Providers", "lock")}
|
||||
{renderProfile("apikey", "API Key Providers", "key")}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -632,12 +632,12 @@ function WaitForCooldownCard({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-xl text-primary">hourglass_top</span>
|
||||
<h2 className="text-lg font-bold">Aguardar Cooldown</h2>
|
||||
<h2 className="text-lg font-bold">Wait for Cooldown</h2>
|
||||
</div>
|
||||
<SectionDescription
|
||||
scope="Requisição atual do cliente"
|
||||
trigger="Quando todas as conexões candidatas já estão em cooldown"
|
||||
effect="Aguarda no servidor e tenta novamente quando o primeiro cooldown expira"
|
||||
scope="Current client request"
|
||||
trigger="When all candidate connections are already in cooldown"
|
||||
effect="Waits on the server and retries when the first cooldown expires"
|
||||
/>
|
||||
</div>
|
||||
<ActionRow
|
||||
@@ -656,26 +656,26 @@ function WaitForCooldownCard({
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-sm text-text-muted">
|
||||
Isso afeta apenas a requisição atual. Não grava estado de conexão nem de provedor.
|
||||
This only affects the current request. No connection or provider state is written.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{editing ? (
|
||||
<>
|
||||
<BooleanField
|
||||
label="Ativar espera no servidor"
|
||||
description="Quando ativo, o OmniRoute aguarda o primeiro cooldown expirar e tenta novamente automaticamente."
|
||||
label="Enable server-side wait"
|
||||
description="When enabled, OmniRoute waits for the first cooldown to expire and retries automatically."
|
||||
checked={draft.enabled}
|
||||
onChange={(enabled) => setDraft((prev) => ({ ...prev, enabled }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Máximo de tentativas"
|
||||
label="Maximum retries"
|
||||
value={draft.maxRetries}
|
||||
min={0}
|
||||
onChange={(maxRetries) => setDraft((prev) => ({ ...prev, maxRetries }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Tempo máximo de espera por tentativa"
|
||||
label="Maximum wait per retry"
|
||||
value={draft.maxRetryWaitSec}
|
||||
min={0}
|
||||
suffix="sec"
|
||||
@@ -685,17 +685,17 @@ function WaitForCooldownCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Ativar espera no servidor</div>
|
||||
<div className="text-xs text-text-muted">Enable server-side wait</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.enabled ? "Ativado" : "Desativado"}
|
||||
{value.enabled ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Máximo de tentativas</div>
|
||||
<div className="text-xs text-text-muted">Maximum retries</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">{value.maxRetries}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Tempo máximo de espera por tentativa</div>
|
||||
<div className="text-xs text-text-muted">Maximum wait per retry</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.maxRetryWaitSec}s
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Card, Collapsible, Input, Select, Toggle } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import FallbackChainsEditor from "./FallbackChainsEditor";
|
||||
import {
|
||||
CLI_COMPAT_PROVIDER_DISPLAY,
|
||||
@@ -629,6 +630,7 @@ export default function RoutingTab() {
|
||||
const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false);
|
||||
const [lkgpCacheStatus, setLkgpCacheStatus] = useState({ type: "", message: "" });
|
||||
const t = useTranslations("settings");
|
||||
const notify = useNotificationStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings")
|
||||
@@ -670,11 +672,15 @@ export default function RoutingTab() {
|
||||
} catch {
|
||||
// body wasn't JSON — keep the HTTP status fallback
|
||||
}
|
||||
notify.error(t("saveFailed"), serverMsg);
|
||||
if (onError) onError(serverMsg);
|
||||
else console.error("Failed to update settings:", serverMsg);
|
||||
} else {
|
||||
notify.success(t("savedSuccessfully"));
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
notify.error(t("saveFailed"), msg);
|
||||
if (onError) onError(msg);
|
||||
else console.error("Failed to update settings:", msg);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function SettingsPage() {
|
||||
<div className="max-w-6xl mx-auto min-w-0">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Tab navigation */}
|
||||
<div className="w-full overflow-x-auto pb-1">
|
||||
<div className="sticky top-0 z-20 w-full overflow-x-auto pb-1 pt-1 bg-bg-primary/95 supports-[backdrop-filter]:bg-bg-primary/80 backdrop-blur">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("settingsSectionsAria")}
|
||||
|
||||
@@ -672,6 +672,8 @@
|
||||
"cloudAgents": "Cloud Agents",
|
||||
"memory": "Memory",
|
||||
"skills": "Skills",
|
||||
"omniSkills": "Omni Skills",
|
||||
"agentSkills": "Agent Skills",
|
||||
"docs": "Docs",
|
||||
"issues": "Issues",
|
||||
"endpoints": "Endpoints",
|
||||
@@ -1458,6 +1460,11 @@
|
||||
"combos": {
|
||||
"title": "Combos",
|
||||
"description": "Create model combos with weighted routing and fallback support",
|
||||
"autoCatalogTitle": "Auto-routing catalog",
|
||||
"autoCatalogTemplateCount": "{count} templates",
|
||||
"autoCatalogDescription": "Built-in auto/* combos resolved dynamically from connected providers. Use any of these IDs as the model field — no setup needed.",
|
||||
"autoCatalogExpand": "Expand auto-routing catalog",
|
||||
"autoCatalogCollapse": "Collapse auto-routing catalog",
|
||||
"createCombo": "Create Combo",
|
||||
"editCombo": "Edit Combo",
|
||||
"deleteCombo": "Delete Combo",
|
||||
@@ -3993,7 +4000,9 @@
|
||||
"multi-turn": "Multi-turn",
|
||||
"thinking": "Thinking",
|
||||
"system-prompt": "System Prompt",
|
||||
"streaming": "Streaming"
|
||||
"streaming": "Streaming",
|
||||
"vision": "Vision",
|
||||
"schema-coercion": "Schema Coercion"
|
||||
},
|
||||
"templateDescriptions": {
|
||||
"simple-chat": "Basic text message",
|
||||
@@ -4001,7 +4010,9 @@
|
||||
"multi-turn": "Conversation with history",
|
||||
"thinking": "Extended thinking / reasoning",
|
||||
"system-prompt": "Complex system instructions",
|
||||
"streaming": "SSE streaming request"
|
||||
"streaming": "SSE streaming request",
|
||||
"vision": "Multimodal request with image input",
|
||||
"schema-coercion": "Structured-output / JSON schema enforcement"
|
||||
},
|
||||
"templatePayloads": {
|
||||
"simpleChat": {
|
||||
@@ -4929,7 +4940,9 @@
|
||||
"system-prompt": "System Prompt",
|
||||
"thinking": "Thinking",
|
||||
"tool-calling": "Tool Calling",
|
||||
"multi-turn": "Multi-turn"
|
||||
"multi-turn": "Multi-turn",
|
||||
"vision": "Vision",
|
||||
"schema-coercion": "Schema Coercion"
|
||||
},
|
||||
"templateDescriptions": {
|
||||
"simple-chat": "Basic chat template with system message",
|
||||
@@ -4937,7 +4950,9 @@
|
||||
"system-prompt": "Template with custom system prompt",
|
||||
"thinking": "Template with reasoning/thinking budget",
|
||||
"tool-calling": "Template for tool/function calling",
|
||||
"multi-turn": "Template for multi-turn conversations"
|
||||
"multi-turn": "Template for multi-turn conversations",
|
||||
"vision": "Multimodal template with image input",
|
||||
"schema-coercion": "Structured-output / JSON schema enforcement"
|
||||
},
|
||||
"templatePayloads": {
|
||||
"simpleChat": {
|
||||
|
||||
121
tests/unit/AutoComboCatalog.test.tsx
Normal file
121
tests/unit/AutoComboCatalog.test.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AUTO_COMBO_TEMPLATES } from "@/domain/assessment/types";
|
||||
|
||||
// Minimal i18n stub — return interpolated value so {count} works.
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||
if (values && typeof values.count !== "undefined") {
|
||||
return `${values.count} ${key}`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => {
|
||||
container.remove();
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("AutoComboCatalog", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) {
|
||||
cleanupCallbacks.pop()?.();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("renders the header with translated title and template-count badge", async () => {
|
||||
const { default: AutoComboCatalog } =
|
||||
await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<AutoComboCatalog />);
|
||||
});
|
||||
expect(container.textContent).toContain("autoCatalogTitle");
|
||||
expect(container.textContent).toContain(
|
||||
`${AUTO_COMBO_TEMPLATES.length} autoCatalogTemplateCount`
|
||||
);
|
||||
});
|
||||
|
||||
it("stays collapsed by default — no template rows in the DOM", async () => {
|
||||
const { default: AutoComboCatalog } =
|
||||
await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<AutoComboCatalog />);
|
||||
});
|
||||
const first = AUTO_COMBO_TEMPLATES[0];
|
||||
expect(container.textContent ?? "").not.toContain(first.name);
|
||||
});
|
||||
|
||||
it("expands when toggled and lists every template name", async () => {
|
||||
const { default: AutoComboCatalog } =
|
||||
await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<AutoComboCatalog />);
|
||||
});
|
||||
const toggle = container.querySelector("button");
|
||||
expect(toggle).toBeTruthy();
|
||||
await act(async () => {
|
||||
toggle?.click();
|
||||
});
|
||||
for (const tpl of AUTO_COMBO_TEMPLATES) {
|
||||
expect(container.textContent ?? "").toContain(tpl.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("flips the toggle aria-label between expand and collapse", async () => {
|
||||
const { default: AutoComboCatalog } =
|
||||
await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<AutoComboCatalog />);
|
||||
});
|
||||
const toggle = container.querySelector("button");
|
||||
expect(toggle?.getAttribute("aria-label")).toBe("autoCatalogExpand");
|
||||
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
|
||||
await act(async () => {
|
||||
toggle?.click();
|
||||
});
|
||||
expect(toggle?.getAttribute("aria-label")).toBe("autoCatalogCollapse");
|
||||
expect(toggle?.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders the strategy badge for each template when expanded", async () => {
|
||||
const { default: AutoComboCatalog } =
|
||||
await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog");
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<AutoComboCatalog />);
|
||||
});
|
||||
await act(async () => {
|
||||
(container.querySelector("button") as HTMLButtonElement | null)?.click();
|
||||
});
|
||||
const strategies = new Set(AUTO_COMBO_TEMPLATES.map((t) => t.strategy));
|
||||
for (const s of strategies) {
|
||||
expect(container.textContent ?? "").toContain(s);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user