@cat-factory/app 0.46.12 → 0.47.1

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.
Files changed (31) hide show
  1. package/app/components/auth/AuthGate.vue +1 -1
  2. package/app/components/auth/LoginScreen.vue +131 -1
  3. package/app/components/layout/AccountDeploymentSettings.vue +232 -5
  4. package/app/components/providers/AiPresetMismatchDialog.vue +26 -13
  5. package/app/components/providers/AiProviderOnboardingModal.vue +18 -15
  6. package/app/components/providers/ApiKeysSection.vue +105 -63
  7. package/app/components/providers/PersonalCredentialModal.vue +35 -29
  8. package/app/components/providers/PersonalSubscriptionSection.vue +17 -2
  9. package/app/components/providers/SignInRequiredNotice.vue +19 -0
  10. package/app/components/providers/VendorCredentialsModal.vue +82 -39
  11. package/app/components/settings/AccountSettingsPanel.vue +9 -6
  12. package/app/components/settings/IssueTrackerPanel.vue +114 -53
  13. package/app/components/settings/LocalModeSettingsPanel.vue +60 -28
  14. package/app/components/settings/LocalModelEndpointsPanel.vue +72 -27
  15. package/app/components/settings/MergeThresholdsPanel.vue +93 -45
  16. package/app/components/settings/ModelConfigurationPanel.vue +62 -36
  17. package/app/components/settings/ObservabilityConnectionPanel.vue +65 -35
  18. package/app/components/settings/OpenRouterCatalogPanel.vue +70 -40
  19. package/app/components/settings/ProviderConnectionPanel.vue +115 -61
  20. package/app/components/settings/ServiceFragmentDefaultsPanel.vue +9 -12
  21. package/app/components/settings/UserSecretsSection.vue +37 -18
  22. package/app/components/settings/WorkspaceSettingsPanel.vue +114 -62
  23. package/app/composables/api/auth.ts +7 -0
  24. package/app/stores/auth.ts +25 -1
  25. package/app/types/accountSettings.ts +4 -0
  26. package/i18n/locales/en.json +663 -0
  27. package/i18n/locales/es.json +651 -0
  28. package/i18n/locales/fr.json +651 -0
  29. package/i18n/locales/pl.json +651 -0
  30. package/i18n/locales/uk.json +651 -0
  31. package/package.json +2 -2
@@ -27,7 +27,7 @@ onMounted(() => auth.bootstrap())
27
27
 
28
28
  <slot v-else-if="isPublicRoute" />
29
29
 
30
- <LoginScreen v-else-if="auth.required && !auth.user" />
30
+ <LoginScreen v-else-if="auth.needsLogin" />
31
31
 
32
32
  <slot v-else />
33
33
  </template>
@@ -1,10 +1,74 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref } from 'vue'
2
+ import { computed, ref, watch } from 'vue'
3
3
  import { apiErrorEnvelope } from '~/composables/api/errors'
4
4
 
5
5
  const auth = useAuthStore()
6
6
  const { t } = useI18n()
7
7
 
8
+ // Local-mode source-control PAT login. GitHub/GitLab are brand names (kept verbatim across
9
+ // locales), as are the token-settings URLs, so they're inline constants rather than catalog
10
+ // keys — same convention as the provider descriptors in ApiKeysSection. The actual link
11
+ // prefers the server's scopes-preselected deep link (`patLogin.setupUrls`); these are the
12
+ // fallback when it's absent.
13
+ type PatProvider = 'github' | 'gitlab'
14
+ const PROVIDER_LABELS: Record<PatProvider, string> = { github: 'GitHub', gitlab: 'GitLab' }
15
+ const PROVIDER_ICONS: Record<PatProvider, string> = {
16
+ github: 'i-lucide-github',
17
+ gitlab: 'i-lucide-gitlab',
18
+ }
19
+ // Fallback token-creation pages, used only if the server didn't advertise a deep link.
20
+ const PROVIDER_TOKEN_URLS: Record<PatProvider, string> = {
21
+ github: 'https://github.com/settings/tokens/new',
22
+ gitlab: 'https://gitlab.com/-/user_settings/personal_access_tokens',
23
+ }
24
+
25
+ const patLoginCfg = computed(() => auth.localMode?.patLogin)
26
+ const configuredProviders = computed<PatProvider[]>(
27
+ () => (patLoginCfg.value?.configured ?? []) as PatProvider[],
28
+ )
29
+ const availableProviders = computed<PatProvider[]>(
30
+ () => (patLoginCfg.value?.available ?? []) as PatProvider[],
31
+ )
32
+ const showLocalLogin = computed(() => availableProviders.value.length > 0)
33
+
34
+ const patProvider = ref<PatProvider>('github')
35
+ const patToken = ref('')
36
+ const patBusy = ref(false)
37
+ const patError = ref<string | null>(null)
38
+
39
+ // Keep the picker on an actually-available provider.
40
+ watch(
41
+ availableProviders,
42
+ (list) => {
43
+ if (list.length && !list.includes(patProvider.value)) patProvider.value = list[0]!
44
+ },
45
+ { immediate: true },
46
+ )
47
+
48
+ const patProviderItems = computed(() =>
49
+ availableProviders.value.map((p) => ({ label: PROVIDER_LABELS[p], value: p })),
50
+ )
51
+
52
+ // Prefer the server's scopes-preselected deep link (it owns the per-provider scopes);
53
+ // fall back to the plain token page if it wasn't advertised.
54
+ const tokenCreateUrl = computed(
55
+ () => patLoginCfg.value?.setupUrls?.[patProvider.value] ?? PROVIDER_TOKEN_URLS[patProvider.value],
56
+ )
57
+
58
+ /** One-click (configured PAT) or pasted-token sign-in; reloads so the app boots signed in. */
59
+ async function submitPat(provider: PatProvider, token?: string) {
60
+ patError.value = null
61
+ patBusy.value = true
62
+ try {
63
+ await auth.patLogin(token ? { provider, token } : { provider })
64
+ if (typeof window !== 'undefined') window.location.assign(window.location.pathname)
65
+ } catch (e) {
66
+ patError.value = apiErrorEnvelope(e)?.message ?? t('auth.localMode.failed')
67
+ } finally {
68
+ patBusy.value = false
69
+ }
70
+ }
71
+
8
72
  // An invite token may ride in on the URL (?invite=…) — it flows through the OAuth
9
73
  // redirect and the password signup so a brand-new user can join the org on first login.
10
74
  const invite = computed(() => {
@@ -90,6 +154,72 @@ const showOAuthDivider = computed(
90
154
  </p>
91
155
  </div>
92
156
 
157
+ <!-- Local mode: sign in with a source-control PAT (no OAuth round-trip needed) -->
158
+ <div v-if="showLocalLogin && mode !== 'forgot'" class="space-y-3">
159
+ <!-- One-click: a PAT is already configured server-side -->
160
+ <UButton
161
+ v-for="p in configuredProviders"
162
+ :key="p"
163
+ block
164
+ size="lg"
165
+ color="primary"
166
+ :icon="PROVIDER_ICONS[p]"
167
+ :loading="patBusy"
168
+ @click="submitPat(p)"
169
+ >
170
+ {{ t('auth.localMode.continueWith', { provider: PROVIDER_LABELS[p] }) }}
171
+ </UButton>
172
+
173
+ <!-- Enter a PAT inline -->
174
+ <form class="space-y-2" @submit.prevent="submitPat(patProvider, patToken.trim())">
175
+ <p class="text-xs font-medium text-slate-400">{{ t('auth.localMode.enterPatTitle') }}</p>
176
+ <USelect
177
+ v-if="patProviderItems.length > 1"
178
+ v-model="patProvider"
179
+ :items="patProviderItems"
180
+ size="lg"
181
+ class="w-full"
182
+ />
183
+ <UTextarea
184
+ v-model="patToken"
185
+ :rows="2"
186
+ :placeholder="
187
+ t('auth.localMode.tokenPlaceholder', { provider: PROVIDER_LABELS[patProvider] })
188
+ "
189
+ class="w-full font-mono"
190
+ />
191
+ <div class="flex items-center justify-between gap-2">
192
+ <a
193
+ :href="tokenCreateUrl"
194
+ target="_blank"
195
+ rel="noopener noreferrer"
196
+ class="text-xs text-indigo-400 hover:underline"
197
+ >
198
+ {{ t('auth.localMode.createToken', { provider: PROVIDER_LABELS[patProvider] }) }}
199
+ </a>
200
+ <UButton
201
+ size="lg"
202
+ color="neutral"
203
+ variant="subtle"
204
+ type="submit"
205
+ :loading="patBusy"
206
+ :disabled="!patToken.trim()"
207
+ >
208
+ {{ t('auth.localMode.submit') }}
209
+ </UButton>
210
+ </div>
211
+ </form>
212
+ <p v-if="patError" class="text-sm text-rose-400">{{ patError }}</p>
213
+ </div>
214
+
215
+ <div
216
+ v-if="showLocalLogin && auth.providers.password && mode !== 'forgot'"
217
+ class="my-4 flex items-center gap-3 text-xs text-slate-500"
218
+ >
219
+ <span class="h-px flex-1 bg-slate-800" /> {{ t('auth.localMode.orDivider') }}
220
+ <span class="h-px flex-1 bg-slate-800" />
221
+ </div>
222
+
93
223
  <!-- OAuth providers -->
94
224
  <div v-if="mode !== 'forgot'" class="space-y-2">
95
225
  <UButton
@@ -1,12 +1,13 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, onMounted, reactive, ref } from 'vue'
3
+ import type { ContentStorageBackend, ContentStorageConfig } from '~/types/accountSettings'
3
4
 
4
5
  // Deployment integration secrets for an account (admin only): the Slack app OAuth
5
- // credentials and the container web-search upstream keys both moved out of env into
6
- // the per-account settings store, sealed at rest. Secrets are write-only: the panel only
7
- // ever shows whether each integration is configured (the `summary`), never the values;
8
- // blank inputs leave a configured secret unchanged. Hidden when the settings store isn't
9
- // wired (no ENCRYPTION_KEY).
6
+ // credentials, the container web-search upstream keys, and the binary-artifact (screenshot)
7
+ // content-storage backend all moved out of env into the per-account settings store, sealed
8
+ // at rest. Secrets are write-only: the panel only ever shows whether each integration is
9
+ // configured (the `summary`), never the values; blank inputs leave a configured secret
10
+ // unchanged. Hidden when the settings store isn't wired (no ENCRYPTION_KEY).
10
11
  const props = defineProps<{ accountId: string }>()
11
12
 
12
13
  const store = useAccountSettingsStore()
@@ -20,9 +21,54 @@ const savingWeb = ref(false)
20
21
 
21
22
  const summary = computed(() => store.view?.summary ?? null)
22
23
 
24
+ // ---- Content storage (binary artifacts / screenshots) --------------------
25
+ // Exhaustive enum→key map (drift guard tier 2): every backend resolves to a static literal
26
+ // `t()` key, so adding a backend without a label fails the typecheck on this Record.
27
+ const contentBackendLabels = computed<Record<ContentStorageBackend, string>>(() => ({
28
+ off: t('layout.accountDeployment.contentStorage.backends.off'),
29
+ fs: t('layout.accountDeployment.contentStorage.backends.fs'),
30
+ s3: t('layout.accountDeployment.contentStorage.backends.s3'),
31
+ r2: t('layout.accountDeployment.contentStorage.backends.r2'),
32
+ db: t('layout.accountDeployment.contentStorage.backends.db'),
33
+ }))
34
+ const storageCapability = computed(() => store.view?.contentStorageCapability ?? null)
35
+ const storageSummary = computed(() => summary.value?.contentStorage ?? null)
36
+ const backendItems = computed(() =>
37
+ (storageCapability.value?.supportedBackends ?? []).map((b) => ({
38
+ label: contentBackendLabels.value[b],
39
+ value: b,
40
+ })),
41
+ )
42
+ const csBackend = ref<ContentStorageBackend>('off')
43
+ const cs = reactive({
44
+ basePath: '',
45
+ region: '',
46
+ bucket: '',
47
+ prefix: '',
48
+ endpoint: '',
49
+ forcePathStyle: false,
50
+ accessKeyId: '',
51
+ secretAccessKey: '',
52
+ })
53
+ const savingStorage = ref(false)
54
+
55
+ function hydrateStorage() {
56
+ const cfg = store.view?.config?.contentStorage
57
+ csBackend.value = cfg?.backend ?? storageCapability.value?.defaultBackend ?? 'off'
58
+ cs.basePath = cfg?.fs?.basePath ?? ''
59
+ cs.region = cfg?.s3?.region ?? ''
60
+ cs.bucket = cfg?.s3?.bucket ?? ''
61
+ cs.prefix = cfg?.s3?.prefix ?? ''
62
+ cs.endpoint = cfg?.s3?.endpoint ?? ''
63
+ cs.forcePathStyle = cfg?.s3?.forcePathStyle ?? false
64
+ cs.accessKeyId = ''
65
+ cs.secretAccessKey = ''
66
+ }
67
+
23
68
  onMounted(async () => {
24
69
  try {
25
70
  await store.load(props.accountId)
71
+ hydrateStorage()
26
72
  } catch (e) {
27
73
  toast.add({
28
74
  title: t('layout.accountDeployment.loadFailed'),
@@ -33,6 +79,72 @@ onMounted(async () => {
33
79
  }
34
80
  })
35
81
 
82
+ async function saveStorage() {
83
+ const backend = csBackend.value
84
+ const config: ContentStorageConfig = { backend }
85
+ if (backend === 'fs' && cs.basePath.trim()) {
86
+ config.fs = { basePath: cs.basePath.trim() }
87
+ }
88
+ if (backend === 's3') {
89
+ if (!cs.region.trim() || !cs.bucket.trim()) {
90
+ toast.add({
91
+ title: t('layout.accountDeployment.contentStorage.regionBucketValidation'),
92
+ color: 'error',
93
+ })
94
+ return
95
+ }
96
+ config.s3 = {
97
+ region: cs.region.trim(),
98
+ bucket: cs.bucket.trim(),
99
+ ...(cs.prefix.trim() ? { prefix: cs.prefix.trim() } : {}),
100
+ ...(cs.endpoint.trim() ? { endpoint: cs.endpoint.trim() } : {}),
101
+ ...(cs.forcePathStyle ? { forcePathStyle: true } : {}),
102
+ }
103
+ }
104
+ const input: Parameters<typeof store.save>[1] = { config: { contentStorage: config } }
105
+ if (backend === 's3') {
106
+ const id = cs.accessKeyId.trim()
107
+ const key = cs.secretAccessKey.trim()
108
+ if (id && key) {
109
+ input.secrets = { s3: { accessKeyId: id, secretAccessKey: key } }
110
+ } else if (id || key) {
111
+ toast.add({
112
+ title: t('layout.accountDeployment.contentStorage.bothKeysValidation'),
113
+ color: 'error',
114
+ })
115
+ return
116
+ } else if (!storageSummary.value?.s3CredentialsConfigured) {
117
+ toast.add({
118
+ title: t('layout.accountDeployment.contentStorage.keysValidation'),
119
+ color: 'error',
120
+ })
121
+ return
122
+ }
123
+ // else: keys already stored and none re-entered → leave them unchanged.
124
+ } else {
125
+ // Switching off S3: drop any stored S3 credentials.
126
+ input.secrets = { s3: null }
127
+ }
128
+ savingStorage.value = true
129
+ try {
130
+ await store.save(props.accountId, input)
131
+ hydrateStorage()
132
+ toast.add({
133
+ title: t('layout.accountDeployment.contentStorage.saved'),
134
+ icon: 'i-lucide-check',
135
+ color: 'success',
136
+ })
137
+ } catch (e) {
138
+ toast.add({
139
+ title: t('layout.accountDeployment.contentStorage.saveFailed'),
140
+ description: e instanceof Error ? e.message : String(e),
141
+ color: 'error',
142
+ })
143
+ } finally {
144
+ savingStorage.value = false
145
+ }
146
+ }
147
+
36
148
  async function saveSlack() {
37
149
  if (!slack.clientId.trim() || !slack.clientSecret.trim() || !slack.redirectUrl.trim()) {
38
150
  toast.add({ title: t('layout.accountDeployment.slack.validation'), color: 'error' })
@@ -275,5 +387,120 @@ async function clearWeb() {
275
387
  </UButton>
276
388
  </div>
277
389
  </section>
390
+
391
+ <!-- Content storage (binary artifacts / screenshots) -->
392
+ <section v-if="storageCapability" class="space-y-2 border-t border-slate-800 pt-6">
393
+ <div class="flex items-center gap-2">
394
+ <h4 class="text-sm font-semibold text-slate-200">
395
+ {{ t('layout.accountDeployment.contentStorage.title') }}
396
+ </h4>
397
+ <UBadge
398
+ :color="
399
+ storageSummary?.backend && storageSummary.backend !== 'off' ? 'success' : 'neutral'
400
+ "
401
+ variant="subtle"
402
+ size="xs"
403
+ >
404
+ {{
405
+ storageSummary?.backend
406
+ ? contentBackendLabels[storageSummary.backend]
407
+ : t('layout.accountDeployment.contentStorage.default', {
408
+ backend: contentBackendLabels[storageCapability.defaultBackend],
409
+ })
410
+ }}
411
+ </UBadge>
412
+ </div>
413
+ <p class="text-[11px] text-slate-400">
414
+ {{ t('layout.accountDeployment.contentStorage.description') }}
415
+ </p>
416
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
417
+ <USelect v-model="csBackend" :items="backendItems" value-key="value" size="sm" />
418
+ </div>
419
+
420
+ <!-- Filesystem -->
421
+ <div v-if="csBackend === 'fs'" class="grid grid-cols-1 gap-2">
422
+ <UInput
423
+ v-model="cs.basePath"
424
+ :placeholder="t('layout.accountDeployment.contentStorage.basePath')"
425
+ size="sm"
426
+ />
427
+ </div>
428
+
429
+ <!-- S3 / S3-compatible -->
430
+ <template v-if="csBackend === 's3'">
431
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
432
+ <UInput
433
+ v-model="cs.region"
434
+ :placeholder="t('layout.accountDeployment.contentStorage.region')"
435
+ size="sm"
436
+ />
437
+ <UInput
438
+ v-model="cs.bucket"
439
+ :placeholder="t('layout.accountDeployment.contentStorage.bucket')"
440
+ size="sm"
441
+ />
442
+ <UInput
443
+ v-model="cs.prefix"
444
+ :placeholder="t('layout.accountDeployment.contentStorage.prefix')"
445
+ size="sm"
446
+ />
447
+ <UInput
448
+ v-model="cs.endpoint"
449
+ :placeholder="t('layout.accountDeployment.contentStorage.endpoint')"
450
+ size="sm"
451
+ />
452
+ </div>
453
+ <UCheckbox
454
+ v-model="cs.forcePathStyle"
455
+ :label="t('layout.accountDeployment.contentStorage.forcePathStyle')"
456
+ size="sm"
457
+ />
458
+ <div class="flex items-center gap-2">
459
+ <span class="text-[11px] text-slate-400">
460
+ {{ t('layout.accountDeployment.contentStorage.accessKeys') }}
461
+ </span>
462
+ <UBadge
463
+ :color="storageSummary?.s3CredentialsConfigured ? 'success' : 'neutral'"
464
+ variant="subtle"
465
+ size="xs"
466
+ >
467
+ {{
468
+ storageSummary?.s3CredentialsConfigured
469
+ ? t('layout.accountDeployment.configured')
470
+ : t('layout.accountDeployment.notSet')
471
+ }}
472
+ </UBadge>
473
+ </div>
474
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
475
+ <UInput
476
+ v-model="cs.accessKeyId"
477
+ type="password"
478
+ :placeholder="t('layout.accountDeployment.contentStorage.accessKeyId')"
479
+ size="sm"
480
+ />
481
+ <UInput
482
+ v-model="cs.secretAccessKey"
483
+ type="password"
484
+ :placeholder="t('layout.accountDeployment.contentStorage.secretAccessKey')"
485
+ size="sm"
486
+ />
487
+ </div>
488
+ <p class="text-[11px] text-slate-400">
489
+ {{ t('layout.accountDeployment.contentStorage.keysHint') }}
490
+ </p>
491
+ </template>
492
+
493
+ <div class="flex gap-2">
494
+ <UButton
495
+ color="primary"
496
+ size="xs"
497
+ icon="i-lucide-save"
498
+ :loading="savingStorage"
499
+ @click="saveStorage"
500
+ >
501
+ {{ t('common.save') }}
502
+ </UButton>
503
+ </div>
504
+ </section>
278
505
  </div>
279
506
  </template>
@@ -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>