@frockbot/plugin-settings 0.1.3 → 0.2.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.
@@ -88,9 +88,12 @@ async function save(): Promise<void> {
88
88
  >
89
89
  <p class="field-hint">
90
90
  Settings of the Packages you have enabled. Model providers are
91
- configured in Models and accounts in Connections.
91
+ configured in Models and accounts in Connectors.
92
92
  </p>
93
93
  <PackageSettingsSection />
94
+ <UiButton type="button" @click="surfaces.open('plugins')">
95
+ Open Plugins
96
+ </UiButton>
94
97
  </UiAnchor>
95
98
  <p v-if="web.settingsError" class="settings-error" role="alert">
96
99
  {{ web.settingsError }}
@@ -174,23 +177,6 @@ async function save(): Promise<void> {
174
177
  font-size: var(--frock-text-sm);
175
178
  }
176
179
 
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
180
  .settings-error {
195
181
  margin: 0;
196
182
  color: var(--frock-danger-text);
@@ -1,183 +1,94 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  decodeModelSelection,
4
- describeModelAssignment,
4
+ describeModelBinding,
5
5
  encodeModelSelection,
6
6
  eligibleModelConnections,
7
7
  isModelConnectionEligible,
8
8
  modelSelectOptions,
9
- resolveBotSettingsModel,
10
9
  } from "./bot-settings.js";
11
10
 
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 = [
11
+ const catalog = [
12
+ {
13
+ packageId: "provider-ollama-cloud",
14
+ displayName: "Ollama Cloud",
15
+ version: "0.0.1",
16
+ capabilities: [
25
17
  {
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
- ],
18
+ id: "ollama-cloud-models",
19
+ kind: "model" as const,
20
+ connectionTypes: ["ollama-cloud-account"],
45
21
  },
46
- ];
22
+ ],
23
+ connectionTypes: [
24
+ {
25
+ id: "ollama-cloud-account",
26
+ displayName: "Ollama Cloud account",
27
+ allowMultiple: true,
28
+ authorizationKind: "api-key" as const,
29
+ capabilities: ["ollama-cloud-models"],
30
+ },
31
+ ],
32
+ },
33
+ ];
34
+ const packages = [
35
+ {
36
+ packageId: "provider-ollama-cloud",
37
+ version: "0.0.1",
38
+ state: "installed" as const,
39
+ },
40
+ ];
41
+ const connections = [
42
+ {
43
+ connectionId: "ollama-1",
44
+ packageId: "provider-ollama-cloud",
45
+ connectionTypeId: "ollama-cloud-account",
46
+ displayName: "Work",
47
+ state: "ready" as const,
48
+ providerType: "ollama-cloud",
49
+ modelCatalog: {
50
+ schemaVersion: 1 as const,
51
+ generation: "catalog-1",
52
+ state: "fresh" as const,
53
+ models: [
54
+ {
55
+ providerModelId: "llama-3:cloud",
56
+ displayName: "Llama 3",
57
+ capabilities: { tools: true, vision: false, reasoning: false },
58
+ source: "discovered" as const,
59
+ },
60
+ ],
61
+ },
62
+ safeMetadata: {},
63
+ },
64
+ {
65
+ connectionId: "ollama-2",
66
+ packageId: "provider-ollama-cloud",
67
+ connectionTypeId: "ollama-cloud-account",
68
+ displayName: "Revoked",
69
+ state: "revoked" as const,
70
+ safeMetadata: {},
71
+ },
72
+ ];
47
73
 
74
+ describe("shared model option building", () => {
75
+ test("requires an enabled model provider Package", () => {
48
76
  expect(
49
77
  isModelConnectionEligible({
50
- connection,
51
- packages: [
52
- {
53
- packageId: "provider-ollama-cloud",
54
- version: "0.0.1",
55
- state: "disabled",
56
- },
57
- ],
78
+ connection: connections[0]!,
79
+ packages: [{ ...packages[0]!, state: "disabled" }],
58
80
  catalog,
59
81
  }),
60
82
  ).toBe(false);
61
83
  expect(
62
84
  isModelConnectionEligible({
63
- connection,
64
- packages: [
65
- {
66
- packageId: "provider-ollama-cloud",
67
- version: "0.0.1",
68
- state: "installed",
69
- },
70
- ],
85
+ connection: connections[0]!,
86
+ packages,
71
87
  catalog,
72
88
  }),
73
89
  ).toBe(true);
74
90
  });
75
91
 
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
92
  test("offers one option per advertised model of an eligible Connection", () => {
182
93
  const eligible = eligibleModelConnections({
183
94
  connections,
@@ -195,7 +106,7 @@ describe("shared model option building", () => {
195
106
  ]);
196
107
  });
197
108
 
198
- test("round-trips a selection and names it for prose", () => {
109
+ test("round-trips a binding and names it for prose", () => {
199
110
  const model = {
200
111
  connectionId: "ollama-1",
201
112
  providerModelId: "llama-3:cloud",
@@ -206,13 +117,13 @@ describe("shared model option building", () => {
206
117
  expect(() => decodeModelSelection("not-json")).toThrow(
207
118
  "A Connection and model ID are required",
208
119
  );
209
- expect(describeModelAssignment(model, connections)).toBe("Llama 3 — Work");
120
+ expect(describeModelBinding(model, connections)).toBe("Llama 3 — Work");
210
121
  expect(
211
- describeModelAssignment(
122
+ describeModelBinding(
212
123
  { connectionId: "ollama-1", providerModelId: "custom:cloud" },
213
124
  connections,
214
125
  ),
215
126
  ).toBe("custom:cloud — Work");
216
- expect(describeModelAssignment(undefined, connections)).toBeUndefined();
127
+ expect(describeModelBinding(undefined, connections)).toBeUndefined();
217
128
  });
218
129
  });
@@ -1,6 +1,6 @@
1
1
  import type {
2
2
  ConnectionView,
3
- ModelAssignment,
3
+ ModelBindingV1,
4
4
  PackageInstallationView,
5
5
  } from "@frockbot/configuration-core";
6
6
  import type { PluginCatalogItem } from "@frockbot/plugin-shell/shared";
@@ -31,43 +31,6 @@ export function isModelConnectionEligible(input: {
31
31
  );
32
32
  }
33
33
 
34
- export function resolveBotSettingsModel(input: {
35
- current?: ModelAssignment;
36
- useExactModel: boolean;
37
- selectedModel: string;
38
- exactConnectionId: string;
39
- exactProviderModelId: string;
40
- }): ModelAssignment | undefined {
41
- if (input.useExactModel) {
42
- const selected = {
43
- connectionId: input.exactConnectionId,
44
- providerModelId: input.exactProviderModelId.trim(),
45
- };
46
- if (!selected.connectionId || !selected.providerModelId) {
47
- throw new Error("A Connection and model ID are required");
48
- }
49
- return selected;
50
- }
51
- if (!input.selectedModel) return input.current;
52
- let value: unknown;
53
- try {
54
- value = JSON.parse(input.selectedModel);
55
- } catch {
56
- throw new Error("A Connection and model ID are required");
57
- }
58
- if (
59
- !Array.isArray(value) ||
60
- value.length !== 2 ||
61
- typeof value[0] !== "string" ||
62
- value[0].length === 0 ||
63
- typeof value[1] !== "string" ||
64
- value[1].length === 0
65
- ) {
66
- throw new Error("A Connection and model ID are required");
67
- }
68
- return { connectionId: value[0], providerModelId: value[1] };
69
- }
70
-
71
34
  export interface ModelSelectOption {
72
35
  value: string;
73
36
  label: string;
@@ -103,15 +66,15 @@ export function modelSelectOptions(
103
66
  );
104
67
  }
105
68
 
106
- export function encodeModelSelection(model?: ModelAssignment): string {
69
+ export function encodeModelSelection(model?: ModelBindingV1): string {
107
70
  return model
108
71
  ? JSON.stringify([model.connectionId, model.providerModelId])
109
72
  : "";
110
73
  }
111
74
 
112
75
  /** How a bound model reads in prose, e.g. "Llama 3 — Work". */
113
- export function describeModelAssignment(
114
- model: ModelAssignment | undefined,
76
+ export function describeModelBinding(
77
+ model: ModelBindingV1 | undefined,
115
78
  connections: readonly ConnectionView[],
116
79
  ): string | undefined {
117
80
  if (!model) return undefined;
@@ -128,7 +91,7 @@ export function describeModelAssignment(
128
91
  /** The inverse of {@link encodeModelSelection}; an empty value means no model. */
129
92
  export function decodeModelSelection(
130
93
  value: string,
131
- ): ModelAssignment | undefined {
94
+ ): ModelBindingV1 | undefined {
132
95
  if (!value) return undefined;
133
96
  let parsed: unknown;
134
97
  try {
@@ -5,50 +5,10 @@ import {
5
5
  type ClientSlotRegistration,
6
6
  } from "@frockbot/client-core";
7
7
  import { createClientSurfaceRegistry } from "@frockbot/client-ui";
8
+ import manifest from "../../frockbot.json";
8
9
  import { settingsClientPlugin } from "./index.js";
9
- import {
10
- assignmentHasPendingOperation,
11
- projectAssignmentOperations,
12
- } from "./assignment-operations.js";
13
10
 
14
11
  describe("settings client contribution", () => {
15
- it("projects every Assignment operation without catalog ownership", () => {
16
- const operations = [
17
- {
18
- commandId: "assign-1",
19
- kind: "assigning" as const,
20
- assignmentId: "orphan-assign",
21
- state: "retrying" as const,
22
- target: {
23
- assignmentId: "orphan-assign",
24
- packageId: "missing-package",
25
- capabilityId: "missing-capability",
26
- },
27
- },
28
- {
29
- commandId: "replace-1",
30
- kind: "replacing" as const,
31
- assignmentId: "orphan-replace",
32
- state: "pending" as const,
33
- },
34
- {
35
- commandId: "unassign-1",
36
- kind: "unassigning" as const,
37
- assignmentId: "orphan-unassign",
38
- state: "retrying" as const,
39
- },
40
- ];
41
- const projected = projectAssignmentOperations({
42
- assignmentOperations: operations,
43
- });
44
- expect(projected).toEqual(operations);
45
- expect(projected).not.toBe(operations);
46
- expect(assignmentHasPendingOperation(projected, "orphan-unassign")).toBe(
47
- true,
48
- );
49
- expect(assignmentHasPendingOperation(projected, "stable")).toBe(false);
50
- });
51
-
52
12
  it("registers feature surfaces and shell-owned trigger seats", () => {
53
13
  const surfaces = createClientSurfaceRegistry();
54
14
  const slots: ClientSlotRegistration[] = [];
@@ -95,6 +55,12 @@ describe("settings client contribution", () => {
95
55
  ]) {
96
56
  expect(surfaces.has(id)).toBe(true);
97
57
  }
58
+ surfaces.open("connections");
59
+ expect(surfaces.active.value?.title).toBe("Connectors");
60
+ surfaces.close();
61
+ expect(manifest.contributions.client.outlets).toContain(
62
+ "frockbot.models-sections",
63
+ );
98
64
 
99
65
  for (const dispose of result.toReversed()) dispose();
100
66
  expect(slots).toEqual([]);
@@ -7,11 +7,11 @@ import {
7
7
  import BotPanel from "./BotPanel.vue";
8
8
  import BotSettingsSurface from "./BotSettingsSurface.vue";
9
9
  import BotSettingsTrigger from "./BotSettingsTrigger.vue";
10
+ import ConnectorsTrigger from "./ConnectorsTrigger.vue";
10
11
  import ConnectionsSurface from "./ConnectionsSurface.vue";
11
12
  import ModelsSurface from "./ModelsSurface.vue";
12
13
  import PluginsSurface from "./PluginsSurface.vue";
13
14
  import PackageCatalogSurface from "./PackageCatalogSurface.vue";
14
- import PluginsTrigger from "./PluginsTrigger.vue";
15
15
  import UserProfileTrigger from "./UserProfileTrigger.vue";
16
16
  import UserSettingsSurface from "./UserSettingsSurface.vue";
17
17
 
@@ -25,8 +25,8 @@ export const settingsClientPlugin: ClientPlugin = (ctx) => {
25
25
  placement: "panel",
26
26
  }),
27
27
  // Enablement and configuration are separate surfaces: Plugins turns a
28
- // Package on and off, Models configures model providers and picks the
29
- // model, and Connections authorizes the accounts a Bot may be given.
28
+ // Package on and off, Models hosts opt-in model configuration, and
29
+ // Connectors authorizes Connections for every Bot the User owns.
30
30
  surfaces.register({
31
31
  id: "plugins",
32
32
  title: "Plugins",
@@ -39,7 +39,7 @@ export const settingsClientPlugin: ClientPlugin = (ctx) => {
39
39
  }),
40
40
  surfaces.register({
41
41
  id: "connections",
42
- title: "Connections",
42
+ title: "Connectors",
43
43
  component: ConnectionsSurface,
44
44
  }),
45
45
  surfaces.register({
@@ -55,7 +55,7 @@ export const settingsClientPlugin: ClientPlugin = (ctx) => {
55
55
  ctx.slot({
56
56
  slot: "frockbot.sidebar-actions",
57
57
  order: 10,
58
- component: PluginsTrigger,
58
+ component: ConnectorsTrigger,
59
59
  }),
60
60
  ctx.slot({
61
61
  slot: "frockbot.user-profile",
@@ -75,4 +75,14 @@ export const settingsClientPlugin: ClientPlugin = (ctx) => {
75
75
  ];
76
76
  };
77
77
 
78
+ export {
79
+ decodeModelSelection,
80
+ describeModelBinding,
81
+ eligibleModelConnections,
82
+ encodeModelSelection,
83
+ isModelConnectionEligible,
84
+ modelSelectOptions,
85
+ type ModelSelectOption,
86
+ } from "./bot-settings.js";
87
+
78
88
  export default settingsClientPlugin;
@@ -72,12 +72,33 @@ const settingsOnly = catalogItem({
72
72
  ] as PluginCatalogItem["settings"],
73
73
  });
74
74
 
75
+ const customModels = catalogItem({
76
+ packageId: "custom-models",
77
+ settings: [
78
+ {
79
+ id: "model",
80
+ schemaVersion: 1,
81
+ scopes: ["user", "bot"],
82
+ role: "model",
83
+ schema: {
84
+ type: "object",
85
+ properties: {
86
+ connectionId: { type: "string" },
87
+ providerModelId: { type: "string" },
88
+ },
89
+ required: ["connectionId", "providerModelId"],
90
+ additionalProperties: false,
91
+ },
92
+ },
93
+ ],
94
+ });
95
+
75
96
  const plain = catalogItem({
76
97
  packageId: "web",
77
98
  capabilities: [{ id: "web-tools", kind: "tool", connectionTypes: [] }],
78
99
  });
79
100
 
80
- const catalog = [provider, connector, settingsOnly, plain];
101
+ const catalog = [provider, connector, settingsOnly, customModels, plain];
81
102
 
82
103
  function installation(
83
104
  packageId: string,
@@ -91,6 +112,7 @@ describe("Package configuration homes", () => {
91
112
  expect(packageConfigurationHome(provider)).toBe("models");
92
113
  expect(packageConfigurationHome(connector)).toBe("connections");
93
114
  expect(packageConfigurationHome(settingsOnly)).toBe("user-settings");
115
+ expect(packageConfigurationHome(customModels)).toBe("models");
94
116
  // Nothing declared is nothing to configure: the Package appears in Plugins
95
117
  // to be enabled and disabled, and on no configuration surface at all.
96
118
  expect(packageConfigurationHome(plain)).toBe("none");
@@ -103,11 +125,15 @@ describe("Package configuration homes", () => {
103
125
  ["provider-ollama-cloud", "models"],
104
126
  ["composio", "connections"],
105
127
  ["image", "user-settings"],
128
+ ["custom-models", "models"],
106
129
  ]);
107
130
  });
108
131
 
109
132
  test("a model provider that declares Connections is Models', not Connections'", () => {
110
- expect(packagesForHome(catalog, "models")).toEqual([provider]);
133
+ expect(packagesForHome(catalog, "models")).toEqual([
134
+ provider,
135
+ customModels,
136
+ ]);
111
137
  expect(packagesForHome(catalog, "connections")).toEqual([connector]);
112
138
  expect(packagesForHome(catalog, "user-settings")).toEqual([settingsOnly]);
113
139
  });
@@ -138,7 +164,7 @@ describe("Package configuration homes", () => {
138
164
 
139
165
  test("names the surface a Plugins row points at", () => {
140
166
  expect(configurationHomeLabel("models")).toBe("Models");
141
- expect(configurationHomeLabel("connections")).toBe("Connections");
167
+ expect(configurationHomeLabel("connections")).toBe("Connectors");
142
168
  expect(configurationHomeLabel("user-settings")).toBe(
143
169
  "Application settings",
144
170
  );
@@ -26,9 +26,18 @@ export function declaresConnections(item: PluginCatalogItem): boolean {
26
26
  export function packageConfigurationHome(
27
27
  item: PluginCatalogItem,
28
28
  ): PackageConfigurationHome {
29
- if (isModelProviderPackage(item)) return "models";
29
+ if (
30
+ isModelProviderPackage(item) ||
31
+ item.settings?.some((setting) => setting.role === "model")
32
+ )
33
+ return "models";
30
34
  if (declaresConnections(item)) return "connections";
31
- if ((item.settings ?? []).length > 0) return "user-settings";
35
+ if (
36
+ item.settings?.some(
37
+ (setting) => setting.role !== "model" && setting.scopes.includes("user"),
38
+ )
39
+ )
40
+ return "user-settings";
32
41
  return "none";
33
42
  }
34
43
 
@@ -87,7 +96,7 @@ export function configurationHomeLabel(
87
96
  home: PackageConfigurationHome,
88
97
  ): string | undefined {
89
98
  if (home === "models") return "Models";
90
- if (home === "connections") return "Connections";
99
+ if (home === "connections") return "Connectors";
91
100
  if (home === "user-settings") return "Application settings";
92
101
  return undefined;
93
102
  }