@cat-factory/app 0.47.0 → 0.47.2

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.
@@ -8,6 +8,7 @@
8
8
  // fixed (or all its models become available).
9
9
  import { computed } from 'vue'
10
10
 
11
+ const { t } = useI18n()
11
12
  const ui = useUiStore()
12
13
  const models = useModelsStore()
13
14
  const modelPresets = useModelPresetsStore()
@@ -18,7 +19,9 @@ const open = computed({
18
19
  set: (v: boolean) => (v ? ui.openAiPresetMismatch() : ui.closeAiPresetMismatch()),
19
20
  })
20
21
 
21
- const presetName = computed(() => modelPresets.defaultPreset?.name ?? 'default preset')
22
+ const presetName = computed(
23
+ () => modelPresets.defaultPreset?.name ?? t('providers.presetMismatch.defaultPresetName'),
24
+ )
22
25
 
23
26
  /** Readable labels for the unavailable model ids (catalog label, else the raw id). */
24
27
  const unavailableLabels = computed(() =>
@@ -32,19 +35,27 @@ function go(action: () => void) {
32
35
  </script>
33
36
 
34
37
  <template>
35
- <UModal v-model:open="open" title="Preset uses unavailable models" :ui="{ content: 'max-w-xl' }">
38
+ <UModal
39
+ v-model:open="open"
40
+ :title="t('providers.presetMismatch.title')"
41
+ :ui="{ content: 'max-w-xl' }"
42
+ >
36
43
  <template #body>
37
44
  <div class="space-y-5">
38
- <p class="text-sm text-slate-300">
39
- The workspace default model preset
40
- <span class="font-medium text-slate-100">“{{ presetName }}”</span>
41
- assigns models that aren't available under the current configuration. Tasks that use this
42
- preset would fail when they reach those steps.
43
- </p>
45
+ <i18n-t
46
+ keypath="providers.presetMismatch.intro"
47
+ tag="p"
48
+ class="text-sm text-slate-300"
49
+ scope="global"
50
+ >
51
+ <template #name>
52
+ <span class="font-medium text-slate-100">{{ presetName }}</span>
53
+ </template>
54
+ </i18n-t>
44
55
 
45
56
  <div class="rounded-lg border border-slate-700 bg-slate-900/50 p-3">
46
57
  <p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
47
- Unavailable
58
+ {{ t('providers.presetMismatch.unavailable') }}
48
59
  </p>
49
60
  <div class="flex flex-wrap gap-1.5">
50
61
  <UBadge
@@ -60,11 +71,13 @@ function go(action: () => void) {
60
71
  </div>
61
72
 
62
73
  <p class="text-[13px] text-slate-400">
63
- Either repoint the preset at models you have configured, or add the missing provider.
74
+ {{ t('providers.presetMismatch.advice') }}
64
75
  </p>
65
76
 
66
77
  <div class="flex flex-wrap justify-end gap-2">
67
- <UButton color="neutral" variant="ghost" size="sm" @click="open = false"> Later </UButton>
78
+ <UButton color="neutral" variant="ghost" size="sm" @click="open = false">
79
+ {{ t('providers.presetMismatch.later') }}
80
+ </UButton>
68
81
  <UButton
69
82
  color="neutral"
70
83
  variant="subtle"
@@ -72,10 +85,10 @@ function go(action: () => void) {
72
85
  icon="i-lucide-key-round"
73
86
  @click="go(ui.openVendorCredentials)"
74
87
  >
75
- Configure vendors
88
+ {{ t('providers.presetMismatch.configureVendors') }}
76
89
  </UButton>
77
90
  <UButton color="primary" size="sm" icon="i-lucide-cpu" @click="go(ui.openModelConfig)">
78
- Edit presets
91
+ {{ t('providers.presetMismatch.editPresets') }}
79
92
  </UButton>
80
93
  </div>
81
94
  </div>
@@ -7,6 +7,7 @@
7
7
  // the banner, disappears automatically the moment a usable source exists.
8
8
  import { computed } from 'vue'
9
9
 
10
+ const { t } = useI18n()
10
11
  const ui = useUiStore()
11
12
 
12
13
  const open = computed({
@@ -32,30 +33,34 @@ interface Route {
32
33
  const routes = computed<Route[]>(() => [
33
34
  {
34
35
  icon: 'i-lucide-key-round',
35
- title: 'Provider keys & subscriptions',
36
- body: 'Add a direct provider API key (OpenAI, Anthropic, Qwen, …) or connect a commercial coding-plan subscription (Kimi, DeepSeek) or a personal one (Claude, GLM, Codex).',
37
- cta: 'Open LLM vendors',
36
+ title: t('providers.onboarding.routes.keys.title'),
37
+ body: t('providers.onboarding.routes.keys.body'),
38
+ cta: t('providers.onboarding.routes.keys.cta'),
38
39
  onSelect: () => go(ui.openVendorCredentials),
39
40
  },
40
41
  {
41
42
  icon: 'i-lucide-route',
42
- title: 'OpenRouter gateway',
43
- body: 'Enable models through the OpenRouter gateway with a single key — browse and turn on the models you want.',
44
- cta: 'Browse OpenRouter models',
43
+ title: t('providers.onboarding.routes.openrouter.title'),
44
+ body: t('providers.onboarding.routes.openrouter.body'),
45
+ cta: t('providers.onboarding.routes.openrouter.cta'),
45
46
  onSelect: () => go(ui.openOpenRouter),
46
47
  },
47
48
  {
48
49
  icon: 'i-lucide-server',
49
- title: 'My local runners',
50
- body: 'Point cat-factory at a model you run yourself (Ollama, LM Studio, llama.cpp, vLLM, …). No API key, no spend.',
51
- cta: 'Configure local runners',
50
+ title: t('providers.onboarding.routes.local.title'),
51
+ body: t('providers.onboarding.routes.local.body'),
52
+ cta: t('providers.onboarding.routes.local.cta'),
52
53
  onSelect: () => go(ui.openLocalModels),
53
54
  },
54
55
  ])
55
56
  </script>
56
57
 
57
58
  <template>
58
- <UModal v-model:open="open" title="Set up an AI model provider" :ui="{ content: 'max-w-2xl' }">
59
+ <UModal
60
+ v-model:open="open"
61
+ :title="t('providers.onboarding.title')"
62
+ :ui="{ content: 'max-w-2xl' }"
63
+ >
59
64
  <template #body>
60
65
  <div class="space-y-5">
61
66
  <div
@@ -64,11 +69,10 @@ const routes = computed<Route[]>(() => [
64
69
  <UIcon name="i-lucide-cpu" class="mt-0.5 h-6 w-6 shrink-0 text-amber-400" />
65
70
  <div class="min-w-0 text-sm text-amber-100/90">
66
71
  <p class="font-medium text-amber-100">
67
- No AI model is available on this workspace yet.
72
+ {{ t('providers.onboarding.noModelTitle') }}
68
73
  </p>
69
74
  <p class="mt-1">
70
- Agents need a model to run. AI works out of the box only on a Cloudflare deployment
71
- with Workers AI enabled — otherwise connect at least one source below.
75
+ {{ t('providers.onboarding.noModelBody') }}
72
76
  </p>
73
77
  </div>
74
78
  </div>
@@ -97,8 +101,7 @@ const routes = computed<Route[]>(() => [
97
101
  </div>
98
102
 
99
103
  <p class="text-[11px] leading-relaxed text-slate-500">
100
- AWS Bedrock and Cloudflare Workers AI are enabled by the deployment operator via
101
- environment configuration, not from this screen.
104
+ {{ t('providers.onboarding.operatorNote') }}
102
105
  </p>
103
106
  </div>
104
107
  </template>
@@ -21,12 +21,12 @@ const props = withDefaults(defineProps<{ accountId?: string; category?: 'direct'
21
21
  category: 'direct',
22
22
  })
23
23
 
24
+ const { t, n } = useI18n()
24
25
  const workspace = useWorkspaceStore()
25
26
  const keys = useApiKeysStore()
26
27
  const models = useModelsStore()
27
28
  const auth = useAuthStore()
28
29
  const toast = useToast()
29
- const { t } = useI18n()
30
30
 
31
31
  /** Account-wide mode (single account scope) vs the default workspace/user toggle. */
32
32
  const isAccount = computed(() => !!props.accountId)
@@ -50,84 +50,89 @@ interface ProviderMeta {
50
50
  caches?: boolean
51
51
  }
52
52
 
53
+ // Provider metadata. Labels + step instructions resolve through i18n (reactive to the
54
+ // locale), so each `t(...)` uses a literal key (kept tier-1 typed-key checkable); only the
55
+ // `value`/`url`/`caches` differentiators stay inline.
53
56
  /** Direct vendors: the key reaches that one vendor's own endpoint. */
54
- const DIRECT_PROVIDERS: ProviderMeta[] = [
57
+ const DIRECT_PROVIDERS = computed<ProviderMeta[]>(() => [
55
58
  {
56
59
  value: 'openai',
57
- label: 'OpenAI',
60
+ label: t('providers.apiKeys.providers.openai.label'),
58
61
  url: 'https://platform.openai.com/api-keys',
59
62
  steps: [
60
- 'Open platform.openai.com → API keys and create a new secret key.',
61
- 'Copy the key (starts with sk-…); it is shown only once.',
63
+ t('providers.apiKeys.providers.openai.step1'),
64
+ t('providers.apiKeys.providers.openai.step2'),
62
65
  ],
63
66
  caches: true,
64
67
  },
65
68
  {
66
69
  value: 'anthropic',
67
- label: 'Anthropic (Claude API)',
70
+ label: t('providers.apiKeys.providers.anthropic.label'),
68
71
  url: 'https://console.anthropic.com/settings/keys',
69
72
  steps: [
70
- 'Open console.anthropic.com → Settings → API Keys and create a key.',
71
- 'Copy the key (starts with sk-ant-…).',
73
+ t('providers.apiKeys.providers.anthropic.step1'),
74
+ t('providers.apiKeys.providers.anthropic.step2'),
72
75
  ],
73
76
  caches: true,
74
77
  },
75
78
  {
76
79
  value: 'qwen',
77
- label: 'Qwen (Alibaba DashScope)',
80
+ label: t('providers.apiKeys.providers.qwen.label'),
78
81
  url: 'https://dashscope.console.aliyun.com/apiKey',
79
82
  steps: [
80
- 'Open the DashScope console (international) → API-KEY and create a key.',
81
- 'Copy the key; it authenticates the OpenAI-compatible Qwen endpoint.',
83
+ t('providers.apiKeys.providers.qwen.step1'),
84
+ t('providers.apiKeys.providers.qwen.step2'),
82
85
  ],
83
86
  caches: true,
84
87
  },
85
88
  {
86
89
  value: 'deepseek',
87
- label: 'DeepSeek',
90
+ label: t('providers.apiKeys.providers.deepseek.label'),
88
91
  url: 'https://platform.deepseek.com/api_keys',
89
92
  steps: [
90
- 'Open platform.deepseek.com → API keys and create a key.',
91
- 'Copy the key (starts with sk-…).',
93
+ t('providers.apiKeys.providers.deepseek.step1'),
94
+ t('providers.apiKeys.providers.deepseek.step2'),
92
95
  ],
93
96
  caches: true,
94
97
  },
95
98
  {
96
99
  value: 'moonshot',
97
- label: 'Moonshot (Kimi API)',
100
+ label: t('providers.apiKeys.providers.moonshot.label'),
98
101
  url: 'https://platform.moonshot.ai/console/api-keys',
99
102
  steps: [
100
- 'Open platform.moonshot.ai → API Keys and create a key.',
101
- 'Copy the key; it authenticates the OpenAI-compatible Moonshot endpoint.',
103
+ t('providers.apiKeys.providers.moonshot.step1'),
104
+ t('providers.apiKeys.providers.moonshot.step2'),
102
105
  ],
103
106
  },
104
- ]
107
+ ])
105
108
 
106
109
  /** Proxies / gateways: one key fronts many vendors. These are intermediaries, not vendors. */
107
- const PROXY_PROVIDERS: ProviderMeta[] = [
110
+ const PROXY_PROVIDERS = computed<ProviderMeta[]>(() => [
108
111
  {
109
112
  value: 'openrouter',
110
- label: 'OpenRouter',
113
+ label: t('providers.apiKeys.providers.openrouter.label'),
111
114
  url: 'https://openrouter.ai/keys',
112
115
  steps: [
113
- 'Open openrouter.ai → Keys and create an API key.',
114
- 'Copy the key (starts with sk-or-…); it reaches 300+ models through one gateway.',
116
+ t('providers.apiKeys.providers.openrouter.step1'),
117
+ t('providers.apiKeys.providers.openrouter.step2'),
115
118
  ],
116
119
  },
117
120
  {
118
121
  value: 'litellm',
119
- label: 'LiteLLM (self-hosted gateway)',
122
+ label: t('providers.apiKeys.providers.litellm.label'),
120
123
  url: 'https://docs.litellm.ai/docs/proxy/virtual_keys',
121
124
  steps: [
122
- 'Generate a virtual key on your LiteLLM gateway (or use its master key).',
123
- "The gateway's base URL is set by your deployment operator (LITELLM_BASE_URL), not here.",
125
+ t('providers.apiKeys.providers.litellm.step1'),
126
+ t('providers.apiKeys.providers.litellm.step2'),
124
127
  ],
125
128
  },
126
- ]
129
+ ])
127
130
 
128
131
  /** Providers for the requested category; labels everywhere fall back to the full set. */
129
- const PROVIDERS = computed(() => (props.category === 'proxy' ? PROXY_PROVIDERS : DIRECT_PROVIDERS))
130
- const ALL_PROVIDERS = [...DIRECT_PROVIDERS, ...PROXY_PROVIDERS]
132
+ const PROVIDERS = computed(() =>
133
+ props.category === 'proxy' ? PROXY_PROVIDERS.value : DIRECT_PROVIDERS.value,
134
+ )
135
+ const ALL_PROVIDERS = computed(() => [...DIRECT_PROVIDERS.value, ...PROXY_PROVIDERS.value])
131
136
 
132
137
  const scope = ref<'workspace' | 'user'>('workspace')
133
138
  const provider = ref<ApiKeyProvider>(props.category === 'proxy' ? 'openrouter' : 'openai')
@@ -167,7 +172,7 @@ const connected = computed<ApiKey[]>(() => {
167
172
  })
168
173
 
169
174
  function providerLabel(p: ApiKeyProvider): string {
170
- return ALL_PROVIDERS.find((x) => x.value === p)?.label ?? p
175
+ return ALL_PROVIDERS.value.find((x) => x.value === p)?.label ?? p
171
176
  }
172
177
 
173
178
  async function add() {
@@ -176,7 +181,8 @@ async function add() {
176
181
  try {
177
182
  const input = {
178
183
  provider: provider.value,
179
- label: label.value.trim() || `${provider.value} key`,
184
+ label:
185
+ label.value.trim() || t('providers.apiKeys.defaultLabel', { provider: provider.value }),
180
186
  key: key.value.trim(),
181
187
  }
182
188
  if (isAccount.value) await keys.addAccountKey(input)
@@ -186,10 +192,14 @@ async function add() {
186
192
  label.value = ''
187
193
  // The picker's selectability depends on configured keys — refresh it.
188
194
  if (workspace.workspaceId) await models.refresh(workspace.workspaceId)
189
- toast.add({ title: 'API key connected', icon: 'i-lucide-check', color: 'success' })
195
+ toast.add({
196
+ title: t('providers.apiKeys.toast.connected'),
197
+ icon: 'i-lucide-check',
198
+ color: 'success',
199
+ })
190
200
  } catch (e) {
191
201
  toast.add({
192
- title: 'Could not connect key',
202
+ title: t('providers.apiKeys.toast.connectFailed'),
193
203
  description: e instanceof Error ? e.message : String(e),
194
204
  color: 'error',
195
205
  })
@@ -206,7 +216,7 @@ async function remove(k: ApiKey) {
206
216
  if (workspace.workspaceId) await models.refresh(workspace.workspaceId)
207
217
  } catch (e) {
208
218
  toast.add({
209
- title: 'Could not remove key',
219
+ title: t('providers.apiKeys.toast.removeFailed'),
210
220
  description: e instanceof Error ? e.message : String(e),
211
221
  color: 'error',
212
222
  })
@@ -218,49 +228,49 @@ async function remove(k: ApiKey) {
218
228
  <div class="space-y-4">
219
229
  <div>
220
230
  <h4 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
221
- {{ category === 'proxy' ? 'Proxy / gateway API keys' : 'Direct provider API keys' }}
231
+ {{
232
+ category === 'proxy'
233
+ ? t('providers.apiKeys.proxyHeading')
234
+ : t('providers.apiKeys.directHeading')
235
+ }}
222
236
  </h4>
223
237
  <template v-if="category === 'proxy'">
224
238
  <p v-if="isAccount" class="mt-1 text-sm text-slate-400">
225
- Connect a <strong>proxy</strong> key (OpenRouter, LiteLLM) shared by
226
- <strong>every workspace</strong> in this account. A proxy is an intermediary that fronts
227
- many vendors behind a single key. Keys are stored encrypted, pooled, and rotated by usage.
239
+ {{ t('providers.apiKeys.proxyAccountIntro') }}
228
240
  </p>
229
241
  <p v-else class="mt-1 text-sm text-slate-400">
230
- Connect a <strong>proxy</strong> key (OpenRouter, LiteLLM). A proxy is an intermediary
231
- that reaches many vendors' models through a single gateway, rather than one vendor
232
- directly. Keys are stored encrypted, pooled, and rotated by usage. Scope a key to this
233
- <strong>workspace</strong> (shared with the team) or to <strong>you</strong> (your own
234
- pool, usable anywhere).
242
+ {{ t('providers.apiKeys.proxyIntro') }}
235
243
  </p>
236
244
  </template>
237
245
  <template v-else>
238
246
  <p v-if="isAccount" class="mt-1 text-sm text-slate-400">
239
- Connect a vendor API key shared by <strong>every workspace</strong> in this account. Keys
240
- are stored encrypted, pooled, and rotated by usage. Account keys are admin-managed.
247
+ {{ t('providers.apiKeys.directAccountIntro') }}
241
248
  </p>
242
249
  <p v-else class="mt-1 text-sm text-slate-400">
243
- Connect a vendor API key so models run directly on that provider. Keys are stored
244
- encrypted, pooled, and rotated by usage. Scope a key to this
245
- <strong>workspace</strong> (shared with the team) or to <strong>you</strong> (your own
246
- pool, usable anywhere).
250
+ {{ t('providers.apiKeys.directIntro') }}
247
251
  </p>
248
252
  </template>
249
253
  </div>
250
254
 
251
255
  <!-- scope + provider -->
252
256
  <div class="flex flex-wrap items-end gap-3">
253
- <UFormField v-if="!isAccount" label="Scope">
257
+ <UFormField v-if="!isAccount" :label="t('providers.apiKeys.scopeField')">
254
258
  <USelect
255
259
  v-model="scope"
256
260
  :items="[
257
- { label: 'This workspace', value: 'workspace' },
258
- { label: 'My keys (only me)', value: 'user' },
261
+ { label: t('providers.apiKeys.scopeWorkspace'), value: 'workspace' },
262
+ { label: t('providers.apiKeys.scopeUser'), value: 'user' },
259
263
  ]"
260
264
  class="w-48"
261
265
  />
262
266
  </UFormField>
263
- <UFormField :label="category === 'proxy' ? 'Proxy' : 'Provider'">
267
+ <UFormField
268
+ :label="
269
+ category === 'proxy'
270
+ ? t('providers.apiKeys.proxyField')
271
+ : t('providers.apiKeys.providerField')
272
+ "
273
+ >
264
274
  <USelect
265
275
  v-model="provider"
266
276
  :items="PROVIDERS.map((p) => ({ label: p.label, value: p.value }))"
@@ -287,7 +297,7 @@ async function remove(k: ApiKey) {
287
297
  rel="noopener noreferrer"
288
298
  class="text-primary-400 underline"
289
299
  >
290
- Open {{ selected.label }} keys ↗
300
+ {{ t('providers.apiKeys.openKeys', { provider: selected.label }) }}
291
301
  </a>
292
302
  </li>
293
303
  </ol>
@@ -296,21 +306,24 @@ async function remove(k: ApiKey) {
296
306
  the caching flavour, so long agentic runs stop re-billing the whole prompt. -->
297
307
  <p v-if="selected.caches" class="flex items-center gap-1.5 text-[12px] text-emerald-400/90">
298
308
  <UIcon name="i-lucide-zap" class="h-3.5 w-3.5 shrink-0" />
299
- Enables prompt caching for {{ selected.label }} models — a long multi-turn run reuses its
300
- cached prompt prefix instead of re-sending it every turn.
309
+ {{ t('providers.apiKeys.cachingNote', { provider: selected.label }) }}
301
310
  </p>
302
311
 
303
312
  <!-- add form -->
304
313
  <div class="space-y-2">
305
- <UFormField label="Label (optional)">
306
- <UInput v-model="label" :disabled="needsSignIn" placeholder="e.g. team key" />
314
+ <UFormField :label="t('providers.apiKeys.labelField')">
315
+ <UInput
316
+ v-model="label"
317
+ :disabled="needsSignIn"
318
+ :placeholder="t('providers.apiKeys.labelPlaceholder')"
319
+ />
307
320
  </UFormField>
308
- <UFormField label="API key">
321
+ <UFormField :label="t('providers.apiKeys.keyField')">
309
322
  <UTextarea
310
323
  v-model="key"
311
324
  :rows="2"
312
325
  :disabled="needsSignIn"
313
- placeholder="paste the API key"
326
+ :placeholder="t('providers.apiKeys.keyPlaceholder')"
314
327
  class="font-mono"
315
328
  />
316
329
  </UFormField>
@@ -321,7 +334,7 @@ async function remove(k: ApiKey) {
321
334
  icon="i-lucide-plus"
322
335
  @click="add()"
323
336
  >
324
- Connect
337
+ {{ t('providers.apiKeys.connect') }}
325
338
  </UButton>
326
339
  </div>
327
340
  </div>
@@ -329,7 +342,7 @@ async function remove(k: ApiKey) {
329
342
  <!-- connected keys for the selected scope -->
330
343
  <div v-if="connected.length" class="space-y-2">
331
344
  <h5 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
332
- Connected ({{ connected.length }})
345
+ {{ t('providers.apiKeys.connected', { count: connected.length }) }}
333
346
  </h5>
334
347
  <div
335
348
  v-for="k in connected"
@@ -340,8 +353,13 @@ async function remove(k: ApiKey) {
340
353
  <span class="font-medium text-slate-200">{{ k.label }}</span>
341
354
  <span class="ml-2 text-xs text-slate-500">{{ providerLabel(k.provider) }}</span>
342
355
  <div class="text-[11px] tabular-nums text-slate-500">
343
- {{ (k.inputTokens + k.outputTokens).toLocaleString() }} tok this window ·
344
- {{ k.requestCount }} call{{ k.requestCount === 1 ? '' : 's' }}
356
+ {{
357
+ t(
358
+ 'providers.apiKeys.usage',
359
+ { tokens: n(k.inputTokens + k.outputTokens, 'decimal'), count: k.requestCount },
360
+ k.requestCount,
361
+ )
362
+ }}
345
363
  </div>
346
364
  </div>
347
365
  <UButton
@@ -5,8 +5,8 @@
5
5
  // credential_required. On submit it transparently retries the gated action and caches the
6
6
  // password. The copy follows the pending vendor (Claude / GLM / ChatGPT-Codex).
7
7
  import { computed, ref, watch } from 'vue'
8
- import type { SubscriptionVendor } from '~/types/domain'
9
8
 
9
+ const { t } = useI18n()
10
10
  const personal = usePersonalSubscriptionsStore()
11
11
  const ui = useUiStore()
12
12
  const toast = useToast()
@@ -29,17 +29,18 @@ const needsConnect = computed(
29
29
  pending.value?.reason === 'no_subscription' || pending.value?.reason === 'subscription_expired',
30
30
  )
31
31
 
32
- const VENDOR_LABELS: Partial<Record<SubscriptionVendor, string>> = {
33
- claude: 'Claude',
34
- glm: 'GLM (Z.ai)',
35
- codex: 'ChatGPT (Codex)',
36
- }
37
-
38
32
  /** Display label for the pending vendor (falls back to the raw vendor string). */
39
33
  const vendorLabel = computed(() => {
40
- const v = pending.value?.vendor
41
- if (!v) return 'subscription'
42
- return VENDOR_LABELS[v] ?? v
34
+ switch (pending.value?.vendor) {
35
+ case 'claude':
36
+ return t('providers.personalCredential.vendors.claude')
37
+ case 'glm':
38
+ return t('providers.personalCredential.vendors.glm')
39
+ case 'codex':
40
+ return t('providers.personalCredential.vendors.codex')
41
+ default:
42
+ return pending.value?.vendor ?? t('providers.personalCredential.fallbackVendor')
43
+ }
43
44
  })
44
45
 
45
46
  watch(open, (isOpen) => {
@@ -49,13 +50,13 @@ watch(open, (isOpen) => {
49
50
  const title = computed(() => {
50
51
  switch (pending.value?.reason) {
51
52
  case 'wrong_password':
52
- return 'Incorrect personal password'
53
+ return t('providers.personalCredential.titleWrongPassword')
53
54
  case 'no_subscription':
54
- return `Connect your ${vendorLabel.value} subscription`
55
+ return t('providers.personalCredential.titleNoSubscription', { vendor: vendorLabel.value })
55
56
  case 'subscription_expired':
56
- return `Your ${vendorLabel.value} subscription expired`
57
+ return t('providers.personalCredential.titleExpired', { vendor: vendorLabel.value })
57
58
  default:
58
- return 'Enter your personal password'
59
+ return t('providers.personalCredential.titleDefault')
59
60
  }
60
61
  })
61
62
 
@@ -64,11 +65,15 @@ async function submit() {
64
65
  busy.value = true
65
66
  try {
66
67
  await pending.value.retry(password.value)
67
- toast.add({ title: 'Run started', icon: 'i-lucide-check', color: 'success' })
68
+ toast.add({
69
+ title: t('providers.personalCredential.toast.started'),
70
+ icon: 'i-lucide-check',
71
+ color: 'success',
72
+ })
68
73
  } catch (e) {
69
74
  // A fresh 428 (e.g. still-wrong password) re-arms `pending`, keeping the modal open.
70
75
  toast.add({
71
- title: 'Could not start the run',
76
+ title: t('providers.personalCredential.toast.startFailed'),
72
77
  description: e instanceof Error ? e.message : String(e),
73
78
  color: 'error',
74
79
  })
@@ -89,36 +94,37 @@ function goConnect() {
89
94
  <div class="space-y-4">
90
95
  <template v-if="needsConnect">
91
96
  <p class="text-sm text-slate-400">
92
- This task uses a {{ vendorLabel }} model, which runs on <strong>your own</strong>
93
- {{ vendorLabel }} subscription. Connect (or renew) it first, then start the run again.
97
+ {{ t('providers.personalCredential.connectBody', { vendor: vendorLabel }) }}
94
98
  </p>
95
99
  <div class="flex justify-end gap-2">
96
- <UButton color="neutral" variant="ghost" @click="open = false">Cancel</UButton>
97
- <UButton icon="i-lucide-shield-check" @click="goConnect()"
98
- >Connect subscription</UButton
99
- >
100
+ <UButton color="neutral" variant="ghost" @click="open = false">
101
+ {{ t('providers.personalCredential.cancel') }}
102
+ </UButton>
103
+ <UButton icon="i-lucide-shield-check" @click="goConnect()">
104
+ {{ t('providers.personalCredential.connectCta') }}
105
+ </UButton>
100
106
  </div>
101
107
  </template>
102
108
 
103
109
  <template v-else>
104
110
  <p class="text-sm text-slate-400">
105
- Enter the personal password that protects your {{ vendorLabel }} subscription. It
106
- unlocks your credential for this run only and is cached in this browser so you won’t be
107
- asked again for a while.
111
+ {{ t('providers.personalCredential.passwordBody', { vendor: vendorLabel }) }}
108
112
  </p>
109
- <UFormField label="Personal password">
113
+ <UFormField :label="t('providers.personalCredential.passwordField')">
110
114
  <UInput
111
115
  v-model="password"
112
116
  type="password"
113
117
  autofocus
114
- placeholder="your personal password"
118
+ :placeholder="t('providers.personalCredential.passwordPlaceholder')"
115
119
  @keydown.enter="submit()"
116
120
  />
117
121
  </UFormField>
118
122
  <div class="flex justify-end gap-2">
119
- <UButton color="neutral" variant="ghost" @click="open = false">Cancel</UButton>
123
+ <UButton color="neutral" variant="ghost" @click="open = false">
124
+ {{ t('providers.personalCredential.cancel') }}
125
+ </UButton>
120
126
  <UButton :loading="busy" :disabled="password.length < 6" @click="submit()">
121
- Unlock &amp; run
127
+ {{ t('providers.personalCredential.submit') }}
122
128
  </UButton>
123
129
  </div>
124
130
  </template>
@@ -9,6 +9,8 @@ import type { SubscriptionVendor } from '~/types/domain'
9
9
 
10
10
  const personal = usePersonalSubscriptionsStore()
11
11
  const auth = useAuthStore()
12
+ const workspace = useWorkspaceStore()
13
+ const models = useModelsStore()
12
14
  const toast = useToast()
13
15
  const { t, d } = useI18n()
14
16
 
@@ -117,6 +119,11 @@ async function connect() {
117
119
  password.value = ''
118
120
  label.value = ''
119
121
  expiresOn.value = ''
122
+ // A connected subscription makes its vendor's models usable, so refresh the catalog:
123
+ // this clears the "No AI model configured" banner and, if the default preset still
124
+ // points at models this subscription doesn't cover, reactively surfaces the
125
+ // preset-mismatch prompt (with its "pick a different preset" link).
126
+ if (workspace.workspaceId) await models.refresh(workspace.workspaceId)
120
127
  toast.add({
121
128
  title: t('personalSubscriptions.toast.connected', { vendor: selectedMeta.value.label }),
122
129
  icon: 'i-lucide-check',
@@ -136,6 +143,9 @@ async function connect() {
136
143
  async function disconnect(v: SubscriptionVendor) {
137
144
  try {
138
145
  await personal.remove(v)
146
+ // Removing the subscription may drop the workspace's last usable model — refresh so the
147
+ // AI-readiness banners re-evaluate (mirrors the API-key flow).
148
+ if (workspace.workspaceId) await models.refresh(workspace.workspaceId)
139
149
  toast.add({ title: t('personalSubscriptions.toast.disconnected'), icon: 'i-lucide-check' })
140
150
  } catch (e) {
141
151
  toast.add({