Skip to content

Commit 25e3785

Browse files
feat: refactor agent profile model preset (#156)
* feat: add agent provider preference editor feat: simplify agent profile provider preferences * Add Provider discovery and capability-complete model catalogs * fix(desktop): remove catalog manage divider Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com> * feat: simplify agent profile model presets --------- Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com>
1 parent c24f55b commit 25e3785

45 files changed

Lines changed: 2582 additions & 407 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

desktop/src/renderer/components/agents/AgentBuilderView.css

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242

4343
/* full-width scroll container keeps the scrollbar at the app's right edge, not on the centered doc */
4444
.agent-builder-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
45-
.agent-builder-doc { max-width: 720px; width: 100%; margin: 0 auto; padding: 22px 30px 56px; }
45+
.agent-builder-doc { max-width: 720px; width: 100%; margin: 0 auto; padding: 22px 30px 56px; container-type: inline-size; }
4646
.agent-builder-id { display: flex; align-items: center; gap: 16px; }
4747
.agent-builder-id-avatar { position: relative; display: inline-flex; flex: 0 0 auto; }
4848
.agent-builder-reroll { position: absolute; right: -4px; bottom: -4px; width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid var(--border-default); border-radius: 999px; background: var(--bg-elevated); color: var(--text-secondary); cursor: pointer; box-shadow: var(--shadow-sm); }
@@ -101,7 +101,37 @@
101101
.agent-builder-instr-preview .markdown-body { font-size: 14px; line-height: 1.8; }
102102
.agent-builder-instr-empty { color: var(--text-dimmed); }
103103

104-
/* Details uses the shared SettingsGroup/SettingsRow (desktop Settings layout) — no bespoke styles. */
104+
/* Provider preference rows stay visually subordinate to the inherit/custom decision. */
105+
.agent-builder-detail-toggle { display: inline-flex; }
106+
.agent-builder-model-settings {
107+
margin: 0 10px 10px;
108+
overflow: hidden;
109+
border: 1px solid var(--border-default);
110+
border-radius: 10px;
111+
background: color-mix(in srgb, var(--bg-tertiary) 55%, transparent);
112+
}
113+
.agent-builder-model-settings .dc-settings-row + .dc-settings-row {
114+
border-top: 1px solid var(--border-default);
115+
}
116+
.agent-builder-model-warning {
117+
padding: 10px 16px;
118+
color: var(--warning);
119+
font-size: 12px;
120+
line-height: 1.45;
121+
border-bottom: 1px solid color-mix(in srgb, var(--warning) 28%, var(--border-default));
122+
background: color-mix(in srgb, var(--warning) 8%, transparent);
123+
}
124+
@container (max-width: 520px) {
125+
.agent-builder-model-settings .dc-settings-row {
126+
flex-direction: column !important;
127+
align-items: stretch !important;
128+
gap: 8px !important;
129+
}
130+
.agent-builder-model-settings .dc-settings-row > div:last-child {
131+
width: 100%;
132+
min-width: 0 !important;
133+
}
134+
}
105135

106136
/* header: auto-save status + "…" overflow menu */
107137
.agent-builder-autosave { font-size: 11.5px; color: var(--text-dimmed); white-space: nowrap; }

desktop/src/renderer/components/agents/AgentBuilderView.tsx

Lines changed: 188 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { createPortal } from 'react-dom'
1515
import { ArrowLeft, BookOpen, CircleHelp, Clock, Eye, FileSearch, FileText, Globe, ListChecks, MoreHorizontal, MousePointer2, Pencil, Plus, RefreshCw, Search, Server, Shuffle, Tag, Trash2, Wrench, X, type LucideIcon } from 'lucide-react'
1616
import { showToast } from '../../stores/toastStore'
1717
import { useModelCatalogStore } from '../../stores/modelCatalogStore'
18+
import { useProvidersStore } from '../../stores/providersStore'
1819
import { useConversationStore } from '../../stores/conversationStore'
1920
import { useUIStore } from '../../stores/uiStore'
2021
import { useLocale, useT } from '../../contexts/LocaleContext'
@@ -23,6 +24,10 @@ import { ConversationPanel } from '../layout/ConversationPanel'
2324
import { DragHandle } from '../layout/DragHandle'
2425
import { InputComposer, type InputComposerSubmitPayload } from '../conversation/InputComposer'
2526
import { useComposerModelControls } from '../conversation/useComposerModelControls'
27+
import {
28+
createCatalogDefaultPreference,
29+
PreferenceModelPicker
30+
} from '../conversation/PreferenceModelPicker'
2631
import { MarkdownRenderer } from '../conversation/MarkdownRenderer'
2732
import type { ThreadConfigurationWire } from '../../types/thread'
2833
import { formatRelativeTime } from '../../utils/relativeTime'
@@ -41,17 +46,22 @@ import { IconButton } from '../ui/IconButton'
4146
import { RobotAvatar } from './RobotAvatar'
4247
import { AGENT_BUILDER_AVATAR, randomAvatar, resolveProfileAvatar, type AvatarSpec } from './agentAvatar'
4348
import { useAgentProfileAvatarStore } from '../../stores/agentProfileAvatarStore'
49+
import {
50+
findProviderPreference,
51+
mergeProviderPreferences,
52+
type ModelPreference,
53+
type ProviderPreferences
54+
} from '../../../shared/modelPreference'
4455
import {
4556
AGENT_CONTROL_OPTIONS,
4657
APPROVAL_OPTIONS,
47-
REASONING_OPTIONS,
4858
createEmptyDraft,
4959
parseProfile,
5060
toMarkdown,
5161
type AgentControl,
62+
type AgentProviderPreference,
5263
type ApprovalPolicy,
5364
type ProfileDraft,
54-
type ReasoningEffort,
5565
type SaveTarget
5666
} from './agentProfileDraft'
5767
import { applyBuilderChange, isBuilderField, type BuilderField, type BuilderToolResult } from './agentBuilderDraftSync'
@@ -91,6 +101,22 @@ interface SkillInfo {
91101
enabled?: boolean
92102
}
93103

104+
function toAgentProviderPreference(
105+
providerId: string,
106+
preference: ModelPreference
107+
): AgentProviderPreference {
108+
return {
109+
providerId,
110+
model: preference.model,
111+
reasoning: {
112+
enabled: preference.reasoning.enabled,
113+
effort: preference.reasoning.effort
114+
},
115+
speed: preference.speed,
116+
contextWindow: { mode: preference.contextWindow.mode }
117+
}
118+
}
119+
94120
type Filter = 'all' | 'builtIn' | 'user' | 'workspace'
95121

96122
type Route =
@@ -133,7 +159,7 @@ const BUILDER_FIELD_LABEL_KEYS: Record<BuilderField, string> = {
133159
'tools.allow': 'agentBuilder.field.tools',
134160
'mcp.servers': 'agentBuilder.field.mcp',
135161
'skills.preload': 'agentBuilder.field.skills',
136-
model: 'agentBuilder.field.model',
162+
providerPreference: 'agentBuilder.field.model',
137163
approval: 'agentBuilder.field.approval',
138164
'tools.agentControl': 'agentBuilder.field.toolControl'
139165
}
@@ -1094,6 +1120,7 @@ interface BuilderViewProps {
10941120

10951121
function BuilderView({ route, setRoute, setDraft, toolCatalog, skillCatalog, mcpServers, viewMode, setViewMode, autoSaveState, editingField, agentDriving, onBack, onDelete, onCreate }: BuilderViewProps): JSX.Element {
10961122
const locale = useLocale()
1123+
const t = useT()
10971124
const { draft, avatar } = route
10981125
const nameMissing = !draft.name.trim()
10991126
const preview = viewMode === 'preview'
@@ -1115,18 +1142,104 @@ function BuilderView({ route, setRoute, setDraft, toolCatalog, skillCatalog, mcp
11151142
return () => document.removeEventListener('mousedown', onDown, true)
11161143
}, [menuOpen])
11171144

1118-
const modelOptions = useModelCatalogStore((s) => s.modelOptions)
1145+
const providers = useProvidersStore((s) => s.providers)
1146+
const models = useModelCatalogStore((s) => s.models)
1147+
const modelCatalogStatus = useModelCatalogStore((s) => s.status)
1148+
const modelCatalogError = useModelCatalogStore((s) => s.errorMessage)
1149+
const effectiveCatalogProviderId = useModelCatalogStore((s) => s.providerId)
1150+
const [workspaceDefaultPreference, setWorkspaceDefaultPreference] = useState<AgentProviderPreference | null>(null)
1151+
const [workspaceProviderPreferences, setWorkspaceProviderPreferences] = useState<ProviderPreferences>({})
1152+
11191153
useEffect(() => {
1120-
void useModelCatalogStore.getState().loadIfNeeded()
1154+
void useProvidersStore.getState().reload()
1155+
const getCore = window.api.workspaceConfig?.getCore
1156+
if (typeof getCore !== 'function') return
1157+
void getCore().then((core) => {
1158+
const providerId = (core.workspace.providerId ?? core.userDefaults.providerId ?? '').trim()
1159+
const preferences = mergeProviderPreferences(
1160+
core.userDefaults.providerPreferences,
1161+
core.workspace.providerPreferences
1162+
)
1163+
const preference = findProviderPreference(preferences, providerId)
1164+
setWorkspaceProviderPreferences(preferences)
1165+
setWorkspaceDefaultPreference(preference ? toAgentProviderPreference(providerId, preference) : null)
1166+
}).catch(() => undefined)
11211167
}, [])
1122-
const modelSelectOptions = useMemo(() => {
1123-
const opts = [{ value: 'inherit', label: 'Inherit (thread default)' }, ...modelOptions.map((id) => ({ value: id, label: id }))]
1124-
if (draft.model && draft.model !== 'inherit' && !modelOptions.includes(draft.model)) {
1125-
opts.push({ value: draft.model, label: draft.model })
1168+
1169+
const selectedProviderId = draft.providerPreference?.providerId ?? null
1170+
useEffect(() => {
1171+
void useModelCatalogStore.getState().loadIfNeeded(false, selectedProviderId)
1172+
}, [selectedProviderId])
1173+
1174+
const selectedModel = useMemo(
1175+
() => models.find((item) => item.id === draft.providerPreference?.model) ?? null,
1176+
[draft.providerPreference?.model, models]
1177+
)
1178+
const pickerPreference = useMemo<ModelPreference | null>(() => {
1179+
const preference = draft.providerPreference
1180+
if (!preference) return null
1181+
return {
1182+
model: preference.model,
1183+
reasoning: {
1184+
enabled: preference.reasoning.enabled,
1185+
effort: preference.reasoning.effort,
1186+
output: selectedModel?.reasoning?.defaultOutput ?? 'full'
1187+
},
1188+
speed: preference.speed,
1189+
contextWindow: { mode: preference.contextWindow.mode }
1190+
}
1191+
}, [draft.providerPreference, selectedModel])
1192+
const providerSelectOptions = useMemo(() => {
1193+
const options = providers.map((provider) => ({ value: provider.id, label: provider.displayName }))
1194+
const current = draft.providerPreference?.providerId
1195+
if (current && !providers.some((provider) => provider.id.toLowerCase() === current.toLowerCase())) {
1196+
options.push({ value: current, label: current })
11261197
}
1127-
return opts
1128-
}, [modelOptions, draft.model])
1198+
return options
1199+
}, [draft.providerPreference?.providerId, providers])
1200+
const seedProviderPreference = useCallback((): AgentProviderPreference | null => {
1201+
if (workspaceDefaultPreference) return structuredClone(workspaceDefaultPreference)
1202+
const providerId = effectiveCatalogProviderId ?? providers[0]?.id
1203+
const model = models[0]
1204+
if (!providerId || !model) return null
1205+
return toAgentProviderPreference(
1206+
providerId,
1207+
createCatalogDefaultPreference(model, model.id)
1208+
)
1209+
}, [effectiveCatalogProviderId, models, providers, workspaceDefaultPreference])
11291210

1211+
const updateProviderPreference = useCallback((
1212+
update: (preference: AgentProviderPreference) => AgentProviderPreference
1213+
): void => {
1214+
setDraft((current) => current.providerPreference
1215+
? { ...current, providerPreference: update(current.providerPreference) }
1216+
: current)
1217+
}, [setDraft])
1218+
1219+
const selectProvider = useCallback((providerId: string): void => {
1220+
updateProviderPreference((preference) => ({ ...preference, providerId }))
1221+
void useModelCatalogStore.getState().loadIfNeeded(true, providerId).then(() => {
1222+
const catalog = useModelCatalogStore.getState()
1223+
const configured = findProviderPreference(workspaceProviderPreferences, providerId)
1224+
const seeded = configured
1225+
?? createCatalogDefaultPreference(catalog.models[0], catalog.models[0]?.id ?? '')
1226+
if (!seeded.model) return
1227+
setDraft((current) => {
1228+
if (current.providerPreference?.providerId !== providerId) return current
1229+
return {
1230+
...current,
1231+
providerPreference: toAgentProviderPreference(providerId, seeded)
1232+
}
1233+
})
1234+
})
1235+
}, [setDraft, updateProviderPreference, workspaceProviderPreferences])
1236+
1237+
const inheritSummary = workspaceDefaultPreference
1238+
? `${workspaceDefaultPreference.providerId} · ${workspaceDefaultPreference.model}`
1239+
: t('agentBuilder.model.inheritDescription')
1240+
const pinnedProviderUnavailable = draft.providerPreference != null
1241+
&& providers.length > 0
1242+
&& !providers.some((provider) => provider.id.toLowerCase() === draft.providerPreference!.providerId.toLowerCase())
11301243
return (
11311244
<div className="agent-builder">
11321245
<header className="agent-builder-edit-head">
@@ -1269,34 +1382,76 @@ function BuilderView({ route, setRoute, setDraft, toolCatalog, skillCatalog, mcp
12691382
<Section label="Details">
12701383
<SettingsGroup>
12711384
<SettingsRow
1272-
label="Model"
1273-
controlMinWidth={200}
1385+
label={t('agentBuilder.model.customSettings')}
1386+
description={draft.providerPreference ? t('agentBuilder.model.customDescription') : inheritSummary}
12741387
control={(
1275-
<FieldAnchor field="model" active={editingField === 'model'} className="agent-builder-detail-control">
1276-
<SettingsSelect<string>
1277-
value={draft.model}
1278-
onValueChange={(v) => setDraft((d) => ({ ...d, model: v }))}
1279-
disabled={preview}
1280-
style={{ width: '100%' }}
1281-
valueProps={{ 'data-agent-builder-marker-target': '' }}
1282-
options={modelSelectOptions}
1388+
<FieldAnchor field="providerPreference" active={editingField === 'providerPreference'} className="agent-builder-detail-toggle">
1389+
<PillSwitch
1390+
checked={draft.providerPreference != null}
1391+
onChange={(checked) => {
1392+
if (!checked) {
1393+
setDraft((current) => ({ ...current, providerPreference: null }))
1394+
return
1395+
}
1396+
const preference = seedProviderPreference()
1397+
if (preference) setDraft((current) => ({ ...current, providerPreference: preference }))
1398+
}}
1399+
disabled={preview || (!draft.providerPreference && !seedProviderPreference())}
1400+
aria-label={t('agentBuilder.model.customSettings')}
12831401
/>
12841402
</FieldAnchor>
12851403
)}
12861404
/>
1287-
<SettingsRow
1288-
label="Reasoning"
1289-
controlMinWidth={200}
1290-
control={(
1291-
<SettingsSelect<ReasoningEffort>
1292-
value={draft.reasoningEffort}
1293-
onValueChange={(v) => setDraft((d) => ({ ...d, reasoningEffort: v }))}
1294-
disabled={preview}
1295-
style={{ width: '100%' }}
1296-
options={REASONING_OPTIONS}
1405+
{draft.providerPreference && (
1406+
<div className="agent-builder-model-settings">
1407+
{pinnedProviderUnavailable && (
1408+
<div className="agent-builder-model-warning" role="status">
1409+
{t('agentBuilder.model.providerUnavailable', { provider: draft.providerPreference.providerId })}
1410+
</div>
1411+
)}
1412+
<SettingsRow
1413+
label={t('agentBuilder.model.provider')}
1414+
controlMinWidth={200}
1415+
control={(
1416+
<SettingsSelect<string>
1417+
value={draft.providerPreference.providerId}
1418+
onValueChange={selectProvider}
1419+
disabled={preview}
1420+
style={{ width: '100%' }}
1421+
options={providerSelectOptions}
1422+
/>
1423+
)}
12971424
/>
1298-
)}
1299-
/>
1425+
{pickerPreference && (
1426+
<SettingsRow
1427+
label={t('agentBuilder.model.model')}
1428+
controlMinWidth={200}
1429+
control={(
1430+
<PreferenceModelPicker
1431+
preference={pickerPreference}
1432+
models={models}
1433+
loading={modelCatalogStatus === 'loading'}
1434+
disabled={preview}
1435+
errorMessage={modelCatalogError}
1436+
manualFallback={modelCatalogStatus !== 'loading' && models.length === 0}
1437+
onRetry={() => {
1438+
void useModelCatalogStore.getState().loadIfNeeded(
1439+
true,
1440+
draft.providerPreference?.providerId ?? null
1441+
)
1442+
}}
1443+
onChange={(preference) => {
1444+
updateProviderPreference((current) =>
1445+
toAgentProviderPreference(current.providerId, preference))
1446+
}}
1447+
inputId="agent-builder-provider-model"
1448+
inputAriaLabel={t('agentBuilder.model.model')}
1449+
/>
1450+
)}
1451+
/>
1452+
)}
1453+
</div>
1454+
)}
13001455
<SettingsRow
13011456
label="Tool self-control"
13021457
description="Whether the agent can manage its own available tools at runtime."

desktop/src/renderer/components/agents/agentBuilderDraftSync.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* marker (the "agent is editing this" affordance). Pure and synchronous: no I/O, no React.
1111
*/
1212

13-
import type { AgentControl, ApprovalPolicy, ProfileDraft } from './agentProfileDraft'
13+
import type { AgentControl, AgentProviderPreference, ApprovalPolicy, ProfileDraft } from './agentProfileDraft'
1414

1515
/** Field paths the builder tools report (and the editor marks). Mirrors the backend `field` values. */
1616
export type BuilderField =
@@ -21,7 +21,7 @@ export type BuilderField =
2121
| 'tools.agentControl'
2222
| 'skills.preload'
2323
| 'mcp.servers'
24-
| 'model'
24+
| 'providerPreference'
2525
| 'approval'
2626

2727
/** The `change` payload a builder tool returns. Scalar edits use `value`; list edits carry the full `list`. */
@@ -31,6 +31,7 @@ export interface BuilderToolChange {
3131
values?: string[] | null
3232
rejected?: string[] | null
3333
list?: string[] | null
34+
providerPreference?: AgentProviderPreference | null
3435
}
3536

3637
/** A builder tool's parsed result. `ok:false` carries an `error` (e.g. a validation rejection). */
@@ -57,7 +58,8 @@ const BUILDER_TOOL_FIELDS: ReadonlyMap<string, BuilderField> = new Map([
5758
['RemoveAgentSkills', 'skills.preload'],
5859
['AddAgentMcpServers', 'mcp.servers'],
5960
['RemoveAgentMcpServers', 'mcp.servers'],
60-
['SetAgentModel', 'model'],
61+
['SetAgentProviderPreference', 'providerPreference'],
62+
['ClearAgentProviderPreference', 'providerPreference'],
6163
['SetAgentApproval', 'approval']
6264
])
6365

@@ -128,8 +130,10 @@ export function applyBuilderChange(
128130
case 'instructions':
129131
next.roleInstructions = ch?.value ?? draft.roleInstructions
130132
break
131-
case 'model':
132-
next.model = ch?.value ?? draft.model
133+
case 'providerPreference':
134+
next.providerPreference = ch?.op === 'remove'
135+
? null
136+
: ch?.providerPreference ?? draft.providerPreference
133137
break
134138
case 'tools.agentControl':
135139
next.tools = { ...draft.tools, agentControl: (ch?.value as AgentControl) ?? draft.tools.agentControl }

0 commit comments

Comments
 (0)