diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 7e9f66bbf39..24a0083c955 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -125,7 +125,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `LIVE_WS_PORT` | `20129` | `src/server/ws/liveServer.ts` | Port for the real-time WebSocket live monitoring server. | | `LIVE_WS_HOST` | `127.0.0.1` | `src/server/ws/liveServer.ts` | Bind address for the live WebSocket server. Set to `0.0.0.0` to expose on LAN (also configure `LIVE_WS_ALLOWED_ORIGINS`). | | `LIVE_WS_ALLOWED_ORIGINS` | _(unset)_ | `src/server/ws/liveServer.ts` | Comma-separated extra origins allowed to open a live WebSocket. Loopback dashboard origins are already permitted by default. | -| `OMNIROUTE_ENABLE_LIVE_WS` | `false` | `src/server/ws/liveServer.ts` | Set to `1` or `true` to enable the real-time WebSocket server (disabled by default). | +| `OMNIROUTE_ENABLE_LIVE_WS` | `true` | `src/server/ws/liveServer.ts` | Set to `0` or `false` to disable the real-time WebSocket server (enabled by default, loopback-bound). | | `OMNIROUTE_DISABLE_LIVE_WS` | `false` | `scripts/start-ws-server.mjs` | CI/harness toggle that disables the standalone live WebSocket helper script. | | `RELAY_IP_PER_MINUTE` | `30` | `src/app/api/v1/relay/chat/completions/route.ts` | Per-(token, IP) relay rate limit, requests/minute. In-memory, per instance. `0` or negative disables the IP-dimension gate (per-token DB limit still applies). | | `NODE_ENV` | `production` | Next.js core | Controls logging verbosity, caching, error detail exposure, and Next.js optimizations. | diff --git a/file-size-baseline.json b/file-size-baseline.json index 588031bc46c..7dbcc932a70 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -1,6 +1,7 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", "_rebaseline_v3.8.25": "Drift consciente do ciclo v3.8.24->v3.8.25 (features #3799-#3806: free-provider-rankings, plugins menu, proxy IP-family selector). 3 arquivos cresceram por feature legitima, nao por regressao de qualidade: ProxyRegistryManager.tsx 1072->1089, sidebarVisibility.ts 990->1006, schemas.ts 2519->2522. Encolher fica como debt para um refactor dedicado.", + "_rebaseline_2026_06_15_3860_compression_ui": "PR #3860 own growth: sidebarVisibility.ts 1006->1100 (+94 = Compression Hub menu entries: Hub + per-engine Lite/Aggressive/Ultra pages + combos editor) and chatCore.ts 5812->5815 (+3 = compression UI config wiring). Cohesive feature growth, not a quality regression.", "cap": 800, "frozen": { "open-sse/config/providerRegistry.ts": 4703, @@ -16,7 +17,7 @@ "open-sse/executors/muse-spark-web.ts": 1284, "open-sse/executors/perplexity-web.ts": 868, "open-sse/handlers/audioSpeech.ts": 952, - "open-sse/handlers/chatCore.ts": 5812, + "open-sse/handlers/chatCore.ts": 5815, "open-sse/handlers/imageGeneration.ts": 3777, "open-sse/handlers/responseSanitizer.ts": 1103, "open-sse/handlers/search.ts": 1442, @@ -100,7 +101,7 @@ "src/shared/constants/cliTools.ts": 875, "src/shared/constants/pricing.ts": 1508, "src/shared/constants/providers.ts": 3147, - "src/shared/constants/sidebarVisibility.ts": 1006, + "src/shared/constants/sidebarVisibility.ts": 1100, "src/shared/services/cliRuntime.ts": 1090, "src/shared/validation/schemas.ts": 2523, "src/sse/handlers/chat.ts": 1425, diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 1e37799084d..bfe6846f44f 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -2486,7 +2486,10 @@ export async function handleChatCore({ const isStackedCompressionCombo = ( compressionCombo: RuntimeCompressionCombo | null ): compressionCombo is RuntimeCompressionCombo => { - return Boolean(compressionCombo && compressionCombo.pipeline.length > 1); + // >= 1: a single-engine default combo (user enabled exactly one layer via the + // per-engine config page) must still apply. applyCompressionComboConfig already + // guards length === 0. + return Boolean(compressionCombo && compressionCombo.pipeline.length >= 1); }; if (isCombo && comboName) { try { diff --git a/src/app/(dashboard)/dashboard/context/aggressive/page.tsx b/src/app/(dashboard)/dashboard/context/aggressive/page.tsx new file mode 100644 index 00000000000..cef8e071f79 --- /dev/null +++ b/src/app/(dashboard)/dashboard/context/aggressive/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { EngineConfigPage } from "@/shared/components/compression/EngineConfigPage"; + +export default function AggressivePage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/context/ccr/page.tsx b/src/app/(dashboard)/dashboard/context/ccr/page.tsx new file mode 100644 index 00000000000..2490d4ef57e --- /dev/null +++ b/src/app/(dashboard)/dashboard/context/ccr/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { EngineConfigPage } from "@/shared/components/compression/EngineConfigPage"; + +export default function CcrPage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx b/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx index 13d212591a6..49ab20578f7 100644 --- a/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx +++ b/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx @@ -1,7 +1,14 @@ "use client"; +// Combos screen = Compression Hub (top) + named-combos manager (below). +// +// IMPORTANT (hydration): no `useTranslations` here. The earlier combos redesign +// failed to hydrate on the production build and the only structural difference from +// the engine pages was a page-level `useTranslations`. Strings are hardcoded (pt-BR), +// matching `EngineConfigPage` / `CompressionHub`, both of which hydrate cleanly. + import { useEffect, useState } from "react"; -import { useTranslations } from "next-intl"; +import CompressionHub from "./CompressionHub"; type PipelineStep = { engine: string; intensity?: string }; type CompressionCombo = { @@ -28,10 +35,13 @@ const ENGINE_INTENSITIES: Record = { lite: ["lite"], aggressive: ["standard"], ultra: ["ultra"], + headroom: ["standard"], + "session-dedup": ["standard"], + ccr: ["standard"], + llmlingua: ["standard"], }; -export default function CompressionCombosPageClient() { - const t = useTranslations("contextCombos"); +function NamedCombosManager() { const [combos, setCombos] = useState([]); const [routingCombos, setRoutingCombos] = useState([]); const [languagePacks, setLanguagePacks] = useState([]); @@ -131,7 +141,7 @@ export default function CompressionCombosPageClient() { }; const deleteCombo = async (combo: CompressionCombo) => { - if (!confirm(t("deleteConfirm"))) return; + if (!confirm(`Excluir o combo "${combo.name}"?`)) return; const res = await fetch(`/api/context/combos/${combo.id}`, { method: "DELETE" }); if (res.ok) refresh(); }; @@ -174,33 +184,40 @@ export default function CompressionCombosPageClient() { }; return ( -
+
+
+

Combos nomeados

+

+ Salve pipelines diferentes e atribua a combos de roteamento específicos. +

+
+
setName(event.target.value)} - placeholder={t("name")} + placeholder="Nome do combo" className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-main" /> setDescription(event.target.value)} - placeholder={t("descriptionField")} + placeholder="Descrição" className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-main" />
-

{t("pipeline")}

+

Pipeline

{pipeline.map((step, index) => ( @@ -232,7 +249,7 @@ export default function CompressionCombosPageClient() { className="rounded-lg border border-border px-3 py-2 text-sm text-text-main" disabled={pipeline.length <= 1} > - {t("removeStep")} + Remover
))} @@ -240,7 +257,7 @@ export default function CompressionCombosPageClient() {
-

{t("languagePacks")}

+

Pacotes de idioma

{languagePacks.map((pack) => (
-

{t("outputMode")}

+

Output mode

set(f.key, e.target.checked)} /> + )} + {f.type === "number" && ( + set(f.key, Number(e.target.value))} + className="border border-border rounded px-2 py-1" + /> + )} + {f.type === "string" && ( + set(f.key, e.target.value)} + className="border border-border rounded px-2 py-1" + /> + )} + {f.type === "select" && ( + + )} + {f.type === "multiselect" && + (f.options ?? []).map((o) => ( + + ))} + + ); + })} +
+ ); +} + +export default EngineConfigForm; diff --git a/src/shared/components/compression/EngineConfigPage.tsx b/src/shared/components/compression/EngineConfigPage.tsx new file mode 100644 index 00000000000..663b0ec913e --- /dev/null +++ b/src/shared/components/compression/EngineConfigPage.tsx @@ -0,0 +1,343 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { EngineConfigField } from "@omniroute/open-sse/services/compression/engines/types"; +import { EngineConfigForm } from "@/shared/components/compression/EngineConfigForm"; + +// ── Types ───────────────────────────────────────────────────────────────── + +interface EngineEntry { + id: string; + name: string; + description: string; + icon: string; + stackable: boolean; + stackPriority: number; + metadata: { description?: string; [key: string]: unknown }; + configSchema: EngineConfigField[]; +} + +interface ComboStep { + engine: string; + intensity?: string; + config?: Record; +} + +interface Analytics { + engineId: string; + runs: number; + tokensSaved: number; + avgSavingsPercent: number; + days: number; +} + +interface PreviewResult { + originalTokens: number; + compressedTokens: number; + savingsPct: number; +} + +// ── 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."; + +// ── Sub-components ──────────────────────────────────────────────────────── + +function StatCard({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────── + +export function EngineConfigPage({ engineId }: { engineId: string }) { + // ── Data state ────────────────────────────────────────────────────────── + const [engine, setEngine] = useState(null); + const [enabled, setEnabled] = useState(false); + const [configState, setConfigState] = useState>({}); + const [analytics, setAnalytics] = useState(null); + const [loadError, setLoadError] = useState(null); + const [loading, setLoading] = useState(true); + + // ── Preview state ─────────────────────────────────────────────────────── + const [previewText, setPreviewText] = useState(PREVIEW_SAMPLE); + const [preview, setPreview] = useState(null); + const [previewError, setPreviewError] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + + // ── Action state ──────────────────────────────────────────────────────── + const [saveError, setSaveError] = useState(null); + const [toggleError, setToggleError] = useState(null); + const [saving, setSaving] = useState(false); + + // ── Initial load ──────────────────────────────────────────────────────── + useEffect(() => { + let cancelled = false; + + async function load() { + setLoading(true); + setLoadError(null); + + // Fire the three independent reads in parallel — load time is the slowest + // single request, not their sum. Each resolves to null on failure (fail-soft). + const asJson = (r: Response) => (r.ok ? r.json() : null); + const [enginesData, comboData, analyticsData] = await Promise.all([ + fetch("/api/compression/engines") + .then(asJson) + .catch(() => null) as Promise<{ engines: EngineEntry[] } | null>, + fetch("/api/context/combos/default") + .then(asJson) + .catch(() => null) as Promise<{ pipeline?: ComboStep[] } | null>, + fetch(`/api/context/analytics/engine?engineId=${engineId}&days=7`) + .then(asJson) + .catch(() => null) as Promise, + ]); + + let foundEngine: EngineEntry | null = null; + if (enginesData) { + foundEngine = enginesData.engines?.find((e) => e.id === engineId) ?? null; + } else { + setLoadError("Failed to load engine information."); + } + + // Derive enabled + currentConfig from the default combo (404/null = defaults) + let currentEnabled = false; + let currentConfig: Record = {}; + const step = comboData?.pipeline?.find((s) => s.engine === engineId); + if (step) { + currentEnabled = true; + currentConfig = step.config ?? {}; + } + + if (!cancelled) { + if (analyticsData) setAnalytics(analyticsData); + setEngine(foundEngine); + setEnabled(currentEnabled); + // Seed configState from defaultValues then override with currentConfig + const defaults: Record = {}; + for (const field of foundEngine?.configSchema ?? []) { + defaults[field.key] = field.defaultValue; + } + setConfigState({ ...defaults, ...currentConfig }); + setLoading(false); + } + } + + void load(); + return () => { + cancelled = true; + }; + }, [engineId]); + + // ── Handlers ───────────────────────────────────────────────────────────── + + async function handleToggle() { + const next = !enabled; + setEnabled(next); + setToggleError(null); + try { + const res = await fetch("/api/context/combos/default", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ engineId, enabled: next }), + }); + if (!res.ok) { + setToggleError("Failed to update engine state."); + setEnabled(!next); // revert + } + } catch { + setToggleError("Failed to update engine state."); + setEnabled(!next); // revert + } + } + + async function handleSave() { + setSaving(true); + setSaveError(null); + try { + const res = await fetch("/api/context/combos/default", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ engineId, enabled, config: configState }), + }); + if (!res.ok) { + setSaveError("Failed to save configuration."); + } + } catch { + setSaveError("Failed to save configuration."); + } finally { + setSaving(false); + } + } + + async function handlePreview() { + setPreviewLoading(true); + setPreviewError(null); + setPreview(null); + try { + const res = await fetch("/api/compression/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + engineId, + messages: [{ role: "user", content: previewText }], + }), + }); + if (res.ok) { + const data = (await res.json()) as PreviewResult; + setPreview(data); + } else { + setPreviewError("Preview failed."); + } + } catch { + setPreviewError("Preview failed."); + } finally { + setPreviewLoading(false); + } + } + + // ── Render ──────────────────────────────────────────────────────────────── + + if (loading) { + return ( +
Loading…
+ ); + } + + if (!engine) { + return ( +
+ {loadError ?? `Engine "${engineId}" not found.`} +
+ ); + } + + const subtitle = engine.metadata?.description ?? engine.description; + + return ( +
+ {/* ── Header ── */} +
+
+ {engine.icon && {engine.icon}} +

{engine.name}

+
+ {subtitle &&

{subtitle}

} +
+ + {loadError && ( +

+ {loadError} +

+ )} + + {/* ── Enable toggle ── */} +
+
+
+ Ativar camada + + {enabled ? "Esta camada está ativa no pipeline padrão." : "Esta camada está inativa."} + +
+ +
+ {toggleError &&

{toggleError}

} +

+ As camadas ativadas rodam quando a compressão está no modo "stacked". Configure + em{" "} + + Compression Settings + + . +

+
+ + {/* ── Config form ── */} +
+

Configuração

+ +
+ + {saveError &&

{saveError}

} +
+
+ + {/* ── Live preview ── */} +
+

Preview

+