dsh-model-rotator 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/README.zh.md +8 -0
- package/client.js +131 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -444,6 +444,14 @@ the session), `failure verdict` and `switch order`.
|
|
|
444
444
|
|
|
445
445
|
## Install
|
|
446
446
|
|
|
447
|
+
From npm (recommended):
|
|
448
|
+
|
|
449
|
+
```
|
|
450
|
+
dsh plugin --profile desktop add dsh-model-rotator
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
From GitHub:
|
|
454
|
+
|
|
447
455
|
```
|
|
448
456
|
dsh plugin --profile desktop add github:chimneyQ/dsh-model-rotator
|
|
449
457
|
```
|
package/README.zh.md
CHANGED
|
@@ -253,6 +253,14 @@ model_rotator_status # 看 "discovered" 一行,那就是过滤后的候
|
|
|
253
253
|
|
|
254
254
|
## 安装
|
|
255
255
|
|
|
256
|
+
从 npm 安装(推荐):
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
dsh plugin --profile desktop add dsh-model-rotator
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
从 GitHub 安装:
|
|
263
|
+
|
|
256
264
|
```bash
|
|
257
265
|
dsh plugin --profile desktop add github:chimneyQ/dsh-model-rotator
|
|
258
266
|
```
|
package/client.js
CHANGED
|
@@ -74,6 +74,10 @@ window.__ModuleLoader__.load({
|
|
|
74
74
|
"models.channelOff": "全部关闭",
|
|
75
75
|
"models.expandAll": "全部展开",
|
|
76
76
|
"models.collapseAll": "全部收起",
|
|
77
|
+
"models.search": "搜索模型…",
|
|
78
|
+
"models.searchClear": "清除",
|
|
79
|
+
"models.searchHits": "命中 {n} / {total} 个模型",
|
|
80
|
+
"models.searchNone": "没有匹配的模型",
|
|
77
81
|
"models.visionBadge": "识图",
|
|
78
82
|
"models.reason.channel": "整个渠道已关闭",
|
|
79
83
|
"models.disableChannel": "关闭本渠道",
|
|
@@ -162,6 +166,10 @@ window.__ModuleLoader__.load({
|
|
|
162
166
|
"models.channelOff": "all off",
|
|
163
167
|
"models.expandAll": "Expand all",
|
|
164
168
|
"models.collapseAll": "Collapse all",
|
|
169
|
+
"models.search": "Search models…",
|
|
170
|
+
"models.searchClear": "Clear",
|
|
171
|
+
"models.searchHits": "{n} of {total} models match",
|
|
172
|
+
"models.searchNone": "No model matches",
|
|
165
173
|
"models.visionBadge": "vision",
|
|
166
174
|
"models.reason.channel": "the whole channel is switched off",
|
|
167
175
|
"models.disableChannel": "Disable channel",
|
|
@@ -297,6 +305,12 @@ window.__ModuleLoader__.load({
|
|
|
297
305
|
};
|
|
298
306
|
|
|
299
307
|
const listStyle = { marginTop: "20px", borderTop: "1px solid rgba(128,128,128,0.25)", paddingTop: "14px" };
|
|
308
|
+
/** The search box: full width, inherits the form's type styling. */
|
|
309
|
+
const searchInputStyle = {
|
|
310
|
+
flex: "1 1 auto", minWidth: "0", padding: "5px 8px", fontSize: "13px",
|
|
311
|
+
border: "1px solid rgba(128,128,128,0.4)", borderRadius: "4px",
|
|
312
|
+
background: "transparent", color: "inherit"
|
|
313
|
+
};
|
|
300
314
|
const listHeadStyle = { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: "12px" };
|
|
301
315
|
const listTitleStyle = { fontSize: "13px", fontWeight: 600 };
|
|
302
316
|
const listHintStyle = { fontSize: "12px", opacity: 0.7, margin: "4px 0 10px", maxWidth: "46em" };
|
|
@@ -354,6 +368,60 @@ window.__ModuleLoader__.load({
|
|
|
354
368
|
// which is the shape that makes ~900 rows readable at all. Every provider
|
|
355
369
|
// line folds on its own from there.
|
|
356
370
|
const [folded, setFolded] = React.useState({ chat: false, vision: true });
|
|
371
|
+
// The roster is ~900 rows, so a reader hunting one model name otherwise has
|
|
372
|
+
// to scroll every channel. The query filters by substring over
|
|
373
|
+
// "provider/model" (case-insensitive); an empty query is the unfiltered view.
|
|
374
|
+
const [query, setQuery] = React.useState("");
|
|
375
|
+
/**
|
|
376
|
+
* Whether a row survives the current query.
|
|
377
|
+
*
|
|
378
|
+
* Matching is a case-insensitive substring over "provider/model" rather than
|
|
379
|
+
* a token list: the roster is read by a human looking for one name they
|
|
380
|
+
* half-remember, and "" always matches, so an empty box is the full list.
|
|
381
|
+
*
|
|
382
|
+
* Declared with the state, above every reader: the header, the counter and
|
|
383
|
+
* the rows all call it during the same render pass.
|
|
384
|
+
*/
|
|
385
|
+
const matches = (row) => {
|
|
386
|
+
const needle = query.trim().toLowerCase();
|
|
387
|
+
if (needle === "") return true;
|
|
388
|
+
return (String(row.provider) + "/" + String(row.model)).toLowerCase().includes(needle);
|
|
389
|
+
};
|
|
390
|
+
// True whenever the box holds something. Declared with the state, before
|
|
391
|
+
// anything that reads it: the box renders before the payload is inspected,
|
|
392
|
+
// so a declaration further down would be a temporal-dead-zone read.
|
|
393
|
+
const searching = query.trim() !== "";
|
|
394
|
+
// How many models match, for the line under the box. Read straight off the
|
|
395
|
+
// payload rather than the later `data` binding: the header renders before
|
|
396
|
+
// that binding exists, and reaching for it here was a temporal-dead-zone
|
|
397
|
+
// read that crashed the whole page on first paint.
|
|
398
|
+
/**
|
|
399
|
+
* How many models match, for the line under the box.
|
|
400
|
+
*
|
|
401
|
+
* Computed where it is read rather than declared once: on the first paint the
|
|
402
|
+
* payload has not arrived yet (the effect fills it a tick later), so a value
|
|
403
|
+
* captured at declaration time stayed 0 and the counter reported "no match"
|
|
404
|
+
* while the matching rows were plainly on screen.
|
|
405
|
+
*/
|
|
406
|
+
const matchedCount = () => {
|
|
407
|
+
if (!searching) return 0;
|
|
408
|
+
const payload = state.data;
|
|
409
|
+
if (payload === null || payload === undefined) return 0;
|
|
410
|
+
// Prefer the flat roster. Fall back to walking the sections when the host
|
|
411
|
+
// sent none: `models` is the newer field, and a host that omits it would
|
|
412
|
+
// otherwise make the counter claim "no match" while the rows were on screen.
|
|
413
|
+
if (Array.isArray(payload.models) && payload.models.length > 0) {
|
|
414
|
+
return payload.models.filter(matches).length;
|
|
415
|
+
}
|
|
416
|
+
const sections = Array.isArray(payload.groups) ? payload.groups : [];
|
|
417
|
+
let n = 0;
|
|
418
|
+
for (const group of sections) {
|
|
419
|
+
for (const channel of group.channels || []) {
|
|
420
|
+
for (const row of channel.models || []) if (matches(row)) n += 1;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return n;
|
|
424
|
+
};
|
|
357
425
|
const flip = (key) => setFolded((prev) => Object.assign({}, prev, { [key]: !prev[key] }));
|
|
358
426
|
/**
|
|
359
427
|
* Collapse or expand every section and channel at once.
|
|
@@ -460,7 +528,47 @@ window.__ModuleLoader__.load({
|
|
|
460
528
|
})
|
|
461
529
|
]
|
|
462
530
|
}));
|
|
463
|
-
|
|
531
|
+
// The box. Kept outside the settings form for the same reason the roster is:
|
|
532
|
+
// it is a view control over a live roster, not a value the page stages and
|
|
533
|
+
// saves, and typing in it must never look like an unsaved edit.
|
|
534
|
+
children.push(jsxs("div", {
|
|
535
|
+
key: "search",
|
|
536
|
+
style: { display: "flex", alignItems: "center", gap: "8px", margin: "10px 0 6px" },
|
|
537
|
+
children: [
|
|
538
|
+
jsx("input", {
|
|
539
|
+
key: "box",
|
|
540
|
+
type: "search",
|
|
541
|
+
value: query,
|
|
542
|
+
placeholder: t("models.search"),
|
|
543
|
+
"aria-label": t("models.search"),
|
|
544
|
+
onChange: (event) => setQuery(event && event.target ? event.target.value : ""),
|
|
545
|
+
style: searchInputStyle
|
|
546
|
+
}),
|
|
547
|
+
searching
|
|
548
|
+
? jsx("button", {
|
|
549
|
+
key: "clear",
|
|
550
|
+
type: "button",
|
|
551
|
+
style: smallButtonStyle,
|
|
552
|
+
onClick: () => setQuery(""),
|
|
553
|
+
children: t("models.searchClear")
|
|
554
|
+
})
|
|
555
|
+
: null
|
|
556
|
+
].filter((child) => child !== null)
|
|
557
|
+
}));
|
|
558
|
+
if (searching) {
|
|
559
|
+
children.push(jsx("div", {
|
|
560
|
+
key: "hits",
|
|
561
|
+
style: { fontSize: "12px", opacity: 0.75, marginBottom: "6px" },
|
|
562
|
+
children: matchedCount() === 0
|
|
563
|
+
? t("models.searchNone")
|
|
564
|
+
: fill(t("models.searchHits"), {
|
|
565
|
+
n: matchedCount(),
|
|
566
|
+
total: state.data === null || state.data === undefined ? 0 : state.data.total
|
|
567
|
+
})
|
|
568
|
+
}));
|
|
569
|
+
} else {
|
|
570
|
+
children.push(jsx("div", { key: "hint", style: listHintStyle, children: t("models.hint") }));
|
|
571
|
+
}
|
|
464
572
|
|
|
465
573
|
/**
|
|
466
574
|
* Health tally for one channel or one section, counted from its own rows.
|
|
@@ -486,6 +594,14 @@ window.__ModuleLoader__.load({
|
|
|
486
594
|
return { total: total, ok: ok, resting: resting, off: off };
|
|
487
595
|
};
|
|
488
596
|
|
|
597
|
+
/**
|
|
598
|
+
* The rows a channel would render under the current query, and the two
|
|
599
|
+
* counters the folded line needs so a collapsed channel never hides whether
|
|
600
|
+
* the match is inside it.
|
|
601
|
+
*/
|
|
602
|
+
const visibleModels = (channel) => channel.models.filter(matches);
|
|
603
|
+
|
|
604
|
+
|
|
489
605
|
/** One collapsible line: chevron, name, counts. */
|
|
490
606
|
const disclosure = (key, label, meta, hint) => jsxs("button", {
|
|
491
607
|
key: key + "#head",
|
|
@@ -567,22 +683,30 @@ window.__ModuleLoader__.load({
|
|
|
567
683
|
// Recorded for the expand/collapse-all buttons: they must cover exactly
|
|
568
684
|
// the rows this render produced, whatever shape the payload had.
|
|
569
685
|
for (const channel of group.channels) channelIds.push(group.key + "/" + channel.provider);
|
|
570
|
-
|
|
686
|
+
// A query narrows the section to the channels that still have a match,
|
|
687
|
+
// and counts the matches rather than every row the channel owns - a
|
|
688
|
+
// folded line that reported the unfiltered tally would contradict the
|
|
689
|
+
// rows below it.
|
|
690
|
+
const matched = group.channels.filter((channel) => visibleModels(channel).length > 0);
|
|
691
|
+
if (matched.length === 0) continue;
|
|
692
|
+
// Searching should reveal, not hide: a hit inside a folded channel would
|
|
693
|
+
// otherwise be invisible, which is exactly what the box was opened to fix.
|
|
694
|
+
const shown = searching || folded[group.key] !== true;
|
|
571
695
|
children.push(disclosure(
|
|
572
696
|
group.key,
|
|
573
697
|
t("models.group." + group.key),
|
|
574
|
-
fill(t("models.counts"), Object.assign({ n:
|
|
698
|
+
fill(t("models.counts"), Object.assign({ n: tallyOf(matched).total }, tallyOf(matched)))
|
|
575
699
|
));
|
|
576
700
|
if (!shown) continue;
|
|
577
701
|
children.push(jsx("div", { key: group.key + "#hint", style: groupHintStyle, children: t("models.groupHint." + group.key) }));
|
|
578
702
|
const wrap = [];
|
|
579
|
-
for (const channel of
|
|
703
|
+
for (const channel of matched) {
|
|
580
704
|
const channelKey = group.key + "/" + channel.provider;
|
|
581
|
-
const channelOpen = folded[channelKey] !== true;
|
|
705
|
+
const channelOpen = searching || folded[channelKey] !== true;
|
|
582
706
|
// The tally is the point of a folded line: "18 models, all usable" or
|
|
583
707
|
// "18 models, 2 cooling" answers the question the fold is hiding, so
|
|
584
708
|
// collapsing never costs the reader the state.
|
|
585
|
-
const tally = fill(t("models.counts"), Object.assign({ n: channel.
|
|
709
|
+
const tally = fill(t("models.counts"), Object.assign({ n: visibleModels(channel).length }, tallyOf([channel])));
|
|
586
710
|
// The channel's own switch. Its label follows the channel rather than
|
|
587
711
|
// the individual rows, so a half-closed channel still offers the one
|
|
588
712
|
// action that finishes the job: close the rest.
|
|
@@ -642,7 +766,7 @@ window.__ModuleLoader__.load({
|
|
|
642
766
|
});
|
|
643
767
|
const rows = [head];
|
|
644
768
|
if (channelOpen) {
|
|
645
|
-
for (const row of channel
|
|
769
|
+
for (const row of visibleModels(channel)) rows.push(modelRow(row));
|
|
646
770
|
}
|
|
647
771
|
wrap.push(jsx("div", { key: channelKey, style: channelStyle, children: rows }));
|
|
648
772
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-model-rotator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Model auto-ranking and transparent failover for DeepSeek Harness: ranks discovered models by measured connectivity and speed, switches to the next candidate inside the same turn when the current one is unusable, and ships a grouped model list with per-channel switches. Never modifies model or provider configuration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|