@skaile/forge-settings 1.2.1 → 1.3.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/CHANGELOG.md +10 -0
- package/README.md +29 -2
- package/dist/module.d.mts +4 -2
- package/dist/module.json +1 -1
- package/dist/module.mjs +5 -0
- package/dist/runtime/components/ForgeModelRolesPanel.d.vue.ts +34 -0
- package/dist/runtime/components/ForgeModelRolesPanel.vue +250 -0
- package/dist/runtime/components/ForgeModelRolesPanel.vue.d.ts +34 -0
- package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +15 -5
- package/dist/runtime/components/ForgeProvidersPanel.vue +24 -2
- package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +15 -5
- package/dist/runtime/server/index.d.ts +1 -0
- package/dist/runtime/server/index.js +1 -0
- package/dist/runtime/shared/model-roles.d.ts +99 -0
- package/dist/runtime/shared/model-roles.js +144 -0
- package/dist/runtime/vue/index.d.ts +3 -0
- package/dist/runtime/vue/index.js +3 -0
- package/dist/runtime/vue/model-roles-panel.d.ts +70 -0
- package/dist/runtime/vue/model-roles-panel.js +54 -0
- package/dist/runtime/vue/providers-client.d.ts +67 -0
- package/dist/runtime/vue/providers-client.js +28 -0
- package/dist/runtime/vue/providers-panel-model.d.ts +17 -4
- package/dist/runtime/vue/useProvidersPanel.d.ts +10 -2
- package/dist/runtime/vue/useProvidersPanel.js +67 -43
- package/package.json +3 -3
- package/src/runtime/components/ForgeModelRolesPanel.vue +312 -0
- package/src/runtime/components/ForgeProvidersPanel.vue +46 -14
- package/src/runtime/server/index.ts +1 -0
- package/src/runtime/shared/model-roles.ts +283 -0
- package/src/runtime/vue/index.ts +3 -0
- package/src/runtime/vue/model-roles-panel.ts +140 -0
- package/src/runtime/vue/providers-client.ts +100 -0
- package/src/runtime/vue/providers-panel-model.ts +17 -4
- package/src/runtime/vue/useProvidersPanel.ts +89 -45
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { $fetch } from "ofetch";
|
|
2
1
|
import {
|
|
3
2
|
computed,
|
|
4
3
|
onMounted,
|
|
@@ -13,11 +12,13 @@ import {
|
|
|
13
12
|
AI_PROVIDER_SURFACES,
|
|
14
13
|
validateCredentialRef
|
|
15
14
|
} from "../shared/provider-registry.js";
|
|
15
|
+
import {
|
|
16
|
+
urlProvidersClient
|
|
17
|
+
} from "./providers-client.js";
|
|
16
18
|
import {
|
|
17
19
|
CREDENTIAL_ENTER_KEY,
|
|
18
20
|
CREDENTIAL_OTHER_REF,
|
|
19
21
|
CUSTOM_PRESET,
|
|
20
|
-
DEFAULT_PRESETS_URL,
|
|
21
22
|
EMPTY_FORM,
|
|
22
23
|
ENV_SOURCE_LABEL,
|
|
23
24
|
envNameOfRef,
|
|
@@ -36,13 +37,30 @@ export function useProvidersPanel(props, emit) {
|
|
|
36
37
|
const loading = ref(false);
|
|
37
38
|
const loadError = ref(null);
|
|
38
39
|
const envError = ref(null);
|
|
40
|
+
function client() {
|
|
41
|
+
if (props.client) return props.client;
|
|
42
|
+
if (!props.providersUrl || !props.providersWriteUrl) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
"ForgeProvidersPanel needs a `client`, or `providersUrl` and `providersWriteUrl`."
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return urlProvidersClient({
|
|
48
|
+
providersUrl: props.providersUrl,
|
|
49
|
+
providersWriteUrl: props.providersWriteUrl,
|
|
50
|
+
presetsUrl: props.presetsUrl,
|
|
51
|
+
environmentUrl: props.environmentUrl
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const environmentOn = computed(
|
|
55
|
+
() => props.environmentEnabled ?? (props.client ? true : Boolean(props.environmentUrl))
|
|
56
|
+
);
|
|
39
57
|
async function loadEnvironment() {
|
|
40
|
-
if (!
|
|
58
|
+
if (!environmentOn.value) {
|
|
41
59
|
envDocument.value = null;
|
|
42
60
|
return;
|
|
43
61
|
}
|
|
44
62
|
try {
|
|
45
|
-
envDocument.value = await
|
|
63
|
+
envDocument.value = await client().environment();
|
|
46
64
|
envError.value = null;
|
|
47
65
|
} catch (err) {
|
|
48
66
|
envError.value = requestErrorMessage(err, "Could not load the environment variables.");
|
|
@@ -52,11 +70,8 @@ export function useProvidersPanel(props, emit) {
|
|
|
52
70
|
loading.value = true;
|
|
53
71
|
loadError.value = null;
|
|
54
72
|
try {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
$fetch(`${props.providersWriteUrl}/detected`),
|
|
58
|
-
loadEnvironment()
|
|
59
|
-
]);
|
|
73
|
+
const api = client();
|
|
74
|
+
const [list, found] = await Promise.all([api.list(), api.detected(), loadEnvironment()]);
|
|
60
75
|
providers.value = Array.isArray(list) ? list : [];
|
|
61
76
|
detected.value = Array.isArray(found) ? found : [];
|
|
62
77
|
} catch (err) {
|
|
@@ -67,15 +82,14 @@ export function useProvidersPanel(props, emit) {
|
|
|
67
82
|
}
|
|
68
83
|
async function loadPresets() {
|
|
69
84
|
try {
|
|
70
|
-
const list = await
|
|
71
|
-
props.presetsUrl ?? DEFAULT_PRESETS_URL
|
|
72
|
-
);
|
|
85
|
+
const list = await client().presets();
|
|
73
86
|
presets.value = Array.isArray(list) ? list : [];
|
|
74
87
|
} catch (err) {
|
|
75
88
|
loadError.value = requestErrorMessage(err, "Could not load the provider presets.");
|
|
76
89
|
}
|
|
77
90
|
}
|
|
78
91
|
async function afterWrite() {
|
|
92
|
+
for (const key of Object.keys(testResults)) delete testResults[key];
|
|
79
93
|
await refresh();
|
|
80
94
|
emit("changed");
|
|
81
95
|
}
|
|
@@ -84,7 +98,7 @@ export function useProvidersPanel(props, emit) {
|
|
|
84
98
|
void refresh();
|
|
85
99
|
});
|
|
86
100
|
watch(
|
|
87
|
-
() => [props.providersUrl, props.providersWriteUrl, props.environmentUrl],
|
|
101
|
+
() => [props.providersUrl, props.providersWriteUrl, props.environmentUrl, props.client],
|
|
88
102
|
() => void refresh()
|
|
89
103
|
);
|
|
90
104
|
const envVars = computed(() => {
|
|
@@ -93,9 +107,7 @@ export function useProvidersPanel(props, emit) {
|
|
|
93
107
|
return byName;
|
|
94
108
|
});
|
|
95
109
|
const envEditable = computed(() => envDocument.value?.editable === true);
|
|
96
|
-
const canEnterKey = computed(
|
|
97
|
-
() => Boolean(props.environmentUrl) && envDocument.value?.editable !== false
|
|
98
|
-
);
|
|
110
|
+
const canEnterKey = computed(() => environmentOn.value && envDocument.value?.editable !== false);
|
|
99
111
|
function stateOf(entry) {
|
|
100
112
|
if (props.layer !== "workspace" || !entry.layers.includes("instance")) return "own";
|
|
101
113
|
return entry.layers.includes("workspace") ? "patched" : "inherited";
|
|
@@ -206,7 +218,7 @@ export function useProvidersPanel(props, emit) {
|
|
|
206
218
|
const baseUrlPlaceholder = computed(
|
|
207
219
|
() => selectedPreset.value?.base_url ?? "https://api.example.com/v1"
|
|
208
220
|
);
|
|
209
|
-
const canSetValue = computed(() =>
|
|
221
|
+
const canSetValue = computed(() => environmentOn.value);
|
|
210
222
|
const showCredentialFields = computed(() => editing.value === null);
|
|
211
223
|
const idError = computed(() => {
|
|
212
224
|
const id = form.id.trim();
|
|
@@ -296,18 +308,15 @@ export function useProvidersPanel(props, emit) {
|
|
|
296
308
|
saving.value = true;
|
|
297
309
|
dialogError.value = null;
|
|
298
310
|
try {
|
|
299
|
-
if (showCredentialFields.value && form.secret &&
|
|
311
|
+
if (showCredentialFields.value && form.secret && environmentOn.value) {
|
|
300
312
|
const update = {
|
|
301
313
|
set: { [form.envName.trim()]: form.secret },
|
|
302
314
|
unset: []
|
|
303
315
|
};
|
|
304
|
-
await
|
|
316
|
+
await client().updateEnvironment(update);
|
|
305
317
|
form.secret = "";
|
|
306
318
|
}
|
|
307
|
-
await
|
|
308
|
-
method: "PUT",
|
|
309
|
-
body: buildBody()
|
|
310
|
-
});
|
|
319
|
+
await client().upsert(id, buildBody());
|
|
311
320
|
dialogVisible.value = false;
|
|
312
321
|
editing.value = null;
|
|
313
322
|
await afterWrite();
|
|
@@ -337,9 +346,7 @@ export function useProvidersPanel(props, emit) {
|
|
|
337
346
|
deleting.value = true;
|
|
338
347
|
deleteError.value = null;
|
|
339
348
|
try {
|
|
340
|
-
await
|
|
341
|
-
method: "DELETE"
|
|
342
|
-
});
|
|
349
|
+
await client().remove(target.id);
|
|
343
350
|
deleteTarget.value = null;
|
|
344
351
|
await afterWrite();
|
|
345
352
|
} catch (err) {
|
|
@@ -362,12 +369,9 @@ export function useProvidersPanel(props, emit) {
|
|
|
362
369
|
detectedBusy.value = preset.id;
|
|
363
370
|
detectedError.value = null;
|
|
364
371
|
try {
|
|
365
|
-
await
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
preset: preset.id,
|
|
369
|
-
credential: { kind: preset.credential.kind, ref: `env:${envName}` }
|
|
370
|
-
}
|
|
372
|
+
await client().upsert(id, {
|
|
373
|
+
preset: preset.id,
|
|
374
|
+
credential: { kind: preset.credential.kind, ref: `env:${envName}` }
|
|
371
375
|
});
|
|
372
376
|
await afterWrite();
|
|
373
377
|
} catch (err) {
|
|
@@ -399,10 +403,7 @@ export function useProvidersPanel(props, emit) {
|
|
|
399
403
|
}
|
|
400
404
|
async function putRowChange(row, change) {
|
|
401
405
|
const body = row.state === "own" ? { ...ownBody(row), ...change } : change;
|
|
402
|
-
await
|
|
403
|
-
method: "PUT",
|
|
404
|
-
body
|
|
405
|
-
});
|
|
406
|
+
await client().upsert(row.id, body);
|
|
406
407
|
}
|
|
407
408
|
function credentialFor(row, ref2) {
|
|
408
409
|
return { kind: row.credentialKind ?? FALLBACK_CREDENTIAL_KIND, ref: ref2 };
|
|
@@ -442,13 +443,32 @@ export function useProvidersPanel(props, emit) {
|
|
|
442
443
|
await runRowWrite(
|
|
443
444
|
row.id,
|
|
444
445
|
async () => {
|
|
445
|
-
await
|
|
446
|
-
method: "DELETE"
|
|
447
|
-
});
|
|
446
|
+
await client().remove(row.id);
|
|
448
447
|
},
|
|
449
448
|
`Could not reset ${row.id}.`
|
|
450
449
|
);
|
|
451
450
|
}
|
|
451
|
+
const canTest = computed(() => Boolean(props.client) && !props.readOnly);
|
|
452
|
+
const testBusy = ref(null);
|
|
453
|
+
const testResults = reactive({});
|
|
454
|
+
async function testProvider(row) {
|
|
455
|
+
if (!props.client || props.readOnly || testBusy.value !== null) return;
|
|
456
|
+
testBusy.value = row.id;
|
|
457
|
+
delete testResults[row.id];
|
|
458
|
+
try {
|
|
459
|
+
testResults[row.id] = await props.client.test(row.id);
|
|
460
|
+
} catch (err) {
|
|
461
|
+
testResults[row.id] = {
|
|
462
|
+
ok: false,
|
|
463
|
+
message: requestErrorMessage(
|
|
464
|
+
err,
|
|
465
|
+
err instanceof Error && err.message ? err.message : `Could not test ${row.id}.`
|
|
466
|
+
)
|
|
467
|
+
};
|
|
468
|
+
} finally {
|
|
469
|
+
testBusy.value = null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
452
472
|
const keyEditing = ref(null);
|
|
453
473
|
const keyForm = reactive({ name: "", value: "" });
|
|
454
474
|
const keyError = ref(null);
|
|
@@ -476,7 +496,7 @@ export function useProvidersPanel(props, emit) {
|
|
|
476
496
|
}
|
|
477
497
|
async function saveKeyEditor() {
|
|
478
498
|
const row = rows.value.find((r) => r.id === keyEditing.value);
|
|
479
|
-
if (!row?.canEditCredential || !
|
|
499
|
+
if (!row?.canEditCredential || !environmentOn.value) return;
|
|
480
500
|
const name = keyForm.name.trim();
|
|
481
501
|
const refusal = validateEnvVarName(name);
|
|
482
502
|
if (refusal) {
|
|
@@ -492,7 +512,7 @@ export function useProvidersPanel(props, emit) {
|
|
|
492
512
|
let wroteEnv = false;
|
|
493
513
|
try {
|
|
494
514
|
const update = { set: { [name]: keyForm.value }, unset: [] };
|
|
495
|
-
await
|
|
515
|
+
await client().updateEnvironment(update);
|
|
496
516
|
wroteEnv = true;
|
|
497
517
|
const ref2 = `env:${name}`;
|
|
498
518
|
if (row.credentialRef !== ref2) {
|
|
@@ -542,11 +562,11 @@ export function useProvidersPanel(props, emit) {
|
|
|
542
562
|
const envValues = reactive({});
|
|
543
563
|
const envBusy = ref(false);
|
|
544
564
|
async function writeEnvironment(update, fallback) {
|
|
545
|
-
if (!
|
|
565
|
+
if (!environmentOn.value || !envEditable.value) return false;
|
|
546
566
|
envBusy.value = true;
|
|
547
567
|
envError.value = null;
|
|
548
568
|
try {
|
|
549
|
-
await
|
|
569
|
+
await client().updateEnvironment(update);
|
|
550
570
|
} catch (err) {
|
|
551
571
|
envError.value = requestErrorMessage(err, fallback);
|
|
552
572
|
return false;
|
|
@@ -607,6 +627,10 @@ export function useProvidersPanel(props, emit) {
|
|
|
607
627
|
resetPatch,
|
|
608
628
|
rowBusy,
|
|
609
629
|
rowError,
|
|
630
|
+
canTest,
|
|
631
|
+
testBusy,
|
|
632
|
+
testResults,
|
|
633
|
+
testProvider,
|
|
610
634
|
// key editor
|
|
611
635
|
canEnterKey,
|
|
612
636
|
keyEditing,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skaile/forge-settings",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Provider and settings operations for Skaile forge apps: provider registry layers, credential store, env layers, model discovery, the providers panel, and editor / AI SDK model adapters.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "bun@1.3.9",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@nuxt/kit": "^4.0.0",
|
|
58
|
-
"@skaile/workspaces": "^7.8.0",
|
|
58
|
+
"@skaile/workspaces": "^7.8.0 || ^8.0.0",
|
|
59
59
|
"ofetch": "^1.4.0",
|
|
60
60
|
"yaml": "^2.9.0"
|
|
61
61
|
},
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
"@nuxt/module-builder": "^1.0.0",
|
|
102
102
|
"@nuxt/schema": "^4.0.0",
|
|
103
103
|
"@nuxt/ui": "^4.11.1",
|
|
104
|
-
"@skaile/forge-editor": "^6.
|
|
104
|
+
"@skaile/forge-editor": "^6.4.2",
|
|
105
105
|
"@types/bun": "^1.2.0",
|
|
106
106
|
"@types/node": "^22.0.0",
|
|
107
107
|
"ai": "^7.0.155",
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div data-testid="model-roles-panel" class="space-y-3">
|
|
3
|
+
<p v-if="loadError" data-testid="model-roles-error" class="text-sm text-error-500">{{ loadError }}</p>
|
|
4
|
+
<div v-else-if="loading && !loaded" class="text-xs text-foreground-subtle py-2 text-center">
|
|
5
|
+
Loading providers…
|
|
6
|
+
</div>
|
|
7
|
+
<div v-else-if="rows.length === 0" class="text-xs text-foreground-subtle py-2 text-center">
|
|
8
|
+
No model roles are enabled.
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div
|
|
12
|
+
v-for="row in rows"
|
|
13
|
+
:key="row.role.id"
|
|
14
|
+
:data-testid="`model-role-${row.role.id}`"
|
|
15
|
+
class="p-3 rounded-lg border border-surface-border space-y-2"
|
|
16
|
+
>
|
|
17
|
+
<div class="flex items-start gap-2">
|
|
18
|
+
<div class="flex-1 min-w-0">
|
|
19
|
+
<p class="text-sm font-medium text-foreground">{{ row.role.label }}</p>
|
|
20
|
+
<p class="text-xs text-foreground-muted leading-relaxed">{{ row.role.description }}</p>
|
|
21
|
+
</div>
|
|
22
|
+
<UBadge
|
|
23
|
+
v-if="!row.state.assigned"
|
|
24
|
+
size="sm"
|
|
25
|
+
variant="subtle"
|
|
26
|
+
color="neutral"
|
|
27
|
+
:data-testid="`model-role-unassigned-${row.role.id}`"
|
|
28
|
+
>Not assigned</UBadge>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div class="flex items-start gap-2 flex-wrap">
|
|
32
|
+
<USelect
|
|
33
|
+
:model-value="row.state.entryId || undefined"
|
|
34
|
+
:items="row.state.providerOptions"
|
|
35
|
+
placeholder="Choose a provider"
|
|
36
|
+
:disabled="props.readOnly"
|
|
37
|
+
:data-testid="`model-role-provider-${row.role.id}`"
|
|
38
|
+
class="min-w-[12rem] bg-surface-ground border-surface-border text-foreground text-xs"
|
|
39
|
+
@update:model-value="onProvider(row.role, String($event ?? ''))"
|
|
40
|
+
/>
|
|
41
|
+
|
|
42
|
+
<template v-if="!row.voice">
|
|
43
|
+
<USelect
|
|
44
|
+
v-if="row.modelChoices"
|
|
45
|
+
:model-value="row.state.modelId || undefined"
|
|
46
|
+
:items="row.modelChoices"
|
|
47
|
+
placeholder="Choose a model"
|
|
48
|
+
:disabled="props.readOnly"
|
|
49
|
+
:data-testid="`model-role-model-${row.role.id}`"
|
|
50
|
+
class="min-w-[16rem] bg-surface-ground border-surface-border text-foreground text-xs font-mono"
|
|
51
|
+
@update:model-value="onModel(row.role, String($event ?? ''))"
|
|
52
|
+
/>
|
|
53
|
+
<UInput
|
|
54
|
+
v-else
|
|
55
|
+
:model-value="drafts[row.role.id] ?? row.state.modelId"
|
|
56
|
+
placeholder="Model id"
|
|
57
|
+
:disabled="props.readOnly || !row.state.entryId || row.modelsLoading"
|
|
58
|
+
:data-testid="`model-role-model-${row.role.id}`"
|
|
59
|
+
class="min-w-[16rem] bg-surface-ground border-surface-border text-foreground text-xs font-mono"
|
|
60
|
+
@update:model-value="drafts[row.role.id] = String($event ?? '')"
|
|
61
|
+
@blur="commitDraft(row.role)"
|
|
62
|
+
@keydown.enter="commitDraft(row.role)"
|
|
63
|
+
/>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
66
|
+
<UButton
|
|
67
|
+
v-if="!props.readOnly && (row.state.assigned || row.state.entryId)"
|
|
68
|
+
size="sm"
|
|
69
|
+
variant="ghost"
|
|
70
|
+
:aria-label="`Clear ${row.role.label}`"
|
|
71
|
+
:data-testid="`model-role-clear-${row.role.id}`"
|
|
72
|
+
@click="clear(row.role)"
|
|
73
|
+
>
|
|
74
|
+
<Icon name="lucide:x" class="mr-1.5 w-3.5 h-3.5" />Clear
|
|
75
|
+
</UButton>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<p
|
|
79
|
+
v-if="row.state.warning"
|
|
80
|
+
:data-testid="`model-role-warning-${row.role.id}`"
|
|
81
|
+
class="text-xs text-amber-600 dark:text-amber-400"
|
|
82
|
+
>{{ row.state.warning }}</p>
|
|
83
|
+
<p
|
|
84
|
+
v-if="row.modelsLoading"
|
|
85
|
+
class="text-xs text-foreground-subtle italic"
|
|
86
|
+
>Loading models…</p>
|
|
87
|
+
<p
|
|
88
|
+
v-else-if="row.modelsHint"
|
|
89
|
+
:data-testid="`model-role-hint-${row.role.id}`"
|
|
90
|
+
class="text-xs text-foreground-subtle italic"
|
|
91
|
+
>{{ row.modelsHint }}</p>
|
|
92
|
+
|
|
93
|
+
<slot name="status" :role="row.role" />
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</template>
|
|
97
|
+
|
|
98
|
+
<script setup lang="ts">
|
|
99
|
+
import { computed, onMounted, reactive, ref, watch } from "vue";
|
|
100
|
+
import type { ModelRoleAssignments, ModelRoleDefinition } from "../shared/model-roles";
|
|
101
|
+
import {
|
|
102
|
+
assignmentOf,
|
|
103
|
+
buildSelector,
|
|
104
|
+
isVoiceRole,
|
|
105
|
+
type ModelRoleOption,
|
|
106
|
+
type ModelRoleRowState,
|
|
107
|
+
type ModelRolesModelsResult,
|
|
108
|
+
type ModelRolesPanelClient,
|
|
109
|
+
modelOptions,
|
|
110
|
+
modelRoleRowState,
|
|
111
|
+
withAssignment,
|
|
112
|
+
} from "../vue/model-roles-panel";
|
|
113
|
+
import type { ProvidersPanelEntry } from "../vue/providers-panel-model";
|
|
114
|
+
|
|
115
|
+
const props = defineProps<{
|
|
116
|
+
/** Every role the host declares; only `enabled` ones are rendered. */
|
|
117
|
+
roles: ModelRoleDefinition[];
|
|
118
|
+
/** Role id to selector (`<entryId>/<modelId>`, or an entry id for `tts` / `stt`). */
|
|
119
|
+
assignments: ModelRoleAssignments;
|
|
120
|
+
/** Reads the registry and its models; a `ProvidersPanelClient` fits. */
|
|
121
|
+
client: ModelRolesPanelClient;
|
|
122
|
+
/** Disables every change. */
|
|
123
|
+
readOnly?: boolean;
|
|
124
|
+
}>();
|
|
125
|
+
|
|
126
|
+
const emit = defineEmits<{
|
|
127
|
+
/** The full new assignments map after a pick or a clear. The host saves it. */
|
|
128
|
+
(event: "update:assignments", assignments: ModelRoleAssignments): void;
|
|
129
|
+
}>();
|
|
130
|
+
|
|
131
|
+
defineSlots<{
|
|
132
|
+
/** Under a role's row: availability or check text the host knows. */
|
|
133
|
+
status?(props: { role: ModelRoleDefinition }): unknown;
|
|
134
|
+
}>();
|
|
135
|
+
|
|
136
|
+
interface ModelsCache {
|
|
137
|
+
loading: boolean;
|
|
138
|
+
result?: ModelRolesModelsResult;
|
|
139
|
+
/** The request failed; the free-text field is offered. */
|
|
140
|
+
error?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const entries = ref<ProvidersPanelEntry[]>([]);
|
|
144
|
+
const loading = ref(false);
|
|
145
|
+
const loaded = ref(false);
|
|
146
|
+
const loadError = ref<string | null>(null);
|
|
147
|
+
const modelsCache = reactive<Record<string, ModelsCache>>({});
|
|
148
|
+
/** Provider picked on a model role before a model exists. */
|
|
149
|
+
const pending = reactive<Record<string, string>>({});
|
|
150
|
+
/** Free-text model input not yet committed. */
|
|
151
|
+
const drafts = reactive<Record<string, string>>({});
|
|
152
|
+
|
|
153
|
+
function errorMessage(error: unknown): string {
|
|
154
|
+
return error instanceof Error && error.message ? error.message : "The request failed.";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function loadEntries(): Promise<void> {
|
|
158
|
+
loading.value = true;
|
|
159
|
+
loadError.value = null;
|
|
160
|
+
try {
|
|
161
|
+
entries.value = await props.client.list();
|
|
162
|
+
loaded.value = true;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
loadError.value = errorMessage(error);
|
|
165
|
+
} finally {
|
|
166
|
+
loading.value = false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Latest request number per provider; a response with another number is stale and dropped. */
|
|
171
|
+
const modelsRequest: Record<string, number> = {};
|
|
172
|
+
let requestCounter = 0;
|
|
173
|
+
|
|
174
|
+
async function loadModels(entryId: string): Promise<void> {
|
|
175
|
+
const request = ++requestCounter;
|
|
176
|
+
modelsRequest[entryId] = request;
|
|
177
|
+
modelsCache[entryId] = { loading: true };
|
|
178
|
+
try {
|
|
179
|
+
const result = await props.client.models(entryId);
|
|
180
|
+
if (modelsRequest[entryId] !== request) return;
|
|
181
|
+
modelsCache[entryId] = { loading: false, result };
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (modelsRequest[entryId] !== request) return;
|
|
184
|
+
modelsCache[entryId] = { loading: false, error: errorMessage(error) };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Reloads the registry and the models of every shown provider. */
|
|
189
|
+
async function refresh(): Promise<void> {
|
|
190
|
+
// Responses still in flight were asked before the reload; their numbers no longer match.
|
|
191
|
+
for (const key of Object.keys(modelsRequest)) delete modelsRequest[key];
|
|
192
|
+
for (const key of Object.keys(modelsCache)) delete modelsCache[key];
|
|
193
|
+
await loadEntries();
|
|
194
|
+
loadModelsForRows();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface Row {
|
|
198
|
+
role: ModelRoleDefinition;
|
|
199
|
+
voice: boolean;
|
|
200
|
+
state: ModelRoleRowState;
|
|
201
|
+
/** Options for the model select; `null` selects the free-text field. */
|
|
202
|
+
modelChoices: ModelRoleOption[] | null;
|
|
203
|
+
modelsLoading: boolean;
|
|
204
|
+
modelsHint?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const rows = computed<Row[]>(() =>
|
|
208
|
+
props.roles
|
|
209
|
+
.filter((role) => role.enabled)
|
|
210
|
+
.map((role) => {
|
|
211
|
+
const voice = isVoiceRole(role.kind);
|
|
212
|
+
const state = modelRoleRowState(role, assignmentOf(props.assignments, role.id), entries.value, pending[role.id]);
|
|
213
|
+
const cache = state.entryId ? modelsCache[state.entryId] : undefined;
|
|
214
|
+
const result = cache?.result;
|
|
215
|
+
const listed = result && result.status !== "unsupported";
|
|
216
|
+
let modelsHint: string | undefined;
|
|
217
|
+
if (!voice && cache && !cache.loading && !listed) {
|
|
218
|
+
modelsHint = cache.error ?? result?.message ?? "This provider does not list models; enter the model id.";
|
|
219
|
+
} else if (!voice && result?.status === "static") {
|
|
220
|
+
modelsHint = result.message;
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
role,
|
|
224
|
+
voice,
|
|
225
|
+
state,
|
|
226
|
+
modelChoices: listed ? modelOptions(result.models, state.modelId) : null,
|
|
227
|
+
modelsLoading: cache?.loading ?? false,
|
|
228
|
+
modelsHint,
|
|
229
|
+
};
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
/** Fetches the models of every provider a model row shows and has not fetched. */
|
|
234
|
+
function loadModelsForRows(): void {
|
|
235
|
+
for (const row of rows.value) {
|
|
236
|
+
const id = row.state.entryId;
|
|
237
|
+
if (row.voice || !id || row.state.warning || modelsCache[id]) continue;
|
|
238
|
+
void loadModels(id);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function emitAssignments(roleId: string, value: string): void {
|
|
243
|
+
emit("update:assignments", withAssignment(props.assignments, roleId, value));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function onProvider(role: ModelRoleDefinition, entryId: string): void {
|
|
247
|
+
if (props.readOnly) return;
|
|
248
|
+
delete drafts[role.id];
|
|
249
|
+
if (isVoiceRole(role.kind)) {
|
|
250
|
+
emitAssignments(role.id, entryId);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// The model belongs to the old provider: drop it, keep the pick until a model is chosen.
|
|
254
|
+
if (entryId === "") delete pending[role.id];
|
|
255
|
+
else pending[role.id] = entryId;
|
|
256
|
+
if (assignmentOf(props.assignments, role.id)) emitAssignments(role.id, "");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function onModel(role: ModelRoleDefinition, modelId: string): void {
|
|
260
|
+
if (props.readOnly) return;
|
|
261
|
+
const entryId = rows.value.find((row) => row.role.id === role.id)?.state.entryId ?? "";
|
|
262
|
+
delete drafts[role.id];
|
|
263
|
+
if (!entryId || modelId === "") return;
|
|
264
|
+
delete pending[role.id];
|
|
265
|
+
emitAssignments(role.id, buildSelector(entryId, modelId));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function commitDraft(role: ModelRoleDefinition): void {
|
|
269
|
+
const draft = drafts[role.id];
|
|
270
|
+
if (draft === undefined) return;
|
|
271
|
+
const modelId = draft.trim();
|
|
272
|
+
const row = rows.value.find((candidate) => candidate.role.id === role.id);
|
|
273
|
+
if (!row || modelId === row.state.modelId) {
|
|
274
|
+
delete drafts[role.id];
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (modelId === "") {
|
|
278
|
+
delete drafts[role.id];
|
|
279
|
+
if (assignmentOf(props.assignments, role.id)) {
|
|
280
|
+
pending[role.id] = row.state.entryId;
|
|
281
|
+
emitAssignments(role.id, "");
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
onModel(role, modelId);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function clear(role: ModelRoleDefinition): void {
|
|
289
|
+
if (props.readOnly) return;
|
|
290
|
+
delete pending[role.id];
|
|
291
|
+
delete drafts[role.id];
|
|
292
|
+
if (assignmentOf(props.assignments, role.id)) emitAssignments(role.id, "");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// A stored assignment supersedes a pick that was waiting for its model.
|
|
296
|
+
watch(
|
|
297
|
+
() => props.assignments,
|
|
298
|
+
(assignments) => {
|
|
299
|
+
for (const roleId of Object.keys(pending)) {
|
|
300
|
+
if (assignmentOf(assignments, roleId)) delete pending[roleId];
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
);
|
|
304
|
+
watch(rows, loadModelsForRows);
|
|
305
|
+
|
|
306
|
+
onMounted(async () => {
|
|
307
|
+
await loadEntries();
|
|
308
|
+
loadModelsForRows();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
defineExpose({ refresh });
|
|
312
|
+
</script>
|
|
@@ -170,6 +170,11 @@
|
|
|
170
170
|
:data-testid="`provider-override-notice-${row.id}`"
|
|
171
171
|
class="text-foreground-subtle"
|
|
172
172
|
>{{ notice }}</p>
|
|
173
|
+
<p
|
|
174
|
+
v-if="testResults[row.id]"
|
|
175
|
+
:data-testid="`provider-test-result-${row.id}`"
|
|
176
|
+
:class="testResults[row.id]?.ok ? 'text-success-500' : 'text-error-500'"
|
|
177
|
+
>{{ testResults[row.id]?.message }}</p>
|
|
173
178
|
<p
|
|
174
179
|
v-if="rowError?.id === row.id"
|
|
175
180
|
:data-testid="`provider-row-error-${row.id}`"
|
|
@@ -187,6 +192,17 @@
|
|
|
187
192
|
</td>
|
|
188
193
|
<td class="px-3 py-2">
|
|
189
194
|
<div class="flex items-center justify-end gap-1">
|
|
195
|
+
<UButton
|
|
196
|
+
v-if="canTest"
|
|
197
|
+
size="sm"
|
|
198
|
+
variant="ghost"
|
|
199
|
+
:loading="testBusy === row.id"
|
|
200
|
+
:disabled="testBusy !== null"
|
|
201
|
+
:data-testid="`provider-test-${row.id}`"
|
|
202
|
+
@click="testProvider(row)"
|
|
203
|
+
>
|
|
204
|
+
<Icon name="lucide:plug-zap" class="mr-1.5 w-3.5 h-3.5" />Test
|
|
205
|
+
</UButton>
|
|
190
206
|
<UButton
|
|
191
207
|
v-if="row.canReset"
|
|
192
208
|
size="sm"
|
|
@@ -376,22 +392,34 @@
|
|
|
376
392
|
</template>
|
|
377
393
|
|
|
378
394
|
<script setup lang="ts">
|
|
395
|
+
import type { ProvidersPanelClient } from "../vue/providers-client";
|
|
379
396
|
import { type ProvidersPanelLayer, useProvidersPanel } from "../vue/useProvidersPanel";
|
|
380
397
|
|
|
381
|
-
const props =
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
398
|
+
const props = withDefaults(
|
|
399
|
+
defineProps<{
|
|
400
|
+
/**
|
|
401
|
+
* The panel's transport. Without it the panel calls the URL props below
|
|
402
|
+
* (`urlProvidersClient`); with it they are ignored and every row offers a Test button.
|
|
403
|
+
*/
|
|
404
|
+
client?: ProvidersPanelClient;
|
|
405
|
+
/** `GET` merged view for this scope (`/api/providers` | `/api/workspaces/:id/providers`). Required without `client`. */
|
|
406
|
+
providersUrl?: string;
|
|
407
|
+
/** `PUT|DELETE ${providersWriteUrl}/:id`; `GET ${providersWriteUrl}/detected`. Required without `client`. */
|
|
408
|
+
providersWriteUrl?: string;
|
|
409
|
+
/** Catalog preset summaries. Default `/api/providers/presets`. */
|
|
410
|
+
presetsUrl?: string;
|
|
411
|
+
/** The scope's `.env` route: hand-entered keys. Omitted, key entry is hidden. */
|
|
412
|
+
environmentUrl?: string;
|
|
413
|
+
/** Key entry and the environment disclosure. Default: `Boolean(environmentUrl)` without `client`, `true` with one. */
|
|
414
|
+
environmentEnabled?: boolean;
|
|
415
|
+
/** Edit/Delete are offered only for entries this layer defines. */
|
|
416
|
+
layer: ProvidersPanelLayer;
|
|
417
|
+
/** Disables add, edit and delete. */
|
|
418
|
+
readOnly?: boolean;
|
|
419
|
+
}>(),
|
|
420
|
+
// Without a default Vue casts an absent boolean to `false`; the panel tells absent from `false`.
|
|
421
|
+
{ environmentEnabled: undefined },
|
|
422
|
+
);
|
|
395
423
|
|
|
396
424
|
const emit = defineEmits<{
|
|
397
425
|
/** After every successful `PUT`/`DELETE`. */
|
|
@@ -417,6 +445,10 @@ const {
|
|
|
417
445
|
showCredentialFields,
|
|
418
446
|
rowBusy,
|
|
419
447
|
rowError,
|
|
448
|
+
canTest,
|
|
449
|
+
testBusy,
|
|
450
|
+
testResults,
|
|
451
|
+
testProvider,
|
|
420
452
|
onCredentialSelect,
|
|
421
453
|
setEnabled,
|
|
422
454
|
resetPatch,
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
export * from "../shared/harness-providers";
|
|
11
11
|
export * from "../shared/harnesses";
|
|
12
|
+
export * from "../shared/model-roles";
|
|
12
13
|
export * from "../shared/provider-registry";
|
|
13
14
|
export type { DomainConfig, SkaileSettings } from "../shared/settings-types";
|
|
14
15
|
export * from "./credential-policy";
|