Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(dashboard):** Compression engine pages now save only the fields edited on that page. A save re-reads the stored settings and applies the edits onto the fresh stored copy, so saving an engine page no longer reverts thresholds and tool strategies the Compression Settings tab saved after the page loaded. An emptied text field clears its stored key, `ultra.enabled` survives engine-page saves, and the Ultra page saves on a default install where the model path was never set. (#15595)
1 change: 1 addition & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -8544,6 +8544,7 @@
"compressionEngineConfig": {
"loading": "Loading…",
"loadFailed": "Failed to load engine information.",
"settingsLoadFailed": "Failed to load the saved settings. The fields show defaults, and Save stays off until the page reloads.",
"engineNotFound": "Engine \"{engine}\" not found.",
"saveFailed": "Failed to save configuration.",
"previewFailed": "Preview failed.",
Expand Down
9 changes: 4 additions & 5 deletions src/shared/components/compression/EngineConfigForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import type { EngineConfigField } from "@omniroute/open-sse/services/compression
export interface EngineConfigFormProps {
schema: EngineConfigField[];
value: Record<string, unknown>;
onChange: (next: Record<string, unknown>) => void;
// Called with one field at a time, so the caller can apply it to its latest state.
onChange: (key: string, value: unknown) => void;
}

export function EngineConfigForm({ schema, value, onChange }: EngineConfigFormProps) {
const set = (k: string, v: unknown) => onChange({ ...value, [k]: v });
export function EngineConfigForm({ schema, value, onChange: set }: EngineConfigFormProps) {
return (
<div className="flex flex-col gap-3">
{schema.map((f) => {
Expand All @@ -24,8 +24,7 @@ export function EngineConfigForm({ schema, value, onChange }: EngineConfigFormPr
<input
type="number"
value={
f.key === "maxToolLength" &&
!(typeof v === "number" && Number.isFinite(v))
f.key === "maxToolLength" && !(typeof v === "number" && Number.isFinite(v))
? ""
: (v as number)
}
Expand Down
106 changes: 62 additions & 44 deletions src/shared/components/compression/EngineConfigPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ 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";
import {
buildEngineDetailUpdate,
forgetSentEdits,
formAfterSave,
seedEngineForm,
withoutEmptyText,
} from "@/shared/components/compression/engineConfigSave";

// ── Types ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -119,6 +126,9 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
// ── Data state ──────────────────────────────────────────────────────────
const [engine, setEngine] = useState<EngineEntry | null>(null);
const [configState, setConfigState] = useState<Record<string, unknown>>({});
// The stored values as of the last load or save, in form shape. A save sends the fields
// changed since.
const [savedConfig, setSavedConfig] = useState<Record<string, unknown>>({});
const [analytics, setAnalytics] = useState<Analytics | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -156,35 +166,26 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
.catch(() => null) as Promise<Analytics | null>,
]);

let foundEngine: EngineEntry | null = null;
if (enginesData) {
foundEngine = enginesData.engines?.find((e) => e.id === engineId) ?? null;
} else {
setLoadError(t("loadFailed"));
}
const foundEngine = enginesData?.engines?.find((e) => e.id === engineId) ?? null;

// Detailed config lives in the engine's settings sub-object (when it has one);
// the on/off + level moved to the panel. 404/null/missing = schema defaults.
// the on/off + level moved to the panel. A missing sub-object means schema defaults.
const subKey = SETTINGS_SUBOBJECT[engineId];
const stored = subKey ? settingsData?.[subKey] : undefined;
const currentConfig: Record<string, unknown> =
stored && typeof stored === "object" ? (stored as Record<string, unknown>) : {};

if (!cancelled) {
if (!enginesData) {
setLoadError(t("loadFailed"));
} else if (subKey && !settingsData) {
// Without the stored settings the form would show defaults as saved values, so
// Save stays off.
setLoadError(t("settingsLoadFailed"));
}
if (analyticsData) setAnalytics(analyticsData);
setEngine(foundEngine);
// Seed configState from defaultValues then override with the stored sub-object.
const defaults: Record<string, unknown> = {};
for (const field of foundEngine?.configSchema ?? []) {
defaults[field.key] = field.defaultValue;
}
// Do not seed lite.maxToolLength from the schema default. Persisting 2000
// would freeze the cap in settings and hide OMNIROUTE_LITE_MAX_TOOL_LENGTH.
// The form still shows 2000 via field.defaultValue until the operator edits it.
if (engineId === "lite" && currentConfig.maxToolLength === undefined) {
delete defaults.maxToolLength;
}
setConfigState({ ...defaults, ...currentConfig });
const seeded = seedEngineForm(engineId, foundEngine?.configSchema ?? [], stored);
setConfigState(seeded);
setSavedConfig(seeded);
setLoading(false);
}
}
Expand All @@ -207,38 +208,55 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
setSaveError(null);
return;
}
// Strip the `enabled` key — engine on/off is the panel's responsibility.
const { enabled: _ignored, ...formDetail } = configState;
void _ignored;
let detail: Record<string, unknown> = formDetail;
if (engineId === "lite") {
const raw = formDetail.maxToolLength;
const compressToolResults = formDetail.compressToolResults !== false;
if (!Object.prototype.hasOwnProperty.call(formDetail, "maxToolLength")) {
detail = { compressToolResults };
} else if (typeof raw === "number" && Number.isFinite(raw)) {
const n = Math.floor(raw);
if (n < 256 || n > 1_000_000) {
setSaveError(t("saveFailed"));
return;
}
detail = { compressToolResults, maxToolLength: n };
} else {
detail = { compressToolResults, maxToolLength: null };
}
// Lite's cap must be in range before anything is sent; the save floors it to a whole number.
const cap = engineId === "lite" ? configState.maxToolLength : undefined;
if (
typeof cap === "number" &&
Number.isFinite(cap) &&
(Math.floor(cap) < 256 || Math.floor(cap) > 1_000_000)
) {
setSaveError(t("saveFailed"));
return;
}
const sent = configState;
setSaving(true);
setSaveError(null);
try {
// The body starts from the copy stored now and changes only the fields edited here. The
// server replaces each sub-object whole (lite merges), so a copy this page loaded earlier
// would write back fields another page saved since.
const current = (await fetch("/api/settings/compression")
.then((r) => (r.ok ? r.json() : null))
.catch(() => null)) as CompressionSettings | null;
if (!current) {
setSaveError(t("saveFailed"));
return;
}
const detail = buildEngineDetailUpdate(engineId, savedConfig, sent, current[subKey]);
const res = await fetch("/api/settings/compression", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [subKey]: detail }),
});
if (!res.ok) {
setSavedConfig((saved) => forgetSentEdits(saved, sent));
setSaveError(t("saveFailed"));
return;
}
// Show what the server now holds, which can include fields another page changed, and
// keep anything typed while the save was out.
const settings = (await res.json().catch(() => null)) as CompressionSettings | null;
const written = seedEngineForm(
engineId,
engine?.configSchema ?? [],
settings?.[subKey] ?? detail
);
setSavedConfig(written);
setConfigState((now) => formAfterSave(written, sent, now));
} catch {
// The PUT may have reached the server before the request failed, so the next save
// sends these fields again.
setSavedConfig((saved) => forgetSentEdits(saved, sent));
setSaveError(t("saveFailed"));
} finally {
setSaving(false);
Expand All @@ -262,9 +280,9 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
},
}
: engineId === "aggressive"
? { aggressive: { ...configState } }
? { aggressive: withoutEmptyText(configState) }
: engineId === "ultra"
? { ultra: { ...configState } }
? { ultra: withoutEmptyText(configState) }
: undefined;
const res = await fetch("/api/compression/preview", {
method: "POST",
Expand Down Expand Up @@ -379,7 +397,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
<EngineConfigForm
schema={visibleConfigSchema}
value={configState}
onChange={setConfigState}
onChange={(key, next) => setConfigState((prev) => ({ ...prev, [key]: next }))}
/>
) : (
<p className="text-sm text-text-muted">{t("noAdditionalConfiguration")}</p>
Expand All @@ -388,7 +406,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
{persistable ? (
<button
onClick={handleSave}
disabled={saving}
disabled={saving || Boolean(loadError)}
className="px-4 py-1.5 rounded bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50"
>
{saving ? t("saving") : t("save")}
Expand Down
88 changes: 88 additions & 0 deletions src/shared/components/compression/engineConfigSave.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { EngineConfigField } from "@omniroute/open-sse/services/compression/engines/types";

type FormValues = Record<string, unknown>;

function asRecord(value: unknown): FormValues {
return value && typeof value === "object" ? (value as FormValues) : {};
}

// The settings schemas reject an empty string, so an emptied text field means "not set".
function isEmptyText(value: unknown): boolean {
return typeof value === "string" && value.trim() === "";
}

/** Form values an engine page shows: schema defaults, then the stored sub-object. */
export function seedEngineForm(
engineId: string,
schema: EngineConfigField[],
stored: unknown
): FormValues {
const current = asRecord(stored);
const defaults: FormValues = Object.fromEntries(schema.map((f) => [f.key, f.defaultValue]));
// Leave lite.maxToolLength out until a cap is stored. The baseline then has no cap, so an
// unset cap stays unset (OMNIROUTE_LITE_MAX_TOOL_LENGTH applies) and typing 2000 still counts
// as an edit. The form still shows 2000 through field.defaultValue.
if (engineId === "lite" && current.maxToolLength === undefined) {
delete defaults.maxToolLength;
}
return { ...defaults, ...current };
}

/**
* The sub-object an engine page PUTs on save. The server replaces each sub-object row whole
* (lite merges), so the body starts from the copy stored at save time and applies only the fields
* edited since `saved`. The page hides `enabled`, so the form never writes it and the stored value
* passes through. An emptied text field removes its key.
*/
export function buildEngineDetailUpdate(
engineId: string,
saved: FormValues,
edited: FormValues,
stored: unknown
): FormValues {
const next = { ...asRecord(stored) };
for (const [key, value] of Object.entries(edited)) {
if (key === "enabled" || Object.is(value, saved[key])) continue;
if (isEmptyText(value)) {
delete next[key];
} else {
next[key] = value;
}
}
if (engineId !== "lite") return next;
// The lite settings schema accepts only these two fields. A cleared cap input holds NaN, and
// null tells the server to drop the stored cap.
const cap = next.maxToolLength;
return {
compressToolResults: next.compressToolResults !== false,
...("maxToolLength" in next
? { maxToolLength: typeof cap === "number" && Number.isFinite(cap) ? Math.floor(cap) : null }
: {}),
};
}

/** The form after a successful save: what the server now holds, plus edits made meanwhile. */
export function formAfterSave(written: FormValues, sent: FormValues, now: FormValues): FormValues {
const next = { ...written };
for (const [key, value] of Object.entries(now)) {
if (!Object.is(value, sent[key])) next[key] = value;
}
return next;
}

/**
* The baseline after a failed save. The server may have applied the save before the request
* failed, so the fields it carried leave the baseline and the next save sends them again.
*/
export function forgetSentEdits(saved: FormValues, sent: FormValues): FormValues {
const next = { ...saved };
for (const [key, value] of Object.entries(sent)) {
if (!Object.is(value, saved[key])) delete next[key];
}
return next;
}

/** Form values without emptied text fields, so a preview config passes the settings schema. */
export function withoutEmptyText(values: FormValues): FormValues {
return Object.fromEntries(Object.entries(values).filter(([, value]) => !isEmptyText(value)));
}
Loading
Loading