@frockbot/plugin-settings 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frockbot.json +55 -0
- package/package.json +43 -6
- package/src/backend.test.ts +397 -0
- package/src/backend.ts +262 -0
- package/src/client/BotPanel.vue +57 -0
- package/src/client/BotSettingsSurface.vue +1076 -0
- package/src/client/BotSettingsTrigger.vue +26 -0
- package/src/client/ConnectionsSurface.vue +813 -0
- package/src/client/ModelsSurface.vue +419 -0
- package/src/client/PackageAccounts.vue +330 -0
- package/src/client/PackageCatalogSurface.vue +584 -0
- package/src/client/PackageSettingsForm.vue +150 -0
- package/src/client/PackageSettingsSection.vue +66 -0
- package/src/client/PluginsSurface.vue +412 -0
- package/src/client/PluginsTrigger.vue +62 -0
- package/src/client/UserProfileTrigger.vue +223 -0
- package/src/client/UserSettingsSurface.vue +242 -0
- package/src/client/assignment-operations.ts +22 -0
- package/src/client/bot-settings.test.ts +218 -0
- package/src/client/bot-settings.ts +150 -0
- package/src/client/index.test.ts +113 -0
- package/src/client/index.ts +78 -0
- package/src/client/package-settings.test.ts +63 -0
- package/src/client/package-settings.ts +77 -0
- package/src/client/package-surfaces.test.ts +147 -0
- package/src/client/package-surfaces.ts +93 -0
- package/src/client/user-display-name.test.ts +44 -0
- package/src/client/user-display-name.ts +16 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +1 -0
- package/src/manifest.ts +3 -0
- package/src/user.test.ts +1277 -0
- package/src/user.ts +1221 -0
- package/tsconfig.json +15 -0
- package/vite.config.ts +30 -0
- package/README.md +0 -3
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { clientSurfaceRegistryKey } from "@frockbot/client-core";
|
|
3
|
+
import { authSessionClientKey } from "@frockbot/plugin-auth/shared";
|
|
4
|
+
import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
|
|
5
|
+
import { computed, inject, onBeforeUnmount, onMounted, ref } from "vue";
|
|
6
|
+
import { resolveUserDisplayName } from "./user-display-name.js";
|
|
7
|
+
|
|
8
|
+
const providedAuth = inject(authSessionClientKey);
|
|
9
|
+
const surfaces = inject(clientSurfaceRegistryKey);
|
|
10
|
+
const web = inject(frockBotWebDataKey);
|
|
11
|
+
if (!providedAuth || !surfaces || !web)
|
|
12
|
+
throw new Error("settings client services were not provided");
|
|
13
|
+
const auth = providedAuth;
|
|
14
|
+
const menuOpen = ref(false);
|
|
15
|
+
const signOutError = ref<string>();
|
|
16
|
+
const sessionUser = computed(() =>
|
|
17
|
+
auth.projection.value.status === "authenticated"
|
|
18
|
+
? auth.projection.value.user
|
|
19
|
+
: undefined,
|
|
20
|
+
);
|
|
21
|
+
const displayName = computed(() =>
|
|
22
|
+
resolveUserDisplayName({
|
|
23
|
+
savedName: web.value.userSettings?.profile.name,
|
|
24
|
+
sessionName: sessionUser.value?.name,
|
|
25
|
+
sessionEmail: sessionUser.value?.email,
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
const developmentIdentity = computed(
|
|
29
|
+
() =>
|
|
30
|
+
auth.projection.value.status === "authenticated" &&
|
|
31
|
+
auth.projection.value.mode === "development",
|
|
32
|
+
);
|
|
33
|
+
const isAdmin = computed(
|
|
34
|
+
() =>
|
|
35
|
+
auth.projection.value.status === "authenticated" &&
|
|
36
|
+
auth.projection.value.user.isAdmin,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
function closeMenu(): void {
|
|
40
|
+
menuOpen.value = false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function openSurface(id: string): void {
|
|
44
|
+
closeMenu();
|
|
45
|
+
surfaces?.open(id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function openAdmin(): void {
|
|
49
|
+
closeMenu();
|
|
50
|
+
surfaces?.open("admin");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function signOut(): Promise<void> {
|
|
54
|
+
signOutError.value = undefined;
|
|
55
|
+
try {
|
|
56
|
+
await auth.signOut();
|
|
57
|
+
closeMenu();
|
|
58
|
+
} catch (error) {
|
|
59
|
+
signOutError.value =
|
|
60
|
+
error instanceof Error ? error.message : "Could not sign out";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
onMounted(() => window.addEventListener("pointerdown", closeMenu));
|
|
65
|
+
onBeforeUnmount(() => window.removeEventListener("pointerdown", closeMenu));
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<template>
|
|
69
|
+
<div class="profile-area" @pointerdown.stop>
|
|
70
|
+
<button
|
|
71
|
+
class="profile-trigger"
|
|
72
|
+
type="button"
|
|
73
|
+
:aria-expanded="menuOpen"
|
|
74
|
+
:title="sessionUser?.email"
|
|
75
|
+
aria-haspopup="menu"
|
|
76
|
+
@click="menuOpen = !menuOpen"
|
|
77
|
+
>
|
|
78
|
+
<span class="profile-face" aria-hidden="true" />
|
|
79
|
+
{{ displayName }}
|
|
80
|
+
</button>
|
|
81
|
+
<div v-if="menuOpen" class="profile-menu" role="menu">
|
|
82
|
+
<button v-if="isAdmin" type="button" role="menuitem" @click="openAdmin">
|
|
83
|
+
Admin
|
|
84
|
+
</button>
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
role="menuitem"
|
|
88
|
+
@click="openSurface('user-settings')"
|
|
89
|
+
>
|
|
90
|
+
Settings
|
|
91
|
+
</button>
|
|
92
|
+
<!-- The three Package surfaces are reachable where the User already is,
|
|
93
|
+
and gated exactly as the sidebar's Plugins trigger is: a shell
|
|
94
|
+
without the Connection protocol shows none of them. -->
|
|
95
|
+
<template v-if="web.connectionsAvailable">
|
|
96
|
+
<button type="button" role="menuitem" @click="openSurface('models')">
|
|
97
|
+
Models
|
|
98
|
+
</button>
|
|
99
|
+
<button type="button" role="menuitem" @click="openSurface('plugins')">
|
|
100
|
+
Plugins
|
|
101
|
+
</button>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
role="menuitem"
|
|
105
|
+
@click="openSurface('connections')"
|
|
106
|
+
>
|
|
107
|
+
Connections
|
|
108
|
+
</button>
|
|
109
|
+
</template>
|
|
110
|
+
<button v-if="developmentIdentity" type="button" role="menuitem" disabled>
|
|
111
|
+
Sign out unavailable
|
|
112
|
+
</button>
|
|
113
|
+
<button
|
|
114
|
+
v-else
|
|
115
|
+
type="button"
|
|
116
|
+
role="menuitem"
|
|
117
|
+
:disabled="auth.signingOut.value"
|
|
118
|
+
@click="signOut"
|
|
119
|
+
>
|
|
120
|
+
{{ auth.signingOut.value ? "Signing out…" : "Sign out" }}
|
|
121
|
+
</button>
|
|
122
|
+
<p v-if="developmentIdentity" class="profile-menu-hint">
|
|
123
|
+
Local development identity is selected by the development login URL.
|
|
124
|
+
</p>
|
|
125
|
+
<p v-if="signOutError" class="profile-menu-error" role="alert">
|
|
126
|
+
{{ signOutError }}
|
|
127
|
+
</p>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</template>
|
|
131
|
+
|
|
132
|
+
<style scoped>
|
|
133
|
+
.profile-area {
|
|
134
|
+
position: relative;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.profile-trigger {
|
|
138
|
+
display: flex;
|
|
139
|
+
width: 100%;
|
|
140
|
+
height: 40px;
|
|
141
|
+
align-items: center;
|
|
142
|
+
gap: 10px;
|
|
143
|
+
padding: 0 8px;
|
|
144
|
+
border-radius: var(--frock-radius-control);
|
|
145
|
+
color: var(--frock-text);
|
|
146
|
+
background: transparent;
|
|
147
|
+
font-size: var(--frock-text-md);
|
|
148
|
+
font-weight: 500;
|
|
149
|
+
text-align: left;
|
|
150
|
+
cursor: pointer;
|
|
151
|
+
transition: background-color var(--frock-motion-fast);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.profile-trigger:hover,
|
|
155
|
+
.profile-trigger[aria-expanded="true"] {
|
|
156
|
+
background: var(--frock-fill-hover);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.profile-trigger:active {
|
|
160
|
+
background: var(--frock-fill-pressed);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.profile-face {
|
|
164
|
+
width: var(--frock-avatar-sm);
|
|
165
|
+
height: var(--frock-avatar-sm);
|
|
166
|
+
flex: 0 0 auto;
|
|
167
|
+
border-radius: 50%;
|
|
168
|
+
background: linear-gradient(
|
|
169
|
+
135deg,
|
|
170
|
+
var(--frock-surface-accent),
|
|
171
|
+
var(--frock-action-primary)
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.profile-menu {
|
|
176
|
+
position: absolute;
|
|
177
|
+
z-index: var(--frock-layer-menu);
|
|
178
|
+
right: 0;
|
|
179
|
+
bottom: 44px;
|
|
180
|
+
left: 0;
|
|
181
|
+
padding: 6px;
|
|
182
|
+
border: 1px solid var(--frock-border);
|
|
183
|
+
border-radius: var(--frock-radius-control);
|
|
184
|
+
background: var(--frock-surface-raised);
|
|
185
|
+
box-shadow: var(--frock-shadow-floating);
|
|
186
|
+
animation: frock-rise-in var(--frock-motion-fast) both;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.profile-menu button {
|
|
190
|
+
width: 100%;
|
|
191
|
+
padding: 8px 10px;
|
|
192
|
+
border-radius: 7px;
|
|
193
|
+
background: transparent;
|
|
194
|
+
font-size: var(--frock-text-base);
|
|
195
|
+
text-align: left;
|
|
196
|
+
cursor: pointer;
|
|
197
|
+
transition: background-color var(--frock-motion-fast);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.profile-menu button:hover:not(:disabled) {
|
|
201
|
+
background: var(--frock-fill-hover);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.profile-menu button:disabled {
|
|
205
|
+
color: var(--frock-text-muted);
|
|
206
|
+
cursor: not-allowed;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.profile-menu-hint,
|
|
210
|
+
.profile-menu-error {
|
|
211
|
+
margin: 5px 10px;
|
|
212
|
+
font-size: var(--frock-text-xs);
|
|
213
|
+
line-height: var(--frock-leading-snug);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.profile-menu-hint {
|
|
217
|
+
color: var(--frock-text-muted);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.profile-menu-error {
|
|
221
|
+
color: var(--frock-danger-text);
|
|
222
|
+
}
|
|
223
|
+
</style>
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { clientSurfaceRegistryKey } from "@frockbot/client-core";
|
|
3
|
+
import { UiAnchor, UiButton, UiField, UiIcon } from "@frockbot/client-ui";
|
|
4
|
+
import { authSessionClientKey } from "@frockbot/plugin-auth/shared";
|
|
5
|
+
import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
|
|
6
|
+
import { settingsLinkV1 } from "@frockbot/plugin-shell/settings-links";
|
|
7
|
+
import { inject, onMounted, ref } from "vue";
|
|
8
|
+
import PackageSettingsSection from "./PackageSettingsSection.vue";
|
|
9
|
+
import { resolveUserDisplayName } from "./user-display-name.js";
|
|
10
|
+
|
|
11
|
+
const providedSurfaces = inject(clientSurfaceRegistryKey);
|
|
12
|
+
const providedAuth = inject(authSessionClientKey);
|
|
13
|
+
const providedWeb = inject(frockBotWebDataKey);
|
|
14
|
+
if (!providedSurfaces || !providedAuth || !providedWeb) {
|
|
15
|
+
throw new Error("settings client services were not provided");
|
|
16
|
+
}
|
|
17
|
+
const surfaces = providedSurfaces;
|
|
18
|
+
const auth = providedAuth;
|
|
19
|
+
const web = providedWeb;
|
|
20
|
+
// Application settings rows are User-scoped, so their links name no Bot.
|
|
21
|
+
const profileLink = settingsLinkV1({ anchor: "user-profile" });
|
|
22
|
+
const packageSettingsLink = settingsLinkV1({ anchor: "user-package-settings" });
|
|
23
|
+
const name = ref("");
|
|
24
|
+
const email = ref("");
|
|
25
|
+
const saving = ref(false);
|
|
26
|
+
|
|
27
|
+
onMounted(async () => {
|
|
28
|
+
await web.value.loadPluginCatalog();
|
|
29
|
+
await web.value.loadUserSettings();
|
|
30
|
+
const settings = web.value.userSettings;
|
|
31
|
+
if (!settings) return;
|
|
32
|
+
const sessionUser =
|
|
33
|
+
auth.projection.value.status === "authenticated"
|
|
34
|
+
? auth.projection.value.user
|
|
35
|
+
: undefined;
|
|
36
|
+
name.value = resolveUserDisplayName({
|
|
37
|
+
savedName: settings.profile.name,
|
|
38
|
+
sessionName: sessionUser?.name,
|
|
39
|
+
sessionEmail: sessionUser?.email,
|
|
40
|
+
});
|
|
41
|
+
email.value = settings.profile.email ?? sessionUser?.email ?? "";
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
async function save(): Promise<void> {
|
|
45
|
+
saving.value = true;
|
|
46
|
+
try {
|
|
47
|
+
await web.value.saveUserProfile({
|
|
48
|
+
name: name.value,
|
|
49
|
+
email: email.value || undefined,
|
|
50
|
+
});
|
|
51
|
+
surfaces?.close();
|
|
52
|
+
} catch (error) {
|
|
53
|
+
web.value.settingsError =
|
|
54
|
+
error instanceof Error ? error.message : "Could not save settings";
|
|
55
|
+
} finally {
|
|
56
|
+
saving.value = false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<template>
|
|
62
|
+
<form class="settings-form" @submit.prevent="save">
|
|
63
|
+
<UiAnchor
|
|
64
|
+
anchor="user-profile"
|
|
65
|
+
label="Your profile"
|
|
66
|
+
:href="profileLink"
|
|
67
|
+
class="settings-row"
|
|
68
|
+
>
|
|
69
|
+
<div class="profile-intro">
|
|
70
|
+
<span class="profile-face" aria-hidden="true" />
|
|
71
|
+
<div>
|
|
72
|
+
<strong>Your profile</strong>
|
|
73
|
+
<p>This identity is shared across your Bots.</p>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
<UiField label="Name">
|
|
77
|
+
<input v-model="name" maxlength="100" required />
|
|
78
|
+
</UiField>
|
|
79
|
+
<UiField label="Email" hint="optional">
|
|
80
|
+
<input v-model="email" maxlength="320" type="email" />
|
|
81
|
+
</UiField>
|
|
82
|
+
</UiAnchor>
|
|
83
|
+
<UiAnchor
|
|
84
|
+
anchor="user-package-settings"
|
|
85
|
+
label="Package settings"
|
|
86
|
+
:href="packageSettingsLink"
|
|
87
|
+
class="settings-row"
|
|
88
|
+
>
|
|
89
|
+
<p class="field-hint">
|
|
90
|
+
Settings of the Packages you have enabled. Model providers are
|
|
91
|
+
configured in Models and accounts in Connections.
|
|
92
|
+
</p>
|
|
93
|
+
<PackageSettingsSection />
|
|
94
|
+
</UiAnchor>
|
|
95
|
+
<p v-if="web.settingsError" class="settings-error" role="alert">
|
|
96
|
+
{{ web.settingsError }}
|
|
97
|
+
</p>
|
|
98
|
+
<div class="settings-actions">
|
|
99
|
+
<UiButton type="submit" variant="primary" :disabled="saving">
|
|
100
|
+
{{ saving ? "Saving…" : "Save settings" }}
|
|
101
|
+
</UiButton>
|
|
102
|
+
</div>
|
|
103
|
+
<details class="advanced">
|
|
104
|
+
<summary>
|
|
105
|
+
<span>Advanced</span>
|
|
106
|
+
<span class="advanced__marker" aria-hidden="true"
|
|
107
|
+
><UiIcon name="arrow-down" size="sm"
|
|
108
|
+
/></span>
|
|
109
|
+
</summary>
|
|
110
|
+
<div class="advanced__body">
|
|
111
|
+
<k-slot name="frockbot.user-settings-sections" />
|
|
112
|
+
</div>
|
|
113
|
+
</details>
|
|
114
|
+
</form>
|
|
115
|
+
</template>
|
|
116
|
+
|
|
117
|
+
<style scoped>
|
|
118
|
+
.settings-row {
|
|
119
|
+
display: flex;
|
|
120
|
+
flex-direction: column;
|
|
121
|
+
gap: 16px;
|
|
122
|
+
padding-right: var(--frock-control-sm);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.settings-form {
|
|
126
|
+
display: flex;
|
|
127
|
+
flex-direction: column;
|
|
128
|
+
gap: 16px;
|
|
129
|
+
padding: 16px;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.profile-intro {
|
|
133
|
+
display: flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
gap: 12px;
|
|
136
|
+
padding: 12px;
|
|
137
|
+
border: 1px solid var(--frock-border);
|
|
138
|
+
border-radius: var(--frock-radius-card);
|
|
139
|
+
background: var(--frock-surface-subtle);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.profile-face {
|
|
143
|
+
width: var(--frock-avatar-md);
|
|
144
|
+
height: var(--frock-avatar-md);
|
|
145
|
+
flex: 0 0 auto;
|
|
146
|
+
border-radius: 50%;
|
|
147
|
+
background: linear-gradient(
|
|
148
|
+
135deg,
|
|
149
|
+
var(--frock-surface-accent),
|
|
150
|
+
var(--frock-action-primary)
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.profile-intro strong,
|
|
155
|
+
.profile-intro p {
|
|
156
|
+
display: block;
|
|
157
|
+
margin: 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.profile-intro strong {
|
|
161
|
+
font-size: var(--frock-text-lg);
|
|
162
|
+
font-weight: 600;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.profile-intro p {
|
|
166
|
+
margin-top: 4px;
|
|
167
|
+
color: var(--frock-text-muted);
|
|
168
|
+
font-size: var(--frock-text-sm);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.field-hint {
|
|
172
|
+
margin: -8px 0 0;
|
|
173
|
+
color: var(--frock-text-muted);
|
|
174
|
+
font-size: var(--frock-text-sm);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.model-empty {
|
|
178
|
+
display: flex;
|
|
179
|
+
flex-direction: column;
|
|
180
|
+
align-items: flex-start;
|
|
181
|
+
gap: 10px;
|
|
182
|
+
padding: 12px;
|
|
183
|
+
border: 1px solid var(--frock-border);
|
|
184
|
+
border-radius: var(--frock-radius-card);
|
|
185
|
+
background: var(--frock-surface-subtle);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.model-empty p {
|
|
189
|
+
margin: 0;
|
|
190
|
+
color: var(--frock-text-muted);
|
|
191
|
+
font-size: var(--frock-text-sm);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.settings-error {
|
|
195
|
+
margin: 0;
|
|
196
|
+
color: var(--frock-danger-text);
|
|
197
|
+
font-size: var(--frock-text-sm);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.settings-actions {
|
|
201
|
+
display: flex;
|
|
202
|
+
justify-content: flex-end;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.advanced {
|
|
206
|
+
border-top: 1px solid var(--frock-border);
|
|
207
|
+
padding-top: 12px;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.advanced summary {
|
|
211
|
+
display: flex;
|
|
212
|
+
align-items: center;
|
|
213
|
+
justify-content: space-between;
|
|
214
|
+
gap: 8px;
|
|
215
|
+
color: var(--frock-text-muted);
|
|
216
|
+
font-size: var(--frock-text-sm);
|
|
217
|
+
font-weight: 600;
|
|
218
|
+
list-style: none;
|
|
219
|
+
cursor: pointer;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.advanced summary::-webkit-details-marker {
|
|
223
|
+
display: none;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.advanced__marker {
|
|
227
|
+
display: grid;
|
|
228
|
+
place-items: center;
|
|
229
|
+
transition: transform var(--frock-motion-fast);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.advanced[open] .advanced__marker {
|
|
233
|
+
transform: rotate(180deg);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.advanced__body {
|
|
237
|
+
display: flex;
|
|
238
|
+
flex-direction: column;
|
|
239
|
+
gap: 8px;
|
|
240
|
+
padding-top: 12px;
|
|
241
|
+
}
|
|
242
|
+
</style>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BotSettingsViewV1,
|
|
3
|
+
CapabilityAssignmentOperationViewV1,
|
|
4
|
+
} from "@frockbot/configuration-core";
|
|
5
|
+
|
|
6
|
+
/** Projects every durable Assignment operation without requiring catalog state. */
|
|
7
|
+
export function projectAssignmentOperations(
|
|
8
|
+
settings: Pick<BotSettingsViewV1, "assignmentOperations"> | undefined,
|
|
9
|
+
): CapabilityAssignmentOperationViewV1[] {
|
|
10
|
+
return (settings?.assignmentOperations ?? []).map((operation) =>
|
|
11
|
+
structuredClone(operation),
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function assignmentHasPendingOperation(
|
|
16
|
+
operations: readonly CapabilityAssignmentOperationViewV1[],
|
|
17
|
+
assignmentId: string,
|
|
18
|
+
): boolean {
|
|
19
|
+
return operations.some(
|
|
20
|
+
(operation) => operation.assignmentId === assignmentId,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
decodeModelSelection,
|
|
4
|
+
describeModelAssignment,
|
|
5
|
+
encodeModelSelection,
|
|
6
|
+
eligibleModelConnections,
|
|
7
|
+
isModelConnectionEligible,
|
|
8
|
+
modelSelectOptions,
|
|
9
|
+
resolveBotSettingsModel,
|
|
10
|
+
} from "./bot-settings.js";
|
|
11
|
+
|
|
12
|
+
describe("Bot settings model selection", () => {
|
|
13
|
+
test("excludes model Connections owned by disabled Packages", () => {
|
|
14
|
+
const connection = {
|
|
15
|
+
connectionId: "ollama-1",
|
|
16
|
+
packageId: "provider-ollama-cloud",
|
|
17
|
+
connectionTypeId: "ollama-cloud-account",
|
|
18
|
+
displayName: "Work",
|
|
19
|
+
state: "ready" as const,
|
|
20
|
+
providerType: "ollama-cloud",
|
|
21
|
+
generation: "generation-1",
|
|
22
|
+
safeMetadata: {},
|
|
23
|
+
};
|
|
24
|
+
const catalog = [
|
|
25
|
+
{
|
|
26
|
+
packageId: "provider-ollama-cloud",
|
|
27
|
+
displayName: "Ollama Cloud",
|
|
28
|
+
version: "0.0.1",
|
|
29
|
+
capabilities: [
|
|
30
|
+
{
|
|
31
|
+
id: "ollama-cloud-models",
|
|
32
|
+
kind: "model" as const,
|
|
33
|
+
connectionTypes: ["ollama-cloud-account"],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
connectionTypes: [
|
|
37
|
+
{
|
|
38
|
+
id: "ollama-cloud-account",
|
|
39
|
+
displayName: "Ollama Cloud account",
|
|
40
|
+
allowMultiple: true,
|
|
41
|
+
authorizationKind: "api-key" as const,
|
|
42
|
+
capabilities: ["ollama-cloud-models"],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
expect(
|
|
49
|
+
isModelConnectionEligible({
|
|
50
|
+
connection,
|
|
51
|
+
packages: [
|
|
52
|
+
{
|
|
53
|
+
packageId: "provider-ollama-cloud",
|
|
54
|
+
version: "0.0.1",
|
|
55
|
+
state: "disabled",
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
catalog,
|
|
59
|
+
}),
|
|
60
|
+
).toBe(false);
|
|
61
|
+
expect(
|
|
62
|
+
isModelConnectionEligible({
|
|
63
|
+
connection,
|
|
64
|
+
packages: [
|
|
65
|
+
{
|
|
66
|
+
packageId: "provider-ollama-cloud",
|
|
67
|
+
version: "0.0.1",
|
|
68
|
+
state: "installed",
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
catalog,
|
|
72
|
+
}),
|
|
73
|
+
).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("preserves a model-less Bot while unrelated settings save", () => {
|
|
77
|
+
expect(
|
|
78
|
+
resolveBotSettingsModel({
|
|
79
|
+
useExactModel: false,
|
|
80
|
+
selectedModel: "",
|
|
81
|
+
exactConnectionId: "",
|
|
82
|
+
exactProviderModelId: "",
|
|
83
|
+
}),
|
|
84
|
+
).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("rejects malformed model selections before settings mutate", () => {
|
|
88
|
+
expect(() =>
|
|
89
|
+
resolveBotSettingsModel({
|
|
90
|
+
useExactModel: false,
|
|
91
|
+
selectedModel: "not-json",
|
|
92
|
+
exactConnectionId: "",
|
|
93
|
+
exactProviderModelId: "",
|
|
94
|
+
}),
|
|
95
|
+
).toThrow("A Connection and model ID are required");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("preserves an existing binding without a new selection", () => {
|
|
99
|
+
expect(
|
|
100
|
+
resolveBotSettingsModel({
|
|
101
|
+
current: {
|
|
102
|
+
connectionId: "ollama-1",
|
|
103
|
+
providerModelId: "glm:cloud",
|
|
104
|
+
},
|
|
105
|
+
useExactModel: false,
|
|
106
|
+
selectedModel: "",
|
|
107
|
+
exactConnectionId: "",
|
|
108
|
+
exactProviderModelId: "",
|
|
109
|
+
}),
|
|
110
|
+
).toEqual({
|
|
111
|
+
connectionId: "ollama-1",
|
|
112
|
+
providerModelId: "glm:cloud",
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("shared model option building", () => {
|
|
118
|
+
const catalog = [
|
|
119
|
+
{
|
|
120
|
+
packageId: "provider-ollama-cloud",
|
|
121
|
+
displayName: "Ollama Cloud",
|
|
122
|
+
version: "0.0.1",
|
|
123
|
+
capabilities: [
|
|
124
|
+
{
|
|
125
|
+
id: "ollama-cloud-models",
|
|
126
|
+
kind: "model" as const,
|
|
127
|
+
connectionTypes: ["ollama-cloud-account"],
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
connectionTypes: [
|
|
131
|
+
{
|
|
132
|
+
id: "ollama-cloud-account",
|
|
133
|
+
displayName: "Ollama Cloud account",
|
|
134
|
+
allowMultiple: true,
|
|
135
|
+
authorizationKind: "api-key" as const,
|
|
136
|
+
capabilities: ["ollama-cloud-models"],
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
const packages = [
|
|
142
|
+
{
|
|
143
|
+
packageId: "provider-ollama-cloud",
|
|
144
|
+
version: "0.0.1",
|
|
145
|
+
state: "installed" as const,
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
const connections = [
|
|
149
|
+
{
|
|
150
|
+
connectionId: "ollama-1",
|
|
151
|
+
packageId: "provider-ollama-cloud",
|
|
152
|
+
connectionTypeId: "ollama-cloud-account",
|
|
153
|
+
displayName: "Work",
|
|
154
|
+
state: "ready" as const,
|
|
155
|
+
providerType: "ollama-cloud",
|
|
156
|
+
modelCatalog: {
|
|
157
|
+
schemaVersion: 1 as const,
|
|
158
|
+
generation: "catalog-1",
|
|
159
|
+
state: "fresh" as const,
|
|
160
|
+
models: [
|
|
161
|
+
{
|
|
162
|
+
providerModelId: "llama-3:cloud",
|
|
163
|
+
displayName: "Llama 3",
|
|
164
|
+
capabilities: { tools: true, vision: false, reasoning: false },
|
|
165
|
+
source: "discovered" as const,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
safeMetadata: {},
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
connectionId: "ollama-2",
|
|
173
|
+
packageId: "provider-ollama-cloud",
|
|
174
|
+
connectionTypeId: "ollama-cloud-account",
|
|
175
|
+
displayName: "Revoked",
|
|
176
|
+
state: "revoked" as const,
|
|
177
|
+
safeMetadata: {},
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
test("offers one option per advertised model of an eligible Connection", () => {
|
|
182
|
+
const eligible = eligibleModelConnections({
|
|
183
|
+
connections,
|
|
184
|
+
packages,
|
|
185
|
+
catalog,
|
|
186
|
+
});
|
|
187
|
+
expect(eligible.map((connection) => connection.connectionId)).toEqual([
|
|
188
|
+
"ollama-1",
|
|
189
|
+
]);
|
|
190
|
+
expect(modelSelectOptions(eligible)).toEqual([
|
|
191
|
+
{
|
|
192
|
+
value: JSON.stringify(["ollama-1", "llama-3:cloud"]),
|
|
193
|
+
label: "Llama 3 — Work",
|
|
194
|
+
},
|
|
195
|
+
]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("round-trips a selection and names it for prose", () => {
|
|
199
|
+
const model = {
|
|
200
|
+
connectionId: "ollama-1",
|
|
201
|
+
providerModelId: "llama-3:cloud",
|
|
202
|
+
};
|
|
203
|
+
expect(decodeModelSelection(encodeModelSelection(model))).toEqual(model);
|
|
204
|
+
expect(encodeModelSelection(undefined)).toBe("");
|
|
205
|
+
expect(decodeModelSelection("")).toBeUndefined();
|
|
206
|
+
expect(() => decodeModelSelection("not-json")).toThrow(
|
|
207
|
+
"A Connection and model ID are required",
|
|
208
|
+
);
|
|
209
|
+
expect(describeModelAssignment(model, connections)).toBe("Llama 3 — Work");
|
|
210
|
+
expect(
|
|
211
|
+
describeModelAssignment(
|
|
212
|
+
{ connectionId: "ollama-1", providerModelId: "custom:cloud" },
|
|
213
|
+
connections,
|
|
214
|
+
),
|
|
215
|
+
).toBe("custom:cloud — Work");
|
|
216
|
+
expect(describeModelAssignment(undefined, connections)).toBeUndefined();
|
|
217
|
+
});
|
|
218
|
+
});
|