feat(settings): add default model and agent settings with UI integration

This commit is contained in:
Bohdan Triapitsyn
2025-12-26 04:38:38 +02:00
parent c0c82663c0
commit 48f1e3ef0f
9 changed files with 429 additions and 38 deletions
@@ -131,6 +131,16 @@ fn sanitize_settings_update(payload: &Value) -> Value {
result_obj.insert("markdownDisplayMode".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("defaultModel") {
if !s.is_empty() {
result_obj.insert("defaultModel".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("defaultAgent") {
if !s.is_empty() {
result_obj.insert("defaultAgent".to_string(), json!(s));
}
}
// Boolean fields
if let Some(Value::Bool(b)) = obj.get("useSystemTheme") {
@@ -0,0 +1,179 @@
import React from 'react';
import { RiInformationLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
import { AgentSelector } from '@/components/sections/commands/AgentSelector';
import { updateDesktopSettings } from '@/lib/persistence';
import { isDesktopRuntime, getDesktopSettings } from '@/lib/desktop';
import { useConfigStore } from '@/stores/useConfigStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
export const DefaultsSettings: React.FC = () => {
const setProvider = useConfigStore((state) => state.setProvider);
const setModel = useConfigStore((state) => state.setModel);
const setAgent = useConfigStore((state) => state.setAgent);
const setSettingsDefaultModel = useConfigStore((state) => state.setSettingsDefaultModel);
const setSettingsDefaultAgent = useConfigStore((state) => state.setSettingsDefaultAgent);
const providers = useConfigStore((state) => state.providers);
const [defaultModel, setDefaultModel] = React.useState<string | undefined>();
const [defaultAgent, setDefaultAgent] = React.useState<string | undefined>();
const [isLoading, setIsLoading] = React.useState(true);
// Parse "provider/model" string into separate parts
const parsedModel = React.useMemo(() => {
if (!defaultModel) return { providerId: '', modelId: '' };
const parts = defaultModel.split('/');
if (parts.length !== 2) return { providerId: '', modelId: '' };
return { providerId: parts[0] || '', modelId: parts[1] || '' };
}, [defaultModel]);
// Load current settings
React.useEffect(() => {
const loadSettings = async () => {
try {
let data: { defaultModel?: string; defaultAgent?: string } | null = null;
// 1. Desktop runtime (Tauri)
if (isDesktopRuntime()) {
data = await getDesktopSettings();
} else {
// 2. Runtime settings API (VSCode)
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
try {
const result = await runtimeSettings.load();
const settings = result?.settings;
if (settings) {
data = {
defaultModel: typeof settings.defaultModel === 'string' ? settings.defaultModel : undefined,
defaultAgent: typeof settings.defaultAgent === 'string' ? settings.defaultAgent : undefined,
};
}
} catch {
// Fall through to fetch
}
}
// 3. Fetch API (Web)
if (!data) {
const response = await fetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
data = await response.json();
}
}
}
if (data) {
setDefaultModel(data.defaultModel);
setDefaultAgent(data.defaultAgent);
}
} catch (error) {
console.warn('Failed to load defaults settings:', error);
} finally {
setIsLoading(false);
}
};
loadSettings();
}, []);
const handleModelChange = React.useCallback(async (providerId: string, modelId: string) => {
const newValue = providerId && modelId ? `${providerId}/${modelId}` : undefined;
setDefaultModel(newValue);
// Update config store settings default (used by setAgent logic)
setSettingsDefaultModel(newValue);
// Also update current model immediately so new sessions use this model
if (providerId && modelId) {
const provider = providers.find((p) => p.id === providerId);
if (provider) {
setProvider(providerId);
setModel(modelId);
}
}
try {
await updateDesktopSettings({
defaultModel: newValue ?? '',
});
} catch (error) {
console.warn('Failed to save default model:', error);
}
}, [providers, setProvider, setModel, setSettingsDefaultModel]);
const handleAgentChange = React.useCallback(async (agentName: string) => {
const newValue = agentName || undefined;
setDefaultAgent(newValue);
// Update config store settings default
setSettingsDefaultAgent(newValue);
// Update current agent (setAgent will respect settingsDefaultModel)
if (agentName) {
setAgent(agentName);
}
try {
await updateDesktopSettings({
defaultAgent: newValue ?? '',
});
} catch (error) {
console.warn('Failed to save default agent:', error);
}
}, [setAgent, setSettingsDefaultAgent]);
if (isLoading) {
return null;
}
return (
<div className="space-y-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Default model & agent</h3>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Set the default model and agent for new sessions.<br />
When not set, uses agent&apos;s preferred model or opencode/big-pickle as fallback.
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-col gap-1.5">
<label className="typography-ui-label text-muted-foreground">Default model</label>
<ModelSelector
providerId={parsedModel.providerId}
modelId={parsedModel.modelId}
onChange={handleModelChange}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="typography-ui-label text-muted-foreground">Default agent</label>
<AgentSelector
agentName={defaultAgent || ''}
onChange={handleAgentChange}
/>
</div>
</div>
{(defaultModel || defaultAgent) && (
<div className="typography-meta text-muted-foreground">
New sessions will start with:{' '}
{defaultModel && <span className="text-foreground">{defaultModel}</span>}
{defaultModel && defaultAgent && ' / '}
{defaultAgent && <span className="text-foreground">{defaultAgent}</span>}
</div>
)}
</div>
);
};
@@ -2,6 +2,7 @@ import React from 'react';
import { AppearanceSettings } from './AppearanceSettings';
import { AboutSettings } from './AboutSettings';
import { SessionRetentionSettings } from './SessionRetentionSettings';
import { DefaultsSettings } from './DefaultsSettings';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useDeviceInfo } from '@/lib/device';
import { isWebRuntime } from '@/lib/desktop';
@@ -16,6 +17,9 @@ export const SettingsPage: React.FC = () => {
className="settings-page-body mx-auto max-w-3xl space-y-3 p-3 sm:space-y-6 sm:p-6"
>
<AppearanceSettings />
<div className="border-t border-border/40 pt-6">
<DefaultsSettings />
</div>
<div className="border-t border-border/40 pt-6">
<SessionRetentionSettings />
</div>
+2
View File
@@ -41,6 +41,8 @@ export type DesktopSettings = {
showReasoningTraces?: boolean;
autoDeleteEnabled?: boolean;
autoDeleteAfterDays?: number;
defaultModel?: string; // format: "provider/model"
defaultAgent?: string;
};
export type DesktopSettingsApi = {
+6
View File
@@ -125,6 +125,12 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) {
result.autoDeleteAfterDays = candidate.autoDeleteAfterDays;
}
if (typeof candidate.defaultModel === 'string' && candidate.defaultModel.length > 0) {
result.defaultModel = candidate.defaultModel;
}
if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) {
result.defaultAgent = candidate.defaultAgent;
}
return result;
};
+10 -3
View File
@@ -31,15 +31,22 @@ export interface AgentConfig {
}
// Extended Agent type for API properties not in SDK types
export type AgentWithExtras = Agent & { native?: boolean; hidden?: boolean };
export type AgentWithExtras = Agent & {
native?: boolean;
hidden?: boolean;
options?: { hidden?: boolean };
};
// Helper to check if agent is built-in (handles both SDK 'builtIn' and API 'native')
export const isAgentBuiltIn = (agent: Agent): boolean =>
agent.builtIn || (agent as AgentWithExtras).native === true;
// Helper to check if agent is hidden (internal agents like title, compaction, summary)
export const isAgentHidden = (agent: Agent): boolean =>
(agent as AgentWithExtras).hidden === true;
// Checks both top-level hidden and options.hidden (OpenCode API inconsistency workaround)
export const isAgentHidden = (agent: Agent): boolean => {
const extended = agent as AgentWithExtras;
return extended.hidden === true || extended.options?.hidden === true;
};
// Helper to filter only visible (non-hidden) agents
export const filterVisibleAgents = (agents: Agent[]): Agent[] =>
+197 -31
View File
@@ -8,10 +8,78 @@ import type { ModelMetadata } from "@/types";
import { getSafeStorage } from "./utils/safeStorage";
import type { SessionStore } from "./types/sessionTypes";
import { filterVisibleAgents } from "./useAgentsStore";
import { isDesktopRuntime, getDesktopSettings } from "@/lib/desktop";
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
import { updateDesktopSettings } from "@/lib/persistence";
const MODELS_DEV_API_URL = "https://models.dev/api.json";
const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata";
const FALLBACK_PROVIDER_ID = "opencode";
const FALLBACK_MODEL_ID = "big-pickle";
interface OpenChamberDefaults {
defaultModel?: string;
defaultAgent?: string;
}
const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
try {
// 1. Desktop runtime (Tauri)
if (isDesktopRuntime()) {
const settings = await getDesktopSettings();
return {
defaultModel: settings?.defaultModel,
defaultAgent: settings?.defaultAgent,
};
}
// 2. Runtime settings API (VSCode)
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
try {
const result = await runtimeSettings.load();
const data = result?.settings;
if (data) {
return {
defaultModel: typeof data?.defaultModel === 'string' ? data.defaultModel : undefined,
defaultAgent: typeof data?.defaultAgent === 'string' ? data.defaultAgent : undefined,
};
}
} catch {
// Fall through to fetch
}
}
// 3. Fetch API (Web)
const response = await fetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
return {};
}
const data = await response.json();
return {
defaultModel: typeof data?.defaultModel === 'string' ? data.defaultModel : undefined,
defaultAgent: typeof data?.defaultAgent === 'string' ? data.defaultAgent : undefined,
};
} catch {
return {};
}
};
const parseModelString = (modelString: string): { providerId: string; modelId: string } | null => {
if (!modelString || typeof modelString !== 'string') {
return null;
}
const parts = modelString.split('/');
if (parts.length !== 2 || !parts[0] || !parts[1]) {
return null;
}
return { providerId: parts[0], modelId: parts[1] };
};
const normalizeProviderId = (value: string) => value?.toLowerCase?.() ?? '';
const isPrimaryMode = (mode?: string) => mode === "primary" || mode === "all" || mode === undefined || mode === null;
@@ -217,12 +285,17 @@ interface ConfigStore {
isConnected: boolean;
isInitialized: boolean;
modelsMetadata: Map<string, ModelMetadata>;
// OpenChamber settings-based defaults (take precedence over agent preferences)
settingsDefaultModel: string | undefined; // format: "provider/model"
settingsDefaultAgent: string | undefined;
loadProviders: () => Promise<void>;
loadAgents: () => Promise<boolean>;
setProvider: (providerId: string) => void;
setModel: (modelId: string) => void;
setAgent: (agentName: string | undefined) => void;
setSettingsDefaultModel: (model: string | undefined) => void;
setSettingsDefaultAgent: (agent: string | undefined) => void;
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void;
getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null;
checkConnection: () => Promise<boolean>;
@@ -257,12 +330,12 @@ export const useConfigStore = create<ConfigStore>()(
isConnected: false,
isInitialized: false,
modelsMetadata: new Map<string, ModelMetadata>(),
settingsDefaultModel: undefined,
settingsDefaultAgent: undefined,
loadProviders: async () => {
const previousProviders = get().providers;
const previousDefaults = get().defaultProviders;
const previousProviderId = get().currentProviderId;
const previousModelId = get().currentModelId;
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt++) {
@@ -281,16 +354,10 @@ export const useConfigStore = create<ConfigStore>()(
};
});
const defaultProviderId = defaults.provider || processedProviders[0]?.id || "";
const provider = processedProviders.find((p) => p.id === defaultProviderId);
const defaultModelId = defaults.model || provider?.models?.[0]?.id || "";
// Only store providers and defaults - model/agent selection handled in loadAgents
set({
providers: processedProviders,
defaultProviders: defaults,
currentProviderId: defaultProviderId,
currentModelId: defaultModelId,
});
const metadata = await metadataPromise;
@@ -310,8 +377,6 @@ export const useConfigStore = create<ConfigStore>()(
set({
providers: previousProviders,
defaultProviders: previousDefaults,
currentProviderId: previousProviderId,
currentModelId: previousModelId,
});
},
@@ -355,9 +420,18 @@ export const useConfigStore = create<ConfigStore>()(
for (let attempt = 0; attempt < 3; attempt++) {
try {
const agents = await opencodeClient.listAgents();
// Fetch agents and OpenChamber settings in parallel
const [agents, openChamberDefaults] = await Promise.all([
opencodeClient.listAgents(),
fetchOpenChamberDefaults(),
]);
const safeAgents = Array.isArray(agents) ? agents : [];
set({ agents: safeAgents });
set({
agents: safeAgents,
// Store settings defaults so setAgent can respect them
settingsDefaultModel: openChamberDefaults.defaultModel,
settingsDefaultAgent: openChamberDefaults.defaultAgent,
});
const { providers } = get();
@@ -366,33 +440,101 @@ export const useConfigStore = create<ConfigStore>()(
return true;
}
// --- Agent Selection ---
// Priority: settings.defaultAgent → build → first primary → first agent
const primaryAgents = safeAgents.filter((agent) => isPrimaryMode(agent.mode));
const buildAgent = primaryAgents.find((agent) => agent.name === "build");
const defaultAgent = buildAgent || primaryAgents[0] || safeAgents[0];
const fallbackAgent = buildAgent || primaryAgents[0] || safeAgents[0];
const existingAgentName = get().currentAgentName;
const existingAgent = existingAgentName ? safeAgents.find((agent) => agent.name === existingAgentName) : undefined;
const resolvedAgentName = existingAgent ? existingAgentName : defaultAgent.name;
let resolvedAgent: Agent | undefined;
if (resolvedAgentName !== existingAgentName) {
set({ currentAgentName: resolvedAgentName });
}
// Helper to validate model exists in providers
const validateModel = (providerId: string, modelId: string): boolean => {
const provider = providers.find((p) => p.id === providerId);
if (!provider) return false;
return provider.models.some((m) => m.id === modelId);
};
const agentForDefaults = existingAgent || defaultAgent;
if (agentForDefaults?.model?.providerID && agentForDefaults?.model?.modelID) {
const agentProvider = providers.find((p) => p.id === agentForDefaults.model!.providerID);
if (agentProvider) {
const agentModel = agentProvider.models.find((model) => model.id === agentForDefaults.model!.modelID);
// Track invalid settings to clear
const invalidSettings: { defaultModel?: string; defaultAgent?: string } = {};
if (agentModel) {
set({
currentProviderId: agentForDefaults.model!.providerID,
currentModelId: agentForDefaults.model!.modelID,
});
// 1. Check OpenChamber settings for default agent
if (openChamberDefaults.defaultAgent) {
const settingsAgent = safeAgents.find((agent) => agent.name === openChamberDefaults.defaultAgent);
if (settingsAgent) {
resolvedAgent = settingsAgent;
} else {
// Agent no longer exists - mark for clearing
invalidSettings.defaultAgent = '';
}
}
// 2. Fall back to default logic
if (!resolvedAgent) {
resolvedAgent = fallbackAgent;
}
set({ currentAgentName: resolvedAgent.name });
// --- Model Selection ---
// Priority: settings.defaultModel → agent's preferred model → opencode/big-pickle
let resolvedProviderId: string | undefined;
let resolvedModelId: string | undefined;
// 1. Check OpenChamber settings for default model
if (openChamberDefaults.defaultModel) {
const parsed = parseModelString(openChamberDefaults.defaultModel);
if (parsed && validateModel(parsed.providerId, parsed.modelId)) {
resolvedProviderId = parsed.providerId;
resolvedModelId = parsed.modelId;
} else {
// Model no longer exists - mark for clearing
invalidSettings.defaultModel = '';
}
}
// 2. Fall back to agent's preferred model
if (!resolvedProviderId && resolvedAgent?.model?.providerID && resolvedAgent?.model?.modelID) {
if (validateModel(resolvedAgent.model.providerID, resolvedAgent.model.modelID)) {
resolvedProviderId = resolvedAgent.model.providerID;
resolvedModelId = resolvedAgent.model.modelID;
}
}
// 3. Fall back to opencode/big-pickle
if (!resolvedProviderId) {
if (validateModel(FALLBACK_PROVIDER_ID, FALLBACK_MODEL_ID)) {
resolvedProviderId = FALLBACK_PROVIDER_ID;
resolvedModelId = FALLBACK_MODEL_ID;
} else {
// Last resort: first provider's first model
const firstProvider = providers[0];
if (firstProvider && firstProvider.models[0]) {
resolvedProviderId = firstProvider.id;
resolvedModelId = firstProvider.models[0].id;
}
}
}
if (resolvedProviderId && resolvedModelId) {
set({
currentProviderId: resolvedProviderId,
currentModelId: resolvedModelId,
});
}
// Clear invalid settings from storage (best-effort cleanup)
if (Object.keys(invalidSettings).length > 0) {
// Also clear from store state
set({
settingsDefaultModel: invalidSettings.defaultModel !== undefined ? undefined : get().settingsDefaultModel,
settingsDefaultAgent: invalidSettings.defaultAgent !== undefined ? undefined : get().settingsDefaultAgent,
});
updateDesktopSettings(invalidSettings).catch(() => {
// Ignore errors - best effort cleanup
});
}
return true;
} catch (error) {
lastError = error;
@@ -407,7 +549,7 @@ export const useConfigStore = create<ConfigStore>()(
},
setAgent: (agentName: string | undefined) => {
const { agents, providers } = get();
const { agents, providers, settingsDefaultModel } = get();
set({ currentAgentName: agentName });
@@ -452,6 +594,22 @@ export const useConfigStore = create<ConfigStore>()(
}
}
// If settings has a default model, use it instead of agent's preferred
if (settingsDefaultModel) {
const parsed = parseModelString(settingsDefaultModel);
if (parsed) {
const settingsProvider = providers.find((p) => p.id === parsed.providerId);
if (settingsProvider?.models.some((m) => m.id === parsed.modelId)) {
set({
currentProviderId: parsed.providerId,
currentModelId: parsed.modelId,
});
return;
}
}
}
// Fall back to agent's preferred model
const agent = agents.find((candidate) => candidate.name === agentName);
if (agent?.model?.providerID && agent?.model?.modelID) {
const agentProvider = providers.find((provider) => provider.id === agent.model!.providerID);
@@ -469,6 +627,14 @@ export const useConfigStore = create<ConfigStore>()(
}
},
setSettingsDefaultModel: (model: string | undefined) => {
set({ settingsDefaultModel: model });
},
setSettingsDefaultAgent: (agent: string | undefined) => {
set({ settingsDefaultAgent: agent });
},
checkConnection: async () => {
const maxAttempts = 5;
let attempt = 0;
+14 -4
View File
@@ -126,10 +126,20 @@ export const useSessionStore = create<SessionStore>()(
try {
const configState = useConfigStore.getState();
const visibleAgents = configState.getVisibleAgents();
const agentName =
configState.currentAgentName ||
visibleAgents.find((agent) => agent.name === 'build')?.name ||
visibleAgents[0]?.name;
// Priority: settingsDefaultAgent → build → first visible
let agentName: string | undefined;
if (configState.settingsDefaultAgent) {
const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent);
if (settingsAgent) {
agentName = settingsAgent.name;
}
}
if (!agentName) {
agentName =
visibleAgents.find((agent) => agent.name === 'build')?.name ||
visibleAgents[0]?.name;
}
if (agentName) {
configState.setAgent(agentName);
+7
View File
@@ -415,6 +415,13 @@ const sanitizeSettingsUpdate = (payload) => {
result.typographySizes = typography;
}
if (typeof candidate.defaultModel === 'string' && candidate.defaultModel.length > 0) {
result.defaultModel = candidate.defaultModel;
}
if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) {
result.defaultAgent = candidate.defaultAgent;
}
return result;
};