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 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
- children.push(jsx("div", { key: "hint", style: listHintStyle, children: t("models.hint") }));
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
- const shown = folded[group.key] !== true;
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: group.models }, tallyOf(group.channels)))
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 group.channels) {
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.models.length }, tallyOf([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.models) rows.push(modelRow(row));
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.1.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",