@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.
- package/app/components/board/AddTaskModal.vue +45 -0
- package/app/components/layout/BoardToolbar.vue +0 -16
- package/app/components/layout/CommandBar.vue +4 -4
- package/app/components/layout/SideBar.vue +25 -2
- package/app/components/panels/inspector/TaskRunSettings.vue +59 -0
- package/app/components/pipeline/PipelineBuilder.vue +2 -2
- package/app/components/settings/ModelConfigurationPanel.vue +487 -0
- package/app/components/settings/OpenRouterCatalogPanel.vue +176 -0
- package/app/composables/api/board.ts +1 -0
- package/app/composables/api/models.ts +16 -12
- package/app/composables/api/presets.ts +24 -1
- package/app/pages/index.vue +8 -2
- package/app/stores/board.ts +2 -0
- package/app/stores/modelPresets.ts +65 -0
- package/app/stores/openrouter.ts +52 -0
- package/app/stores/ui.ts +19 -8
- package/app/stores/workspace.ts +2 -3
- package/app/types/domain.ts +5 -11
- package/app/types/model-presets.ts +33 -0
- package/app/types/models.ts +4 -4
- package/app/types/openrouter.ts +45 -0
- package/package.json +2 -2
- package/app/components/settings/ModelDefaultsPanel.vue +0 -250
- package/app/stores/modelDefaults.ts +0 -76
|
@@ -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>
|