@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
package/lib/index.js
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { Config, UNLIMITED_CONCURRENCY, concurrencyFor, resolveConfig } from "./config.js";
|
|
2
|
+
import { CtlEventLog } from "./events.js";
|
|
3
|
+
import { resolveDelay } from "./delay.js";
|
|
4
|
+
import { ProviderGate } from "./queue.js";
|
|
5
|
+
import { RetryBudget, WAITABLE_CODES, cancellableDelay, decideReactive } from "./reactive.js";
|
|
6
|
+
import { LlmCtlController } from "./controller.js";
|
|
7
|
+
import { createRoutes } from "./routes.js";
|
|
8
|
+
import { VISIBILITY_SETTINGS_NS, installVisibilitySettings, } from "./visibility-settings.js";
|
|
9
|
+
import { discoverProviderModels } from "./discover.js";
|
|
10
|
+
import { isModelVisible, pickFallback } from "./visibility.js";
|
|
11
|
+
/** Cordis plugin name. */
|
|
12
|
+
export const name = 'llm-ctl';
|
|
13
|
+
/** No service is required; both seams are event listeners. */
|
|
14
|
+
export const inject = [];
|
|
15
|
+
export { Config };
|
|
16
|
+
/** Terminal error chunk used to refuse admission without dispatching. */
|
|
17
|
+
function refusalChunk(code, message) {
|
|
18
|
+
return { type: 'finish', reason: { kind: 'error', failure: { code, message } } };
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Install admission control and standalone recovery.
|
|
22
|
+
*
|
|
23
|
+
* @param ctx - plugin context owning both listeners and every pending wait.
|
|
24
|
+
* @param config - queue and recovery configuration.
|
|
25
|
+
* @param internals - deterministic hooks for tests.
|
|
26
|
+
*/
|
|
27
|
+
export function apply(ctx, config = {}, internals = {}) {
|
|
28
|
+
const resolved = resolveConfig(config);
|
|
29
|
+
const random = internals.random ?? Math.random;
|
|
30
|
+
const now = internals.now;
|
|
31
|
+
const events = new CtlEventLog(200);
|
|
32
|
+
const budget = new RetryBudget();
|
|
33
|
+
const lifetime = new AbortController();
|
|
34
|
+
const active = new Set();
|
|
35
|
+
const gate = new ProviderGate({
|
|
36
|
+
...(internals.scheduler === undefined ? {} : { scheduler: internals.scheduler }),
|
|
37
|
+
concurrencyFor: (provider) => concurrencyFor(resolved.queue.perProviderConcurrency, provider),
|
|
38
|
+
maxQueueDepth: resolved.queue.maxQueueDepth,
|
|
39
|
+
maxWaitMs: resolved.queue.maxWaitMs,
|
|
40
|
+
onEvent: (kind, detail) => {
|
|
41
|
+
events.push({ kind, ...detail });
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
/** Cordis composition base for the global queue budget; the settings user layer may override it. */
|
|
45
|
+
const baseMaxWaitMs = resolved.queue.maxWaitMs;
|
|
46
|
+
const baseMaxQueueDepth = resolved.queue.maxQueueDepth;
|
|
47
|
+
const basePerProviderConcurrency = { ...resolved.queue.perProviderConcurrency };
|
|
48
|
+
const baseDefaultConcurrency = basePerProviderConcurrency['default'] ?? UNLIMITED_CONCURRENCY;
|
|
49
|
+
/** Track one in-flight recovery so disposal can drain it. */
|
|
50
|
+
function track(operation) {
|
|
51
|
+
const tracked = operation.finally(() => active.delete(tracked));
|
|
52
|
+
active.add(tracked);
|
|
53
|
+
return tracked;
|
|
54
|
+
}
|
|
55
|
+
function resolveFailureDelay(provider, failure, attempt) {
|
|
56
|
+
return resolveDelay({
|
|
57
|
+
providerRetryAfterMs: failure.providerRetryAfterMs,
|
|
58
|
+
attempt,
|
|
59
|
+
backoff: resolved.queue.backoff,
|
|
60
|
+
maxWaitMs: resolved.queue.maxWaitMs,
|
|
61
|
+
honorRetryAfter: resolved.queue.honorRetryAfter,
|
|
62
|
+
random,
|
|
63
|
+
...(now === undefined ? {} : { now }),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** Register the cooldown a terminal failure implies. */
|
|
67
|
+
function observeTerminal(provider, origin, chunk) {
|
|
68
|
+
if (chunk.type !== 'finish')
|
|
69
|
+
return;
|
|
70
|
+
const reason = chunk.reason;
|
|
71
|
+
if (reason.kind !== 'error' && reason.kind !== 'aborted')
|
|
72
|
+
return;
|
|
73
|
+
const failure = reason.failure;
|
|
74
|
+
if (!WAITABLE_CODES.includes(failure.code))
|
|
75
|
+
return;
|
|
76
|
+
const delay = resolveFailureDelay(provider, failure, 1);
|
|
77
|
+
ctx.logger.debug('llm-ctl: provider %s reported %s (%s), cooldown %dms', provider, failure.code, delay.source, delay.delayMs);
|
|
78
|
+
gate.registerCooldown(provider, delay.delayMs, 'terminal-failure', delay.source);
|
|
79
|
+
}
|
|
80
|
+
const disposeStream = ctx.on('llm/stream', (options, next) => {
|
|
81
|
+
const provider = options.provider;
|
|
82
|
+
const origin = options.purpose === undefined ? 'loop' : 'background';
|
|
83
|
+
const signal = options.signal;
|
|
84
|
+
return (async function* admission() {
|
|
85
|
+
const outcome = await gate.acquire(provider, { origin, signal });
|
|
86
|
+
if (!outcome.ok) {
|
|
87
|
+
ctx.logger.warn('llm-ctl: refusing %s request for provider %s (%s)', origin, provider, outcome.reason);
|
|
88
|
+
yield refusalChunk(outcome.code, `dsh-llm-ctl: ${outcome.code} for provider "${provider}" (${outcome.reason})`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
for await (const chunk of next()) {
|
|
93
|
+
observeTerminal(provider, origin, chunk);
|
|
94
|
+
yield chunk;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
outcome.release();
|
|
99
|
+
}
|
|
100
|
+
})();
|
|
101
|
+
}, { global: true, prepend: true });
|
|
102
|
+
/** Standalone recovery: cooldown first, downstream second, own budget last. */
|
|
103
|
+
async function recover(payload, next) {
|
|
104
|
+
const { agent, provider, failure, turn, step, signal } = payload;
|
|
105
|
+
const key = `${agent.session.id}:${provider}:${turn}:${step}`;
|
|
106
|
+
const attempts = budget.attempts(key);
|
|
107
|
+
const delay = resolveFailureDelay(provider, failure, attempts + 1);
|
|
108
|
+
gate.registerCooldown(provider, delay.delayMs, 'request-error', delay.source);
|
|
109
|
+
let delegated = false;
|
|
110
|
+
let downstreamError;
|
|
111
|
+
try {
|
|
112
|
+
const action = await next();
|
|
113
|
+
delegated = action?.kind === 'retry';
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
downstreamError = error;
|
|
117
|
+
}
|
|
118
|
+
if (delegated) {
|
|
119
|
+
events.push({ kind: 'retry-delegated', provider, code: failure.code, reason: 'downstream', attempt: attempts });
|
|
120
|
+
return { kind: 'retry' };
|
|
121
|
+
}
|
|
122
|
+
const decision = decideReactive({
|
|
123
|
+
code: failure.code,
|
|
124
|
+
delegated: false,
|
|
125
|
+
attempts,
|
|
126
|
+
limit: resolved.reactiveRetryLimit,
|
|
127
|
+
delay,
|
|
128
|
+
});
|
|
129
|
+
if (!decision.retry) {
|
|
130
|
+
events.push({
|
|
131
|
+
kind: 'retry-skipped',
|
|
132
|
+
provider,
|
|
133
|
+
code: failure.code,
|
|
134
|
+
reason: decision.reason,
|
|
135
|
+
attempt: attempts,
|
|
136
|
+
delayMs: decision.delayMs,
|
|
137
|
+
});
|
|
138
|
+
if (downstreamError !== undefined)
|
|
139
|
+
throw downstreamError;
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
budget.record(key);
|
|
143
|
+
const fused = AbortSignal.any([signal, lifetime.signal]);
|
|
144
|
+
events.push({
|
|
145
|
+
kind: 'retry-scheduled',
|
|
146
|
+
provider,
|
|
147
|
+
code: failure.code,
|
|
148
|
+
reason: decision.reason,
|
|
149
|
+
attempt: attempts + 1,
|
|
150
|
+
delayMs: decision.delayMs,
|
|
151
|
+
source: delay.source,
|
|
152
|
+
});
|
|
153
|
+
const waited = await cancellableDelay(decision.delayMs, fused);
|
|
154
|
+
if (!waited || fused.aborted) {
|
|
155
|
+
events.push({ kind: 'retry-skipped', provider, code: failure.code, reason: 'cancelled', attempt: attempts + 1 });
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
return { kind: 'retry' };
|
|
159
|
+
}
|
|
160
|
+
const disposeError = ctx.on('agent/request-error', (payload, next) => {
|
|
161
|
+
if (lifetime.signal.aborted)
|
|
162
|
+
return Promise.resolve(undefined);
|
|
163
|
+
return track(recover(payload, next));
|
|
164
|
+
}, { global: true, prepend: true });
|
|
165
|
+
/** Best-effort handle on the llm service; undefined in compositions without one. */
|
|
166
|
+
function llmService() {
|
|
167
|
+
try {
|
|
168
|
+
return ctx.get('llm');
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Late-bound queue-override reader.
|
|
176
|
+
*
|
|
177
|
+
* The settings attach may fire `onChange` synchronously inside
|
|
178
|
+
* `installVisibilitySettings`, i.e. before the handle binding below is
|
|
179
|
+
* assigned; reading the handle directly there would throw a TDZ error.
|
|
180
|
+
*/
|
|
181
|
+
let readQueueOverride = () => ({});
|
|
182
|
+
let visibilityReady = false;
|
|
183
|
+
/** Reconcile the effective queue budget: settings override wins, cordis base is the fallback. */
|
|
184
|
+
function applyQueueOverrides() {
|
|
185
|
+
const override = readQueueOverride();
|
|
186
|
+
const nextTable = {
|
|
187
|
+
...basePerProviderConcurrency,
|
|
188
|
+
...(override.defaultConcurrency === undefined ? {} : { default: override.defaultConcurrency }),
|
|
189
|
+
...override.perProviderConcurrency,
|
|
190
|
+
};
|
|
191
|
+
const next = {
|
|
192
|
+
maxWaitMs: override.maxWaitMs ?? baseMaxWaitMs,
|
|
193
|
+
maxQueueDepth: override.maxQueueDepth ?? baseMaxQueueDepth,
|
|
194
|
+
};
|
|
195
|
+
const tableBefore = JSON.stringify(resolved.queue.perProviderConcurrency);
|
|
196
|
+
const changed = next.maxWaitMs !== resolved.queue.maxWaitMs ||
|
|
197
|
+
next.maxQueueDepth !== resolved.queue.maxQueueDepth ||
|
|
198
|
+
JSON.stringify(nextTable) !== tableBefore;
|
|
199
|
+
resolved.queue.maxWaitMs = next.maxWaitMs;
|
|
200
|
+
resolved.queue.maxQueueDepth = next.maxQueueDepth;
|
|
201
|
+
// The gate's concurrency lookup closes over this table object, so replacing
|
|
202
|
+
// it takes effect for every admission decided after this call.
|
|
203
|
+
resolved.queue.perProviderConcurrency = nextTable;
|
|
204
|
+
gate.updateLimits(next);
|
|
205
|
+
if (changed)
|
|
206
|
+
events.push({ kind: 'queue-config-changed', provider: '*', reason: 'settings' });
|
|
207
|
+
}
|
|
208
|
+
const controlState = () => ({
|
|
209
|
+
at: Date.now(),
|
|
210
|
+
queue: gate.snapshot(),
|
|
211
|
+
events: events.list(100),
|
|
212
|
+
reactive: { mode: resolved.reactiveRetryMode, limit: resolved.reactiveRetryLimit },
|
|
213
|
+
queueConfig: (() => {
|
|
214
|
+
const override = visibility.queue();
|
|
215
|
+
const { default: _default, ...perProvider } = resolved.queue.perProviderConcurrency;
|
|
216
|
+
void _default;
|
|
217
|
+
return {
|
|
218
|
+
maxWaitMs: resolved.queue.maxWaitMs,
|
|
219
|
+
maxQueueDepth: resolved.queue.maxQueueDepth,
|
|
220
|
+
defaultConcurrency: resolved.queue.perProviderConcurrency['default'] ?? UNLIMITED_CONCURRENCY,
|
|
221
|
+
perProviderConcurrency: perProvider,
|
|
222
|
+
defaults: { maxWaitMs: baseMaxWaitMs, maxQueueDepth: baseMaxQueueDepth, defaultConcurrency: baseDefaultConcurrency },
|
|
223
|
+
overridden: override.maxWaitMs !== undefined ||
|
|
224
|
+
override.maxQueueDepth !== undefined ||
|
|
225
|
+
override.defaultConcurrency !== undefined ||
|
|
226
|
+
override.perProviderConcurrency !== undefined,
|
|
227
|
+
revision: visibility.revision() ?? 0,
|
|
228
|
+
};
|
|
229
|
+
})(),
|
|
230
|
+
visibility: {
|
|
231
|
+
settings: visibility.read(),
|
|
232
|
+
patterns: resolved.hiddenPatterns,
|
|
233
|
+
configurableProviders: (() => {
|
|
234
|
+
try {
|
|
235
|
+
return llmService()?.listConfigurableProviders() ?? [];
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
})(),
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
// ── model visibility ───────────────────────────────────────────────────────
|
|
244
|
+
const visibility = installVisibilitySettings(ctx, {
|
|
245
|
+
namespace: VISIBILITY_SETTINGS_NS,
|
|
246
|
+
patterns: resolved.hiddenPatterns,
|
|
247
|
+
onChange: () => {
|
|
248
|
+
events.push({ kind: 'visibility-changed', provider: '*', reason: 'settings' });
|
|
249
|
+
// Attach may fire synchronously inside installVisibilitySettings, before
|
|
250
|
+
// the handle binding below is assigned; the explicit calls after install
|
|
251
|
+
// cover that first transition.
|
|
252
|
+
if (!visibilityReady)
|
|
253
|
+
return;
|
|
254
|
+
applyQueueOverrides();
|
|
255
|
+
void reconcileDefaultModel();
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
readQueueOverride = () => visibility.queue();
|
|
259
|
+
visibilityReady = true;
|
|
260
|
+
applyQueueOverrides();
|
|
261
|
+
void reconcileDefaultModel();
|
|
262
|
+
/** Enumerate the advisory catalog for fallback selection. */
|
|
263
|
+
async function collectCatalog() {
|
|
264
|
+
const llm = ctx.get('llm');
|
|
265
|
+
if (llm === undefined)
|
|
266
|
+
return [];
|
|
267
|
+
const out = [];
|
|
268
|
+
for (const provider of llm.listProviders()) {
|
|
269
|
+
try {
|
|
270
|
+
for (const model of await llm.listModels(provider.id)) {
|
|
271
|
+
out.push({ provider: provider.id, model: model.id, name: model.name });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// Advisory catalog only; an unreachable provider simply contributes none.
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Move the default selection off a hidden model so fresh sessions stay usable.
|
|
282
|
+
* The catalog default is exactly what new agents read, so this is the one place
|
|
283
|
+
* a visibility switch can strand them.
|
|
284
|
+
*/
|
|
285
|
+
async function reconcileDefaultModel() {
|
|
286
|
+
const service = ctx.get('agentDefaultModel');
|
|
287
|
+
if (service === undefined)
|
|
288
|
+
return;
|
|
289
|
+
const current = service.currentSelection();
|
|
290
|
+
const settings = visibility.read();
|
|
291
|
+
const config = { hiddenPatterns: resolved.hiddenPatterns };
|
|
292
|
+
if (isModelVisible(current.provider, current.model, settings, config))
|
|
293
|
+
return;
|
|
294
|
+
const fallback = pickFallback(current, await collectCatalog(), settings, config);
|
|
295
|
+
if (fallback === undefined) {
|
|
296
|
+
events.push({
|
|
297
|
+
kind: 'default-model-hidden',
|
|
298
|
+
provider: current.provider,
|
|
299
|
+
code: 'MODEL_HIDDEN',
|
|
300
|
+
reason: 'no-visible-fallback',
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
await service.saveSelection({ provider: fallback.provider, model: fallback.model });
|
|
306
|
+
events.push({
|
|
307
|
+
kind: 'default-model-fallback',
|
|
308
|
+
provider: fallback.provider,
|
|
309
|
+
code: 'MODEL_HIDDEN',
|
|
310
|
+
reason: `${current.provider}:${current.model}`,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
ctx.logger.warn('llm-ctl: default model fallback failed: %o', error);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const controller = new LlmCtlController(ctx, {
|
|
318
|
+
snapshot: () => controlState(),
|
|
319
|
+
cancel: (queueId) => gate.cancel(queueId),
|
|
320
|
+
});
|
|
321
|
+
void controller;
|
|
322
|
+
// The browser half talks HTTP: a third-party Typert namespace is not reachable
|
|
323
|
+
// from the generated client proxy. The route seat exists only in web profiles,
|
|
324
|
+
// so the injection is optional and the plugin stays loadable headless.
|
|
325
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
326
|
+
const server = webCtx.webServer;
|
|
327
|
+
const adapt = (result) => ({
|
|
328
|
+
ok: result.ok,
|
|
329
|
+
...(result.code === undefined ? {} : { code: result.code }),
|
|
330
|
+
...(result.message === undefined ? {} : { message: result.message }),
|
|
331
|
+
...(result.revision === undefined ? {} : { revision: result.revision }),
|
|
332
|
+
});
|
|
333
|
+
const disposers = createRoutes({
|
|
334
|
+
state: controlState,
|
|
335
|
+
cancel: (queueId) => gate.cancel(queueId),
|
|
336
|
+
setVisibility: async (input) => adapt(input.model === undefined ? await visibility.setProvider(input.provider, input.visible) : await visibility.setModel(input.provider, input.model, input.visible)),
|
|
337
|
+
resetVisibility: async () => adapt(await visibility.resetAll()),
|
|
338
|
+
setQueue: async (input) => adapt(await visibility.setQueue(input, input.expectedRevision)),
|
|
339
|
+
resetQueue: async (input) => adapt(await visibility.resetQueue(input.expectedRevision)),
|
|
340
|
+
discover: async (input) => {
|
|
341
|
+
const llm = llmService();
|
|
342
|
+
let advertised = [];
|
|
343
|
+
if (llm !== undefined) {
|
|
344
|
+
try {
|
|
345
|
+
advertised = (await llm.listModels(input.provider)).map((model) => model.id);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
advertised = [];
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return discoverProviderModels({
|
|
352
|
+
llm,
|
|
353
|
+
logger: {
|
|
354
|
+
info: (first, ...rest) => {
|
|
355
|
+
ctx.logger.info(first, ...rest);
|
|
356
|
+
},
|
|
357
|
+
warn: (first, ...rest) => {
|
|
358
|
+
ctx.logger.warn(first, ...rest);
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
}, { provider: input.provider, baseURL: input.baseURL, api: input.api, advertised });
|
|
362
|
+
},
|
|
363
|
+
}).map((route) => server.register(route));
|
|
364
|
+
webCtx.effect(() => () => {
|
|
365
|
+
for (const dispose of disposers)
|
|
366
|
+
dispose();
|
|
367
|
+
}, 'llm-ctl: unregister control routes');
|
|
368
|
+
});
|
|
369
|
+
ctx.effect(() => async () => {
|
|
370
|
+
disposeStream();
|
|
371
|
+
disposeError();
|
|
372
|
+
lifetime.abort(new Error('dsh-llm-ctl disposed'));
|
|
373
|
+
visibility.dispose();
|
|
374
|
+
gate.dispose();
|
|
375
|
+
await Promise.allSettled([...active]);
|
|
376
|
+
}, 'llm-ctl: abort and drain queue and recovery');
|
|
377
|
+
ctx.logger.info('llm-ctl: queue maxWaitMs=%d maxQueueDepth=%d reactiveRetry=%s', resolved.queue.maxWaitMs, resolved.queue.maxQueueDepth, String(resolved.reactiveRetryMode));
|
|
378
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM filtering for the model-selection popup (PRD FR3.2 / FR3.3).
|
|
3
|
+
*
|
|
4
|
+
* The module owns no state and no event listeners of its own: callers hand it
|
|
5
|
+
* the popup roots (or a container to search) and it reports what it hid. The
|
|
6
|
+
* selectors are deliberately fuzzy — the popup's hashed class prefixes change
|
|
7
|
+
* on every harness build, so only `[class*=...]` matching and the stable
|
|
8
|
+
* ARIA/role contract may be relied upon.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-llm-ctl/menu-filter
|
|
11
|
+
*/
|
|
12
|
+
/** The popup nodes this module operates on. */
|
|
13
|
+
export interface ModelMenuRoots {
|
|
14
|
+
/** The `div[role="menu"]` element of the model-selection popup. */
|
|
15
|
+
menu: HTMLElement;
|
|
16
|
+
/** The container holding the `section[role="group"]` provider groups. */
|
|
17
|
+
groups: HTMLElement;
|
|
18
|
+
}
|
|
19
|
+
/** One selectable model row and the display names read from the DOM. */
|
|
20
|
+
export interface ModelMenuRow {
|
|
21
|
+
/** The `[role="menuitemradio"]` button element. */
|
|
22
|
+
row: HTMLElement;
|
|
23
|
+
/** Provider name, i.e. the owning group's title text. */
|
|
24
|
+
providerName: string;
|
|
25
|
+
/** Model display name resolved from the row. */
|
|
26
|
+
modelName: string;
|
|
27
|
+
}
|
|
28
|
+
/** Filter inputs for {@link applyMenuFilter}. */
|
|
29
|
+
export interface MenuFilterOptions {
|
|
30
|
+
/** Free-text query; empty or whitespace-only means "no query". */
|
|
31
|
+
query?: string;
|
|
32
|
+
/** Extra predicate; a row must also pass it to stay visible. */
|
|
33
|
+
isRowVisible?: (row: ModelMenuRow) => boolean;
|
|
34
|
+
/** When false, `query` is ignored and only `isRowVisible` applies. Defaults to true. */
|
|
35
|
+
hideUnmatched?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/** Outcome of one {@link applyMenuFilter} pass. */
|
|
38
|
+
export interface MenuFilterResult {
|
|
39
|
+
/** Rows left visible. */
|
|
40
|
+
shown: number;
|
|
41
|
+
/** Rows set to `display: none`. */
|
|
42
|
+
hidden: number;
|
|
43
|
+
/** Groups set to `display: none` because none of their rows stayed visible. */
|
|
44
|
+
groupsHidden: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Find the model-selection popup inside a container.
|
|
48
|
+
*
|
|
49
|
+
* Matches `div[role="menu"]` whose aria-label mentions the model menu in either
|
|
50
|
+
* locale ("模型与推理等级", "Model and reasoning effort", or any label containing
|
|
51
|
+
* `model`/`推理等级`/`effort`). When no labelled candidate exists, an unlabelled
|
|
52
|
+
* menu that already contains `[role="group"]` is accepted. The first match wins.
|
|
53
|
+
*
|
|
54
|
+
* @param root Document, fragment, or element to search within.
|
|
55
|
+
* @returns The popup roots, or undefined when no model menu is present.
|
|
56
|
+
*/
|
|
57
|
+
export declare function findModelMenu(root: ParentNode): ModelMenuRoots | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* List every model row of the popup in DOM order, including rows currently hidden.
|
|
60
|
+
*
|
|
61
|
+
* @param roots Popup roots from {@link findModelMenu}.
|
|
62
|
+
* @returns One entry per `[role="menuitemradio"]` inside a `section[role="group"]`.
|
|
63
|
+
*/
|
|
64
|
+
export declare function listModelRows(roots: ModelMenuRoots): ModelMenuRow[];
|
|
65
|
+
/**
|
|
66
|
+
* Parsed search query: plain tokens match the model or provider name, while a
|
|
67
|
+
* `p:` or `provider:` prefix restricts that token to the provider name only.
|
|
68
|
+
* A bare prefix with no term (`p:`) is ignored so a half-typed token never
|
|
69
|
+
* hides the whole menu.
|
|
70
|
+
*/
|
|
71
|
+
export interface ParsedMenuQuery {
|
|
72
|
+
providerTerms: string[];
|
|
73
|
+
modelTerms: string[];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Split one query line into provider-scoped and plain terms.
|
|
77
|
+
*
|
|
78
|
+
* @param raw Raw query text as typed by the user.
|
|
79
|
+
* @returns Lowercased provider terms and model terms.
|
|
80
|
+
*/
|
|
81
|
+
export declare function parseMenuQuery(raw: string): ParsedMenuQuery;
|
|
82
|
+
/**
|
|
83
|
+
* Apply the query and visibility predicate to every row and collapse empty groups.
|
|
84
|
+
*
|
|
85
|
+
* A row is shown only when it passes `isRowVisible` (when supplied) and matches
|
|
86
|
+
* `query` (when `hideUnmatched` is true). Query tokens match the model or provider
|
|
87
|
+
* name; a `p:` or `provider:` prefix restricts that token to the provider name.
|
|
88
|
+
* A group whose rows are all hidden is itself hidden; groups without rows are
|
|
89
|
+
* left untouched.
|
|
90
|
+
*
|
|
91
|
+
* @param roots Popup roots from {@link findModelMenu}.
|
|
92
|
+
* @param options Filter inputs; omit for "show everything".
|
|
93
|
+
* @returns Counts of shown rows, hidden rows, and hidden groups.
|
|
94
|
+
*/
|
|
95
|
+
export declare function applyMenuFilter(roots: ModelMenuRoots, options?: MenuFilterOptions): MenuFilterResult;
|
|
96
|
+
/**
|
|
97
|
+
* Clear every inline `display` this module may have written.
|
|
98
|
+
*
|
|
99
|
+
* Elements are never removed; rows and groups return to their natural layout.
|
|
100
|
+
*
|
|
101
|
+
* @param roots Popup roots from {@link findModelMenu}.
|
|
102
|
+
*/
|
|
103
|
+
export declare function resetMenuFilter(roots: ModelMenuRoots): void;
|
|
104
|
+
/**
|
|
105
|
+
* Detect a search widget injected by `dsh-model-search-plugin`.
|
|
106
|
+
*
|
|
107
|
+
* A foreign widget sits before the groups container, either as its previous
|
|
108
|
+
* sibling or anywhere inside the menu (or the menu's parent). When one is found
|
|
109
|
+
* the caller must not inject a second search box (PRD FR3.2).
|
|
110
|
+
*
|
|
111
|
+
* @param roots Popup roots from {@link findModelMenu}.
|
|
112
|
+
* @returns True when a foreign search widget occupies the slot above the groups.
|
|
113
|
+
*/
|
|
114
|
+
export declare function hasForeignSearchWidget(roots: ModelMenuRoots): boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Insert (or update) the "everything is filtered out" empty state above the groups.
|
|
117
|
+
*
|
|
118
|
+
* The element carries the message text plus one button that invokes `onAction`,
|
|
119
|
+
* so a caller can restore the hidden entries in one click (PRD FR3.3). Repeated
|
|
120
|
+
* calls reuse the same element and replace its text, button label, and handler.
|
|
121
|
+
*
|
|
122
|
+
* @param roots Popup roots from {@link findModelMenu}.
|
|
123
|
+
* @param text Message shown before the action button.
|
|
124
|
+
* @param actionLabel Button label, e.g. "显示 3 个隐藏项".
|
|
125
|
+
* @param onAction Click handler for that button.
|
|
126
|
+
* @returns The inserted `#dsh-llm-ctl-empty` element.
|
|
127
|
+
*/
|
|
128
|
+
export declare function ensureEmptyState(roots: ModelMenuRoots, text: string, actionLabel: string, onAction: () => void): HTMLElement;
|
|
129
|
+
/**
|
|
130
|
+
* Remove the empty state injected by {@link ensureEmptyState}, if present.
|
|
131
|
+
*
|
|
132
|
+
* @param roots Popup roots from {@link findModelMenu}.
|
|
133
|
+
*/
|
|
134
|
+
export declare function removeEmptyState(roots: ModelMenuRoots): void;
|