@frockbot/plugin-settings 0.0.0 → 0.1.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.
Files changed (36) hide show
  1. package/frockbot.json +55 -0
  2. package/package.json +43 -6
  3. package/src/backend.test.ts +397 -0
  4. package/src/backend.ts +262 -0
  5. package/src/client/BotPanel.vue +57 -0
  6. package/src/client/BotSettingsSurface.vue +1076 -0
  7. package/src/client/BotSettingsTrigger.vue +26 -0
  8. package/src/client/ConnectionsSurface.vue +813 -0
  9. package/src/client/ModelsSurface.vue +419 -0
  10. package/src/client/PackageAccounts.vue +330 -0
  11. package/src/client/PackageCatalogSurface.vue +584 -0
  12. package/src/client/PackageSettingsForm.vue +150 -0
  13. package/src/client/PackageSettingsSection.vue +66 -0
  14. package/src/client/PluginsSurface.vue +412 -0
  15. package/src/client/PluginsTrigger.vue +62 -0
  16. package/src/client/UserProfileTrigger.vue +223 -0
  17. package/src/client/UserSettingsSurface.vue +242 -0
  18. package/src/client/assignment-operations.ts +22 -0
  19. package/src/client/bot-settings.test.ts +218 -0
  20. package/src/client/bot-settings.ts +150 -0
  21. package/src/client/index.test.ts +113 -0
  22. package/src/client/index.ts +78 -0
  23. package/src/client/package-settings.test.ts +63 -0
  24. package/src/client/package-settings.ts +77 -0
  25. package/src/client/package-surfaces.test.ts +147 -0
  26. package/src/client/package-surfaces.ts +93 -0
  27. package/src/client/user-display-name.test.ts +44 -0
  28. package/src/client/user-display-name.ts +16 -0
  29. package/src/env.d.ts +6 -0
  30. package/src/index.ts +1 -0
  31. package/src/manifest.ts +3 -0
  32. package/src/user.test.ts +1277 -0
  33. package/src/user.ts +1221 -0
  34. package/tsconfig.json +15 -0
  35. package/vite.config.ts +30 -0
  36. package/README.md +0 -3
@@ -0,0 +1,419 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Models: where a User configures model provider Packages and picks a model.
4
+ *
5
+ * Enablement is not here — Plugins turns a provider on and off, and a disabled
6
+ * provider disappears from this surface with its accounts and its catalogs
7
+ * intact.
8
+ */
9
+ import { clientSurfaceRegistryKey } from "@frockbot/client-core";
10
+ import { UiAnchor, UiButton, UiField } from "@frockbot/client-ui";
11
+ import {
12
+ frockBotWebDataKey,
13
+ type PluginCatalogItem,
14
+ } from "@frockbot/plugin-shell/shared";
15
+ import { settingsLinkV1 } from "@frockbot/plugin-shell/settings-links";
16
+ import { computed, inject, onMounted, ref } from "vue";
17
+ import {
18
+ decodeModelSelection,
19
+ eligibleModelConnections,
20
+ encodeModelSelection,
21
+ modelSelectOptions,
22
+ } from "./bot-settings.js";
23
+ import { configurablePackages } from "./package-surfaces.js";
24
+ import PackageAccounts from "./PackageAccounts.vue";
25
+ import PackageSettingsForm from "./PackageSettingsForm.vue";
26
+
27
+ const providedSurfaces = inject(clientSurfaceRegistryKey);
28
+ const providedWeb = inject(frockBotWebDataKey);
29
+ if (!providedSurfaces || !providedWeb) {
30
+ throw new Error("settings client services were not provided");
31
+ }
32
+ const surfaces = providedSurfaces;
33
+ const web = providedWeb;
34
+ // Model configuration is User-scoped, so these links name no Bot.
35
+ const defaultModelLink = settingsLinkV1({ anchor: "user-default-model" });
36
+ const providersLink = settingsLinkV1({ anchor: "user-model-providers" });
37
+
38
+ const defaultModel = ref("");
39
+ const savingModel = ref(false);
40
+
41
+ /**
42
+ * The endpoint root of an Ollama Cloud Connection, declared by that Connection
43
+ * Type as `api-base-url`. It is the only Connection Type setting the shared
44
+ * API-key form has a field for.
45
+ */
46
+ const OLLAMA_PACKAGE_ID = "provider-ollama-cloud";
47
+ const apiKeyPackageId = ref<string>();
48
+ const apiKeyConnectionTypeId = ref<string>();
49
+ const apiKeyLabel = ref("");
50
+ const apiKey = ref("");
51
+ const apiKeyBaseUrl = ref("");
52
+
53
+ const providers = computed(() =>
54
+ configurablePackages({
55
+ catalog: web.value.pluginCatalog,
56
+ packages: web.value.userSettings?.packages ?? [],
57
+ home: "models",
58
+ }),
59
+ );
60
+ const readyConnections = computed(() =>
61
+ eligibleModelConnections({
62
+ connections: web.value.userSettings?.connections ?? [],
63
+ packages: web.value.userSettings?.packages ?? [],
64
+ catalog: web.value.pluginCatalog,
65
+ }),
66
+ );
67
+ const modelOptions = computed(() => modelSelectOptions(readyConnections.value));
68
+
69
+ onMounted(async () => {
70
+ await web.value.loadPluginCatalog();
71
+ defaultModel.value = encodeModelSelection(
72
+ web.value.userSettings?.newBotModelTemplate,
73
+ );
74
+ });
75
+
76
+ async function saveDefaultModel(): Promise<void> {
77
+ savingModel.value = true;
78
+ try {
79
+ await web.value.saveDefaultModel(decodeModelSelection(defaultModel.value));
80
+ } catch (error) {
81
+ web.value.settingsError =
82
+ error instanceof Error ? error.message : "Could not save the model";
83
+ } finally {
84
+ savingModel.value = false;
85
+ }
86
+ }
87
+
88
+ function connectionCount(packageId: string): number {
89
+ return (web.value.userSettings?.connections ?? []).filter(
90
+ (connection) =>
91
+ connection.packageId === packageId && connection.state !== "revoked",
92
+ ).length;
93
+ }
94
+
95
+ function beginConnect(item: PluginCatalogItem): void {
96
+ const connectionType = item.connectionTypes[0];
97
+ if (!connectionType) return;
98
+ apiKeyPackageId.value = item.packageId;
99
+ apiKeyConnectionTypeId.value = connectionType.id;
100
+ apiKeyLabel.value = item.displayName;
101
+ apiKey.value = "";
102
+ apiKeyBaseUrl.value = "";
103
+ }
104
+
105
+ function cancelConnect(): void {
106
+ apiKey.value = "";
107
+ apiKeyBaseUrl.value = "";
108
+ apiKeyPackageId.value = undefined;
109
+ apiKeyConnectionTypeId.value = undefined;
110
+ }
111
+
112
+ async function connectApiKey(): Promise<void> {
113
+ if (!apiKeyPackageId.value || !apiKeyConnectionTypeId.value) return;
114
+ try {
115
+ // An empty endpoint field means the Package default, so the command
116
+ // carries no settings at all rather than an empty bag.
117
+ const apiBaseUrl = apiKeyBaseUrl.value.trim();
118
+ await web.value.createApiKeyConnection({
119
+ packageId: apiKeyPackageId.value,
120
+ connectionTypeId: apiKeyConnectionTypeId.value,
121
+ label: apiKeyLabel.value,
122
+ apiKey: apiKey.value,
123
+ ...(apiBaseUrl ? { settings: { "api-base-url": apiBaseUrl } } : {}),
124
+ });
125
+ cancelConnect();
126
+ } catch (error) {
127
+ apiKey.value = "";
128
+ web.value.settingsError =
129
+ error instanceof Error ? error.message : "Could not create Connection";
130
+ }
131
+ }
132
+ </script>
133
+
134
+ <template>
135
+ <div class="models-surface">
136
+ <UiAnchor
137
+ anchor="user-default-model"
138
+ label="Default model"
139
+ :href="defaultModelLink"
140
+ class="settings-row"
141
+ >
142
+ <UiField
143
+ v-if="modelOptions.length > 0"
144
+ label="Default model"
145
+ hint="used by every Bot"
146
+ >
147
+ <select v-model="defaultModel">
148
+ <option value="">No default model</option>
149
+ <option
150
+ v-for="model in modelOptions"
151
+ :key="model.value"
152
+ :value="model.value"
153
+ >
154
+ {{ model.label }}
155
+ </option>
156
+ </select>
157
+ </UiField>
158
+ <p v-if="modelOptions.length > 0" class="field-hint">
159
+ Used by every Bot unless a Bot overrides it in its own settings.
160
+ </p>
161
+ <div v-else class="model-empty">
162
+ <p>
163
+ Connect a model provider below, then choose the model your Bots use.
164
+ </p>
165
+ </div>
166
+ <div v-if="modelOptions.length > 0" class="settings-actions">
167
+ <UiButton
168
+ type="button"
169
+ variant="primary"
170
+ :disabled="savingModel"
171
+ @click="saveDefaultModel"
172
+ >
173
+ {{ savingModel ? "Saving…" : "Save model" }}
174
+ </UiButton>
175
+ </div>
176
+ </UiAnchor>
177
+
178
+ <UiAnchor
179
+ anchor="user-model-providers"
180
+ label="Model providers"
181
+ :href="providersLink"
182
+ class="settings-row"
183
+ >
184
+ <p class="field-hint">
185
+ Accounts and endpoints for the model providers you have enabled.
186
+ </p>
187
+ <div class="provider-grid">
188
+ <article
189
+ v-for="item in providers"
190
+ :key="item.packageId"
191
+ class="provider-card"
192
+ >
193
+ <div class="provider-summary">
194
+ <span class="provider-logo" aria-hidden="true">
195
+ {{ item.displayName.slice(0, 1) }}
196
+ </span>
197
+ <span class="provider-copy">
198
+ <strong>{{ item.displayName }}</strong>
199
+ <small>
200
+ {{
201
+ connectionCount(item.packageId) === 0
202
+ ? "No account connected"
203
+ : `${connectionCount(item.packageId)} account(s)`
204
+ }}
205
+ </small>
206
+ </span>
207
+ <UiButton
208
+ v-if="
209
+ connectionCount(item.packageId) === 0 ||
210
+ item.connectionTypes[0]?.allowMultiple
211
+ "
212
+ @click="beginConnect(item)"
213
+ >
214
+ {{
215
+ connectionCount(item.packageId) === 0
216
+ ? "Connect"
217
+ : "Add another account"
218
+ }}
219
+ </UiButton>
220
+ </div>
221
+
222
+ <PackageAccounts :item="item" />
223
+
224
+ <form
225
+ v-if="apiKeyPackageId === item.packageId"
226
+ class="api-key-form"
227
+ @submit.prevent="connectApiKey"
228
+ >
229
+ <label>
230
+ <span>Connection label</span>
231
+ <input v-model="apiKeyLabel" maxlength="120" required />
232
+ </label>
233
+ <label>
234
+ <span>API key</span>
235
+ <input
236
+ v-model="apiKey"
237
+ type="password"
238
+ autocomplete="new-password"
239
+ required
240
+ />
241
+ </label>
242
+ <label v-if="item.packageId === OLLAMA_PACKAGE_ID">
243
+ <span>API base URL</span>
244
+ <input
245
+ v-model="apiKeyBaseUrl"
246
+ type="url"
247
+ inputmode="url"
248
+ autocomplete="off"
249
+ maxlength="2048"
250
+ placeholder="https://ollama.com"
251
+ />
252
+ </label>
253
+ <p v-if="item.packageId === OLLAMA_PACKAGE_ID" class="field-hint">
254
+ Leave empty for Ollama Cloud. Point this at a local Ollama server
255
+ (http://127.0.0.1:11434) or any Ollama-compatible endpoint.
256
+ </p>
257
+ <div class="api-key-actions">
258
+ <UiButton @click="cancelConnect">Cancel</UiButton>
259
+ <UiButton type="submit" variant="primary">
260
+ Connect account
261
+ </UiButton>
262
+ </div>
263
+ </form>
264
+
265
+ <PackageSettingsForm :item="item" />
266
+ </article>
267
+ </div>
268
+ <div v-if="providers.length === 0" class="model-empty">
269
+ <p>No model provider is enabled.</p>
270
+ <UiButton type="button" @click="surfaces.open('plugins')">
271
+ Open Plugins
272
+ </UiButton>
273
+ </div>
274
+ </UiAnchor>
275
+
276
+ <p v-if="web.settingsError" class="settings-error" role="alert">
277
+ {{ web.settingsError }}
278
+ </p>
279
+ </div>
280
+ </template>
281
+
282
+ <style scoped>
283
+ .models-surface {
284
+ display: flex;
285
+ flex-direction: column;
286
+ gap: 16px;
287
+ padding: 16px;
288
+ }
289
+
290
+ .settings-row {
291
+ display: flex;
292
+ flex-direction: column;
293
+ gap: 16px;
294
+ padding-right: var(--frock-control-sm);
295
+ }
296
+
297
+ .field-hint {
298
+ margin: 0;
299
+ color: var(--frock-text-muted);
300
+ font-size: var(--frock-text-sm);
301
+ }
302
+
303
+ .model-empty {
304
+ display: flex;
305
+ flex-direction: column;
306
+ align-items: flex-start;
307
+ gap: 10px;
308
+ padding: 12px;
309
+ border: 1px solid var(--frock-border);
310
+ border-radius: var(--frock-radius-card);
311
+ background: var(--frock-surface-subtle);
312
+ }
313
+
314
+ .model-empty p {
315
+ margin: 0;
316
+ color: var(--frock-text-muted);
317
+ font-size: var(--frock-text-sm);
318
+ }
319
+
320
+ .provider-grid {
321
+ display: grid;
322
+ gap: 12px;
323
+ }
324
+
325
+ .provider-card {
326
+ min-width: 0;
327
+ padding: 8px;
328
+ border: 1px solid var(--frock-border);
329
+ border-radius: var(--frock-radius-card);
330
+ background: var(--frock-surface-raised);
331
+ box-shadow: var(--frock-shadow-card);
332
+ }
333
+
334
+ .provider-summary {
335
+ display: grid;
336
+ width: 100%;
337
+ min-width: 0;
338
+ grid-template-columns: 44px minmax(0, 1fr) auto;
339
+ align-items: center;
340
+ gap: 12px;
341
+ padding: 8px;
342
+ }
343
+
344
+ .provider-logo {
345
+ display: grid;
346
+ width: 44px;
347
+ height: 44px;
348
+ place-items: center;
349
+ border-radius: 12px;
350
+ color: var(--frock-action-secondary-text);
351
+ background: var(--frock-surface-accent);
352
+ font-weight: 800;
353
+ }
354
+
355
+ .provider-copy {
356
+ min-width: 0;
357
+ }
358
+
359
+ .provider-copy strong,
360
+ .provider-copy small {
361
+ display: block;
362
+ overflow: hidden;
363
+ white-space: nowrap;
364
+ text-overflow: ellipsis;
365
+ }
366
+
367
+ .provider-copy strong {
368
+ font-size: var(--frock-text-md);
369
+ font-weight: 600;
370
+ }
371
+
372
+ .provider-copy small {
373
+ margin-top: 4px;
374
+ color: var(--frock-text-muted);
375
+ font-size: var(--frock-text-sm);
376
+ }
377
+
378
+ .api-key-form {
379
+ display: grid;
380
+ gap: 12px;
381
+ margin: 0 8px;
382
+ padding: 12px 0 8px;
383
+ border-top: 1px solid var(--frock-border);
384
+ animation: frock-rise-in var(--frock-motion-enter) both;
385
+ }
386
+
387
+ .api-key-form label {
388
+ display: grid;
389
+ gap: 6px;
390
+ }
391
+
392
+ .api-key-form span {
393
+ color: var(--frock-text-muted);
394
+ font-size: var(--frock-text-sm);
395
+ }
396
+
397
+ .api-key-form input {
398
+ min-width: 0;
399
+ padding: 8px 11px;
400
+ border: 1px solid var(--frock-border);
401
+ border-radius: 9px;
402
+ background: var(--frock-surface-raised);
403
+ color: var(--frock-text);
404
+ font-size: var(--frock-text-base);
405
+ }
406
+
407
+ .api-key-actions,
408
+ .settings-actions {
409
+ display: flex;
410
+ justify-content: flex-end;
411
+ gap: 8px;
412
+ }
413
+
414
+ .settings-error {
415
+ margin: 0;
416
+ color: var(--frock-danger-text);
417
+ font-size: var(--frock-text-sm);
418
+ }
419
+ </style>