@cat-factory/app 0.7.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,487 @@
1
+ <script setup lang="ts">
2
+ // Workspace settings: the Model Configuration screen. A workspace keeps a library of
3
+ // MODEL PRESETS — each a named base model applied to every agent kind plus optional
4
+ // per-agent overrides. A task picks a preset (or the workspace default); the chosen
5
+ // preset decides which model each pipeline step runs on, unless the task pins a model
6
+ // directly. The built-in default ("Kimi K2.7") points every agent at Kimi K2.7; a
7
+ // second built-in points everything at GLM-5.2.
8
+ //
9
+ // Styled as a dark full-screen window like the agent-output review overlay rather than
10
+ // a light modal, so the text stays readable regardless of the OS colour-mode
11
+ // preference. The list view shows each preset; the editor view creates/edits one with a
12
+ // base-model picker plus a filterable per-agent override list.
13
+ import { computed, ref, watch } from 'vue'
14
+ import { onKeyStroke } from '@vueuse/core'
15
+ import type { AgentKind } from '~/types/domain'
16
+ import type { ModelPreset } from '~/types/model-presets'
17
+ import { MODEL_CONFIGURABLE_SYSTEM_KINDS } from '~/utils/catalog'
18
+ import { contextLabel, costLabel, displayFlavor, isSelectable } from '~/stores/models'
19
+
20
+ const ui = useUiStore()
21
+ const models = useModelsStore()
22
+ const presets = useModelPresetsStore()
23
+ const agents = useAgentsStore()
24
+ const creds = useVendorCredentialsStore()
25
+ const workspace = useWorkspaceStore()
26
+ const toast = useToast()
27
+
28
+ const open = computed({
29
+ get: () => ui.modelConfigOpen,
30
+ set: (v: boolean) => (v ? ui.openModelConfig() : ui.closeModelConfig()),
31
+ })
32
+
33
+ // null = the preset list; an object = the create/edit form for one preset.
34
+ interface EditorState {
35
+ id?: string
36
+ name: string
37
+ baseModelId: string
38
+ overrides: Record<string, string>
39
+ isDefault: boolean
40
+ }
41
+ const editor = ref<EditorState | null>(null)
42
+ const busy = ref(false)
43
+ // Narrows the agent-kind override rows, for finding a kind fast in a long catalog.
44
+ const filter = ref('')
45
+
46
+ // The palette archetypes PLUS the engine-driven kinds that still run an LLM
47
+ // (spec-writer, merger, the fixers/resolver). The pure gates run no model, so they
48
+ // stay out — exactly the set the per-agent override list should cover.
49
+ const configurableKinds = computed(() => [...agents.archetypes, ...MODEL_CONFIGURABLE_SYSTEM_KINDS])
50
+ const filteredKinds = computed(() => {
51
+ const q = filter.value.trim().toLowerCase()
52
+ if (!q) return configurableKinds.value
53
+ return configurableKinds.value.filter(
54
+ (a) => a.label.toLowerCase().includes(q) || String(a.kind).toLowerCase().includes(q),
55
+ )
56
+ })
57
+
58
+ watch(open, (isOpen) => {
59
+ if (isOpen) {
60
+ editor.value = null
61
+ filter.value = ''
62
+ void models.ensureLoaded(workspace.workspaceId ?? undefined)
63
+ if (workspace.workspaceId) void creds.load(workspace.workspaceId)
64
+ }
65
+ })
66
+
67
+ onKeyStroke('Escape', () => {
68
+ if (!open.value) return
69
+ // Esc backs out of the editor first, then closes the panel.
70
+ if (editor.value) editor.value = null
71
+ else open.value = false
72
+ })
73
+
74
+ /** The selectable catalog models, as `{ id, label, suffix }` for a dropdown. */
75
+ const selectableModels = computed(() => {
76
+ const configured = creds.configuredVendors
77
+ return models.models
78
+ .filter((m) => isSelectable(m, configured))
79
+ .map((m) => {
80
+ const flavor = displayFlavor(m, configured)
81
+ const ctx = contextLabel(flavor.contextTokens)
82
+ const price = costLabel(flavor) ?? (flavor.quotaBased ? 'quota' : undefined)
83
+ const suffix = [flavor.providerLabel, ctx, price].filter(Boolean).join(' · ')
84
+ return {
85
+ id: m.id,
86
+ label: m.label,
87
+ suffix,
88
+ icon: flavor.quotaBased ? 'i-lucide-infinity' : 'i-lucide-cpu',
89
+ }
90
+ })
91
+ })
92
+
93
+ /** A readable label for a model id (catalog label + provider, else the raw id). */
94
+ function modelLabel(id: string | undefined): string {
95
+ if (!id) return '—'
96
+ const m = models.getModel(id)
97
+ if (!m) return id
98
+ return `${m.label} · ${displayFlavor(m, creds.configuredVendors).providerLabel}`
99
+ }
100
+
101
+ // ---- preset list -----------------------------------------------------------
102
+ const sortedPresets = computed(() => [...presets.presets].sort((a, b) => a.createdAt - b.createdAt))
103
+
104
+ function startCreate() {
105
+ editor.value = {
106
+ name: '',
107
+ baseModelId: selectableModels.value[0]?.id ?? 'kimi-k2.7',
108
+ overrides: {},
109
+ isDefault: false,
110
+ }
111
+ filter.value = ''
112
+ }
113
+ function startEdit(p: ModelPreset) {
114
+ editor.value = {
115
+ id: p.id,
116
+ name: p.name,
117
+ baseModelId: p.baseModelId,
118
+ overrides: { ...p.overrides },
119
+ isDefault: p.isDefault,
120
+ }
121
+ filter.value = ''
122
+ }
123
+
124
+ async function setDefault(p: ModelPreset) {
125
+ if (p.isDefault) return
126
+ busy.value = true
127
+ try {
128
+ await presets.update(p.id, { isDefault: true })
129
+ } catch (e) {
130
+ fail('Could not set the default preset', e)
131
+ } finally {
132
+ busy.value = false
133
+ }
134
+ }
135
+
136
+ async function remove(p: ModelPreset) {
137
+ busy.value = true
138
+ try {
139
+ await presets.remove(p.id)
140
+ } catch (e) {
141
+ fail('Could not delete the preset', e)
142
+ } finally {
143
+ busy.value = false
144
+ }
145
+ }
146
+
147
+ // ---- editor ----------------------------------------------------------------
148
+ /** The base-model dropdown items. */
149
+ const baseMenu = computed(() => [
150
+ selectableModels.value.map((m) => ({
151
+ label: `${m.label} · ${m.suffix}`,
152
+ icon: m.icon,
153
+ onSelect: () => {
154
+ if (editor.value) editor.value.baseModelId = m.id
155
+ },
156
+ })),
157
+ ])
158
+
159
+ /** A per-kind override dropdown: "Use base model" reset plus the catalog. */
160
+ function overrideMenu(kind: AgentKind) {
161
+ return [
162
+ [
163
+ {
164
+ label: 'Use base model',
165
+ icon: 'i-lucide-rotate-ccw',
166
+ onSelect: () => setOverride(kind, null),
167
+ },
168
+ ...selectableModels.value.map((m) => ({
169
+ label: `${m.label} · ${m.suffix}`,
170
+ icon: m.icon,
171
+ onSelect: () => setOverride(kind, m.id),
172
+ })),
173
+ ],
174
+ ]
175
+ }
176
+ function setOverride(kind: AgentKind, modelId: string | null) {
177
+ if (!editor.value) return
178
+ const next = { ...editor.value.overrides }
179
+ if (modelId) next[kind] = modelId
180
+ else delete next[kind]
181
+ editor.value.overrides = next
182
+ }
183
+ /** The label on a kind's override button: its override model, else "Base model". */
184
+ function overrideLabel(kind: AgentKind): string {
185
+ const id = editor.value?.overrides[kind]
186
+ return id ? modelLabel(id) : 'Base model'
187
+ }
188
+
189
+ async function save() {
190
+ const e = editor.value
191
+ if (!e) return
192
+ if (!e.name.trim()) {
193
+ fail('Name required', new Error('Give the preset a name.'))
194
+ return
195
+ }
196
+ busy.value = true
197
+ try {
198
+ if (e.id) {
199
+ await presets.update(e.id, {
200
+ name: e.name.trim(),
201
+ baseModelId: e.baseModelId,
202
+ overrides: e.overrides,
203
+ isDefault: e.isDefault,
204
+ })
205
+ } else {
206
+ await presets.create({
207
+ name: e.name.trim(),
208
+ baseModelId: e.baseModelId,
209
+ overrides: e.overrides,
210
+ isDefault: e.isDefault,
211
+ })
212
+ }
213
+ editor.value = null
214
+ } catch (err) {
215
+ fail('Could not save the preset', err)
216
+ } finally {
217
+ busy.value = false
218
+ }
219
+ }
220
+
221
+ function fail(title: string, e: unknown) {
222
+ toast.add({
223
+ title,
224
+ description: e instanceof Error ? e.message : String(e),
225
+ icon: 'i-lucide-triangle-alert',
226
+ color: 'error',
227
+ })
228
+ }
229
+ </script>
230
+
231
+ <template>
232
+ <Teleport to="body">
233
+ <Transition name="reader-fade">
234
+ <div
235
+ v-if="open"
236
+ class="fixed inset-0 z-50 flex flex-col bg-slate-950/96 backdrop-blur-sm"
237
+ role="dialog"
238
+ aria-modal="true"
239
+ >
240
+ <header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
241
+ <div
242
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-indigo-500/15"
243
+ >
244
+ <UIcon name="i-lucide-cpu" class="h-5 w-5 text-indigo-300" />
245
+ </div>
246
+ <div class="min-w-0">
247
+ <h1 class="truncate text-base font-semibold text-white">Model Configuration</h1>
248
+ <p class="truncate text-xs text-slate-500">
249
+ Presets that map a model to every agent. A task picks one; tasks default to the
250
+ workspace default preset.
251
+ </p>
252
+ </div>
253
+ <UButton
254
+ v-if="editor"
255
+ icon="i-lucide-arrow-left"
256
+ color="neutral"
257
+ variant="ghost"
258
+ size="sm"
259
+ class="ml-auto"
260
+ @click="editor = null"
261
+ >
262
+ Back
263
+ </UButton>
264
+ <UButton
265
+ icon="i-lucide-x"
266
+ color="neutral"
267
+ variant="ghost"
268
+ size="sm"
269
+ :class="editor ? '' : 'ml-auto'"
270
+ title="Close (Esc)"
271
+ @click="open = false"
272
+ />
273
+ </header>
274
+
275
+ <div class="flex-1 overflow-auto px-6 py-6">
276
+ <div class="mx-auto max-w-3xl space-y-5">
277
+ <!-- ===== list view ===== -->
278
+ <template v-if="!editor">
279
+ <div class="flex items-center justify-between">
280
+ <p class="text-sm leading-relaxed text-slate-400">
281
+ Each preset sets a <span class="text-slate-300">base model</span> for every agent,
282
+ with optional per-agent overrides. A model pinned on an individual task still
283
+ overrides the preset.
284
+ </p>
285
+ <UButton
286
+ icon="i-lucide-plus"
287
+ color="primary"
288
+ size="sm"
289
+ class="shrink-0"
290
+ @click="startCreate"
291
+ >
292
+ New preset
293
+ </UButton>
294
+ </div>
295
+
296
+ <p v-if="models.models.length === 0" class="py-4 text-center text-sm text-slate-500">
297
+ Loading model catalog…
298
+ </p>
299
+
300
+ <div v-else class="space-y-3">
301
+ <div
302
+ v-for="p in sortedPresets"
303
+ :key="p.id"
304
+ class="rounded-xl border border-slate-800 bg-slate-900/50 p-4"
305
+ >
306
+ <div class="flex items-center gap-2">
307
+ <span class="truncate text-sm font-semibold text-slate-100">{{ p.name }}</span>
308
+ <UBadge v-if="p.isDefault" color="primary" variant="subtle" size="xs">
309
+ Default
310
+ </UBadge>
311
+ <div class="ml-auto flex items-center gap-1">
312
+ <UButton
313
+ v-if="!p.isDefault"
314
+ size="xs"
315
+ variant="ghost"
316
+ color="neutral"
317
+ icon="i-lucide-star"
318
+ :loading="busy"
319
+ title="Set as workspace default"
320
+ @click="setDefault(p)"
321
+ />
322
+ <UButton
323
+ size="xs"
324
+ variant="ghost"
325
+ color="neutral"
326
+ icon="i-lucide-pencil"
327
+ title="Edit preset"
328
+ @click="startEdit(p)"
329
+ />
330
+ <UButton
331
+ size="xs"
332
+ variant="ghost"
333
+ color="error"
334
+ icon="i-lucide-trash-2"
335
+ :disabled="p.isDefault"
336
+ :loading="busy"
337
+ :title="
338
+ p.isDefault ? 'The default preset cannot be deleted' : 'Delete preset'
339
+ "
340
+ @click="remove(p)"
341
+ />
342
+ </div>
343
+ </div>
344
+ <div class="mt-1.5 text-[11px] text-slate-400">
345
+ Base: <span class="text-slate-300">{{ modelLabel(p.baseModelId) }}</span>
346
+ <span v-if="Object.keys(p.overrides).length">
347
+ · {{ Object.keys(p.overrides).length }} override<span
348
+ v-if="Object.keys(p.overrides).length !== 1"
349
+ >s</span
350
+ >
351
+ </span>
352
+ </div>
353
+ </div>
354
+ <p
355
+ v-if="sortedPresets.length === 0"
356
+ class="py-6 text-center text-sm text-slate-500"
357
+ >
358
+ No presets yet — create one to map models to your agents.
359
+ </p>
360
+ </div>
361
+ </template>
362
+
363
+ <!-- ===== editor view ===== -->
364
+ <template v-else>
365
+ <div class="space-y-4 rounded-xl border border-slate-800 bg-slate-900/50 p-4">
366
+ <div>
367
+ <label
368
+ class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-400"
369
+ >
370
+ Name
371
+ </label>
372
+ <UInput
373
+ v-model="editor.name"
374
+ placeholder="e.g. Kimi K2.7"
375
+ size="sm"
376
+ class="w-full"
377
+ />
378
+ </div>
379
+
380
+ <div>
381
+ <label
382
+ class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-400"
383
+ >
384
+ Base model (applied to every agent)
385
+ </label>
386
+ <UDropdownMenu
387
+ :items="baseMenu"
388
+ :ui="{ content: 'max-h-80 overflow-y-auto z-[60]' }"
389
+ >
390
+ <UButton
391
+ size="sm"
392
+ color="primary"
393
+ variant="subtle"
394
+ trailing-icon="i-lucide-chevron-down"
395
+ class="w-full justify-between"
396
+ >
397
+ <span class="truncate">{{ modelLabel(editor.baseModelId) }}</span>
398
+ </UButton>
399
+ </UDropdownMenu>
400
+ </div>
401
+
402
+ <label class="flex items-center gap-2 text-sm text-slate-300">
403
+ <UCheckbox v-model="editor.isDefault" />
404
+ Make this the workspace default
405
+ </label>
406
+ </div>
407
+
408
+ <div>
409
+ <div class="mb-1 flex items-center justify-between">
410
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
411
+ Per-agent overrides
412
+ </span>
413
+ </div>
414
+ <UInput
415
+ v-model="filter"
416
+ icon="i-lucide-search"
417
+ size="sm"
418
+ placeholder="Filter agents…"
419
+ class="mb-3 w-full"
420
+ />
421
+ <div
422
+ class="divide-y divide-slate-800 rounded-xl border border-slate-800 bg-slate-900/50"
423
+ >
424
+ <div
425
+ v-for="a in filteredKinds"
426
+ :key="a.kind"
427
+ class="flex items-center gap-3 px-4 py-3"
428
+ >
429
+ <UIcon
430
+ :name="a.icon"
431
+ class="h-4 w-4 shrink-0"
432
+ :style="{ color: a.color }"
433
+ :title="a.description"
434
+ />
435
+ <div class="min-w-0 flex-1" :title="a.description">
436
+ <p class="truncate text-sm text-slate-200">{{ a.label }}</p>
437
+ </div>
438
+ <UDropdownMenu
439
+ :items="overrideMenu(a.kind)"
440
+ :ui="{ content: 'max-h-80 overflow-y-auto z-[60]' }"
441
+ >
442
+ <UButton
443
+ size="xs"
444
+ :color="editor.overrides[a.kind] ? 'primary' : 'neutral'"
445
+ :variant="editor.overrides[a.kind] ? 'subtle' : 'soft'"
446
+ trailing-icon="i-lucide-chevron-down"
447
+ class="w-64 shrink-0 justify-between"
448
+ >
449
+ <span class="truncate">{{ overrideLabel(a.kind) }}</span>
450
+ </UButton>
451
+ </UDropdownMenu>
452
+ </div>
453
+ <p
454
+ v-if="filteredKinds.length === 0"
455
+ class="px-4 py-6 text-center text-sm text-slate-500"
456
+ >
457
+ No agents match "{{ filter }}".
458
+ </p>
459
+ </div>
460
+ </div>
461
+
462
+ <div class="flex items-center justify-end gap-2">
463
+ <UButton color="neutral" variant="ghost" size="sm" @click="editor = null">
464
+ Cancel
465
+ </UButton>
466
+ <UButton color="primary" size="sm" :loading="busy" @click="save">
467
+ {{ editor.id ? 'Save changes' : 'Create preset' }}
468
+ </UButton>
469
+ </div>
470
+ </template>
471
+ </div>
472
+ </div>
473
+ </div>
474
+ </Transition>
475
+ </Teleport>
476
+ </template>
477
+
478
+ <style scoped>
479
+ .reader-fade-enter-active,
480
+ .reader-fade-leave-active {
481
+ transition: opacity 0.18s ease;
482
+ }
483
+ .reader-fade-enter-from,
484
+ .reader-fade-leave-to {
485
+ opacity: 0;
486
+ }
487
+ </style>
@@ -0,0 +1,176 @@
1
+ <script setup lang="ts">
2
+ // Workspace settings: "OpenRouter models" — browse OpenRouter's 300+ gateway models and
3
+ // enable a subset for this workspace. OpenRouter is reached via the workspace's API-key pool
4
+ // (connect an OpenRouter key first under "Provider keys"); "Refresh" probes its live catalog
5
+ // server-side, then tick the models to enable. Enabled models — with their context window and
6
+ // price — appear automatically in the model picker and meter against the spend budget.
7
+ import { computed, ref, watch } from 'vue'
8
+ import type { OpenRouterModelMeta } from '~/types/openrouter'
9
+
10
+ const ui = useUiStore()
11
+ const workspace = useWorkspaceStore()
12
+ const store = useOpenRouterStore()
13
+ const toast = useToast()
14
+
15
+ const open = computed({
16
+ get: () => ui.openRouterOpen,
17
+ set: (v: boolean) => (v ? ui.openOpenRouter() : ui.closeOpenRouter()),
18
+ })
19
+
20
+ // The enabled slugs the user has ticked (seeded from the persisted catalog on open).
21
+ const selected = ref<Set<string>>(new Set())
22
+ const filter = ref('')
23
+ const busy = ref(false)
24
+
25
+ // Load the persisted catalog whenever the panel opens; seed the tick selection from it.
26
+ watch(open, (isOpen) => {
27
+ if (!isOpen || !workspace.workspaceId) return
28
+ void store.load(workspace.workspaceId).then(() => {
29
+ selected.value = new Set(store.enabled.map((m) => m.id))
30
+ })
31
+ })
32
+
33
+ // The list to show: the live browse list once refreshed, else the persisted enabled set.
34
+ const source = computed<OpenRouterModelMeta[]>(() =>
35
+ store.browse.length ? store.browse : store.enabled,
36
+ )
37
+
38
+ const visible = computed(() => {
39
+ const q = filter.value.trim().toLowerCase()
40
+ if (!q) return source.value
41
+ return source.value.filter(
42
+ (m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q),
43
+ )
44
+ })
45
+
46
+ const selectedCount = computed(() => selected.value.size)
47
+
48
+ function contextLabel(tokens: number | undefined): string {
49
+ if (!tokens) return ''
50
+ return tokens >= 1000 ? `${Math.round(tokens / 1000)}K ctx` : `${tokens} ctx`
51
+ }
52
+
53
+ function priceLabel(m: OpenRouterModelMeta): string {
54
+ return `${m.inputPerMillion}/${m.outputPerMillion} per Mtok`
55
+ }
56
+
57
+ function toggle(id: string, on: boolean) {
58
+ const next = new Set(selected.value)
59
+ if (on) next.add(id)
60
+ else next.delete(id)
61
+ selected.value = next
62
+ }
63
+
64
+ async function refresh() {
65
+ if (!workspace.workspaceId) return
66
+ const result = await store.refresh(workspace.workspaceId)
67
+ if (!result.reachable) {
68
+ toast.add({
69
+ title: 'Could not reach OpenRouter',
70
+ description: store.refreshError ?? 'Connect an OpenRouter key under Provider keys first.',
71
+ icon: 'i-lucide-triangle-alert',
72
+ color: 'error',
73
+ })
74
+ }
75
+ }
76
+
77
+ async function save() {
78
+ if (!workspace.workspaceId) return
79
+ busy.value = true
80
+ try {
81
+ // Persist the ticked models, carrying the metadata from whichever list they came from.
82
+ const byId = new Map(source.value.map((m) => [m.id, m]))
83
+ const models = [...selected.value]
84
+ .map((id) => byId.get(id))
85
+ .filter((m): m is OpenRouterModelMeta => !!m)
86
+ await store.save(workspace.workspaceId, models)
87
+ toast.add({ title: 'OpenRouter catalog saved', icon: 'i-lucide-check', color: 'success' })
88
+ } catch (e) {
89
+ toast.add({
90
+ title: 'Could not save catalog',
91
+ description: e instanceof Error ? e.message : String(e),
92
+ icon: 'i-lucide-triangle-alert',
93
+ color: 'error',
94
+ })
95
+ } finally {
96
+ busy.value = false
97
+ }
98
+ }
99
+ </script>
100
+
101
+ <template>
102
+ <UModal v-model:open="open" title="OpenRouter models" :ui="{ content: 'max-w-2xl' }">
103
+ <template #body>
104
+ <div class="space-y-4">
105
+ <p class="text-xs text-slate-400">
106
+ Reach <strong>300+ models</strong> through one gateway. Connect an OpenRouter key under
107
+ <span class="text-slate-300">Provider keys</span> first, then
108
+ <span class="text-slate-300">Refresh</span> to browse the live catalog and enable the
109
+ models you want. Enabled models appear in the model picker with their context window and
110
+ price, and meter against your spend budget.
111
+ </p>
112
+
113
+ <div class="flex items-center gap-2">
114
+ <UButton
115
+ color="neutral"
116
+ variant="soft"
117
+ size="sm"
118
+ icon="i-lucide-refresh-cw"
119
+ :loading="store.refreshing"
120
+ @click="refresh()"
121
+ >
122
+ Refresh catalog
123
+ </UButton>
124
+ <UInput
125
+ v-model="filter"
126
+ size="sm"
127
+ class="flex-1"
128
+ icon="i-lucide-search"
129
+ placeholder="Filter by name or slug…"
130
+ />
131
+ </div>
132
+
133
+ <p v-if="store.refreshError" class="text-xs text-rose-400">{{ store.refreshError }}</p>
134
+
135
+ <div v-if="visible.length" class="max-h-96 space-y-1 overflow-y-auto pr-1">
136
+ <label
137
+ v-for="m in visible"
138
+ :key="m.id"
139
+ class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-900/40 px-2.5 py-1.5 text-sm"
140
+ >
141
+ <UCheckbox
142
+ :model-value="selected.has(m.id)"
143
+ @update:model-value="(v: boolean | 'indeterminate') => toggle(m.id, v === true)"
144
+ />
145
+ <span class="min-w-0 flex-1">
146
+ <span class="block truncate text-slate-200">{{ m.name }}</span>
147
+ <span class="block truncate font-mono text-[11px] text-slate-500">{{ m.id }}</span>
148
+ </span>
149
+ <span class="shrink-0 text-right text-[11px] text-slate-500">
150
+ <span v-if="m.contextLength" class="block">{{ contextLabel(m.contextLength) }}</span>
151
+ <span class="block">{{ priceLabel(m) }}</span>
152
+ </span>
153
+ </label>
154
+ </div>
155
+ <p v-else class="text-xs text-slate-500">
156
+ No models yet — hit <span class="text-slate-300">Refresh catalog</span> to load
157
+ OpenRouter's live list.
158
+ </p>
159
+
160
+ <div class="flex items-center justify-between">
161
+ <span class="text-xs text-slate-500">{{ selectedCount }} enabled</span>
162
+ <UButton
163
+ color="primary"
164
+ variant="soft"
165
+ size="sm"
166
+ icon="i-lucide-save"
167
+ :loading="busy"
168
+ @click="save()"
169
+ >
170
+ Save
171
+ </UButton>
172
+ </div>
173
+ </div>
174
+ </template>
175
+ </UModal>
176
+ </template>
@@ -39,6 +39,7 @@ export function boardApi({ http, ws }: ApiContext) {
39
39
  taskType?: CreateTaskType
40
40
  taskTypeFields?: TaskTypeFields
41
41
  mergePresetId?: string
42
+ modelPresetId?: string
42
43
  pipelineId?: string
43
44
  agentConfig?: Record<string, string>
44
45
  },