@leaves615/dsh-llm-ctl 0.1.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/LICENSE +21 -0
- package/README.md +174 -0
- package/cordis.patch.yml +7 -0
- package/lib/client-plugin.d.ts +82 -0
- package/lib/client-plugin.js +685 -0
- package/lib/client.js +1712 -0
- package/lib/concurrency.d.ts +28 -0
- package/lib/concurrency.js +36 -0
- package/lib/config.d.ts +203 -0
- package/lib/config.js +67 -0
- package/lib/controller.d.ts +42 -0
- package/lib/controller.js +51 -0
- package/lib/delay.d.ts +68 -0
- package/lib/delay.js +134 -0
- package/lib/discover-ui.d.ts +94 -0
- package/lib/discover-ui.js +91 -0
- package/lib/discover.d.ts +79 -0
- package/lib/discover.js +141 -0
- package/lib/events.d.ts +45 -0
- package/lib/events.js +37 -0
- package/lib/index.d.ts +38 -0
- package/lib/index.js +378 -0
- package/lib/menu-filter.d.ts +134 -0
- package/lib/menu-filter.js +428 -0
- package/lib/menu-visibility.d.ts +26 -0
- package/lib/menu-visibility.js +77 -0
- package/lib/queue-dock.d.ts +85 -0
- package/lib/queue-dock.js +291 -0
- package/lib/queue.d.ts +128 -0
- package/lib/queue.js +313 -0
- package/lib/reactive.d.ts +57 -0
- package/lib/reactive.js +75 -0
- package/lib/reasoning-efforts.d.ts +120 -0
- package/lib/reasoning-efforts.js +143 -0
- package/lib/routes.d.ts +126 -0
- package/lib/routes.js +267 -0
- package/lib/settings-ui.d.ts +183 -0
- package/lib/settings-ui.js +367 -0
- package/lib/visibility-settings.d.ts +193 -0
- package/lib/visibility-settings.js +225 -0
- package/lib/visibility.d.ts +152 -0
- package/lib/visibility.js +235 -0
- package/package.json +92 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of dsh-llm-ctl.
|
|
3
|
+
*
|
|
4
|
+
* Three jobs, all over the host's plain HTTP routes plus the official slots:
|
|
5
|
+
*
|
|
6
|
+
* 1. a composer-dock queue seat (queue depth, cooldown, cancel);
|
|
7
|
+
* 2. model-picker filtering: hidden models are removed from the menu, a search
|
|
8
|
+
* box appears only when \`dsh-model-search-plugin\` has not injected one, and a
|
|
9
|
+
* fully filtered menu offers a one-click restore;
|
|
10
|
+
* 3. the two \`settings.models\` seats: per-provider switches and a footer with the
|
|
11
|
+
* hidden totals and restore-all.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-llm-ctl/client
|
|
14
|
+
*/
|
|
15
|
+
import React from 'react';
|
|
16
|
+
import { isModelVisible, isProviderVisible } from "./visibility.js";
|
|
17
|
+
import { PluginConfigCard, ProviderVisibilityCard, VisibilityFooter, buildProviderViews, summarize, } from "./settings-ui.js";
|
|
18
|
+
import { applyMenuFilter, ensureEmptyState, findModelMenu, hasForeignSearchWidget, listModelRows, removeEmptyState, resetMenuFilter, } from "./menu-filter.js";
|
|
19
|
+
import { applyVisibilityOnly } from "./menu-visibility.js";
|
|
20
|
+
import { DiscoveredModelsList, RefreshModelsButton, buildDiscoveredRows } from "./discover-ui.js";
|
|
21
|
+
import { buildQueueDockView, QueueDockPanel } from "./queue-dock.js";
|
|
22
|
+
/** Cordis service names the browser half needs. */
|
|
23
|
+
export const inject = ['slots', 'remote', 'remote.session'];
|
|
24
|
+
const STATE_URL = '/api/llm-ctl/state';
|
|
25
|
+
const CANCEL_URL = '/api/llm-ctl/cancel';
|
|
26
|
+
const DISCOVER_URL = '/api/llm-ctl/discover';
|
|
27
|
+
const VISIBILITY_URL = '/api/llm-ctl/visibility';
|
|
28
|
+
const VISIBILITY_RESET_URL = '/api/llm-ctl/visibility/reset';
|
|
29
|
+
const QUEUE_URL = '/api/llm-ctl/queue';
|
|
30
|
+
const QUEUE_RESET_URL = '/api/llm-ctl/queue/reset';
|
|
31
|
+
const POLL_MS = 1_000;
|
|
32
|
+
/** Poll interval while the tab is hidden: stay fresh without burning frames. */
|
|
33
|
+
const HIDDEN_POLL_MS = 5_000;
|
|
34
|
+
/** Upper bound for the failure backoff ladder. */
|
|
35
|
+
const MAX_POLL_MS = 30_000;
|
|
36
|
+
const CATALOG_TTL_MS = 30_000;
|
|
37
|
+
const SEARCH_ID = 'dsh-llm-ctl-search';
|
|
38
|
+
let ctxRef;
|
|
39
|
+
let latestState;
|
|
40
|
+
let catalog;
|
|
41
|
+
let catalogAt = 0;
|
|
42
|
+
let catalogRequest;
|
|
43
|
+
let attached;
|
|
44
|
+
let searchInput;
|
|
45
|
+
let query = '';
|
|
46
|
+
let timer;
|
|
47
|
+
/** Consecutive `state` fetch failures; drives the backoff ladder. */
|
|
48
|
+
let fetchFailures = 0;
|
|
49
|
+
/** Signature of the last notified state; idle polls skip re-render. */
|
|
50
|
+
let lastNotifiedSignature;
|
|
51
|
+
let observer;
|
|
52
|
+
let syncScheduled = false;
|
|
53
|
+
const subscribers = new Set();
|
|
54
|
+
/** Notify every mounted view that host state changed. */
|
|
55
|
+
function notify() {
|
|
56
|
+
for (const listener of [...subscribers])
|
|
57
|
+
listener();
|
|
58
|
+
}
|
|
59
|
+
/** Subscribe a React view to host state; returns the unsubscribe callback. */
|
|
60
|
+
function useCtlState() {
|
|
61
|
+
const [, force] = React.useState(0);
|
|
62
|
+
React.useEffect(() => {
|
|
63
|
+
const listener = () => force((value) => value + 1);
|
|
64
|
+
subscribers.add(listener);
|
|
65
|
+
return () => {
|
|
66
|
+
subscribers.delete(listener);
|
|
67
|
+
};
|
|
68
|
+
}, []);
|
|
69
|
+
return { state: latestState, catalog };
|
|
70
|
+
}
|
|
71
|
+
/** Inject the menu stylesheet once (search box + empty state). */
|
|
72
|
+
function ensureMenuStyles() {
|
|
73
|
+
if (document.getElementById(`${SEARCH_ID}-style`) !== null)
|
|
74
|
+
return;
|
|
75
|
+
const style = document.createElement('style');
|
|
76
|
+
style.id = `${SEARCH_ID}-style`;
|
|
77
|
+
style.textContent = `
|
|
78
|
+
#${SEARCH_ID} {
|
|
79
|
+
width: calc(100% - 8px);
|
|
80
|
+
margin: 4px;
|
|
81
|
+
height: 28px;
|
|
82
|
+
padding: 0 8px;
|
|
83
|
+
border: 1px solid var(--dsw-alias-border-inverted, rgba(127, 127, 127, 0.35));
|
|
84
|
+
border-radius: 6px;
|
|
85
|
+
background: transparent;
|
|
86
|
+
color: inherit;
|
|
87
|
+
font-size: 13px;
|
|
88
|
+
outline: none;
|
|
89
|
+
}
|
|
90
|
+
.dsh-llm-ctl-empty {
|
|
91
|
+
padding: 12px 8px;
|
|
92
|
+
display: flex;
|
|
93
|
+
flex-direction: column;
|
|
94
|
+
gap: 8px;
|
|
95
|
+
align-items: flex-start;
|
|
96
|
+
font-size: 12px;
|
|
97
|
+
color: var(--dsw-alias-label-secondary, #93a1c0);
|
|
98
|
+
}
|
|
99
|
+
.dsh-llm-ctl-empty-action {
|
|
100
|
+
border: 1px solid var(--dsw-alias-border-inverted, rgba(127, 127, 127, 0.35));
|
|
101
|
+
background: transparent;
|
|
102
|
+
color: inherit;
|
|
103
|
+
border-radius: 6px;
|
|
104
|
+
padding: 2px 8px;
|
|
105
|
+
font-size: 12px;
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
}
|
|
108
|
+
`;
|
|
109
|
+
document.head.appendChild(style);
|
|
110
|
+
}
|
|
111
|
+
/** Inject the dock stylesheet once (pill hover/expanded + cancel hover). */
|
|
112
|
+
function ensureDockStyles() {
|
|
113
|
+
if (document.getElementById('dsh-llm-ctl-dock-style') !== null)
|
|
114
|
+
return;
|
|
115
|
+
const style = document.createElement('style');
|
|
116
|
+
style.id = 'dsh-llm-ctl-dock-style';
|
|
117
|
+
style.textContent = [
|
|
118
|
+
'.dsh-llm-ctl-dock-pill { background: transparent; border: none; cursor: pointer; }',
|
|
119
|
+
'.dsh-llm-ctl-dock-pill:hover, .dsh-llm-ctl-dock-pill[aria-expanded="true"] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }',
|
|
120
|
+
'.dsh-llm-ctl-dock-pill:focus-visible { outline: 2px solid var(--dsw-alias-label-tertiary); outline-offset: -2px; }',
|
|
121
|
+
'.dsh-llm-ctl-dock-cancel:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }',
|
|
122
|
+
'.dsh-llm-ctl-queue-dialog { cursor: default; }',
|
|
123
|
+
].join('\n');
|
|
124
|
+
document.head.appendChild(style);
|
|
125
|
+
}
|
|
126
|
+
/** POST one JSON body to a control route. */
|
|
127
|
+
async function postJson(url, body) {
|
|
128
|
+
const response = await fetch(url, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { 'content-type': 'application/json' },
|
|
131
|
+
credentials: 'same-origin',
|
|
132
|
+
body: JSON.stringify(body),
|
|
133
|
+
});
|
|
134
|
+
let parsed;
|
|
135
|
+
try {
|
|
136
|
+
parsed = await response.json();
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
parsed = undefined;
|
|
140
|
+
}
|
|
141
|
+
return { ok: response.ok, status: response.status, body: parsed };
|
|
142
|
+
}
|
|
143
|
+
/** Read the control state document. */
|
|
144
|
+
async function fetchState() {
|
|
145
|
+
const response = await fetch(STATE_URL, { headers: { accept: 'application/json' }, credentials: 'same-origin' });
|
|
146
|
+
if (!response.ok)
|
|
147
|
+
throw new Error(`state ${response.status}`);
|
|
148
|
+
return (await response.json());
|
|
149
|
+
}
|
|
150
|
+
/** Refresh the model catalog, coalescing concurrent callers. */
|
|
151
|
+
function refreshCatalog() {
|
|
152
|
+
if (catalogRequest !== undefined)
|
|
153
|
+
return catalogRequest;
|
|
154
|
+
const client = ctxRef;
|
|
155
|
+
if (client === undefined)
|
|
156
|
+
return Promise.resolve();
|
|
157
|
+
catalogRequest = (async () => {
|
|
158
|
+
try {
|
|
159
|
+
const result = await client.remote.session.modelCatalog();
|
|
160
|
+
if (result.ok && result.value !== undefined) {
|
|
161
|
+
const fresh = result.value;
|
|
162
|
+
catalogAt = Date.now();
|
|
163
|
+
// The catalog rarely changes; re-render seats only when it does so a
|
|
164
|
+
// background refresh never janks a scroll.
|
|
165
|
+
if (catalog === undefined || JSON.stringify(fresh) !== JSON.stringify(catalog)) {
|
|
166
|
+
catalog = fresh;
|
|
167
|
+
notify();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
client.logger?.warn('llm-ctl: model catalog failed', error);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
catalogRequest = undefined;
|
|
176
|
+
}
|
|
177
|
+
})();
|
|
178
|
+
return catalogRequest;
|
|
179
|
+
}
|
|
180
|
+
/** Current visibility switches (empty tables before the first poll). */
|
|
181
|
+
function visibilitySettings() {
|
|
182
|
+
return latestState?.visibility.settings ?? { providers: {}, models: {} };
|
|
183
|
+
}
|
|
184
|
+
/** Composition preset patterns, read-only. */
|
|
185
|
+
function hiddenPatterns() {
|
|
186
|
+
return latestState?.visibility.patterns ?? [];
|
|
187
|
+
}
|
|
188
|
+
/** Index catalog display names back to exact provider/model ids. */
|
|
189
|
+
function nameIndex() {
|
|
190
|
+
const index = new Map();
|
|
191
|
+
for (const group of catalog?.groups ?? []) {
|
|
192
|
+
const models = new Map();
|
|
193
|
+
for (const model of group.models)
|
|
194
|
+
models.set(model.name, { provider: group.id, model: model.id });
|
|
195
|
+
index.set(group.name, models);
|
|
196
|
+
}
|
|
197
|
+
return index;
|
|
198
|
+
}
|
|
199
|
+
/** Views for the settings page: catalog groups plus declared providers missing from it. */
|
|
200
|
+
function providerViews() {
|
|
201
|
+
const settings = visibilitySettings();
|
|
202
|
+
const patterns = hiddenPatterns();
|
|
203
|
+
const entries = (catalog?.groups ?? []).map((group) => ({
|
|
204
|
+
provider: group.id,
|
|
205
|
+
displayName: group.name,
|
|
206
|
+
models: group.models.map((model) => ({ model: model.id, name: model.name })),
|
|
207
|
+
}));
|
|
208
|
+
const seen = new Set(entries.map((entry) => entry.provider));
|
|
209
|
+
for (const declared of latestState?.visibility.configurableProviders ?? []) {
|
|
210
|
+
if (seen.has(declared.provider))
|
|
211
|
+
continue;
|
|
212
|
+
seen.add(declared.provider);
|
|
213
|
+
entries.push({ provider: declared.provider, displayName: declared.displayName, models: [] });
|
|
214
|
+
}
|
|
215
|
+
return buildProviderViews({ providers: entries, settings, patterns });
|
|
216
|
+
}
|
|
217
|
+
/** Upstream discovery state per provider, fed by the refresh buttons. */
|
|
218
|
+
const discoveries = new Map();
|
|
219
|
+
/** Refresh one provider's upstream model list. */
|
|
220
|
+
async function refreshDiscovery(provider) {
|
|
221
|
+
const current = discoveries.get(provider);
|
|
222
|
+
if (current !== undefined && current.pending)
|
|
223
|
+
return;
|
|
224
|
+
discoveries.set(provider, { rows: current?.rows ?? [], pending: true, failed: undefined });
|
|
225
|
+
notify();
|
|
226
|
+
try {
|
|
227
|
+
const response = await postJson(DISCOVER_URL, { provider });
|
|
228
|
+
const body = response.body;
|
|
229
|
+
if (!response.ok)
|
|
230
|
+
throw new Error('discover ' + response.status);
|
|
231
|
+
if (body !== undefined && typeof body.error === 'string' && body.error.length > 0) {
|
|
232
|
+
discoveries.set(provider, { rows: current?.rows ?? [], pending: false, failed: body.error });
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
const rows = buildDiscoveredRows({
|
|
236
|
+
provider,
|
|
237
|
+
discovered: body?.discovered ?? [],
|
|
238
|
+
advertisedIds: (catalog?.groups ?? []).find((group) => group.id === provider)?.models.map((model) => model.id) ?? [],
|
|
239
|
+
settings: visibilitySettings(),
|
|
240
|
+
patterns: hiddenPatterns(),
|
|
241
|
+
});
|
|
242
|
+
discoveries.set(provider, { rows, pending: false });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
discoveries.set(provider, {
|
|
247
|
+
rows: current?.rows ?? [],
|
|
248
|
+
pending: false,
|
|
249
|
+
failed: error instanceof Error ? error.message : String(error),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
notify();
|
|
253
|
+
}
|
|
254
|
+
/** Apply visibility and the current query to the open model menu. */
|
|
255
|
+
function applyToMenu() {
|
|
256
|
+
const roots = findModelMenu(document);
|
|
257
|
+
if (roots === undefined)
|
|
258
|
+
return;
|
|
259
|
+
attached = roots;
|
|
260
|
+
// The popup opens on a root pane (model / effort cells) with no rows yet.
|
|
261
|
+
// Filtering or showing the empty state there would be wrong, and the pane
|
|
262
|
+
// swap replaces the groups container, so resolve everything fresh each time.
|
|
263
|
+
if (listModelRows(roots).length === 0) {
|
|
264
|
+
searchInput?.remove();
|
|
265
|
+
searchInput = undefined;
|
|
266
|
+
removeEmptyState(roots);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const index = nameIndex();
|
|
270
|
+
const settings = visibilitySettings();
|
|
271
|
+
const config = { hiddenPatterns: hiddenPatterns() };
|
|
272
|
+
const isRowVisible = (row) => {
|
|
273
|
+
const ids = index.get(row.providerName)?.get(row.modelName);
|
|
274
|
+
if (ids === undefined)
|
|
275
|
+
return true;
|
|
276
|
+
return isModelVisible(ids.provider, ids.model, settings, config);
|
|
277
|
+
};
|
|
278
|
+
// A foreign search widget owns query filtering, groups, and empty states in
|
|
279
|
+
// its menu. Full filtering here would rewrite every passing row and wipe its
|
|
280
|
+
// work on every tick, so only hide our own rejected rows and return.
|
|
281
|
+
if (hasForeignSearchWidget(roots)) {
|
|
282
|
+
applyVisibilityOnly(roots, isRowVisible);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const result = applyMenuFilter(roots, { query, isRowVisible });
|
|
286
|
+
// Only touch the empty state on a transition: ensureEmptyState rewrites its
|
|
287
|
+
// children, and an unconditional call would feed the MutationObserver forever.
|
|
288
|
+
const sibling = roots.groups.previousElementSibling;
|
|
289
|
+
const hasEmptyState = sibling !== null && sibling.nodeType === 1 && sibling.id === 'dsh-llm-ctl-empty';
|
|
290
|
+
if (result.shown === 0) {
|
|
291
|
+
if (!hasEmptyState) {
|
|
292
|
+
ensureEmptyState(roots, '无可见模型', '显示全部隐藏项', () => {
|
|
293
|
+
void resetAllVisibility();
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
else if (hasEmptyState) {
|
|
298
|
+
removeEmptyState(roots);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** Inject our search box unless dsh-model-search-plugin already owns one. */
|
|
302
|
+
function ensureSearchBox(roots) {
|
|
303
|
+
if (listModelRows(roots).length === 0)
|
|
304
|
+
return;
|
|
305
|
+
if (hasForeignSearchWidget(roots)) {
|
|
306
|
+
searchInput?.remove();
|
|
307
|
+
searchInput = undefined;
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
// A correctly anchored box stays untouched: moving it would feed the
|
|
311
|
+
// MutationObserver on every poll. Walk the siblings before the groups so the
|
|
312
|
+
// empty-state element (inserted between box and groups) does not matter.
|
|
313
|
+
if (searchInput !== undefined) {
|
|
314
|
+
let sibling = roots.groups.previousElementSibling;
|
|
315
|
+
while (sibling !== null) {
|
|
316
|
+
if (sibling === searchInput) {
|
|
317
|
+
if (!document.body.contains(searchInput))
|
|
318
|
+
break;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
sibling = sibling.previousElementSibling;
|
|
322
|
+
}
|
|
323
|
+
searchInput.remove();
|
|
324
|
+
searchInput = undefined;
|
|
325
|
+
}
|
|
326
|
+
const input = document.createElement('input');
|
|
327
|
+
input.id = SEARCH_ID;
|
|
328
|
+
input.type = 'search';
|
|
329
|
+
input.name = 'dsh-llm-ctl-model-search';
|
|
330
|
+
input.setAttribute('aria-label', '搜索模型');
|
|
331
|
+
input.placeholder = '搜索模型… (Ctrl+F) · p: 限定提供方';
|
|
332
|
+
input.value = query;
|
|
333
|
+
// Isolate from other menu plugins: our keystrokes and focus shortcuts must
|
|
334
|
+
// not bubble into the popup or the page behind it.
|
|
335
|
+
input.addEventListener('input', (event) => {
|
|
336
|
+
event.stopPropagation();
|
|
337
|
+
query = input.value;
|
|
338
|
+
applyToMenu();
|
|
339
|
+
});
|
|
340
|
+
input.addEventListener('keydown', (event) => {
|
|
341
|
+
event.stopPropagation();
|
|
342
|
+
if (event.key === 'Escape') {
|
|
343
|
+
input.value = '';
|
|
344
|
+
query = '';
|
|
345
|
+
applyToMenu();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
roots.groups.parentNode?.insertBefore(input, roots.groups);
|
|
349
|
+
searchInput = input;
|
|
350
|
+
}
|
|
351
|
+
/** Detach from a menu that closed. */
|
|
352
|
+
function detachMenu() {
|
|
353
|
+
if (attached !== undefined)
|
|
354
|
+
resetMenuFilter(attached);
|
|
355
|
+
if (attached !== undefined)
|
|
356
|
+
removeEmptyState(attached);
|
|
357
|
+
attached = undefined;
|
|
358
|
+
searchInput?.remove();
|
|
359
|
+
searchInput = undefined;
|
|
360
|
+
}
|
|
361
|
+
/** Coalesce DOM mutations into one reconciliation per frame-ish window. */
|
|
362
|
+
function scheduleSync() {
|
|
363
|
+
if (syncScheduled)
|
|
364
|
+
return;
|
|
365
|
+
syncScheduled = true;
|
|
366
|
+
setTimeout(() => {
|
|
367
|
+
syncScheduled = false;
|
|
368
|
+
syncMenu();
|
|
369
|
+
}, 50);
|
|
370
|
+
}
|
|
371
|
+
/** Reconcile the attached menu with the current DOM. */
|
|
372
|
+
function syncMenu() {
|
|
373
|
+
const roots = findModelMenu(document);
|
|
374
|
+
if (roots === undefined) {
|
|
375
|
+
if (attached !== undefined)
|
|
376
|
+
detachMenu();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (attached !== undefined && attached.menu === roots.menu) {
|
|
380
|
+
ensureSearchBox(roots);
|
|
381
|
+
applyToMenu();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
detachMenu();
|
|
385
|
+
attached = roots;
|
|
386
|
+
ensureSearchBox(roots);
|
|
387
|
+
applyToMenu();
|
|
388
|
+
}
|
|
389
|
+
/** Write one visibility switch and refresh. */
|
|
390
|
+
async function writeVisibility(input) {
|
|
391
|
+
try {
|
|
392
|
+
await postJson(VISIBILITY_URL, input);
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
ctxRef?.logger?.warn('llm-ctl: visibility write failed', error);
|
|
396
|
+
}
|
|
397
|
+
await tick();
|
|
398
|
+
applyToMenu();
|
|
399
|
+
}
|
|
400
|
+
/** Restore every hidden provider and model. */
|
|
401
|
+
async function resetAllVisibility() {
|
|
402
|
+
try {
|
|
403
|
+
await postJson(VISIBILITY_RESET_URL, {});
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
ctxRef?.logger?.warn('llm-ctl: visibility reset failed', error);
|
|
407
|
+
}
|
|
408
|
+
await tick();
|
|
409
|
+
applyToMenu();
|
|
410
|
+
}
|
|
411
|
+
/** Persist one global queue-budget override and refresh. */
|
|
412
|
+
async function writeQueue(input) {
|
|
413
|
+
try {
|
|
414
|
+
const response = await postJson(QUEUE_URL, input);
|
|
415
|
+
const body = response.body;
|
|
416
|
+
const error = typeof body?.error === 'string' && body.error.length > 0 ? body.error : undefined;
|
|
417
|
+
if (!response.ok)
|
|
418
|
+
return { ok: false, ...(error === undefined ? {} : { error }) };
|
|
419
|
+
return { ok: true };
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
ctxRef?.logger?.warn('llm-ctl: queue write failed', error);
|
|
423
|
+
return { ok: false };
|
|
424
|
+
}
|
|
425
|
+
finally {
|
|
426
|
+
await tick();
|
|
427
|
+
applyToMenu();
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/** Drop the queue override, re-inheriting the cordis base, and refresh. */
|
|
431
|
+
async function resetQueue(expectedRevision) {
|
|
432
|
+
let ok = true;
|
|
433
|
+
try {
|
|
434
|
+
ok = (await postJson(QUEUE_RESET_URL, { ...(expectedRevision === undefined ? {} : { expectedRevision }) })).ok;
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
ctxRef?.logger?.warn('llm-ctl: queue reset failed', error);
|
|
438
|
+
ok = false;
|
|
439
|
+
}
|
|
440
|
+
await tick();
|
|
441
|
+
applyToMenu();
|
|
442
|
+
return ok;
|
|
443
|
+
}
|
|
444
|
+
/** Settings-page footer seat. */
|
|
445
|
+
function FooterSeat() {
|
|
446
|
+
const { state } = useCtlState();
|
|
447
|
+
if (state === undefined)
|
|
448
|
+
return null;
|
|
449
|
+
const views = providerViews();
|
|
450
|
+
const cooling = state.queue.lanes.filter((lane) => lane.cooldownRemainingMs > 0).length;
|
|
451
|
+
const element = VisibilityFooter({
|
|
452
|
+
summary: summarize(views),
|
|
453
|
+
queue: { queued: state.queue.waiters.length, cooling },
|
|
454
|
+
actions: {
|
|
455
|
+
setProvider: (provider, visible) => void writeVisibility({ provider, visible }),
|
|
456
|
+
setModel: (provider, model, visible) => void writeVisibility({ provider, model, visible }),
|
|
457
|
+
resetAll: () => void resetAllVisibility(),
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
return element;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Plugin-config tab seat (settings.plugin.item, keyed by the llm-ctl settings
|
|
464
|
+
* namespace).
|
|
465
|
+
*
|
|
466
|
+
* The tab pairs the settings namespaces the Host serves with the cards
|
|
467
|
+
* registered under those keys, so registering here is what makes the plugin's
|
|
468
|
+
* global queue budget editable from 设置 → 插件 → 插件配置.
|
|
469
|
+
*/
|
|
470
|
+
function PluginConfigSeat() {
|
|
471
|
+
const { state } = useCtlState();
|
|
472
|
+
if (state === undefined)
|
|
473
|
+
return null;
|
|
474
|
+
const views = providerViews();
|
|
475
|
+
const element = PluginConfigCard({
|
|
476
|
+
config: state.queueConfig,
|
|
477
|
+
providers: views.map((view) => ({ provider: view.provider, displayName: view.displayName })),
|
|
478
|
+
actions: {
|
|
479
|
+
setQueue: (input) => writeQueue(input),
|
|
480
|
+
resetQueue: (revision) => resetQueue(revision),
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
return element;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Resolve the provider a seat instance controls.
|
|
487
|
+
*
|
|
488
|
+
* Exported for tests: shared-namespace rows must resolve from owner props.
|
|
489
|
+
*
|
|
490
|
+
* @param owner Owner props dispatched by the slot; may be absent in tests.
|
|
491
|
+
* @param fallbackProviderId Registration-time provider id, last resort only.
|
|
492
|
+
* @returns The provider id every switch in this seat writes.
|
|
493
|
+
*/
|
|
494
|
+
export function resolveSeatProvider(owner, fallbackProviderId) {
|
|
495
|
+
const dispatched = owner?.provider?.provider;
|
|
496
|
+
return typeof dispatched === 'string' && dispatched.length > 0 ? dispatched : fallbackProviderId;
|
|
497
|
+
}
|
|
498
|
+
/** Provider-card extras seat for one provider row, plus its upstream discovery list. */
|
|
499
|
+
function makeProviderCardSeat(fallbackProviderId) {
|
|
500
|
+
return function ProviderCardSeat(props) {
|
|
501
|
+
const { state } = useCtlState();
|
|
502
|
+
if (state === undefined)
|
|
503
|
+
return null;
|
|
504
|
+
// Rows that share one settings namespace (every dormant pi-ai route shares
|
|
505
|
+
// 'llm-pi-ai') all receive this same seat: the row's own provider id comes
|
|
506
|
+
// from the owner props, never from the registration closure. Using the
|
|
507
|
+
// closure id here would toggle the wrong provider on every shared row.
|
|
508
|
+
const rowProvider = resolveSeatProvider(props, fallbackProviderId);
|
|
509
|
+
const settings = visibilitySettings();
|
|
510
|
+
const patterns = hiddenPatterns();
|
|
511
|
+
const view = providerViews().find((candidate) => candidate.provider === rowProvider) ?? {
|
|
512
|
+
provider: rowProvider,
|
|
513
|
+
displayName: typeof props.provider?.displayName === 'string' && props.provider.displayName.length > 0
|
|
514
|
+
? props.provider.displayName
|
|
515
|
+
: rowProvider,
|
|
516
|
+
visible: isProviderVisible(rowProvider, settings, { hiddenPatterns: patterns }),
|
|
517
|
+
models: [],
|
|
518
|
+
};
|
|
519
|
+
const discovery = discoveries.get(rowProvider) ?? { rows: [], pending: false };
|
|
520
|
+
const actions = {
|
|
521
|
+
setProvider: (provider, visible) => void writeVisibility({ provider, visible }),
|
|
522
|
+
setModel: (provider, model, visible) => void writeVisibility({ provider, model, visible }),
|
|
523
|
+
resetAll: () => void resetAllVisibility(),
|
|
524
|
+
};
|
|
525
|
+
// The results list renders nothing before the first refresh, so the entry
|
|
526
|
+
// button lives outside it: exactly one refresh affordance in every state.
|
|
527
|
+
const showEntryRefresh = discovery.rows.length === 0 && !discovery.pending && discovery.failed === undefined;
|
|
528
|
+
const element = React.createElement(React.Fragment, null, ProviderVisibilityCard({ view, actions }), showEntryRefresh
|
|
529
|
+
? RefreshModelsButton({ pending: false, onRefresh: () => void refreshDiscovery(rowProvider) })
|
|
530
|
+
: null, DiscoveredModelsList({
|
|
531
|
+
provider: rowProvider,
|
|
532
|
+
rows: discovery.rows,
|
|
533
|
+
pending: discovery.pending,
|
|
534
|
+
failed: discovery.failed,
|
|
535
|
+
onToggle: (provider, model, visible) => void writeVisibility({ provider, model, visible }),
|
|
536
|
+
onRefresh: () => void refreshDiscovery(rowProvider),
|
|
537
|
+
}));
|
|
538
|
+
return element;
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Composer-dock seat: queue depth, cooldown, cancel. Returns null while the
|
|
543
|
+
* control plane is idle so the dock collapses to nothing.
|
|
544
|
+
*/
|
|
545
|
+
function QueueDockSeat() {
|
|
546
|
+
const { state } = useCtlState();
|
|
547
|
+
if (state === undefined)
|
|
548
|
+
return null;
|
|
549
|
+
const view = buildQueueDockView(state.queue);
|
|
550
|
+
if (view === undefined)
|
|
551
|
+
return null;
|
|
552
|
+
return QueueDockPanel({ view, onCancel: (queueId) => void postJson(CANCEL_URL, { queueId }) });
|
|
553
|
+
}
|
|
554
|
+
/** Register the plugin-config, footer, and composer-dock seats immediately; provider cards follow the directory. */
|
|
555
|
+
function registerSeats(client) {
|
|
556
|
+
const slots = client.slots;
|
|
557
|
+
if (slots === undefined)
|
|
558
|
+
return;
|
|
559
|
+
// 设置 → 插件 → 插件配置 renders one card per settings namespace the Host
|
|
560
|
+
// serves; keying on `llm-ctl` is what pairs it with the section we persist.
|
|
561
|
+
slots.inject('settings.plugin.item', () => slots.register({ name: 'settings.plugin.item', key: 'llm-ctl', order: 100 }, PluginConfigSeat));
|
|
562
|
+
slots.inject('settings.models.footer', () => slots.register({ name: 'settings.models.footer', id: 'llm-ctl-visibility', order: 100 }, FooterSeat));
|
|
563
|
+
// The dock is a session list slot below the composer card. Seats render
|
|
564
|
+
// as siblings inside one display:contents anchor: the official stats pills
|
|
565
|
+
// (chat StatsPills seat, order 0, its own `.bOPqQW_root` pill row) come
|
|
566
|
+
// first; our queue pills go last. QueueDockPanel mirrors the stats
|
|
567
|
+
// pill-row language so the two stacked rows read as one dock.
|
|
568
|
+
// Register with locale 'llm-ctl' so the slot system injects the `t` function.
|
|
569
|
+
slots.inject('conversation.composer.dock', () => slots.register({ name: 'conversation.composer.dock', id: 'llm-ctl-queue', order: 1000 }, QueueDockSeat));
|
|
570
|
+
}
|
|
571
|
+
/** Register one provider-card seat per declared settings namespace, once. */
|
|
572
|
+
let providerSeatsRegistered = false;
|
|
573
|
+
function ensureProviderSeats(client) {
|
|
574
|
+
if (providerSeatsRegistered)
|
|
575
|
+
return;
|
|
576
|
+
const providers = latestState?.visibility.configurableProviders;
|
|
577
|
+
if (providers === undefined || providers.length === 0)
|
|
578
|
+
return;
|
|
579
|
+
const slots = client.slots;
|
|
580
|
+
if (slots === undefined)
|
|
581
|
+
return;
|
|
582
|
+
providerSeatsRegistered = true;
|
|
583
|
+
const seen = new Set();
|
|
584
|
+
for (const entry of providers) {
|
|
585
|
+
if (typeof entry.settingsNs !== 'string' || entry.settingsNs.length === 0)
|
|
586
|
+
continue;
|
|
587
|
+
if (seen.has(entry.settingsNs))
|
|
588
|
+
continue;
|
|
589
|
+
seen.add(entry.settingsNs);
|
|
590
|
+
slots.inject('settings.models.provider-card', () => slots.register({ name: 'settings.models.provider-card', key: entry.settingsNs }, makeProviderCardSeat(entry.provider)));
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Backoff ladder for failed polls: 1s, 2s, 4s … capped at 30s, so a
|
|
595
|
+
* struggling host is not hammered every second (each failed fetch also logs
|
|
596
|
+
* a console "Failed to load resource" line, which is the 503 spam).
|
|
597
|
+
*
|
|
598
|
+
* Exported for tests: the ladder is the contract that bounds poll pressure.
|
|
599
|
+
*
|
|
600
|
+
* @param failures Consecutive fetch failures.
|
|
601
|
+
* @returns Delay in ms before the next poll.
|
|
602
|
+
*/
|
|
603
|
+
export function pollDelayForFailures(failures) {
|
|
604
|
+
if (failures <= 0)
|
|
605
|
+
return POLL_MS;
|
|
606
|
+
return Math.min(POLL_MS * 2 ** failures, MAX_POLL_MS);
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Stable identity of everything seats render. `at` is excluded on purpose:
|
|
610
|
+
* it changes on every poll while nothing visible changed.
|
|
611
|
+
*/
|
|
612
|
+
function stateSignature(state) {
|
|
613
|
+
return JSON.stringify([state.queue, state.visibility, state.reactive, state.queueConfig]);
|
|
614
|
+
}
|
|
615
|
+
/** Schedule the next poll on the backoff ladder. */
|
|
616
|
+
function scheduleNext() {
|
|
617
|
+
timer = setTimeout(() => void loop(), pollDelayForFailures(fetchFailures));
|
|
618
|
+
}
|
|
619
|
+
/** One scheduled poll; skipped (not failed) while the tab is hidden. */
|
|
620
|
+
async function loop() {
|
|
621
|
+
timer = undefined;
|
|
622
|
+
if (typeof document !== 'undefined' && document.hidden) {
|
|
623
|
+
timer = setTimeout(() => void loop(), HIDDEN_POLL_MS);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
await tick();
|
|
627
|
+
// A standalone tick (write paths below) never schedules; only the loop
|
|
628
|
+
// chains. Guard against disposal while the fetch was in flight.
|
|
629
|
+
if (ctxRef === undefined)
|
|
630
|
+
return;
|
|
631
|
+
scheduleNext();
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* One poll: state, menu, seats.
|
|
635
|
+
*
|
|
636
|
+
* Seats re-render only when the visible state actually changed: `at` ticks
|
|
637
|
+
* every second, so an unconditional notify would re-render every provider
|
|
638
|
+
* card on the models page every second — jank under the user's scroll.
|
|
639
|
+
*/
|
|
640
|
+
async function tick() {
|
|
641
|
+
try {
|
|
642
|
+
latestState = await fetchState();
|
|
643
|
+
fetchFailures = 0;
|
|
644
|
+
const signature = stateSignature(latestState);
|
|
645
|
+
if (signature !== lastNotifiedSignature) {
|
|
646
|
+
lastNotifiedSignature = signature;
|
|
647
|
+
notify();
|
|
648
|
+
}
|
|
649
|
+
if (ctxRef !== undefined)
|
|
650
|
+
ensureProviderSeats(ctxRef);
|
|
651
|
+
if (catalog === undefined || Date.now() - catalogAt > CATALOG_TTL_MS)
|
|
652
|
+
void refreshCatalog();
|
|
653
|
+
syncMenu();
|
|
654
|
+
}
|
|
655
|
+
catch (error) {
|
|
656
|
+
fetchFailures += 1;
|
|
657
|
+
ctxRef?.logger?.warn('llm-ctl: state fetch failed', error);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
/** Start the browser half. */
|
|
661
|
+
export function apply(ctx) {
|
|
662
|
+
ctxRef = ctx;
|
|
663
|
+
fetchFailures = 0;
|
|
664
|
+
lastNotifiedSignature = undefined;
|
|
665
|
+
ensureMenuStyles();
|
|
666
|
+
ensureDockStyles();
|
|
667
|
+
registerSeats(ctx);
|
|
668
|
+
void refreshCatalog();
|
|
669
|
+
void tick();
|
|
670
|
+
scheduleNext();
|
|
671
|
+
observer = new MutationObserver(() => {
|
|
672
|
+
scheduleSync();
|
|
673
|
+
});
|
|
674
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
675
|
+
ctx.effect?.(() => () => {
|
|
676
|
+
if (timer !== undefined)
|
|
677
|
+
clearTimeout(timer);
|
|
678
|
+
timer = undefined;
|
|
679
|
+
observer?.disconnect();
|
|
680
|
+
observer = undefined;
|
|
681
|
+
detachMenu();
|
|
682
|
+
subscribers.clear();
|
|
683
|
+
ctxRef = undefined;
|
|
684
|
+
}, 'llm-ctl: stop polling and menu observation');
|
|
685
|
+
}
|