0 ? "border-warning/30 bg-warning-muted text-warning" : "border-success/30 bg-success-muted text-success"}`}>
+ return
0 ? "border-warning/30 bg-warning-muted text-warning" : "border-success/30 bg-success-muted text-success"}`}>
{modelsAppliedLive &&
Model and Profile changes applied live.}
{modelsAppliedLive && restartRequiredSections.length > 0 ? " " : null}
{restartRequiredSections.length > 0 &&
Restart required for: {restartRequiredSections.map((section) => restartSectionLabels[section]).join(", ")}.}
@@ -58,6 +58,7 @@ export function SettingsBody({ snapshot, adapterCatalog, servers, onReload, sect
}, [requestedSection]);
const dirty = useMemo(() => hasConfigChanges(draft, snapshot.config), [draft, snapshot.config]);
+ const invalidProfileCount = useMemo(() => missingProfileVariants(draft).length, [draft]);
const onJsonValidationChange = useCallback((path: string, error?: string) => {
setJsonErrors((current) => {
if (error === undefined) {
@@ -89,10 +90,14 @@ export function SettingsBody({ snapshot, adapterCatalog, servers, onReload, sect
await onReload();
setModelsAppliedLive(modelSettingsChanged);
} catch (error) {
- setErrors(toFieldErrors(error));
+ const nextErrors = toFieldErrors(error);
+ setErrors(nextErrors);
+ const firstValidationMessage = Object.values(nextErrors)[0];
setSaveError(error instanceof ApiError && error.code === "CONFIG_REVISION_CONFLICT"
? "This configuration was changed elsewhere. Reload the latest version before saving."
- : error instanceof Error ? error.message : "Unable to save settings");
+ : firstValidationMessage !== undefined
+ ? `Configuration validation failed: ${firstValidationMessage}`
+ : error instanceof Error ? error.message : "Unable to save settings");
} finally {
setSaving(false);
}
@@ -103,21 +108,24 @@ export function SettingsBody({ snapshot, adapterCatalog, servers, onReload, sect
onSectionChange?.(next);
};
- return <>{section !== "updates" &&
}
-
- {section === "updates"
- ?
- :
}
-
>;
+ return
+ {section !== "updates" &&
}
+
+
+ {section === "updates"
+ ?
+ :
}
+
+
;
}
export function SettingsDialog({ open, section = "models", onClose }: { open: boolean; section?: SettingsSection; onClose: () => void }) {
@@ -178,8 +186,8 @@ export function SettingsDialog({ open, section = "models", onClose }: { open: bo
: "Loading settings…"}};
}
-function SettingsSidebar({ section, onSelect }: { section: SettingsSection; onSelect: (section: SettingsSection) => void }) {
- return
;
+function SettingsSidebar({ section, onSelect, invalidProfileCount = 0 }: { section: SettingsSection; onSelect: (section: SettingsSection) => void; invalidProfileCount?: number }) {
+ return
;
}
function UpdatesOnlyWorkspace({ section, onSelect }: { section: SettingsSection; onSelect: (section: SettingsSection) => void }) {
diff --git a/apps/web/src/components/features/settings-helpers.ts b/apps/web/src/components/features/settings-helpers.ts
index 01f62d3a..cf5c46f8 100644
--- a/apps/web/src/components/features/settings-helpers.ts
+++ b/apps/web/src/components/features/settings-helpers.ts
@@ -11,6 +11,36 @@ export const PROFILE_NAMES = [
export const BUILT_IN_MCP_NAMES = BUILTIN_MCP_SERVER_NAMES;
+export interface MissingProfileVariant {
+ profile: typeof PROFILE_NAMES[number];
+ model: string;
+ variant: string;
+}
+
+export function missingProfileVariant(
+ config: ServerConfig,
+ profile: typeof PROFILE_NAMES[number],
+): MissingProfileVariant | undefined {
+ const item = config.profiles[profile];
+ if (item.variant === undefined) return undefined;
+ const separator = item.model.indexOf(":");
+ if (separator < 0) return undefined;
+ const providerId = item.model.slice(0, separator);
+ const modelId = item.model.slice(separator + 1);
+ const model = config.provider[providerId]?.models[modelId];
+ if (!model || Object.prototype.hasOwnProperty.call(model.variants ?? {}, item.variant)) {
+ return undefined;
+ }
+ return { profile, model: item.model, variant: item.variant };
+}
+
+export function missingProfileVariants(config: ServerConfig): MissingProfileVariant[] {
+ return PROFILE_NAMES.flatMap((profile) => {
+ const issue = missingProfileVariant(config, profile);
+ return issue === undefined ? [] : [issue];
+ });
+}
+
export function cloneConfig(config: ServerConfig): ServerConfig {
return structuredClone(config);
}
diff --git a/apps/web/src/components/features/settings-panels.tsx b/apps/web/src/components/features/settings-panels.tsx
index eaf1d1fd..44c338b4 100644
--- a/apps/web/src/components/features/settings-panels.tsx
+++ b/apps/web/src/components/features/settings-panels.tsx
@@ -2,7 +2,7 @@ import type { ConfigSecretMutation, McpServerStatus, ProviderAdapterCatalog, Pro
import { ChevronRight, Plus, Trash2 } from "lucide-react";
import type { ModelCallOptions, ServerConfig, ServerMcpConfig, ServerModelConfig } from "../../api/config";
import { Field, JsonObjectField, NumberField, RenameInput, SecretField, SecretRecordEditor, TextInput } from "./settings-fields";
-import { PROFILE_NAMES, BUILT_IN_MCP_NAMES, defaultMemoryConfig, errorAtOrBelow, type FieldErrors, type SettingsSection, withDraft } from "./settings-helpers";
+import { PROFILE_NAMES, BUILT_IN_MCP_NAMES, defaultMemoryConfig, errorAtOrBelow, missingProfileVariant, type FieldErrors, type SettingsSection, withDraft } from "./settings-helpers";
type JsonValidationChange = (path: string, error?: string) => void;
@@ -10,6 +10,8 @@ const secondaryActionClass = "inline-flex h-8 items-center justify-center gap-2
const subtleActionClass = "inline-flex h-7 items-center justify-center gap-2 rounded-sm px-2 text-[12px] font-medium text-brand transition-colors duration-[var(--motion-hover)] hover:bg-brand-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand";
const dangerActionClass = "inline-flex h-7 items-center justify-center gap-2 rounded-sm px-2 text-[12px] font-medium text-error transition-colors duration-[var(--motion-hover)] hover:bg-error-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand";
const selectClass = "h-8 w-full rounded-sm border border-border-control bg-bg-base px-3 text-[12px] text-text-primary outline-none transition-colors duration-[var(--motion-hover)] hover:border-text-secondary focus:border-brand focus:ring-2 focus:ring-brand-subtle";
+const MODEL_MODALITIES = ["text", "image", "audio", "video"] as const;
+type ModelModality = typeof MODEL_MODALITIES[number];
function PanelHeader({ title, description }: { title: string; description: string }) {
return