@kabmo-labs/kabmo 0.5.3 → 0.6.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/dist/client/index.html +1 -1
- package/dist/client/index.js +359 -350
- package/dist/client/static/js/{index-9js9mrmj.js → index-ae9z0yvm.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,126 +4,219 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Model role editor — role → model selector overrides in config.yml
|
|
8
|
-
*
|
|
9
|
-
* writes go through /api/model-roles, which edits only `modelRoles` in the
|
|
10
|
-
* global config. Empty input removes the role override.
|
|
7
|
+
* Model role editor — `role → model selector` overrides in config.yml, with
|
|
8
|
+
* presets.
|
|
11
9
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* resolves `stt.language` from config; the model comes from this role), so the
|
|
18
|
-
* missing row was the one a non-English user needed most.
|
|
10
|
+
* Roles come from omp's own `MODEL_ROLES` table (see
|
|
11
|
+
* `@/shared/lib/omp/config/model-roles`), and the candidate models from omp's
|
|
12
|
+
* own catalog, so a row can only name a model omp can resolve. Writes go
|
|
13
|
+
* through `/api/model-roles`: `PUT` replaces the whole map (an empty value
|
|
14
|
+
* removes that role's override), `POST` saves a preset, `DELETE` removes one.
|
|
19
15
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
16
|
+
* Two things this panel used to get wrong and must not again:
|
|
17
|
+
*
|
|
18
|
+
* - **A role row was a free-text `provider/model` field.** That is a value a
|
|
19
|
+
* user cannot type reliably, and a typo is invisible: omp falls back to its
|
|
20
|
+
* own resolution and the role silently runs on a different model. Every row
|
|
21
|
+
* is a picker over the catalog now, with the stored value kept selectable
|
|
22
|
+
* even when the catalog no longer lists it.
|
|
23
|
+
* - **`widget` was missing from the role table** (omp 18.12.6), which made it
|
|
24
|
+
* the one role unreachable from the console.
|
|
25
|
+
*
|
|
26
|
+
* The presets are the reason this panel exists in its current shape: switching
|
|
27
|
+
* a whole role map used to mean sixteen edits. Two facts are kept apart on
|
|
28
|
+
* purpose — the SELECTED preset (the target of Update/Delete, which a hand edit
|
|
29
|
+
* must not clear, or the save-back flow would be impossible) and whether the
|
|
30
|
+
* live map still equals it (`in use` vs `modified`).
|
|
24
31
|
*/
|
|
25
32
|
|
|
26
|
-
import { useState } from 'preact/hooks';
|
|
27
|
-
import { Check } from 'lucide-preact';
|
|
33
|
+
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
|
|
34
|
+
import { AlertCircle, Check } from 'lucide-preact';
|
|
35
|
+
import { candidatesForRole, matchPreset, MODEL_ROLES, roleMapsEqual, ROLE_IDS } from '@/shared/lib/omp/config/model-roles';
|
|
36
|
+
import type { ModelRoleCandidate, ModelRolePreset } from '@/shared/types/model-roles';
|
|
37
|
+
import { RoleRow } from '@/client/components/settings/categories/omp-settings/RoleRow';
|
|
38
|
+
import { PresetBar } from '@/client/components/settings/categories/omp-settings/PresetBar';
|
|
28
39
|
|
|
29
|
-
interface
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
interface RoleEditorProps {
|
|
41
|
+
roles: Record<string, string>;
|
|
42
|
+
presets: ModelRolePreset[];
|
|
43
|
+
candidates: ModelRoleCandidate[];
|
|
44
|
+
/** Why the catalog could not be read; the rows still render. */
|
|
45
|
+
candidatesError?: string | null;
|
|
46
|
+
/** Replace the whole map (an empty value removes that role's override). */
|
|
47
|
+
onSaveRoles: (roles: Record<string, string>) => Promise<boolean>;
|
|
48
|
+
onSavePreset: (name: string, roles: Record<string, string>) => Promise<boolean>;
|
|
49
|
+
onUpdatePreset: (preset: ModelRolePreset) => Promise<boolean>;
|
|
50
|
+
onDeletePreset: (preset: ModelRolePreset) => Promise<boolean>;
|
|
51
|
+
savedRole: string | null;
|
|
52
|
+
/** The last write's refusal; shown in place so a rejected save is not silent. */
|
|
53
|
+
writeError?: string | null;
|
|
54
|
+
onDismissWriteError?: () => void;
|
|
34
55
|
}
|
|
35
56
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
57
|
+
const SECTIONS: Array<{ id: 'chat' | 'kind'; title: string; note: string }> = [
|
|
58
|
+
{
|
|
59
|
+
id: 'chat',
|
|
60
|
+
title: 'Chat roles',
|
|
61
|
+
note: 'Each takes a chat model. “omp default” removes the override and omp resolves the role itself.',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'kind',
|
|
65
|
+
title: 'Model-kind roles',
|
|
66
|
+
note: 'Each takes a model of that kind only — a chat model here is silently unusable.',
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
export function RoleEditor({
|
|
71
|
+
roles,
|
|
72
|
+
presets,
|
|
73
|
+
candidates,
|
|
74
|
+
candidatesError,
|
|
75
|
+
onSaveRoles,
|
|
76
|
+
onSavePreset,
|
|
77
|
+
onUpdatePreset,
|
|
78
|
+
onDeletePreset,
|
|
79
|
+
savedRole,
|
|
80
|
+
writeError,
|
|
81
|
+
onDismissWriteError,
|
|
82
|
+
}: RoleEditorProps) {
|
|
83
|
+
const [draft, setDraft] = useState<Record<string, string>>(roles);
|
|
84
|
+
const [busy, setBusy] = useState(false);
|
|
85
|
+
const [selectedId, setSelectedId] = useState<string | null>(() => matchPreset(presets, roles)?.id ?? null);
|
|
86
|
+
|
|
87
|
+
// The server's map is the truth; a save answers with the stored copy.
|
|
88
|
+
useEffect(() => { setDraft(roles); }, [roles]);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Keep the selection honest as the list and the map arrive:
|
|
92
|
+
* - a deleted preset cannot stay selected;
|
|
93
|
+
* - with nothing selected, a map that equals a preset adopts it — this is the
|
|
94
|
+
* load path (the payload lands after mount) and a reload of a saved state.
|
|
95
|
+
* A hand edit does NOT clear the selection: it is the target of `Update`.
|
|
96
|
+
*/
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
setSelectedId((current) => {
|
|
99
|
+
if (current && !presets.some((preset) => preset.id === current)) return null;
|
|
100
|
+
if (current) return current;
|
|
101
|
+
return matchPreset(presets, draft)?.id ?? null;
|
|
102
|
+
});
|
|
103
|
+
}, [presets, draft]);
|
|
104
|
+
|
|
105
|
+
const selectedPreset = useMemo(
|
|
106
|
+
() => presets.find((preset) => preset.id === selectedId) ?? null,
|
|
107
|
+
[presets, selectedId],
|
|
108
|
+
);
|
|
109
|
+
const inUse = useMemo(
|
|
110
|
+
() => Boolean(selectedPreset && roleMapsEqual(selectedPreset.roles, draft)),
|
|
111
|
+
[selectedPreset, draft],
|
|
85
112
|
);
|
|
86
|
-
}
|
|
87
113
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
114
|
+
const commit = useCallback(async (next: Record<string, string>) => {
|
|
115
|
+
setDraft(next);
|
|
116
|
+
setBusy(true);
|
|
117
|
+
try {
|
|
118
|
+
await onSaveRoles(next);
|
|
119
|
+
} finally {
|
|
120
|
+
setBusy(false);
|
|
121
|
+
}
|
|
122
|
+
}, [onSaveRoles]);
|
|
123
|
+
|
|
124
|
+
const changeRole = useCallback((role: string, value: string) => {
|
|
125
|
+
const next = { ...draft };
|
|
126
|
+
if (value) next[role] = value;
|
|
127
|
+
else delete next[role];
|
|
128
|
+
void commit(next);
|
|
129
|
+
}, [commit, draft]);
|
|
130
|
+
|
|
131
|
+
const applyPreset = useCallback((preset: ModelRolePreset) => {
|
|
132
|
+
setSelectedId(preset.id);
|
|
133
|
+
void commit({ ...preset.roles });
|
|
134
|
+
}, [commit]);
|
|
135
|
+
|
|
136
|
+
const saveAs = useCallback(async (name: string) => {
|
|
137
|
+
setBusy(true);
|
|
138
|
+
try {
|
|
139
|
+
await onSavePreset(name, draft);
|
|
140
|
+
} finally {
|
|
141
|
+
setBusy(false);
|
|
142
|
+
}
|
|
143
|
+
}, [draft, onSavePreset]);
|
|
144
|
+
|
|
145
|
+
const update = useCallback(async () => {
|
|
146
|
+
if (!selectedPreset) return;
|
|
147
|
+
setBusy(true);
|
|
148
|
+
try {
|
|
149
|
+
await onUpdatePreset({ ...selectedPreset, roles: draft });
|
|
150
|
+
} finally {
|
|
151
|
+
setBusy(false);
|
|
152
|
+
}
|
|
153
|
+
}, [selectedPreset, draft, onUpdatePreset]);
|
|
93
154
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
title: 'Chat roles',
|
|
105
|
-
note: 'Each takes a chat model. Empty removes the override and omp falls back to its own resolution.',
|
|
106
|
-
},
|
|
107
|
-
{
|
|
108
|
-
id: 'kind',
|
|
109
|
-
title: 'Model-kind roles',
|
|
110
|
-
note: 'Each takes a model of that kind only — a chat model here is silently unusable.',
|
|
111
|
-
},
|
|
112
|
-
];
|
|
155
|
+
const remove = useCallback(async () => {
|
|
156
|
+
if (!selectedPreset) return;
|
|
157
|
+
setBusy(true);
|
|
158
|
+
try {
|
|
159
|
+
await onDeletePreset(selectedPreset);
|
|
160
|
+
setSelectedId(null);
|
|
161
|
+
} finally {
|
|
162
|
+
setBusy(false);
|
|
163
|
+
}
|
|
164
|
+
}, [selectedPreset, onDeletePreset]);
|
|
113
165
|
|
|
114
166
|
return (
|
|
115
167
|
<div className="pt-2">
|
|
116
168
|
<div className="flex items-center justify-between pb-2">
|
|
117
169
|
<div>
|
|
118
170
|
<h3 className="text-sm font-semibold text-ink">Model Roles</h3>
|
|
119
|
-
<p className="text-ink/50 mt-0.5">
|
|
171
|
+
<p className="text-ink/50 mt-0.5">
|
|
172
|
+
Role → model selectors from config.yml. Pick a preset to switch every role at once.
|
|
173
|
+
</p>
|
|
120
174
|
</div>
|
|
121
175
|
{savedRole && (
|
|
122
176
|
<span className="inline-flex items-center gap-1 text-[10px] text-ink/60"><Check size={11} /> saved</span>
|
|
123
177
|
)}
|
|
124
178
|
</div>
|
|
125
|
-
|
|
126
|
-
|
|
179
|
+
|
|
180
|
+
<PresetBar
|
|
181
|
+
presets={presets}
|
|
182
|
+
selectedPreset={selectedPreset}
|
|
183
|
+
inUse={inUse}
|
|
184
|
+
busy={busy}
|
|
185
|
+
onApply={applyPreset}
|
|
186
|
+
onSave={saveAs}
|
|
187
|
+
onUpdate={update}
|
|
188
|
+
onDelete={remove}
|
|
189
|
+
/>
|
|
190
|
+
|
|
191
|
+
{writeError && (
|
|
192
|
+
<div className="flex items-start gap-1.5 pb-2 text-[10px] text-error">
|
|
193
|
+
<AlertCircle size={11} className="mt-px flex-shrink-0" />
|
|
194
|
+
<span className="flex-1">{writeError}</span>
|
|
195
|
+
{onDismissWriteError && (
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
onClick={onDismissWriteError}
|
|
199
|
+
className="text-ink/40 hover:text-ink cursor-pointer"
|
|
200
|
+
aria-label="Dismiss role write error"
|
|
201
|
+
>
|
|
202
|
+
×
|
|
203
|
+
</button>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
{candidatesError && (
|
|
209
|
+
<div className="flex items-start gap-1.5 pb-2 text-[10px] text-warning">
|
|
210
|
+
<AlertCircle size={11} className="mt-px flex-shrink-0" />
|
|
211
|
+
<span>
|
|
212
|
+
Model catalog unavailable ({candidatesError}). Stored selectors stay editable; new picks need a working
|
|
213
|
+
omp install.
|
|
214
|
+
</span>
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
|
|
218
|
+
{SECTIONS.map((section) => {
|
|
219
|
+
const sectionRoles = ROLE_IDS.filter((role) => MODEL_ROLES[role].section === section.id);
|
|
127
220
|
return (
|
|
128
221
|
<div key={section.id} className="mb-3">
|
|
129
222
|
<div className="pb-1">
|
|
@@ -131,12 +224,15 @@ export function RoleEditor({ modelRoles, onSaveRole, savedRole }: RoleEditorProp
|
|
|
131
224
|
<div className="text-[10px] text-ink/40 leading-snug">{section.note}</div>
|
|
132
225
|
</div>
|
|
133
226
|
<div className="rounded-lg border border-ink/10 divide-y divide-ink/5">
|
|
134
|
-
{
|
|
227
|
+
{sectionRoles.map((role) => (
|
|
135
228
|
<RoleRow
|
|
136
229
|
key={role}
|
|
137
230
|
role={role}
|
|
138
|
-
|
|
139
|
-
|
|
231
|
+
info={MODEL_ROLES[role]}
|
|
232
|
+
value={draft[role] ?? ''}
|
|
233
|
+
candidates={candidatesForRole(role, candidates)}
|
|
234
|
+
disabled={busy}
|
|
235
|
+
onChange={changeRole}
|
|
140
236
|
/>
|
|
141
237
|
))}
|
|
142
238
|
</div>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One role row: the role's name, its id, and a MODEL PICKER.
|
|
8
|
+
*
|
|
9
|
+
* A `<select>` grouped by provider rather than a text field or a hand-rolled
|
|
10
|
+
* popup: the value is a `provider/model` selector, which a user cannot be
|
|
11
|
+
* expected to type correctly (and a typo in `modelRoles` is a role that
|
|
12
|
+
* silently falls back), while a native select gives type-ahead, keyboard
|
|
13
|
+
* navigation and screen-reader semantics for free. The candidates come from
|
|
14
|
+
* omp's own catalog, so a row can only name a model omp can resolve.
|
|
15
|
+
*
|
|
16
|
+
* The current value is always present as an option, even when the catalog no
|
|
17
|
+
* longer lists it (a model removed from models.yml, a provider whose credential
|
|
18
|
+
* was removed, a catalog read that failed): dropping it would make the select
|
|
19
|
+
* show a different model than the config holds, and the next edit would write
|
|
20
|
+
* that one over it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ModelRoleCandidate } from '@/shared/types/model-roles';
|
|
24
|
+
import type { ModelRoleInfo } from '@/shared/lib/omp/config/model-roles';
|
|
25
|
+
|
|
26
|
+
interface RoleRowProps {
|
|
27
|
+
role: string;
|
|
28
|
+
info: ModelRoleInfo;
|
|
29
|
+
value: string;
|
|
30
|
+
candidates: ModelRoleCandidate[];
|
|
31
|
+
disabled: boolean;
|
|
32
|
+
onChange: (role: string, value: string) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Candidates grouped by provider, in the order they arrive (already sorted). */
|
|
36
|
+
function groupByProvider(candidates: ModelRoleCandidate[]): Array<{ provider: string; rows: ModelRoleCandidate[] }> {
|
|
37
|
+
const groups: Array<{ provider: string; rows: ModelRoleCandidate[] }> = [];
|
|
38
|
+
for (const candidate of candidates) {
|
|
39
|
+
const last = groups[groups.length - 1];
|
|
40
|
+
if (last && last.provider === candidate.provider) last.rows.push(candidate);
|
|
41
|
+
else groups.push({ provider: candidate.provider, rows: [candidate] });
|
|
42
|
+
}
|
|
43
|
+
return groups;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function RoleRow({ role, info, value, candidates, disabled, onChange }: RoleRowProps) {
|
|
47
|
+
const known = candidates.some((candidate) => candidate.selector === value);
|
|
48
|
+
const groups = groupByProvider(candidates);
|
|
49
|
+
const label = `${info.label} (${role}) model`;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex items-center justify-between gap-3 px-3 py-2.5">
|
|
53
|
+
<div className="min-w-0">
|
|
54
|
+
<div className="font-medium text-ink">{info.label}</div>
|
|
55
|
+
<div className="font-mono text-[9px] text-ink/30">{role}</div>
|
|
56
|
+
</div>
|
|
57
|
+
<select
|
|
58
|
+
aria-label={label}
|
|
59
|
+
title={value || 'omp decides this role'}
|
|
60
|
+
value={value}
|
|
61
|
+
disabled={disabled}
|
|
62
|
+
onChange={(event) => onChange(role, event.currentTarget.value)}
|
|
63
|
+
className="w-60 max-w-[60%] bg-paper border border-ink/15 rounded-md px-2 py-1 text-ink font-mono text-[11px] cursor-pointer disabled:opacity-50"
|
|
64
|
+
>
|
|
65
|
+
<option value="">— omp default —</option>
|
|
66
|
+
{value && !known && <option value={value}>{value} (not in catalog)</option>}
|
|
67
|
+
{groups.map((group) => (
|
|
68
|
+
<optgroup key={group.provider} label={group.provider}>
|
|
69
|
+
{group.rows.map((candidate) => (
|
|
70
|
+
<option key={candidate.selector} value={candidate.selector}>
|
|
71
|
+
{candidate.name}
|
|
72
|
+
</option>
|
|
73
|
+
))}
|
|
74
|
+
</optgroup>
|
|
75
|
+
))}
|
|
76
|
+
</select>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -3087,8 +3087,8 @@
|
|
|
3087
3087
|
},
|
|
3088
3088
|
{
|
|
3089
3089
|
"value": "long",
|
|
3090
|
-
"label": "Long (1h)",
|
|
3091
|
-
"description": "
|
|
3090
|
+
"label": "Long (1h+)",
|
|
3091
|
+
"description": "Longer TTL where supported (Anthropic/Bedrock 1h, OpenAI GPT-5.6+ Responses 12h); pricier writes, only warmed during active runs"
|
|
3092
3092
|
},
|
|
3093
3093
|
{
|
|
3094
3094
|
"value": "none",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bridge, driven through the transport the macOS shell installs.
|
|
3
|
+
*
|
|
4
|
+
* The interesting failures are about what the page can tell apart: a browser has
|
|
5
|
+
* no shell at all, a shell announces itself before the first render, and a
|
|
6
|
+
* refused command keeps the shell's own sentence. None of that is visible in the
|
|
7
|
+
* panel's markup — a control that never works looks exactly like one that does
|
|
8
|
+
* until it is pressed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test, beforeEach } from 'bun:test';
|
|
12
|
+
|
|
13
|
+
type Reply = (id: number, ok: boolean, payload: unknown) => void;
|
|
14
|
+
|
|
15
|
+
interface ShellWindow {
|
|
16
|
+
__kabmoCall?: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
|
|
17
|
+
__kabmoDesktop?: unknown;
|
|
18
|
+
__kabmoReply?: Reply;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const shell = () => globalThis as unknown as { window?: ShellWindow };
|
|
22
|
+
|
|
23
|
+
describe('desktop bridge', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
delete shell().window;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a browser has no shell, and every call refuses', async () => {
|
|
29
|
+
shell().window = {};
|
|
30
|
+
const bridge = await import('@/client/lib/desktop/bridge');
|
|
31
|
+
expect(bridge.isDesktopShell()).toBe(false);
|
|
32
|
+
expect(await bridge.desktopInfo()).toBeNull();
|
|
33
|
+
await expect(bridge.readServers()).rejects.toThrow('Not running inside the Kabmo desktop shell.');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('the injected identity is readable before any command runs', async () => {
|
|
37
|
+
shell().window = {
|
|
38
|
+
__kabmoCall: async () => null,
|
|
39
|
+
__kabmoDesktop: { platform: 'macos', shellVersion: '0.1.0', overlayTitlebar: true, trafficLightInset: '5rem' },
|
|
40
|
+
};
|
|
41
|
+
const bridge = await import('@/client/lib/desktop/bridge');
|
|
42
|
+
expect(bridge.isDesktopShell()).toBe(true);
|
|
43
|
+
const info = await bridge.desktopInfo();
|
|
44
|
+
expect(info?.overlayTitlebar).toBe(true);
|
|
45
|
+
expect(info?.trafficLightInset).toBe('5rem');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a command answers through __kabmoCall', async () => {
|
|
49
|
+
const seen: string[] = [];
|
|
50
|
+
shell().window = {
|
|
51
|
+
__kabmoCall: async (method) => {
|
|
52
|
+
seen.push(method);
|
|
53
|
+
return { entries: [], detected: [], activeId: 'local:1' };
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
const bridge = await import('@/client/lib/desktop/bridge');
|
|
57
|
+
const state = await bridge.readServers();
|
|
58
|
+
expect(seen).toEqual(['servers_state']);
|
|
59
|
+
expect(state.activeId).toBe('local:1');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The shell sends its own sentence, which is what the UI shows. Stripping a
|
|
64
|
+
* prefix would eat the first word of a message that starts with "Error".
|
|
65
|
+
*/
|
|
66
|
+
test('a refused command keeps the shell’s own sentence, unstripped', async () => {
|
|
67
|
+
const bridge = await import('@/client/lib/desktop/bridge');
|
|
68
|
+
expect(bridge.bridgeError(new Error('Use the server’s root address — a base path is not supported.'))).toBe(
|
|
69
|
+
'Use the server’s root address — a base path is not supported.',
|
|
70
|
+
);
|
|
71
|
+
expect(bridge.bridgeError('plain')).toBe('plain');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The server list the desktop shell owns, as the navbar reads it.
|
|
8
|
+
*
|
|
9
|
+
* One store per window, not per component: the navbar button, its popover and
|
|
10
|
+
* anything else that asks "which server am I on" must not each hold a copy —
|
|
11
|
+
* two copies is how a start on one side leaves the other showing `Stopped`.
|
|
12
|
+
*
|
|
13
|
+
* Every mutation answers with the WHOLE state (`ServersState`), and the shell is
|
|
14
|
+
* the source of truth: it re-reads its own list, re-probes every URL and
|
|
15
|
+
* re-discovers local instances before answering. Adopting the response verbatim
|
|
16
|
+
* is what keeps the panel from inventing a running state the shell does not
|
|
17
|
+
* agree with.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
21
|
+
import {
|
|
22
|
+
activateServer,
|
|
23
|
+
addRemoteServer,
|
|
24
|
+
bridgeError,
|
|
25
|
+
isDesktopShell,
|
|
26
|
+
readServers,
|
|
27
|
+
removeServer,
|
|
28
|
+
startLocalServer,
|
|
29
|
+
stopLocalServer,
|
|
30
|
+
type DesktopServersState,
|
|
31
|
+
} from '@/client/lib/desktop/bridge';
|
|
32
|
+
|
|
33
|
+
export interface DesktopServersStore {
|
|
34
|
+
available: boolean;
|
|
35
|
+
state: DesktopServersState | null;
|
|
36
|
+
/** A request is in flight; the panel disables its controls rather than queueing. */
|
|
37
|
+
busy: boolean;
|
|
38
|
+
error: string | null;
|
|
39
|
+
reload: () => Promise<void>;
|
|
40
|
+
activate: (id: string) => Promise<boolean>;
|
|
41
|
+
add: (label: string, url: string) => Promise<boolean>;
|
|
42
|
+
remove: (id: string) => Promise<boolean>;
|
|
43
|
+
start: (port?: number) => Promise<boolean>;
|
|
44
|
+
stop: (id: string) => Promise<boolean>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How often the open popover re-reads the list.
|
|
49
|
+
*
|
|
50
|
+
* A local server started in a terminal, a `bun run dev` coming up, a managed
|
|
51
|
+
* one that just died — none of those announce themselves to this page, and the
|
|
52
|
+
* popover is the surface that has to show them. Only while it is open: a poll
|
|
53
|
+
* running behind a closed popover would probe every server every few seconds
|
|
54
|
+
* for a panel nobody is looking at.
|
|
55
|
+
*/
|
|
56
|
+
const REFRESH_INTERVAL_MS = 4000;
|
|
57
|
+
|
|
58
|
+
export function useDesktopServers(open: boolean): DesktopServersStore {
|
|
59
|
+
const available = isDesktopShell();
|
|
60
|
+
const [state, setState] = useState<DesktopServersState | null>(null);
|
|
61
|
+
const [busy, setBusy] = useState(false);
|
|
62
|
+
const [error, setError] = useState<string | null>(null);
|
|
63
|
+
// A mutation and a poll can overlap; the poll must not overwrite the answer
|
|
64
|
+
// the user's own click produced. The counter is what tells them apart.
|
|
65
|
+
const mutationsRef = useRef(0);
|
|
66
|
+
|
|
67
|
+
const reload = useCallback(async () => {
|
|
68
|
+
if (!available) return;
|
|
69
|
+
const ticket = mutationsRef.current;
|
|
70
|
+
try {
|
|
71
|
+
const next = await readServers();
|
|
72
|
+
if (mutationsRef.current === ticket) setState(next);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (mutationsRef.current === ticket) setError(bridgeError(err));
|
|
75
|
+
}
|
|
76
|
+
}, [available]);
|
|
77
|
+
|
|
78
|
+
const run = useCallback(
|
|
79
|
+
async (action: () => Promise<DesktopServersState>) => {
|
|
80
|
+
mutationsRef.current += 1;
|
|
81
|
+
setBusy(true);
|
|
82
|
+
setError(null);
|
|
83
|
+
try {
|
|
84
|
+
setState(await action());
|
|
85
|
+
return true;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
setError(bridgeError(err));
|
|
88
|
+
return false;
|
|
89
|
+
} finally {
|
|
90
|
+
setBusy(false);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
[],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!available) return;
|
|
98
|
+
void reload();
|
|
99
|
+
}, [available, reload]);
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!available || !open) return;
|
|
103
|
+
const timer = setInterval(() => void reload(), REFRESH_INTERVAL_MS);
|
|
104
|
+
return () => clearInterval(timer);
|
|
105
|
+
}, [available, open, reload]);
|
|
106
|
+
|
|
107
|
+
const activate = useCallback(
|
|
108
|
+
// The shell NAVIGATES on success, so this page is replaced and the resolved
|
|
109
|
+
// state never renders. A failure is what comes back here.
|
|
110
|
+
(id: string) => run(() => activateServer(id)),
|
|
111
|
+
[run],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const add = useCallback(
|
|
115
|
+
(label: string, url: string) => run(() => addRemoteServer(label, url)),
|
|
116
|
+
[run],
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const remove = useCallback((id: string) => run(() => removeServer(id)), [run]);
|
|
120
|
+
|
|
121
|
+
const start = useCallback((port?: number) => run(() => startLocalServer(port)), [run]);
|
|
122
|
+
|
|
123
|
+
const stop = useCallback((id: string) => run(() => stopLocalServer(id)), [run]);
|
|
124
|
+
|
|
125
|
+
return { available, state, busy, error, reload, activate, add, remove, start, stop };
|
|
126
|
+
}
|