@cat-factory/app 0.19.0 → 0.20.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.
@@ -15,6 +15,8 @@ const tasks = useTasksStore()
15
15
  const tracker = useTrackerStore()
16
16
  const releaseHealth = useReleaseHealthStore()
17
17
  const userSecrets = useUserSecretsStore()
18
+ const apiKeys = useApiKeysStore()
19
+ const workspace = useWorkspaceStore()
18
20
 
19
21
  // The selected filing tracker, as a badge label ("GitHub Issues" / "Jira").
20
22
  const trackerLabel = computed(() => {
@@ -31,6 +33,8 @@ watch(
31
33
  if (isOpen) {
32
34
  void releaseHealth.ensureLoaded().catch(() => {})
33
35
  void userSecrets.load().catch(() => {})
36
+ // Drives the OpenRouter row's "Key connected" badge.
37
+ if (workspace.workspaceId) void apiKeys.load(workspace.workspaceId).catch(() => {})
34
38
  }
35
39
  },
36
40
  )
@@ -66,6 +70,38 @@ function go(fn: () => void) {
66
70
  const groups = computed<IntegrationGroup[]>(() => {
67
71
  const out: IntegrationGroup[] = []
68
72
 
73
+ // --- Models & providers ----------------------------------------------------
74
+ // Top of the hub: an OpenRouter key is the fastest path to 300+ models, so it leads.
75
+ const openRouterKeyConnected = apiKeys.configuredProviders.has('openrouter')
76
+ out.push({
77
+ title: 'Models & providers',
78
+ items: [
79
+ {
80
+ key: 'openrouter',
81
+ icon: 'i-lucide-waypoints',
82
+ label: 'OpenRouter',
83
+ description: 'One gateway to 300+ models — add your key and enable models in one place.',
84
+ status: openRouterKeyConnected ? 'Key connected' : undefined,
85
+ connected: openRouterKeyConnected,
86
+ onClick: () => go(ui.openOpenRouter),
87
+ },
88
+ {
89
+ key: 'vendors',
90
+ icon: 'i-lucide-key-round',
91
+ label: 'Vendors & keys',
92
+ description: 'LLM vendor subscriptions and provider API keys.',
93
+ onClick: () => go(ui.openVendorCredentials),
94
+ },
95
+ {
96
+ key: 'local-runners',
97
+ icon: 'i-lucide-server',
98
+ label: 'My local runners',
99
+ description: 'Your own-machine model runners (Ollama, LM Studio, vLLM…).',
100
+ onClick: () => go(ui.openLocalModels),
101
+ },
102
+ ],
103
+ })
104
+
69
105
  // --- Source control --------------------------------------------------------
70
106
  const code: IntegrationItem[] = []
71
107
  if (github.available) {
@@ -188,34 +224,6 @@ const groups = computed<IntegrationGroup[]>(() => {
188
224
  })
189
225
  }
190
226
 
191
- // --- Models & providers ----------------------------------------------------
192
- out.push({
193
- title: 'Models & providers',
194
- items: [
195
- {
196
- key: 'vendors',
197
- icon: 'i-lucide-key-round',
198
- label: 'Vendors & keys',
199
- description: 'LLM vendor subscriptions and provider API keys.',
200
- onClick: () => go(ui.openVendorCredentials),
201
- },
202
- {
203
- key: 'local-runners',
204
- icon: 'i-lucide-server',
205
- label: 'My local runners',
206
- description: 'Your own-machine model runners (Ollama, LM Studio, vLLM…).',
207
- onClick: () => go(ui.openLocalModels),
208
- },
209
- {
210
- key: 'openrouter',
211
- icon: 'i-lucide-waypoints',
212
- label: 'OpenRouter models',
213
- description: 'Browse and enable models from the OpenRouter gateway.',
214
- onClick: () => go(ui.openOpenRouter),
215
- },
216
- ],
217
- })
218
-
219
227
  return out
220
228
  })
221
229
  </script>
@@ -1,15 +1,20 @@
1
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.
2
+ // "OpenRouter" — the one-stop OpenRouter setup panel. OpenRouter is a single gateway to
3
+ // 300+ models reached via the workspace's API-key pool, so this panel owns the whole flow:
4
+ // 1. Connect an OpenRouter key inline (no need to detour through Vendors & keys).
5
+ // 2. The live catalog auto-refreshes as soon as a key exists.
6
+ // 3. Tick models (or one-click "Enable recommended") and Save.
7
+ // Enabled models — with their context window and price — appear in the model picker and
8
+ // meter against the spend budget. The Vendors & keys → Proxies tab remains a valid second
9
+ // entry point for the key; this panel just makes OpenRouter self-sufficient.
7
10
  import { computed, ref, watch } from 'vue'
8
11
  import type { OpenRouterModelMeta } from '~/types/openrouter'
9
12
 
10
13
  const ui = useUiStore()
11
14
  const workspace = useWorkspaceStore()
12
15
  const store = useOpenRouterStore()
16
+ const apiKeys = useApiKeysStore()
17
+ const models = useModelsStore()
13
18
  const toast = useToast()
14
19
 
15
20
  const open = computed({
@@ -17,16 +22,39 @@ const open = computed({
17
22
  set: (v: boolean) => (v ? ui.openOpenRouter() : ui.closeOpenRouter()),
18
23
  })
19
24
 
25
+ // Popular slugs offered by "Enable recommended" — these mirror the curated `openrouter`
26
+ // refs in the backend MODEL_CATALOG. Only the ones present in the live browse list are
27
+ // ticked, so a recommendation never enables a slug OpenRouter doesn't actually serve.
28
+ const RECOMMENDED_SLUGS = [
29
+ 'anthropic/claude-opus-4.8',
30
+ 'openai/gpt-5.5',
31
+ 'google/gemini-3-pro',
32
+ 'deepseek/deepseek-chat',
33
+ ]
34
+
35
+ // Whether the workspace/user has an OpenRouter key connected at any reachable scope.
36
+ const keyConnected = computed(() => apiKeys.configuredProviders.has('openrouter'))
37
+
20
38
  // The enabled slugs the user has ticked (seeded from the persisted catalog on open).
21
39
  const selected = ref<Set<string>>(new Set())
22
40
  const filter = ref('')
23
41
  const busy = ref(false)
24
42
 
25
- // Load the persisted catalog whenever the panel opens; seed the tick selection from it.
43
+ // Inline key-entry form state (shown until an OpenRouter key is connected).
44
+ const keyScope = ref<'workspace' | 'user'>('workspace')
45
+ const keyLabel = ref('')
46
+ const keyValue = ref('')
47
+ const connectingKey = ref(false)
48
+
49
+ // Load key state + persisted catalog whenever the panel opens; seed the tick selection,
50
+ // then auto-refresh the live catalog if a key is already connected (no extra click).
26
51
  watch(open, (isOpen) => {
27
52
  if (!isOpen || !workspace.workspaceId) return
28
- void store.load(workspace.workspaceId).then(() => {
53
+ const ws = workspace.workspaceId
54
+ void apiKeys.load(ws).catch(() => {})
55
+ void store.load(ws).then(() => {
29
56
  selected.value = new Set(store.enabled.map((m) => m.id))
57
+ if (keyConnected.value && store.browse.length === 0) void refresh()
30
58
  })
31
59
  })
32
60
 
@@ -45,6 +73,12 @@ const visible = computed(() => {
45
73
 
46
74
  const selectedCount = computed(() => selected.value.size)
47
75
 
76
+ // Recommended slugs that are actually available in the current browse/enabled list.
77
+ const recommendedAvailable = computed(() => {
78
+ const ids = new Set(source.value.map((m) => m.id))
79
+ return RECOMMENDED_SLUGS.filter((slug) => ids.has(slug))
80
+ })
81
+
48
82
  function contextLabel(tokens: number | undefined): string {
49
83
  if (!tokens) return ''
50
84
  return tokens >= 1000 ? `${Math.round(tokens / 1000)}K ctx` : `${tokens} ctx`
@@ -61,13 +95,47 @@ function toggle(id: string, on: boolean) {
61
95
  selected.value = next
62
96
  }
63
97
 
98
+ function enableRecommended() {
99
+ const next = new Set(selected.value)
100
+ for (const slug of recommendedAvailable.value) next.add(slug)
101
+ selected.value = next
102
+ }
103
+
104
+ async function connectKey() {
105
+ if (!keyValue.value.trim() || !workspace.workspaceId) return
106
+ connectingKey.value = true
107
+ try {
108
+ const input = {
109
+ provider: 'openrouter' as const,
110
+ label: keyLabel.value.trim() || 'openrouter key',
111
+ key: keyValue.value.trim(),
112
+ }
113
+ if (keyScope.value === 'workspace') await apiKeys.addWorkspaceKey(input)
114
+ else await apiKeys.addUserKey(input)
115
+ keyValue.value = ''
116
+ keyLabel.value = ''
117
+ toast.add({ title: 'OpenRouter key connected', icon: 'i-lucide-check', color: 'success' })
118
+ // Now that a key exists, load the live catalog automatically.
119
+ await refresh()
120
+ } catch (e) {
121
+ toast.add({
122
+ title: 'Could not connect key',
123
+ description: e instanceof Error ? e.message : String(e),
124
+ icon: 'i-lucide-triangle-alert',
125
+ color: 'error',
126
+ })
127
+ } finally {
128
+ connectingKey.value = false
129
+ }
130
+ }
131
+
64
132
  async function refresh() {
65
133
  if (!workspace.workspaceId) return
66
134
  const result = await store.refresh(workspace.workspaceId)
67
135
  if (!result.reachable) {
68
136
  toast.add({
69
137
  title: 'Could not reach OpenRouter',
70
- description: store.refreshError ?? 'Connect an OpenRouter key under Provider keys first.',
138
+ description: store.refreshError ?? 'Connect an OpenRouter key first.',
71
139
  icon: 'i-lucide-triangle-alert',
72
140
  color: 'error',
73
141
  })
@@ -80,10 +148,12 @@ async function save() {
80
148
  try {
81
149
  // Persist the ticked models, carrying the metadata from whichever list they came from.
82
150
  const byId = new Map(source.value.map((m) => [m.id, m]))
83
- const models = [...selected.value]
151
+ const models2 = [...selected.value]
84
152
  .map((id) => byId.get(id))
85
153
  .filter((m): m is OpenRouterModelMeta => !!m)
86
- await store.save(workspace.workspaceId, models)
154
+ await store.save(workspace.workspaceId, models2)
155
+ // Reflect newly-enabled models in the picker immediately.
156
+ await models.refresh(workspace.workspaceId)
87
157
  toast.add({ title: 'OpenRouter catalog saved', icon: 'i-lucide-check', color: 'success' })
88
158
  } catch (e) {
89
159
  toast.add({
@@ -96,80 +166,171 @@ async function save() {
96
166
  busy.value = false
97
167
  }
98
168
  }
169
+
170
+ function manageKeys() {
171
+ ui.closeOpenRouter()
172
+ ui.openVendorCredentials()
173
+ }
99
174
  </script>
100
175
 
101
176
  <template>
102
- <UModal v-model:open="open" title="OpenRouter models" :ui="{ content: 'max-w-2xl' }">
177
+ <UModal v-model:open="open" title="OpenRouter" :ui="{ content: 'max-w-2xl' }">
103
178
  <template #body>
104
179
  <div class="space-y-4">
105
180
  <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.
181
+ Reach <strong>300+ models</strong> through one gateway. Add your OpenRouter key below,
182
+ then enable the models you want — they appear in the model picker with their context
183
+ window and price, and meter against your spend budget.
111
184
  </p>
112
185
 
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)"
186
+ <!-- Step 1: connect a key (inline) — hidden once a key is connected -->
187
+ <div
188
+ v-if="!keyConnected"
189
+ class="space-y-3 rounded-lg border border-slate-700 bg-slate-900/60 p-4"
190
+ >
191
+ <h4 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
192
+ Connect your OpenRouter key
193
+ </h4>
194
+ <ol class="list-decimal space-y-1 pl-5 text-sm text-slate-300">
195
+ <li>
196
+ Open
197
+ <a
198
+ href="https://openrouter.ai/keys"
199
+ target="_blank"
200
+ rel="noopener noreferrer"
201
+ class="text-primary-400 underline"
202
+ >openrouter.ai Keys ↗</a
203
+ >
204
+ and create an API key.
205
+ </li>
206
+ <li>
207
+ Copy the key (starts with <span class="font-mono">sk-or-…</span>) and paste it below.
208
+ </li>
209
+ </ol>
210
+ <div class="flex flex-wrap items-end gap-3">
211
+ <UFormField label="Scope">
212
+ <USelect
213
+ v-model="keyScope"
214
+ :items="[
215
+ { label: 'This workspace', value: 'workspace' },
216
+ { label: 'My keys (only me)', value: 'user' },
217
+ ]"
218
+ class="w-48"
219
+ />
220
+ </UFormField>
221
+ <UFormField label="Label (optional)" class="flex-1">
222
+ <UInput v-model="keyLabel" placeholder="e.g. team key" />
223
+ </UFormField>
224
+ </div>
225
+ <UFormField label="API key">
226
+ <UTextarea
227
+ v-model="keyValue"
228
+ :rows="2"
229
+ placeholder="paste your OpenRouter key (sk-or-…)"
230
+ class="font-mono"
144
231
  />
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>
232
+ </UFormField>
233
+ <div class="flex justify-end">
234
+ <UButton
235
+ :loading="connectingKey"
236
+ :disabled="!keyValue.trim()"
237
+ icon="i-lucide-plus"
238
+ @click="connectKey()"
239
+ >
240
+ Connect & browse
241
+ </UButton>
242
+ </div>
154
243
  </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
244
 
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
245
+ <!-- Key connected status -->
246
+ <div
247
+ v-else
248
+ class="flex items-center justify-between rounded-lg border border-slate-700 bg-slate-900/60 px-3 py-2 text-sm"
249
+ >
250
+ <span class="flex items-center gap-2 text-slate-300">
251
+ <UIcon name="i-lucide-check-circle" class="h-4 w-4 text-emerald-400" />
252
+ OpenRouter key connected
253
+ </span>
254
+ <UButton color="neutral" variant="ghost" size="xs" @click="manageKeys()">
255
+ Manage in Vendors & keys
171
256
  </UButton>
172
257
  </div>
258
+
259
+ <!-- Step 2: browse + enable models (only once a key exists) -->
260
+ <template v-if="keyConnected">
261
+ <div class="flex items-center gap-2">
262
+ <UButton
263
+ color="neutral"
264
+ variant="soft"
265
+ size="sm"
266
+ icon="i-lucide-refresh-cw"
267
+ :loading="store.refreshing"
268
+ @click="refresh()"
269
+ >
270
+ Refresh catalog
271
+ </UButton>
272
+ <UButton
273
+ v-if="recommendedAvailable.length"
274
+ color="primary"
275
+ variant="soft"
276
+ size="sm"
277
+ icon="i-lucide-sparkles"
278
+ @click="enableRecommended()"
279
+ >
280
+ Enable recommended
281
+ </UButton>
282
+ <UInput
283
+ v-model="filter"
284
+ size="sm"
285
+ class="flex-1"
286
+ icon="i-lucide-search"
287
+ placeholder="Filter by name or slug…"
288
+ />
289
+ </div>
290
+
291
+ <p v-if="store.refreshError" class="text-xs text-rose-400">{{ store.refreshError }}</p>
292
+
293
+ <div v-if="visible.length" class="max-h-96 space-y-1 overflow-y-auto pr-1">
294
+ <label
295
+ v-for="m in visible"
296
+ :key="m.id"
297
+ class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-900/40 px-2.5 py-1.5 text-sm"
298
+ >
299
+ <UCheckbox
300
+ :model-value="selected.has(m.id)"
301
+ @update:model-value="(v: boolean | 'indeterminate') => toggle(m.id, v === true)"
302
+ />
303
+ <span class="min-w-0 flex-1">
304
+ <span class="block truncate text-slate-200">{{ m.name }}</span>
305
+ <span class="block truncate font-mono text-[11px] text-slate-500">{{ m.id }}</span>
306
+ </span>
307
+ <span class="shrink-0 text-right text-[11px] text-slate-500">
308
+ <span v-if="m.contextLength" class="block">{{
309
+ contextLabel(m.contextLength)
310
+ }}</span>
311
+ <span class="block">{{ priceLabel(m) }}</span>
312
+ </span>
313
+ </label>
314
+ </div>
315
+ <p v-else class="text-xs text-slate-500">
316
+ No models yet — hit <span class="text-slate-300">Refresh catalog</span> to load
317
+ OpenRouter's live list.
318
+ </p>
319
+
320
+ <div class="flex items-center justify-between">
321
+ <span class="text-xs text-slate-500">{{ selectedCount }} enabled</span>
322
+ <UButton
323
+ color="primary"
324
+ variant="soft"
325
+ size="sm"
326
+ icon="i-lucide-save"
327
+ :loading="busy"
328
+ @click="save()"
329
+ >
330
+ Save
331
+ </UButton>
332
+ </div>
333
+ </template>
173
334
  </div>
174
335
  </template>
175
336
  </UModal>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",