@cat-factory/app 0.18.1 → 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.
- package/app/components/layout/IntegrationsHub.vue +55 -29
- package/app/components/settings/OpenRouterCatalogPanel.vue +232 -71
- package/app/components/settings/UserSecretsSection.vue +214 -0
- package/app/composables/api/userSecrets.ts +31 -0
- package/app/composables/useApi.ts +2 -0
- package/app/pages/index.vue +2 -0
- package/app/stores/ui.ts +10 -0
- package/app/stores/userSecrets.ts +64 -0
- package/app/types/userSecrets.ts +49 -0
- package/package.json +1 -1
|
@@ -14,6 +14,9 @@ const documents = useDocumentsStore()
|
|
|
14
14
|
const tasks = useTasksStore()
|
|
15
15
|
const tracker = useTrackerStore()
|
|
16
16
|
const releaseHealth = useReleaseHealthStore()
|
|
17
|
+
const userSecrets = useUserSecretsStore()
|
|
18
|
+
const apiKeys = useApiKeysStore()
|
|
19
|
+
const workspace = useWorkspaceStore()
|
|
17
20
|
|
|
18
21
|
// The selected filing tracker, as a badge label ("GitHub Issues" / "Jira").
|
|
19
22
|
const trackerLabel = computed(() => {
|
|
@@ -27,7 +30,12 @@ const trackerLabel = computed(() => {
|
|
|
27
30
|
watch(
|
|
28
31
|
() => ui.integrationsOpen,
|
|
29
32
|
(isOpen) => {
|
|
30
|
-
if (isOpen)
|
|
33
|
+
if (isOpen) {
|
|
34
|
+
void releaseHealth.ensureLoaded().catch(() => {})
|
|
35
|
+
void userSecrets.load().catch(() => {})
|
|
36
|
+
// Drives the OpenRouter row's "Key connected" badge.
|
|
37
|
+
if (workspace.workspaceId) void apiKeys.load(workspace.workspaceId).catch(() => {})
|
|
38
|
+
}
|
|
31
39
|
},
|
|
32
40
|
)
|
|
33
41
|
|
|
@@ -62,6 +70,38 @@ function go(fn: () => void) {
|
|
|
62
70
|
const groups = computed<IntegrationGroup[]>(() => {
|
|
63
71
|
const out: IntegrationGroup[] = []
|
|
64
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
|
+
|
|
65
105
|
// --- Source control --------------------------------------------------------
|
|
66
106
|
const code: IntegrationItem[] = []
|
|
67
107
|
if (github.available) {
|
|
@@ -75,6 +115,20 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
75
115
|
onClick: () => go(ui.openGitHub),
|
|
76
116
|
})
|
|
77
117
|
}
|
|
118
|
+
// Per-user GitHub PAT — works on every runtime (used for runs you initiate). Always
|
|
119
|
+
// offered; the badge reflects whether the signed-in user has stored one.
|
|
120
|
+
{
|
|
121
|
+
const pat = userSecrets.statusFor('github_pat')
|
|
122
|
+
code.push({
|
|
123
|
+
key: 'github-pat',
|
|
124
|
+
icon: 'i-lucide-key-round',
|
|
125
|
+
label: 'My GitHub token',
|
|
126
|
+
description: 'A personal access token used for runs you start (pushes, PRs, CI, merge).',
|
|
127
|
+
status: pat ? 'Connected' : undefined,
|
|
128
|
+
connected: !!pat,
|
|
129
|
+
onClick: () => go(ui.openUserSecrets),
|
|
130
|
+
})
|
|
131
|
+
}
|
|
78
132
|
if (code.length) out.push({ title: 'Source control', items: code })
|
|
79
133
|
|
|
80
134
|
// --- Communication ---------------------------------------------------------
|
|
@@ -170,34 +224,6 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
170
224
|
})
|
|
171
225
|
}
|
|
172
226
|
|
|
173
|
-
// --- Models & providers ----------------------------------------------------
|
|
174
|
-
out.push({
|
|
175
|
-
title: 'Models & providers',
|
|
176
|
-
items: [
|
|
177
|
-
{
|
|
178
|
-
key: 'vendors',
|
|
179
|
-
icon: 'i-lucide-key-round',
|
|
180
|
-
label: 'Vendors & keys',
|
|
181
|
-
description: 'LLM vendor subscriptions and provider API keys.',
|
|
182
|
-
onClick: () => go(ui.openVendorCredentials),
|
|
183
|
-
},
|
|
184
|
-
{
|
|
185
|
-
key: 'local-runners',
|
|
186
|
-
icon: 'i-lucide-server',
|
|
187
|
-
label: 'My local runners',
|
|
188
|
-
description: 'Your own-machine model runners (Ollama, LM Studio, vLLM…).',
|
|
189
|
-
onClick: () => go(ui.openLocalModels),
|
|
190
|
-
},
|
|
191
|
-
{
|
|
192
|
-
key: 'openrouter',
|
|
193
|
-
icon: 'i-lucide-waypoints',
|
|
194
|
-
label: 'OpenRouter models',
|
|
195
|
-
description: 'Browse and enable models from the OpenRouter gateway.',
|
|
196
|
-
onClick: () => go(ui.openOpenRouter),
|
|
197
|
-
},
|
|
198
|
-
],
|
|
199
|
-
})
|
|
200
|
-
|
|
201
227
|
return out
|
|
202
228
|
})
|
|
203
229
|
</script>
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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.
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
>
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
>
|
|
170
|
-
|
|
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>
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Per-user settings: "My GitHub token" (and future per-user repository/provider secrets).
|
|
3
|
+
// A generic, descriptor-driven connect form: the backend declares each kind's fields
|
|
4
|
+
// (one secret + optional metadata) and whether a connection test is available; this
|
|
5
|
+
// renders them without hard-coding any kind. Stored PER USER (runs you initiate use YOUR
|
|
6
|
+
// access); the secret is write-only server-side and never shown again.
|
|
7
|
+
import { computed, ref, watch } from 'vue'
|
|
8
|
+
import type { ProviderConfigField, UserSecretKind } from '~/types/userSecrets'
|
|
9
|
+
|
|
10
|
+
const ui = useUiStore()
|
|
11
|
+
const store = useUserSecretsStore()
|
|
12
|
+
const toast = useToast()
|
|
13
|
+
|
|
14
|
+
const open = computed({
|
|
15
|
+
get: () => ui.userSecretsOpen,
|
|
16
|
+
set: (v: boolean) => (v ? ui.openUserSecrets() : ui.closeUserSecrets()),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// The kind being edited (only `github_pat` today; descriptors drive the rest generically).
|
|
20
|
+
const kind = ref<UserSecretKind>('github_pat')
|
|
21
|
+
const descriptor = computed(() => store.descriptorFor(kind.value))
|
|
22
|
+
const status = computed(() => store.statusFor(kind.value))
|
|
23
|
+
|
|
24
|
+
// Per-field draft values, keyed by field key. The secret field maps to the wire `secret`;
|
|
25
|
+
// all other fields map into `metadata`.
|
|
26
|
+
const values = ref<Record<string, string>>({})
|
|
27
|
+
const labelDraft = ref('')
|
|
28
|
+
const testResult = ref<{ ok: boolean; message?: string } | null>(null)
|
|
29
|
+
const testing = ref(false)
|
|
30
|
+
const busy = ref(false)
|
|
31
|
+
|
|
32
|
+
function resetDraft() {
|
|
33
|
+
values.value = {}
|
|
34
|
+
labelDraft.value = ''
|
|
35
|
+
testResult.value = null
|
|
36
|
+
// Prefill non-secret metadata from the stored status (secret stays blank — write-only).
|
|
37
|
+
const meta = status.value?.metadata
|
|
38
|
+
if (meta) for (const [k, v] of Object.entries(meta)) values.value[k] = v
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
watch(open, (isOpen) => {
|
|
42
|
+
if (isOpen) void store.load().then(resetDraft)
|
|
43
|
+
})
|
|
44
|
+
watch(kind, resetDraft)
|
|
45
|
+
|
|
46
|
+
const secretField = computed<ProviderConfigField | undefined>(() =>
|
|
47
|
+
descriptor.value?.configFields.find((f) => f.secret),
|
|
48
|
+
)
|
|
49
|
+
const metadataFields = computed<ProviderConfigField[]>(() =>
|
|
50
|
+
(descriptor.value?.configFields ?? []).filter((f) => !f.secret),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
/** Build the wire payload: the secret field → `secret`, the rest → `metadata`. */
|
|
54
|
+
function buildPayload(): { secret: string; metadata?: Record<string, string> } | null {
|
|
55
|
+
const sf = secretField.value
|
|
56
|
+
if (!sf) return null
|
|
57
|
+
const secret = (values.value[sf.key] ?? '').trim()
|
|
58
|
+
if (!secret) return null
|
|
59
|
+
const metadata: Record<string, string> = {}
|
|
60
|
+
for (const f of metadataFields.value) {
|
|
61
|
+
const v = (values.value[f.key] ?? '').trim()
|
|
62
|
+
if (v) metadata[f.key] = v
|
|
63
|
+
}
|
|
64
|
+
return { secret, ...(Object.keys(metadata).length ? { metadata } : {}) }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function notifyError(title: string, e: unknown) {
|
|
68
|
+
toast.add({
|
|
69
|
+
title,
|
|
70
|
+
description: e instanceof Error ? e.message : String(e),
|
|
71
|
+
icon: 'i-lucide-triangle-alert',
|
|
72
|
+
color: 'error',
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function test() {
|
|
77
|
+
const payload = buildPayload()
|
|
78
|
+
if (!payload) return
|
|
79
|
+
testing.value = true
|
|
80
|
+
testResult.value = null
|
|
81
|
+
try {
|
|
82
|
+
testResult.value = await store.test(kind.value, payload)
|
|
83
|
+
} catch (e) {
|
|
84
|
+
testResult.value = { ok: false, message: e instanceof Error ? e.message : String(e) }
|
|
85
|
+
} finally {
|
|
86
|
+
testing.value = false
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function save() {
|
|
91
|
+
const payload = buildPayload()
|
|
92
|
+
if (!payload) return
|
|
93
|
+
busy.value = true
|
|
94
|
+
try {
|
|
95
|
+
await store.store(kind.value, { ...payload, label: labelDraft.value.trim() || undefined })
|
|
96
|
+
values.value[secretField.value!.key] = ''
|
|
97
|
+
testResult.value = null
|
|
98
|
+
toast.add({
|
|
99
|
+
title: `${descriptor.value?.label ?? 'Secret'} saved`,
|
|
100
|
+
icon: 'i-lucide-check',
|
|
101
|
+
color: 'success',
|
|
102
|
+
})
|
|
103
|
+
} catch (e) {
|
|
104
|
+
notifyError('Could not save secret', e)
|
|
105
|
+
} finally {
|
|
106
|
+
busy.value = false
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function remove() {
|
|
111
|
+
busy.value = true
|
|
112
|
+
try {
|
|
113
|
+
await store.remove(kind.value)
|
|
114
|
+
resetDraft()
|
|
115
|
+
toast.add({ title: 'Secret removed', icon: 'i-lucide-check' })
|
|
116
|
+
} catch (e) {
|
|
117
|
+
notifyError('Could not remove secret', e)
|
|
118
|
+
} finally {
|
|
119
|
+
busy.value = false
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
</script>
|
|
123
|
+
|
|
124
|
+
<template>
|
|
125
|
+
<UModal v-model:open="open" title="My GitHub token" :ui="{ content: 'max-w-xl' }">
|
|
126
|
+
<template #body>
|
|
127
|
+
<div class="space-y-4">
|
|
128
|
+
<p class="text-xs text-slate-400">
|
|
129
|
+
Store a personal access token used for the runs <strong>you</strong> start — pushes, pull
|
|
130
|
+
requests, the CI gate and merges are attributed to your GitHub access. Stored
|
|
131
|
+
<span class="text-slate-300">just for you</span>; the token is write-only and never shown
|
|
132
|
+
again.
|
|
133
|
+
</p>
|
|
134
|
+
|
|
135
|
+
<div
|
|
136
|
+
v-if="status"
|
|
137
|
+
class="flex items-center justify-between rounded-md border border-slate-700 bg-slate-900/50 px-3 py-2 text-sm"
|
|
138
|
+
>
|
|
139
|
+
<div>
|
|
140
|
+
<span class="font-medium text-slate-200">{{ status.label }}</span>
|
|
141
|
+
<div class="text-[11px] text-emerald-400">Connected · token stored</div>
|
|
142
|
+
</div>
|
|
143
|
+
<UButton
|
|
144
|
+
icon="i-lucide-trash-2"
|
|
145
|
+
color="error"
|
|
146
|
+
variant="ghost"
|
|
147
|
+
size="xs"
|
|
148
|
+
:disabled="busy"
|
|
149
|
+
@click="remove()"
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div
|
|
154
|
+
v-if="descriptor"
|
|
155
|
+
class="rounded-lg border border-dashed border-slate-700 p-3 space-y-3"
|
|
156
|
+
>
|
|
157
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
158
|
+
{{ status ? 'Replace token' : 'Add a token' }}
|
|
159
|
+
</p>
|
|
160
|
+
|
|
161
|
+
<UFormField label="Label (optional)">
|
|
162
|
+
<UInput v-model="labelDraft" :placeholder="descriptor.label" />
|
|
163
|
+
</UFormField>
|
|
164
|
+
|
|
165
|
+
<UFormField
|
|
166
|
+
v-for="field in descriptor.configFields"
|
|
167
|
+
:key="field.key"
|
|
168
|
+
:label="field.label + (field.required ? '' : ' (optional)')"
|
|
169
|
+
:help="field.help"
|
|
170
|
+
>
|
|
171
|
+
<UInput
|
|
172
|
+
v-model="values[field.key]"
|
|
173
|
+
:type="field.secret ? 'password' : 'text'"
|
|
174
|
+
class="font-mono"
|
|
175
|
+
:placeholder="field.placeholder"
|
|
176
|
+
/>
|
|
177
|
+
</UFormField>
|
|
178
|
+
|
|
179
|
+
<div v-if="descriptor.supportsTest" class="flex items-center gap-2">
|
|
180
|
+
<UButton
|
|
181
|
+
color="neutral"
|
|
182
|
+
variant="soft"
|
|
183
|
+
size="sm"
|
|
184
|
+
icon="i-lucide-plug-zap"
|
|
185
|
+
:loading="testing"
|
|
186
|
+
:disabled="!buildPayload()"
|
|
187
|
+
@click="test()"
|
|
188
|
+
>
|
|
189
|
+
Test connection
|
|
190
|
+
</UButton>
|
|
191
|
+
<span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
|
|
192
|
+
{{ testResult.message ?? 'Token valid' }}
|
|
193
|
+
</span>
|
|
194
|
+
<span v-else-if="testResult" class="text-xs text-rose-400">
|
|
195
|
+
{{ testResult.message ?? 'Token rejected' }}
|
|
196
|
+
</span>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<div class="flex justify-end">
|
|
200
|
+
<UButton
|
|
201
|
+
color="primary"
|
|
202
|
+
size="sm"
|
|
203
|
+
:loading="busy"
|
|
204
|
+
:disabled="!buildPayload()"
|
|
205
|
+
@click="save()"
|
|
206
|
+
>
|
|
207
|
+
{{ status ? 'Save' : 'Add token' }}
|
|
208
|
+
</UButton>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
</template>
|
|
213
|
+
</UModal>
|
|
214
|
+
</template>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConnectionTestResult,
|
|
3
|
+
StoreUserSecretInput,
|
|
4
|
+
TestUserSecretInput,
|
|
5
|
+
UserSecretDescriptor,
|
|
6
|
+
UserSecretKind,
|
|
7
|
+
UserSecretStatus,
|
|
8
|
+
} from '~/types/userSecrets'
|
|
9
|
+
import type { ApiContext } from './context'
|
|
10
|
+
|
|
11
|
+
// Per-USER generic secrets (a GitHub PAT today). User-scoped (no workspace); the secret
|
|
12
|
+
// is write-only server-side and never returned — only status metadata + a `hasSecret`
|
|
13
|
+
// flag. `descriptors` drive the generic connect form; `test` probes before save.
|
|
14
|
+
export function userSecretsApi({ http }: ApiContext) {
|
|
15
|
+
return {
|
|
16
|
+
listUserSecrets: () =>
|
|
17
|
+
http<{ secrets: UserSecretStatus[]; descriptors: UserSecretDescriptor[] }>('/user-secrets'),
|
|
18
|
+
|
|
19
|
+
storeUserSecret: (kind: UserSecretKind, body: StoreUserSecretInput) =>
|
|
20
|
+
http<UserSecretStatus>(`/user-secrets/${encodeURIComponent(kind)}`, { method: 'POST', body }),
|
|
21
|
+
|
|
22
|
+
deleteUserSecret: (kind: UserSecretKind) =>
|
|
23
|
+
http(`/user-secrets/${encodeURIComponent(kind)}`, { method: 'DELETE' }),
|
|
24
|
+
|
|
25
|
+
testUserSecret: (kind: UserSecretKind, body: TestUserSecretInput) =>
|
|
26
|
+
http<ConnectionTestResult>(`/user-secrets/${encodeURIComponent(kind)}/test`, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
body,
|
|
29
|
+
}),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -17,6 +17,7 @@ import { reviewsApi } from './api/reviews'
|
|
|
17
17
|
import { slackApi } from './api/slack'
|
|
18
18
|
import { specApi } from './api/spec'
|
|
19
19
|
import { tasksApi } from './api/tasks'
|
|
20
|
+
import { userSecretsApi } from './api/userSecrets'
|
|
20
21
|
import { workspacesApi } from './api/workspaces'
|
|
21
22
|
|
|
22
23
|
/**
|
|
@@ -87,5 +88,6 @@ export function useApi() {
|
|
|
87
88
|
...githubApi(ctx),
|
|
88
89
|
...slackApi(ctx),
|
|
89
90
|
...bootstrapApi(ctx),
|
|
91
|
+
...userSecretsApi(ctx),
|
|
90
92
|
}
|
|
91
93
|
}
|
package/app/pages/index.vue
CHANGED
|
@@ -31,6 +31,7 @@ import WorkspaceSettingsPanel from '~/components/settings/WorkspaceSettingsPanel
|
|
|
31
31
|
import ObservabilityConnectionPanel from '~/components/settings/ObservabilityConnectionPanel.vue'
|
|
32
32
|
import ModelConfigurationPanel from '~/components/settings/ModelConfigurationPanel.vue'
|
|
33
33
|
import LocalModelEndpointsPanel from '~/components/settings/LocalModelEndpointsPanel.vue'
|
|
34
|
+
import UserSecretsSection from '~/components/settings/UserSecretsSection.vue'
|
|
34
35
|
import OpenRouterCatalogPanel from '~/components/settings/OpenRouterCatalogPanel.vue'
|
|
35
36
|
import VendorCredentialsModal from '~/components/providers/VendorCredentialsModal.vue'
|
|
36
37
|
import PersonalCredentialModal from '~/components/providers/PersonalCredentialModal.vue'
|
|
@@ -183,6 +184,7 @@ watch(
|
|
|
183
184
|
<ObservabilityConnectionPanel />
|
|
184
185
|
<ModelConfigurationPanel />
|
|
185
186
|
<LocalModelEndpointsPanel />
|
|
187
|
+
<UserSecretsSection />
|
|
186
188
|
<OpenRouterCatalogPanel />
|
|
187
189
|
<VendorCredentialsModal />
|
|
188
190
|
<PersonalCredentialModal />
|
package/app/stores/ui.ts
CHANGED
|
@@ -102,6 +102,7 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
102
102
|
const vendorCredentialsOpen = ref(false)
|
|
103
103
|
// Per-user settings panel: the signed-in user's own-machine local model runners.
|
|
104
104
|
const localModelsOpen = ref(false)
|
|
105
|
+
const userSecretsOpen = ref(false)
|
|
105
106
|
// Per-workspace settings panel: the OpenRouter dynamic catalog (browse/enable gateway models).
|
|
106
107
|
const openRouterOpen = ref(false)
|
|
107
108
|
|
|
@@ -358,6 +359,12 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
358
359
|
function closeLocalModels() {
|
|
359
360
|
localModelsOpen.value = false
|
|
360
361
|
}
|
|
362
|
+
function openUserSecrets() {
|
|
363
|
+
userSecretsOpen.value = true
|
|
364
|
+
}
|
|
365
|
+
function closeUserSecrets() {
|
|
366
|
+
userSecretsOpen.value = false
|
|
367
|
+
}
|
|
361
368
|
function openOpenRouter() {
|
|
362
369
|
openRouterOpen.value = true
|
|
363
370
|
}
|
|
@@ -451,6 +458,7 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
451
458
|
modelConfigOpen,
|
|
452
459
|
vendorCredentialsOpen,
|
|
453
460
|
localModelsOpen,
|
|
461
|
+
userSecretsOpen,
|
|
454
462
|
openRouterOpen,
|
|
455
463
|
aiProviderSetupOpen,
|
|
456
464
|
aiPresetMismatchOpen,
|
|
@@ -512,6 +520,8 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
512
520
|
closeVendorCredentials,
|
|
513
521
|
openLocalModels,
|
|
514
522
|
closeLocalModels,
|
|
523
|
+
openUserSecrets,
|
|
524
|
+
closeUserSecrets,
|
|
515
525
|
openOpenRouter,
|
|
516
526
|
closeOpenRouter,
|
|
517
527
|
openAiProviderSetup,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type {
|
|
4
|
+
ConnectionTestResult,
|
|
5
|
+
StoreUserSecretInput,
|
|
6
|
+
TestUserSecretInput,
|
|
7
|
+
UserSecretDescriptor,
|
|
8
|
+
UserSecretKind,
|
|
9
|
+
UserSecretStatus,
|
|
10
|
+
} from '~/types/userSecrets'
|
|
11
|
+
|
|
12
|
+
// The signed-in user's generic secrets (a GitHub PAT today). Stored PER USER (a run you
|
|
13
|
+
// initiate uses YOUR GitHub access), write-only server-side — this store carries only the
|
|
14
|
+
// status metadata + a `hasSecret` flag, plus the kind descriptors that drive the generic
|
|
15
|
+
// connect form. Loaded INDEPENDENTLY of the workspace snapshot, like local runners.
|
|
16
|
+
export const useUserSecretsStore = defineStore('userSecrets', () => {
|
|
17
|
+
const api = useApi()
|
|
18
|
+
const secrets = ref<UserSecretStatus[]>([])
|
|
19
|
+
const descriptors = ref<UserSecretDescriptor[]>([])
|
|
20
|
+
const loading = ref(false)
|
|
21
|
+
|
|
22
|
+
async function load() {
|
|
23
|
+
loading.value = true
|
|
24
|
+
try {
|
|
25
|
+
const { secrets: list, descriptors: descs } = await api.listUserSecrets()
|
|
26
|
+
secrets.value = list
|
|
27
|
+
descriptors.value = descs
|
|
28
|
+
} catch {
|
|
29
|
+
// Auth disabled / not signed in / feature off → nothing to surface.
|
|
30
|
+
secrets.value = []
|
|
31
|
+
descriptors.value = []
|
|
32
|
+
} finally {
|
|
33
|
+
loading.value = false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function statusFor(kind: UserSecretKind): UserSecretStatus | undefined {
|
|
38
|
+
return secrets.value.find((s) => s.kind === kind)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function descriptorFor(kind: UserSecretKind): UserSecretDescriptor | undefined {
|
|
42
|
+
return descriptors.value.find((d) => d.kind === kind)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function store(kind: UserSecretKind, input: StoreUserSecretInput) {
|
|
46
|
+
const status = await api.storeUserSecret(kind, input)
|
|
47
|
+
secrets.value = [...secrets.value.filter((s) => s.kind !== kind), status]
|
|
48
|
+
return status
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function remove(kind: UserSecretKind) {
|
|
52
|
+
await api.deleteUserSecret(kind)
|
|
53
|
+
secrets.value = secrets.value.filter((s) => s.kind !== kind)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function test(
|
|
57
|
+
kind: UserSecretKind,
|
|
58
|
+
input: TestUserSecretInput,
|
|
59
|
+
): Promise<ConnectionTestResult> {
|
|
60
|
+
return await api.testUserSecret(kind, input)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { secrets, descriptors, loading, load, statusFor, descriptorFor, store, remove, test }
|
|
64
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Frontend mirrors of the per-user secret + provider-config wire contracts
|
|
2
|
+
// (`@cat-factory/contracts` user-secret.ts + provider-config.ts).
|
|
3
|
+
|
|
4
|
+
export type UserSecretKind = 'github_pat'
|
|
5
|
+
|
|
6
|
+
/** One config value a kind needs, rendered as a single form field. */
|
|
7
|
+
export interface ProviderConfigField {
|
|
8
|
+
key: string
|
|
9
|
+
label: string
|
|
10
|
+
help?: string
|
|
11
|
+
placeholder?: string
|
|
12
|
+
secret?: boolean
|
|
13
|
+
required?: boolean
|
|
14
|
+
type?: 'text' | 'password' | 'select'
|
|
15
|
+
options?: { value: string; label: string }[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Read-only status of one stored per-user secret — never the secret value. */
|
|
19
|
+
export interface UserSecretStatus {
|
|
20
|
+
kind: UserSecretKind
|
|
21
|
+
label: string
|
|
22
|
+
hasSecret: boolean
|
|
23
|
+
metadata?: Record<string, string>
|
|
24
|
+
connectedAt: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A kind's self-description for the generic connect form. */
|
|
28
|
+
export interface UserSecretDescriptor {
|
|
29
|
+
kind: UserSecretKind
|
|
30
|
+
label: string
|
|
31
|
+
configFields: ProviderConfigField[]
|
|
32
|
+
supportsTest: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface StoreUserSecretInput {
|
|
36
|
+
label?: string
|
|
37
|
+
secret: string
|
|
38
|
+
metadata?: Record<string, string>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TestUserSecretInput {
|
|
42
|
+
secret: string
|
|
43
|
+
metadata?: Record<string, string>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ConnectionTestResult {
|
|
47
|
+
ok: boolean
|
|
48
|
+
message?: string
|
|
49
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "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",
|