@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.
- package/app/components/providers/AiPresetMismatchDialog.vue +26 -13
- package/app/components/providers/AiProviderOnboardingModal.vue +18 -15
- package/app/components/providers/ApiKeysSection.vue +81 -63
- package/app/components/providers/PersonalCredentialModal.vue +35 -29
- package/app/components/providers/PersonalSubscriptionSection.vue +10 -0
- package/app/components/providers/VendorCredentialsModal.vue +82 -39
- package/i18n/locales/en.json +167 -0
- package/i18n/locales/es.json +161 -0
- package/i18n/locales/fr.json +161 -0
- package/i18n/locales/pl.json +161 -0
- package/i18n/locales/uk.json +161 -0
- package/package.json +1 -1
|
@@ -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(
|
|
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
|
|
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
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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">
|
|
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
|
-
|
|
88
|
+
{{ t('providers.presetMismatch.configureVendors') }}
|
|
76
89
|
</UButton>
|
|
77
90
|
<UButton color="primary" size="sm" icon="i-lucide-cpu" @click="go(ui.openModelConfig)">
|
|
78
|
-
|
|
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: '
|
|
36
|
-
body: '
|
|
37
|
-
cta: '
|
|
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: '
|
|
43
|
-
body: '
|
|
44
|
-
cta: '
|
|
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: '
|
|
50
|
-
body: '
|
|
51
|
-
cta: '
|
|
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
|
|
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
|
-
|
|
72
|
+
{{ t('providers.onboarding.noModelTitle') }}
|
|
68
73
|
</p>
|
|
69
74
|
<p class="mt-1">
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
57
|
+
const DIRECT_PROVIDERS = computed<ProviderMeta[]>(() => [
|
|
55
58
|
{
|
|
56
59
|
value: 'openai',
|
|
57
|
-
label: '
|
|
60
|
+
label: t('providers.apiKeys.providers.openai.label'),
|
|
58
61
|
url: 'https://platform.openai.com/api-keys',
|
|
59
62
|
steps: [
|
|
60
|
-
'
|
|
61
|
-
'
|
|
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: '
|
|
70
|
+
label: t('providers.apiKeys.providers.anthropic.label'),
|
|
68
71
|
url: 'https://console.anthropic.com/settings/keys',
|
|
69
72
|
steps: [
|
|
70
|
-
'
|
|
71
|
-
'
|
|
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: '
|
|
80
|
+
label: t('providers.apiKeys.providers.qwen.label'),
|
|
78
81
|
url: 'https://dashscope.console.aliyun.com/apiKey',
|
|
79
82
|
steps: [
|
|
80
|
-
'
|
|
81
|
-
'
|
|
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: '
|
|
90
|
+
label: t('providers.apiKeys.providers.deepseek.label'),
|
|
88
91
|
url: 'https://platform.deepseek.com/api_keys',
|
|
89
92
|
steps: [
|
|
90
|
-
'
|
|
91
|
-
'
|
|
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: '
|
|
100
|
+
label: t('providers.apiKeys.providers.moonshot.label'),
|
|
98
101
|
url: 'https://platform.moonshot.ai/console/api-keys',
|
|
99
102
|
steps: [
|
|
100
|
-
'
|
|
101
|
-
'
|
|
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
|
|
110
|
+
const PROXY_PROVIDERS = computed<ProviderMeta[]>(() => [
|
|
108
111
|
{
|
|
109
112
|
value: 'openrouter',
|
|
110
|
-
label: '
|
|
113
|
+
label: t('providers.apiKeys.providers.openrouter.label'),
|
|
111
114
|
url: 'https://openrouter.ai/keys',
|
|
112
115
|
steps: [
|
|
113
|
-
'
|
|
114
|
-
'
|
|
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: '
|
|
122
|
+
label: t('providers.apiKeys.providers.litellm.label'),
|
|
120
123
|
url: 'https://docs.litellm.ai/docs/proxy/virtual_keys',
|
|
121
124
|
steps: [
|
|
122
|
-
'
|
|
123
|
-
|
|
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(() =>
|
|
130
|
-
|
|
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:
|
|
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({
|
|
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: '
|
|
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: '
|
|
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
|
-
{{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
257
|
+
<UFormField v-if="!isAccount" :label="t('providers.apiKeys.scopeField')">
|
|
254
258
|
<USelect
|
|
255
259
|
v-model="scope"
|
|
256
260
|
:items="[
|
|
257
|
-
{ label: '
|
|
258
|
-
{ label: '
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
306
|
-
<UInput
|
|
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="
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{{
|
|
344
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 '
|
|
53
|
+
return t('providers.personalCredential.titleWrongPassword')
|
|
53
54
|
case 'no_subscription':
|
|
54
|
-
return
|
|
55
|
+
return t('providers.personalCredential.titleNoSubscription', { vendor: vendorLabel.value })
|
|
55
56
|
case 'subscription_expired':
|
|
56
|
-
return
|
|
57
|
+
return t('providers.personalCredential.titleExpired', { vendor: vendorLabel.value })
|
|
57
58
|
default:
|
|
58
|
-
return '
|
|
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({
|
|
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: '
|
|
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
|
-
|
|
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">
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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="
|
|
113
|
+
<UFormField :label="t('providers.personalCredential.passwordField')">
|
|
110
114
|
<UInput
|
|
111
115
|
v-model="password"
|
|
112
116
|
type="password"
|
|
113
117
|
autofocus
|
|
114
|
-
placeholder="
|
|
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">
|
|
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
|
-
|
|
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({
|