@frockbot/plugin-custom-models 0.0.0 → 0.3.12

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.
@@ -0,0 +1,188 @@
1
+ <script setup lang="ts">
2
+ import { UiButton } from "@frockbot/client-ui";
3
+ import type {
4
+ PackageSettingDefinition,
5
+ PackageSettingSchema,
6
+ } from "@frockbot/kernel-composition";
7
+ import {
8
+ frockBotWebDataKey,
9
+ type PluginCatalogItem,
10
+ } from "@frockbot/plugin-shell/shared";
11
+ import { computed, inject, ref, watch } from "vue";
12
+
13
+ const props = defineProps<{ item: PluginCatalogItem }>();
14
+
15
+ const providedWeb = inject(frockBotWebDataKey);
16
+ if (!providedWeb) throw new Error("shell client data was not provided");
17
+ const web = providedWeb;
18
+
19
+ type FieldKind = "enum" | "boolean" | "number" | "text";
20
+ type DraftValue = string | number | boolean;
21
+
22
+ function fieldKind(schema: PackageSettingSchema): FieldKind {
23
+ if (schema.enum && schema.enum.length > 0) return "enum";
24
+ if (schema.type === "boolean") return "boolean";
25
+ if (schema.type === "number" || schema.type === "integer") return "number";
26
+ return "text";
27
+ }
28
+
29
+ function label(definition: PackageSettingDefinition): string {
30
+ return definition.schema.title ?? definition.id;
31
+ }
32
+
33
+ const definitions = computed(() =>
34
+ (props.item.settings ?? []).filter(
35
+ (definition) =>
36
+ definition.role !== "model" && definition.scopes.includes("user"),
37
+ ),
38
+ );
39
+ const stored = computed<Record<string, unknown>>(() => {
40
+ const installation = web.value.userSettings?.packages.find(
41
+ (candidate) => candidate.packageId === props.item.packageId,
42
+ );
43
+ return (installation?.values ?? {}) as Record<string, unknown>;
44
+ });
45
+ const draft = ref<Record<string, DraftValue>>({});
46
+
47
+ watch(
48
+ [definitions, stored],
49
+ () => {
50
+ draft.value = Object.fromEntries(
51
+ definitions.value.map((definition) => {
52
+ const value = stored.value[definition.id];
53
+ if (
54
+ typeof value === "string" ||
55
+ typeof value === "number" ||
56
+ typeof value === "boolean"
57
+ ) {
58
+ return [definition.id, value];
59
+ }
60
+ return [
61
+ definition.id,
62
+ fieldKind(definition.schema) === "boolean" ? false : "",
63
+ ];
64
+ }),
65
+ );
66
+ },
67
+ { immediate: true },
68
+ );
69
+
70
+ function values(): Record<string, DraftValue> {
71
+ const result: Record<string, DraftValue> = {};
72
+ for (const definition of definitions.value) {
73
+ const kind = fieldKind(definition.schema);
74
+ const value = draft.value[definition.id];
75
+ if (kind === "boolean") {
76
+ result[definition.id] = value === true;
77
+ } else if (value !== "" && value !== undefined) {
78
+ const normalized = kind === "number" ? Number(value) : String(value);
79
+ if (typeof normalized !== "number" || Number.isFinite(normalized)) {
80
+ result[definition.id] = normalized;
81
+ }
82
+ }
83
+ }
84
+ return result;
85
+ }
86
+
87
+ async function save(): Promise<void> {
88
+ const patch = values();
89
+ if (Object.keys(patch).length === 0) return;
90
+ try {
91
+ await web.value.savePackageSettings(props.item.packageId, patch);
92
+ } catch (error) {
93
+ web.value.settingsError =
94
+ error instanceof Error
95
+ ? error.message
96
+ : "Could not save the provider settings";
97
+ }
98
+ }
99
+ </script>
100
+
101
+ <template>
102
+ <form
103
+ v-if="definitions.length > 0"
104
+ class="provider-settings"
105
+ @submit.prevent="save"
106
+ >
107
+ <label v-for="definition in definitions" :key="definition.id">
108
+ <span>{{ label(definition) }}</span>
109
+ <select
110
+ v-if="fieldKind(definition.schema) === 'enum'"
111
+ v-model="draft[definition.id]"
112
+ >
113
+ <option value="">Default</option>
114
+ <option
115
+ v-for="choice in definition.schema.enum ?? []"
116
+ :key="String(choice)"
117
+ :value="choice ?? ''"
118
+ >
119
+ {{ String(choice) }}
120
+ </option>
121
+ </select>
122
+ <input
123
+ v-else-if="fieldKind(definition.schema) === 'boolean'"
124
+ v-model="draft[definition.id]"
125
+ type="checkbox"
126
+ />
127
+ <input
128
+ v-else-if="fieldKind(definition.schema) === 'number'"
129
+ v-model="draft[definition.id]"
130
+ type="number"
131
+ inputmode="numeric"
132
+ :min="definition.schema.minimum"
133
+ :max="definition.schema.maximum"
134
+ :step="definition.schema.type === 'integer' ? 1 : 'any'"
135
+ />
136
+ <input
137
+ v-else
138
+ v-model="draft[definition.id]"
139
+ type="text"
140
+ :maxlength="definition.schema.maxLength"
141
+ />
142
+ <small v-if="definition.schema.description">
143
+ {{ definition.schema.description }}
144
+ </small>
145
+ </label>
146
+ <div class="provider-settings__actions">
147
+ <UiButton type="submit" variant="primary">Save settings</UiButton>
148
+ </div>
149
+ </form>
150
+ </template>
151
+
152
+ <style scoped>
153
+ .provider-settings {
154
+ display: grid;
155
+ gap: 12px;
156
+ margin: 0 8px;
157
+ padding: 12px 0 8px;
158
+ border-top: 1px solid var(--frock-border);
159
+ }
160
+
161
+ .provider-settings label {
162
+ display: grid;
163
+ gap: 6px;
164
+ }
165
+
166
+ .provider-settings span,
167
+ .provider-settings small {
168
+ color: var(--frock-text-muted);
169
+ font-size: var(--frock-text-sm);
170
+ }
171
+
172
+ .provider-settings input,
173
+ .provider-settings select {
174
+ min-width: 0;
175
+ padding: 8px 11px;
176
+ border: 1px solid var(--frock-border);
177
+ border-radius: var(--frock-radius-control);
178
+ background: var(--frock-surface-raised);
179
+ color: var(--frock-text);
180
+ font-size: var(--frock-text-base);
181
+ }
182
+
183
+ .provider-settings__actions {
184
+ display: flex;
185
+ justify-content: flex-end;
186
+ gap: 8px;
187
+ }
188
+ </style>
@@ -0,0 +1,182 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type {
3
+ ClientPluginContext,
4
+ ClientSlotRegistration,
5
+ } from "@frockbot/client-core";
6
+ import {
7
+ decodeConfigurationCommandV1,
8
+ initializeBotSettingsV1,
9
+ type ConfigurationCommandV1,
10
+ type ModelBindingV1,
11
+ type UserSettingsViewV1,
12
+ } from "@frockbot/configuration-core";
13
+ import {
14
+ frockBotWebDataKey,
15
+ type FrockBotWebData,
16
+ } from "@frockbot/plugin-shell/shared";
17
+ import { ref, type Ref } from "vue";
18
+ import {
19
+ customModelsClientPlugin,
20
+ customModelsClientStateKey,
21
+ type CustomModelsClientState,
22
+ } from "./index.js";
23
+
24
+ function fixture(accountModel?: ModelBindingV1): {
25
+ slots: ClientSlotRegistration[];
26
+ commands: ConfigurationCommandV1[];
27
+ state: CustomModelsClientState;
28
+ userLoads(): number;
29
+ botLoads(): number;
30
+ dispose(): void;
31
+ } {
32
+ const slots: ClientSlotRegistration[] = [];
33
+ const commands: ConfigurationCommandV1[] = [];
34
+ const user: UserSettingsViewV1 = {
35
+ schemaVersion: 1,
36
+ revision: 3,
37
+ profile: { name: "User" },
38
+ packages: [
39
+ {
40
+ packageId: "custom-models",
41
+ version: "0.0.1",
42
+ state: "installed",
43
+ ...(accountModel ? { values: { "account-model": accountModel } } : {}),
44
+ },
45
+ ],
46
+ connections: [],
47
+ };
48
+ const bot = initializeBotSettingsV1("scout");
49
+ let userLoadCount = 0;
50
+ let botLoadCount = 0;
51
+ const web = ref({
52
+ activeBotId: bot.botId,
53
+ botSettings: bot,
54
+ userSettings: user,
55
+ loadUserSettings: () => {
56
+ userLoadCount += 1;
57
+ return Promise.resolve();
58
+ },
59
+ loadBotSettings: () => {
60
+ botLoadCount += 1;
61
+ return Promise.resolve();
62
+ },
63
+ }) as unknown as Ref<FrockBotWebData>;
64
+ let state: CustomModelsClientState | undefined;
65
+ const context: ClientPluginContext = {
66
+ transport: {
67
+ turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
68
+ executeConfiguration: (input) => {
69
+ const command = decodeConfigurationCommandV1(input);
70
+ commands.push(command);
71
+ return Promise.resolve({
72
+ schemaVersion: 1,
73
+ commandId: command.commandId,
74
+ revision: command.expectedRevision + 1,
75
+ status: "applied",
76
+ });
77
+ },
78
+ },
79
+ inject: ((key: unknown) => {
80
+ if (key === frockBotWebDataKey) return web;
81
+ throw new Error("unexpected client provider");
82
+ }) as ClientPluginContext["inject"],
83
+ provide: ((key: unknown, value: unknown) => {
84
+ if (key === customModelsClientStateKey) {
85
+ state = value as CustomModelsClientState;
86
+ }
87
+ return () => {};
88
+ }) as ClientPluginContext["provide"],
89
+ slot: (registration) => {
90
+ slots.push(registration);
91
+ return () => slots.splice(slots.indexOf(registration), 1);
92
+ },
93
+ };
94
+ const result = customModelsClientPlugin(context);
95
+ if (result instanceof Promise) throw new Error("expected synchronous plugin");
96
+ if (!state) throw new Error("Custom models state was not provided");
97
+ return {
98
+ slots,
99
+ commands,
100
+ state,
101
+ userLoads: () => userLoadCount,
102
+ botLoads: () => botLoadCount,
103
+ dispose: () => {
104
+ if (Array.isArray(result)) {
105
+ for (const dispose of result.toReversed()) dispose();
106
+ } else if (typeof result === "function") {
107
+ result();
108
+ }
109
+ },
110
+ };
111
+ }
112
+
113
+ describe("Custom models client Contribution", () => {
114
+ test("mounts the account and Bot model sections", () => {
115
+ const mounted = fixture();
116
+ expect(mounted.slots.map((slot) => slot.slot)).toEqual([
117
+ "frockbot.models-sections",
118
+ "frockbot.bot-settings-sections",
119
+ ]);
120
+ mounted.dispose();
121
+ });
122
+
123
+ test("round-trips selections and clears through User and Bot Package-setting commands", async () => {
124
+ const mounted = fixture();
125
+ const model: ModelBindingV1 = {
126
+ connectionId: "flock-ai",
127
+ providerModelId: "@frock/manual",
128
+ };
129
+
130
+ await mounted.state.setAccountModel(model);
131
+ await mounted.state.setAccountModel(undefined);
132
+ await mounted.state.setBotModel(model);
133
+ await mounted.state.setBotModel(undefined);
134
+
135
+ expect(mounted.commands).toMatchObject([
136
+ {
137
+ type: "user/set-package-settings",
138
+ packageId: "custom-models",
139
+ values: { "account-model": model },
140
+ },
141
+ {
142
+ type: "user/set-package-settings",
143
+ packageId: "custom-models",
144
+ unset: ["account-model"],
145
+ },
146
+ {
147
+ type: "bot/set-package-settings",
148
+ botId: "scout",
149
+ packageId: "custom-models",
150
+ values: { model },
151
+ },
152
+ {
153
+ type: "bot/set-package-settings",
154
+ botId: "scout",
155
+ packageId: "custom-models",
156
+ unset: ["model"],
157
+ },
158
+ ]);
159
+ expect(mounted.userLoads()).toBe(2);
160
+ expect(mounted.botLoads()).toBe(2);
161
+ mounted.dispose();
162
+ });
163
+
164
+ test("clears an account model whose Connection no longer resolves", async () => {
165
+ const mounted = fixture({
166
+ connectionId: "ollama-legacy",
167
+ providerModelId: "glm-5.3-flash:cloud",
168
+ });
169
+
170
+ await mounted.state.setAccountModel(undefined);
171
+
172
+ expect(mounted.commands).toMatchObject([
173
+ {
174
+ type: "user/set-package-settings",
175
+ packageId: "custom-models",
176
+ unset: ["account-model"],
177
+ },
178
+ ]);
179
+ expect(mounted.userLoads()).toBe(1);
180
+ mounted.dispose();
181
+ });
182
+ });
@@ -0,0 +1,47 @@
1
+ /// <reference path="../env.d.ts" />
2
+
3
+ import type { ClientPlugin } from "@frockbot/client-core";
4
+ import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
5
+ import AccountModelsSection from "./AccountModelsSection.vue";
6
+ import BotModelSection from "./BotModelSection.vue";
7
+ import {
8
+ createCustomModelsClientState,
9
+ customModelsClientStateKey,
10
+ } from "./state.js";
11
+ import { defineClientContribution } from "@frockbot/kernel-contracts/contributions";
12
+
13
+ export const customModelsClientPlugin: ClientPlugin = (ctx) => {
14
+ const web = ctx.inject(frockBotWebDataKey);
15
+ const state = createCustomModelsClientState(ctx.transport, web);
16
+ return [
17
+ ctx.provide(customModelsClientStateKey, state),
18
+ ctx.slot({
19
+ slot: "frockbot.models-sections",
20
+ order: 0,
21
+ component: AccountModelsSection,
22
+ }),
23
+ ctx.slot({
24
+ slot: "frockbot.bot-settings-sections",
25
+ order: 0,
26
+ component: BotModelSection,
27
+ }),
28
+ ];
29
+ };
30
+
31
+ export {
32
+ createCustomModelsClientState,
33
+ customModelsClientStateKey,
34
+ type CustomModelsClientState,
35
+ } from "./state.js";
36
+
37
+ export default customModelsClientPlugin;
38
+
39
+ /**
40
+ * The manifest's `client` entry, resolved by specifier. The application looks
41
+ * this descriptor up in its Contribution table; it never branches on which
42
+ * Package it belongs to.
43
+ */
44
+ export const clientContribution = defineClientContribution<ClientPlugin>({
45
+ specifier: "@frockbot/plugin-custom-models/client",
46
+ plugin: customModelsClientPlugin,
47
+ });
@@ -0,0 +1,94 @@
1
+ import type { ClientPluginContext } from "@frockbot/client-core";
2
+ import type {
3
+ BotSettingsViewV1,
4
+ ModelBindingV1,
5
+ UserSettingsViewV1,
6
+ } from "@frockbot/configuration-core";
7
+ import type { FrockBotWebData } from "@frockbot/plugin-shell/shared";
8
+ import type { InjectionKey, Ref } from "vue";
9
+ import {
10
+ ACCOUNT_MODEL_SETTING_ID_V1,
11
+ BOT_MODEL_SETTING_ID_V1,
12
+ } from "../model-settings.js";
13
+
14
+ export interface CustomModelsClientState {
15
+ setAccountModel(model: ModelBindingV1 | undefined): Promise<void>;
16
+ setBotModel(model: ModelBindingV1 | undefined): Promise<void>;
17
+ }
18
+
19
+ export const customModelsClientStateKey: InjectionKey<CustomModelsClientState> =
20
+ Symbol("frockbot.custom-models.client-state");
21
+
22
+ type CustomModelsWebData = Pick<
23
+ FrockBotWebData,
24
+ | "activeBotId"
25
+ | "botSettings"
26
+ | "loadBotSettings"
27
+ | "loadUserSettings"
28
+ | "userSettings"
29
+ >;
30
+
31
+ function settingChange(
32
+ settingId: string,
33
+ model: ModelBindingV1 | undefined,
34
+ ): { values: Record<string, ModelBindingV1> } | { unset: [settingId: string] } {
35
+ return model ? { values: { [settingId]: model } } : { unset: [settingId] };
36
+ }
37
+
38
+ async function rejectRefusal(
39
+ receipt: Awaited<
40
+ ReturnType<
41
+ NonNullable<ClientPluginContext["transport"]["executeConfiguration"]>
42
+ >
43
+ >,
44
+ ): Promise<void> {
45
+ if (receipt.status === "rejected") throw new Error(receipt.failure);
46
+ }
47
+
48
+ /**
49
+ * Package-local command actions. The User and Bot Durable Objects remain the
50
+ * authorities: each action submits one versioned command and re-reads the
51
+ * projection instead of editing client state optimistically.
52
+ */
53
+ export function createCustomModelsClientState(
54
+ transport: ClientPluginContext["transport"],
55
+ web: Ref<CustomModelsWebData>,
56
+ ): CustomModelsClientState {
57
+ return {
58
+ async setAccountModel(model) {
59
+ const current: UserSettingsViewV1 | undefined = web.value.userSettings;
60
+ if (!current || !transport.executeConfiguration) {
61
+ throw new Error("Model settings are unavailable");
62
+ }
63
+ const receipt = await transport.executeConfiguration({
64
+ schemaVersion: 1,
65
+ type: "user/set-package-settings",
66
+ commandId: crypto.randomUUID(),
67
+ expectedRevision: current.revision,
68
+ packageId: "custom-models",
69
+ ...settingChange(ACCOUNT_MODEL_SETTING_ID_V1, model),
70
+ });
71
+ await web.value.loadUserSettings();
72
+ await rejectRefusal(receipt);
73
+ },
74
+
75
+ async setBotModel(model) {
76
+ const current: BotSettingsViewV1 | undefined = web.value.botSettings;
77
+ const botId = web.value.activeBotId;
78
+ if (!current || !botId || !transport.executeConfiguration) {
79
+ throw new Error("Bot model settings are unavailable");
80
+ }
81
+ const receipt = await transport.executeConfiguration({
82
+ schemaVersion: 1,
83
+ type: "bot/set-package-settings",
84
+ commandId: crypto.randomUUID(),
85
+ expectedRevision: current.revision,
86
+ botId,
87
+ packageId: "custom-models",
88
+ ...settingChange(BOT_MODEL_SETTING_ID_V1, model),
89
+ });
90
+ await web.value.loadBotSettings();
91
+ await rejectRefusal(receipt);
92
+ },
93
+ };
94
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ declare module "*.vue" {
2
+ import type { DefineComponent } from "vue";
3
+
4
+ const component: DefineComponent;
5
+ export default component;
6
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export {
2
+ ACCOUNT_MODEL_SETTING_ID_V1,
3
+ BOT_MODEL_SETTING_ID_V1,
4
+ } from "./model-settings.js";
5
+ export { default as manifest } from "./manifest.js";
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { decodeFrockBotManifest } from "@frockbot/kernel-composition";
3
+ import type { PackageSettingSchema } from "@frockbot/kernel-composition";
4
+ import foundationApplication from "../../../applications/foundation/frockbot.application.json" with { type: "json" };
5
+ import ollamaManifest from "../../plugin-provider-ollama-cloud/frockbot.json" with { type: "json" };
6
+ import rawManifest from "../frockbot.json" with { type: "json" };
7
+
8
+ const MODEL_BINDING_SCHEMA = {
9
+ type: "object",
10
+ properties: {
11
+ connectionId: { type: "string" },
12
+ providerModelId: { type: "string" },
13
+ },
14
+ required: ["connectionId", "providerModelId"],
15
+ additionalProperties: false,
16
+ } satisfies PackageSettingSchema;
17
+
18
+ describe("Custom models manifest", () => {
19
+ test("is default-disabled and declares exact User and Bot model-role settings", () => {
20
+ const manifest = decodeFrockBotManifest(rawManifest);
21
+
22
+ expect(manifest).toMatchObject({
23
+ id: "custom-models",
24
+ displayName: "Custom models",
25
+ version: "0.0.1",
26
+ defaultEnablement: "disabled",
27
+ });
28
+ expect(manifest.configuration?.settings).toEqual([
29
+ {
30
+ id: "account-model",
31
+ schemaVersion: 1,
32
+ scopes: ["user"],
33
+ role: "model",
34
+ schema: MODEL_BINDING_SCHEMA,
35
+ },
36
+ {
37
+ id: "model",
38
+ schemaVersion: 1,
39
+ scopes: ["bot"],
40
+ role: "model",
41
+ schema: MODEL_BINDING_SCHEMA,
42
+ },
43
+ ]);
44
+ });
45
+
46
+ test("is registered first-party in Foundation", () => {
47
+ expect(foundationApplication.packages).toContainEqual({
48
+ specifier: "@frockbot/plugin-custom-models",
49
+ version: "0.0.1",
50
+ grants: [],
51
+ });
52
+ });
53
+
54
+ test("is the declared Ollama Cloud dependency", () => {
55
+ expect(ollamaManifest.dependencies["custom-models"]).toBe(">=0.0.1");
56
+ });
57
+ });
@@ -0,0 +1,3 @@
1
+ import manifest from "../frockbot.json" with { type: "json" };
2
+
3
+ export default manifest;