@kabmo-labs/kabmo 0.5.3 → 0.6.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/dist/client/index.html +1 -1
- package/dist/client/index.js +359 -350
- package/dist/client/static/js/{index-9js9mrmj.js → index-aq0e78gv.js} +704 -704
- package/dist/client/static/js/index-nzyjf92m.css +1 -0
- package/marketplace/plugins/stats/README.md +26 -4
- package/marketplace/plugins/stats/package.json +2 -2
- package/marketplace/plugins/stats/src/HeaderReadout.test.tsx +157 -0
- package/marketplace/plugins/stats/src/HeaderReadout.tsx +115 -0
- package/marketplace/plugins/stats/src/app.tsx +11 -0
- package/marketplace/plugins/stats/src/format.test.ts +27 -1
- package/marketplace/plugins/stats/src/format.ts +41 -0
- package/package.json +2 -2
- package/src/client/bootstrap.d.ts +11 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +38 -1
- package/src/client/components/common/header-controls/index.tsx +9 -2
- package/src/client/components/common/header-controls/plugin-entry.test.tsx +229 -0
- package/src/client/components/common/header-panel-buttons/entries.test.ts +46 -0
- package/src/client/components/common/header-panel-buttons/entries.ts +109 -0
- package/src/client/components/common/header-panel-buttons/index.tsx +71 -38
- package/src/client/components/common/server-switcher/index.test.tsx +150 -0
- package/src/client/components/common/server-switcher/index.tsx +281 -0
- package/src/client/components/common/window-controls/index.test.tsx +113 -0
- package/src/client/components/common/window-controls/index.tsx +66 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +3 -6
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +59 -3
- package/src/client/components/layout/desktop-layout/index.tsx +34 -26
- package/src/client/components/layout/session-sidebar/Footer.tsx +12 -12
- package/src/client/components/layout/session-sidebar/Header.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +36 -0
- package/src/client/components/mobile/RightSidebar.tsx +2 -2
- package/src/client/components/mobile/SessionSidebar.tsx +5 -8
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +15 -12
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +9 -2
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.test.ts +41 -0
- package/src/client/components/mobile/mobile-session-sidebar/folder-gap.ts +39 -0
- package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +6 -2
- package/src/client/components/settings/categories/OmpSettings.tsx +25 -29
- package/src/client/components/settings/categories/omp-settings/PresetBar.tsx +187 -0
- package/src/client/components/settings/categories/omp-settings/RoleEditor.test.ts +260 -32
- package/src/client/components/settings/categories/omp-settings/RoleEditor.tsx +198 -102
- package/src/client/components/settings/categories/omp-settings/RoleRow.tsx +79 -0
- package/src/client/data/settings/omp-schema.json +2 -2
- package/src/client/hooks/desktop/servers.test.ts +73 -0
- package/src/client/hooks/desktop/servers.ts +126 -0
- package/src/client/hooks/settings/model-roles.ts +156 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
- package/src/client/hooks/ui/updates/shell-download.ts +220 -0
- package/src/client/hooks/workspace/keybindings.ts +44 -0
- package/src/client/hooks/workspace/open-panel.ts +42 -0
- package/src/client/lib/desktop/bridge.ts +158 -0
- package/src/client/lib/desktop/titlebar.test.ts +99 -0
- package/src/client/lib/desktop/titlebar.ts +97 -0
- package/src/client/lib/plugins/slots.test.ts +54 -0
- package/src/client/lib/plugins/slots.ts +37 -1
- package/src/server/db.server.test.ts +44 -3
- package/src/server/db.server.ts +26 -2
- package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
- package/src/server/lib/model-roles/catalog.server.ts +112 -0
- package/src/server/lib/model-roles/mock.server.ts +40 -0
- package/src/server/lib/model-roles/schema.server.ts +36 -0
- package/src/server/lib/model-roles/store.server.test.ts +218 -0
- package/src/server/lib/model-roles/store.server.ts +188 -0
- package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
- package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
- package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
- package/src/server/lib/omp/rpc/manager.ts +18 -13
- package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
- package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
- package/src/server/lib/omp/rpc/session-commands.ts +7 -0
- package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
- package/src/server/lib/omp/session/auto-title.server.ts +150 -120
- package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
- package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
- package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
- package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
- package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
- package/src/server/lib/omp/session/title-request.server.ts +213 -0
- package/src/server/lib/panels/baseline.server.test.ts +4 -4
- package/src/server/lib/panels/compat.server.test.ts +4 -4
- package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
- package/src/server/lib/panels/registry.server.test.ts +4 -4
- package/src/server/lib/panels/state.server.test.ts +4 -4
- package/src/server/lib/panels/sync.server.test.ts +4 -4
- package/src/server/lib/plugins/background.test.ts +2 -2
- package/src/server/lib/plugins/e2e.test.ts +2 -2
- package/src/server/lib/plugins/platform.test.ts +2 -2
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
- package/src/server/lib/queue/delivery.server.ts +11 -2
- package/src/server/lib/updates/shell-release.test.ts +184 -0
- package/src/server/lib/updates/shell-release.ts +215 -0
- package/src/server/lib/waiting/store.server.test.ts +2 -2
- package/src/server/plugins/ssr.ts +13 -10
- package/src/server/routes/chat/stream.test.ts +4 -4
- package/src/server/routes/models/roles.ts +73 -14
- package/src/server/routes/panels/css.test.ts +4 -4
- package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
- package/src/server/routes/plugins/routes.test.ts +4 -4
- package/src/server/routes/sessions/batch-delete.test.ts +4 -4
- package/src/server/routes/updates/android.ts +2 -2
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/updates/ios.ts +26 -0
- package/src/server/routes/waiting/routes.test.ts +2 -2
- package/src/shared/lib/models/server-cache.ts +11 -0
- package/src/shared/lib/omp/config/model-roles.ts +129 -0
- package/src/shared/lib/updates/shell-ua.test.ts +56 -24
- package/src/shared/lib/updates/shell-ua.ts +41 -20
- package/src/shared/lib/workspace/panel-open.test.ts +56 -0
- package/src/shared/lib/workspace/panel-open.ts +76 -0
- package/src/shared/types/model-roles.ts +54 -0
- package/src/test-support/isolated-db.ts +17 -5
- package/dist/client/static/js/index-3qamd33d.css +0 -1
- package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
- package/src/client/hooks/ui/updates/android-download.ts +0 -143
- package/src/server/lib/updates/android-release.test.ts +0 -138
- package/src/server/lib/updates/android-release.ts +0 -183
|
@@ -4,35 +4,77 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* The model-role editor
|
|
7
|
+
* The model-role editor: role coverage, the model PICKER, and presets.
|
|
8
8
|
*
|
|
9
|
-
* Pinned because
|
|
10
|
-
* offered 9 of omp's 15 built-in roles, so `memory`, `image`, `web`, `speech`,
|
|
11
|
-
* `dictation` and `judge` could not be set from the console even though
|
|
12
|
-
* `omp config set`/`modelRoles` accept them. `dictation` is the one with a
|
|
13
|
-
* feature behind it — Kabmo's own dictation reads `stt.language` from
|
|
14
|
-
* config, and the local STT MODEL comes from that role — so an editor without
|
|
15
|
-
* it left the model choice to omp's default with no way to change it here.
|
|
9
|
+
* Pinned because each of these was a bug or a gap first:
|
|
16
10
|
*
|
|
17
|
-
* The
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
11
|
+
* - **Role coverage.** The panel offered 9 of omp's 16 built-in roles, so
|
|
12
|
+
* `memory`, `image`, `web`, `speech`, `dictation`, `judge` and `widget`
|
|
13
|
+
* could not be set from the console. The expected list is omp's own
|
|
14
|
+
* `MODEL_ROLE_IDS` at 18.12.6 (read from the installed package,
|
|
15
|
+
* `pi-tui/src/overlays/model-browser.ts`), so an upstream addition surfaces
|
|
16
|
+
* as a failure here rather than as a silently missing row.
|
|
17
|
+
* - **A role row is a SELECT, not a text field.** A free-text `provider/model`
|
|
18
|
+
* box cannot be typed reliably and a typo silently falls back to omp's own
|
|
19
|
+
* resolution. The candidates are grouped per provider, only the role's own
|
|
20
|
+
* kinds are offered, and the stored value stays selectable when the catalog
|
|
21
|
+
* no longer lists it.
|
|
22
|
+
* - **The selection and "in use" are two facts.** `in use` is derived by
|
|
23
|
+
* comparing the live map with the preset, so there is no stored "last
|
|
24
|
+
* applied" flag to go stale. The SELECTION survives a hand edit, because it
|
|
25
|
+
* is the target of `Update` — tying it to the comparison made Update
|
|
26
|
+
* unreachable in the save-back flow it exists for.
|
|
21
27
|
*/
|
|
22
28
|
|
|
23
29
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
24
30
|
import { Window } from 'happy-dom';
|
|
25
31
|
import { h, render } from 'preact';
|
|
26
32
|
import { act } from 'preact/test-utils';
|
|
27
|
-
import {
|
|
33
|
+
import { RoleEditor } from '@/client/components/settings/categories/omp-settings/RoleEditor';
|
|
34
|
+
import {
|
|
35
|
+
MODEL_ROLES,
|
|
36
|
+
ROLE_IDS,
|
|
37
|
+
candidatesForRole,
|
|
38
|
+
matchPreset,
|
|
39
|
+
roleAcceptsCandidate,
|
|
40
|
+
roleMapsEqual,
|
|
41
|
+
} from '@/shared/lib/omp/config/model-roles';
|
|
42
|
+
import type { ModelRoleCandidate, ModelRolePreset } from '@/shared/types/model-roles';
|
|
28
43
|
|
|
29
|
-
/** omp 18.
|
|
44
|
+
/** omp 18.12.6 `MODEL_ROLE_IDS`. */
|
|
30
45
|
const OMP_ROLES = [
|
|
31
|
-
'default', 'smol', 'slow', 'vision', 'plan', 'commit', 'tiny', 'memory', 'task', 'advisor',
|
|
46
|
+
'default', 'smol', 'slow', 'vision', 'plan', 'commit', 'tiny', 'memory', 'task', 'advisor', 'widget',
|
|
32
47
|
'image', 'web', 'speech', 'dictation', 'judge',
|
|
33
48
|
];
|
|
34
|
-
/** The
|
|
35
|
-
const PREVIOUSLY_MISSING = ['memory', 'image', 'web', 'speech', 'dictation', 'judge'];
|
|
49
|
+
/** The roles this panel used to omit. */
|
|
50
|
+
const PREVIOUSLY_MISSING = ['memory', 'image', 'web', 'speech', 'dictation', 'judge', 'widget'];
|
|
51
|
+
|
|
52
|
+
const CANDIDATES: ModelRoleCandidate[] = [
|
|
53
|
+
{ selector: 'kenari/deepseek-v4-flash', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', provider: 'kenari', kind: 'chat' },
|
|
54
|
+
{ selector: 'kenari/glm-5', id: 'glm-5', name: 'GLM-5', provider: 'kenari', kind: 'chat' },
|
|
55
|
+
{ selector: 'local/gemma-3-1b', id: 'gemma-3-1b', name: 'Gemma 3 1B', provider: 'local', kind: 'tiny' },
|
|
56
|
+
{ selector: 'web/brave', id: 'brave', name: 'Brave', provider: 'web', kind: 'search' },
|
|
57
|
+
{ selector: 'local/whisper-base', id: 'whisper-base', name: 'Whisper Base', provider: 'local', kind: 'stt' },
|
|
58
|
+
{ selector: 'local/kokoro', id: 'kokoro', name: 'Kokoro-82M', provider: 'local', kind: 'tts' },
|
|
59
|
+
{ selector: 'local/flux', id: 'flux', name: 'FLUX.1', provider: 'local', kind: 'image' },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const PRESETS: ModelRolePreset[] = [
|
|
63
|
+
{
|
|
64
|
+
id: 'p-cheap',
|
|
65
|
+
name: 'cheap',
|
|
66
|
+
roles: { default: 'kenari/deepseek-v4-flash', tiny: 'local/gemma-3-1b' },
|
|
67
|
+
createdAt: 1,
|
|
68
|
+
updatedAt: 1,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'p-think',
|
|
72
|
+
name: 'thinking',
|
|
73
|
+
roles: { default: 'kenari/glm-5' },
|
|
74
|
+
createdAt: 2,
|
|
75
|
+
updatedAt: 2,
|
|
76
|
+
},
|
|
77
|
+
];
|
|
36
78
|
|
|
37
79
|
let container: HTMLElement;
|
|
38
80
|
|
|
@@ -56,39 +98,225 @@ afterAll(() => {
|
|
|
56
98
|
}
|
|
57
99
|
});
|
|
58
100
|
|
|
59
|
-
async function mount(
|
|
101
|
+
async function mount(roles: Record<string, string>, overrides: Partial<Parameters<typeof RoleEditor>[0]> = {}): Promise<HTMLElement> {
|
|
60
102
|
container = document.createElement('div');
|
|
61
103
|
document.body.appendChild(container);
|
|
104
|
+
const props = {
|
|
105
|
+
roles,
|
|
106
|
+
presets: PRESETS,
|
|
107
|
+
candidates: CANDIDATES,
|
|
108
|
+
candidatesError: null,
|
|
109
|
+
onSaveRoles: async () => true,
|
|
110
|
+
onSavePreset: async () => true,
|
|
111
|
+
onUpdatePreset: async () => true,
|
|
112
|
+
onDeletePreset: async () => true,
|
|
113
|
+
savedRole: null,
|
|
114
|
+
...overrides,
|
|
115
|
+
};
|
|
62
116
|
await act(async () => {
|
|
63
|
-
render(h(RoleEditor,
|
|
117
|
+
render(h(RoleEditor, props), container);
|
|
64
118
|
});
|
|
65
119
|
return container;
|
|
66
120
|
}
|
|
67
121
|
|
|
68
|
-
describe('
|
|
122
|
+
describe('role table', () => {
|
|
69
123
|
test('every one of omp’s built-in roles is present', () => {
|
|
70
|
-
expect(
|
|
124
|
+
expect([...ROLE_IDS].sort()).toEqual([...OMP_ROLES].sort());
|
|
71
125
|
});
|
|
72
126
|
|
|
73
|
-
test('the
|
|
127
|
+
test('the roles the editor used to omit are each offered as a picker', async () => {
|
|
74
128
|
const el = await mount({});
|
|
75
129
|
for (const role of PREVIOUSLY_MISSING) {
|
|
76
|
-
const
|
|
77
|
-
expect(
|
|
130
|
+
const select = el.querySelector(`select[aria-label*="(${role})"]`);
|
|
131
|
+
expect(select, `${role} must be editable`).not.toBeNull();
|
|
78
132
|
}
|
|
79
133
|
});
|
|
80
134
|
|
|
81
|
-
test('a chat role and a kind role fall under the section omp assigns them',
|
|
82
|
-
// `judge` accepts a judge/tiny/chat model; a chat role accepts a chat model.
|
|
135
|
+
test('a chat role and a kind role fall under the section omp assigns them', () => {
|
|
83
136
|
expect(MODEL_ROLES.advisor.section).toBe('chat');
|
|
137
|
+
expect(MODEL_ROLES.widget.section).toBe('chat');
|
|
84
138
|
expect(MODEL_ROLES.dictation.section).toBe('kind');
|
|
85
139
|
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('role candidate rules', () => {
|
|
143
|
+
test('a chat role offers chat models only, and a kind role its own kind', () => {
|
|
144
|
+
expect(candidatesForRole('default', CANDIDATES).every((c) => c.kind === 'chat')).toBe(true);
|
|
145
|
+
expect(candidatesForRole('web', CANDIDATES).map((c) => c.selector)).toEqual(['web/brave']);
|
|
146
|
+
expect(candidatesForRole('dictation', CANDIDATES).map((c) => c.selector)).toEqual(['local/whisper-base']);
|
|
147
|
+
expect(candidatesForRole('speech', CANDIDATES).map((c) => c.selector)).toEqual(['local/kokoro']);
|
|
148
|
+
expect(candidatesForRole('image', CANDIDATES).map((c) => c.selector)).toEqual(['local/flux']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('tiny and judge accept a tiny model as well as a chat model', () => {
|
|
152
|
+
const tiny = candidatesForRole('tiny', CANDIDATES).map((c) => c.selector);
|
|
153
|
+
expect(tiny).toContain('local/gemma-3-1b');
|
|
154
|
+
expect(tiny).toContain('kenari/glm-5');
|
|
155
|
+
const judge = candidatesForRole('judge', CANDIDATES).map((c) => c.selector);
|
|
156
|
+
expect(judge).toContain('local/gemma-3-1b');
|
|
157
|
+
expect(judge).toContain('kenari/deepseek-v4-flash');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('web also admits a chat model that declares grounding, and nothing else does', () => {
|
|
161
|
+
const grounded: ModelRoleCandidate = {
|
|
162
|
+
selector: 'kenari/glm-5', id: 'glm-5', name: 'GLM-5', provider: 'kenari', kind: 'chat', webCapable: true,
|
|
163
|
+
};
|
|
164
|
+
expect(roleAcceptsCandidate('web', grounded)).toBe(true);
|
|
165
|
+
expect(roleAcceptsCandidate('web', { ...grounded, webCapable: false })).toBe(false);
|
|
166
|
+
expect(roleAcceptsCandidate('default', grounded)).toBe(true);
|
|
167
|
+
// A search model is not a chat role's business.
|
|
168
|
+
expect(roleAcceptsCandidate('default', CANDIDATES[3])).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('an unknown role accepts nothing', () => {
|
|
172
|
+
expect(candidatesForRole('nope', CANDIDATES)).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('role rows', () => {
|
|
177
|
+
test('the picker groups candidates by provider and carries the stored value', async () => {
|
|
178
|
+
const el = await mount({ default: 'kenari/glm-5' });
|
|
179
|
+
const select = el.querySelector('select[aria-label*="(default)"]') as HTMLSelectElement;
|
|
180
|
+
expect(select.value).toBe('kenari/glm-5');
|
|
181
|
+
const groups = [...select.querySelectorAll('optgroup')].map((group) => group.getAttribute('label'));
|
|
182
|
+
expect(groups).toEqual(['kenari']);
|
|
183
|
+
// "omp default" is always the first option — it is how an override is removed.
|
|
184
|
+
expect(select.options[0].value).toBe('');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('a stored selector the catalog no longer lists stays selectable', async () => {
|
|
188
|
+
const el = await mount({ default: 'gone/model-x' });
|
|
189
|
+
const select = el.querySelector('select[aria-label*="(default)"]') as HTMLSelectElement;
|
|
190
|
+
expect(select.value).toBe('gone/model-x');
|
|
191
|
+
const values = [...select.options].map((option) => option.value);
|
|
192
|
+
expect(values).toContain('gone/model-x');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('choosing a model writes the whole map with that role set', async () => {
|
|
196
|
+
const writes: Array<Record<string, string>> = [];
|
|
197
|
+
const el = await mount({ tiny: 'local/gemma-3-1b' }, {
|
|
198
|
+
onSaveRoles: async (roles) => { writes.push(roles); return true; },
|
|
199
|
+
});
|
|
200
|
+
const select = el.querySelector('select[aria-label*="(default)"]') as HTMLSelectElement;
|
|
201
|
+
await act(async () => {
|
|
202
|
+
select.value = 'kenari/glm-5';
|
|
203
|
+
select.dispatchEvent(new window.Event('change'));
|
|
204
|
+
});
|
|
205
|
+
expect(writes).toEqual([{ tiny: 'local/gemma-3-1b', default: 'kenari/glm-5' }]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('choosing “omp default” removes the role from the map', async () => {
|
|
209
|
+
const writes: Array<Record<string, string>> = [];
|
|
210
|
+
const el = await mount({ default: 'kenari/glm-5', tiny: 'local/gemma-3-1b' }, {
|
|
211
|
+
onSaveRoles: async (roles) => { writes.push(roles); return true; },
|
|
212
|
+
});
|
|
213
|
+
const select = el.querySelector('select[aria-label*="(default)"]') as HTMLSelectElement;
|
|
214
|
+
await act(async () => {
|
|
215
|
+
select.value = '';
|
|
216
|
+
select.dispatchEvent(new window.Event('change'));
|
|
217
|
+
});
|
|
218
|
+
expect(writes).toEqual([{ tiny: 'local/gemma-3-1b' }]);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('presets', () => {
|
|
223
|
+
test('the preset equal to the live map reads as active', () => {
|
|
224
|
+
expect(matchPreset(PRESETS, { ...PRESETS[0].roles })?.name).toBe('cheap');
|
|
225
|
+
expect(matchPreset(PRESETS, { default: 'kenari/glm-5', extra: 'a/b' })).toBeNull();
|
|
226
|
+
expect(roleMapsEqual({ a: '1' }, { a: '1' })).toBe(true);
|
|
227
|
+
expect(roleMapsEqual({ a: '1' }, { a: '1', b: '2' })).toBe(false);
|
|
228
|
+
expect(roleMapsEqual({ a: '1' }, { a: '2' })).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('the picker names the preset the live map equals, and reads in use', async () => {
|
|
232
|
+
const active = await mount({ ...PRESETS[0].roles });
|
|
233
|
+
const picker = active.querySelector('select[aria-label="Model role preset"]') as HTMLSelectElement;
|
|
234
|
+
expect(picker.value).toBe('p-cheap');
|
|
235
|
+
expect(active.textContent).toContain('in use');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('a map no preset matches leaves the picker on Custom', async () => {
|
|
239
|
+
const el = await mount({ default: 'kenari/deepseek-v4-flash' });
|
|
240
|
+
const picker = el.querySelector('select[aria-label="Model role preset"]') as HTMLSelectElement;
|
|
241
|
+
expect(picker.value).toBe('');
|
|
242
|
+
expect(el.textContent).not.toContain('in use');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('a hand edit keeps the selection and reads modified, so Update stays reachable', async () => {
|
|
246
|
+
const el = await mount({ ...PRESETS[0].roles });
|
|
247
|
+
const select = el.querySelector('select[aria-label*="(default)"]') as HTMLSelectElement;
|
|
248
|
+
await act(async () => {
|
|
249
|
+
select.value = 'kenari/glm-5';
|
|
250
|
+
select.dispatchEvent(new window.Event('change'));
|
|
251
|
+
});
|
|
252
|
+
const picker = el.querySelector('select[aria-label="Model role preset"]') as HTMLSelectElement;
|
|
253
|
+
expect(picker.value).toBe('p-cheap');
|
|
254
|
+
expect(el.textContent).toContain('modified');
|
|
255
|
+
expect(el.textContent).not.toContain('in use');
|
|
256
|
+
const update = [...el.querySelectorAll('button')].find((button) => button.textContent?.includes('Update'))!;
|
|
257
|
+
expect(update.disabled).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('Update stores the live map into the selected preset', async () => {
|
|
261
|
+
const updates: ModelRolePreset[] = [];
|
|
262
|
+
const el = await mount({ ...PRESETS[0].roles }, {
|
|
263
|
+
onUpdatePreset: async (preset) => { updates.push(preset); return true; },
|
|
264
|
+
});
|
|
265
|
+
const select = el.querySelector('select[aria-label*="(default)"]') as HTMLSelectElement;
|
|
266
|
+
await act(async () => {
|
|
267
|
+
select.value = 'kenari/glm-5';
|
|
268
|
+
select.dispatchEvent(new window.Event('change'));
|
|
269
|
+
});
|
|
270
|
+
const update = [...el.querySelectorAll('button')].find((button) => button.textContent?.includes('Update'))!;
|
|
271
|
+
await act(async () => { update.click(); });
|
|
272
|
+
expect(updates.length).toBe(1);
|
|
273
|
+
expect(updates[0].id).toBe('p-cheap');
|
|
274
|
+
expect(updates[0].roles).toEqual({ default: 'kenari/glm-5', tiny: 'local/gemma-3-1b' });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('choosing a preset applies its whole role map', async () => {
|
|
278
|
+
const writes: Array<Record<string, string>> = [];
|
|
279
|
+
const el = await mount({}, { onSaveRoles: async (roles) => { writes.push(roles); return true; } });
|
|
280
|
+
const picker = el.querySelector('select[aria-label="Model role preset"]') as HTMLSelectElement;
|
|
281
|
+
await act(async () => {
|
|
282
|
+
picker.value = 'p-think';
|
|
283
|
+
picker.dispatchEvent(new window.Event('change'));
|
|
284
|
+
});
|
|
285
|
+
expect(writes).toEqual([PRESETS[1].roles]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('Update and Delete are refused with no selected preset', async () => {
|
|
289
|
+
const el = await mount({ default: 'kenari/deepseek-v4-flash' });
|
|
290
|
+
const update = [...el.querySelectorAll('button')].find((button) => button.textContent?.includes('Update'))!;
|
|
291
|
+
expect(update.disabled).toBe(true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('a failed catalog is reported and the rows stay editable', async () => {
|
|
295
|
+
const el = await mount({ default: 'kenari/glm-5' }, { candidates: [], candidatesError: 'omp binary not found' });
|
|
296
|
+
expect(el.textContent).toContain('Model catalog unavailable');
|
|
297
|
+
expect(el.textContent).toContain('omp binary not found');
|
|
298
|
+
const select = el.querySelector('select[aria-label*="(default)"]') as HTMLSelectElement;
|
|
299
|
+
expect(select.disabled).toBe(false);
|
|
300
|
+
// The stored selector survives even with no catalog to draw options from.
|
|
301
|
+
expect(select.value).toBe('kenari/glm-5');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('a refused write is shown in place', async () => {
|
|
305
|
+
const el = await mount({}, { writeError: 'Invalid value: modelRoles' });
|
|
306
|
+
expect(el.textContent).toContain('Invalid value: modelRoles');
|
|
307
|
+
});
|
|
86
308
|
|
|
87
|
-
test('
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
309
|
+
test('deleting clears the selection', async () => {
|
|
310
|
+
const deleted: string[] = [];
|
|
311
|
+
const el = await mount({ ...PRESETS[0].roles }, {
|
|
312
|
+
onDeletePreset: async (preset) => { deleted.push(preset.id); return true; },
|
|
313
|
+
});
|
|
314
|
+
const trash = el.querySelector('button[title^="Delete"]') as HTMLButtonElement;
|
|
315
|
+
await act(async () => { trash.click(); });
|
|
316
|
+
const confirm = [...el.querySelectorAll('button')].find((button) => button.textContent?.trim() === 'Delete')!;
|
|
317
|
+
await act(async () => { confirm.click(); });
|
|
318
|
+
expect(deleted).toEqual(['p-cheap']);
|
|
319
|
+
const picker = el.querySelector('select[aria-label="Model role preset"]') as HTMLSelectElement;
|
|
320
|
+
expect(picker.value).toBe('');
|
|
93
321
|
});
|
|
94
322
|
});
|