dsh-model-rotator 0.1.0 → 0.2.1

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… (space-separated terms must all match)",
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,67 @@ 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
+ // Split on whitespace and require EVERY token, rather than matching the whole
387
+ // box as one substring. A reader types "deepseek flash", not
388
+ // "deepseek-v4-flash": as a single substring the first form matches nothing
389
+ // while the second matches only the rows that happen to be hyphenated that
390
+ // way. Per-token AND is what the half-remembered name actually needs.
391
+ const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
392
+ if (tokens.length === 0) return true;
393
+ const hay = (String(row.provider) + "/" + String(row.model)).toLowerCase();
394
+ for (const token of tokens) if (!hay.includes(token)) return false;
395
+ return true;
396
+ };
397
+ // True whenever the box holds something. Declared with the state, before
398
+ // anything that reads it: the box renders before the payload is inspected,
399
+ // so a declaration further down would be a temporal-dead-zone read.
400
+ const searching = query.trim() !== "";
401
+ // How many models match, for the line under the box. Read straight off the
402
+ // payload rather than the later `data` binding: the header renders before
403
+ // that binding exists, and reaching for it here was a temporal-dead-zone
404
+ // read that crashed the whole page on first paint.
405
+ /**
406
+ * How many models match, for the line under the box.
407
+ *
408
+ * Computed where it is read rather than declared once: on the first paint the
409
+ * payload has not arrived yet (the effect fills it a tick later), so a value
410
+ * captured at declaration time stayed 0 and the counter reported "no match"
411
+ * while the matching rows were plainly on screen.
412
+ */
413
+ const matchedCount = () => {
414
+ if (!searching) return 0;
415
+ const payload = state.data;
416
+ if (payload === null || payload === undefined) return 0;
417
+ // Prefer the flat roster. Fall back to walking the sections when the host
418
+ // sent none: `models` is the newer field, and a host that omits it would
419
+ // otherwise make the counter claim "no match" while the rows were on screen.
420
+ if (Array.isArray(payload.models) && payload.models.length > 0) {
421
+ return payload.models.filter(matches).length;
422
+ }
423
+ const sections = Array.isArray(payload.groups) ? payload.groups : [];
424
+ let n = 0;
425
+ for (const group of sections) {
426
+ for (const channel of group.channels || []) {
427
+ for (const row of channel.models || []) if (matches(row)) n += 1;
428
+ }
429
+ }
430
+ return n;
431
+ };
357
432
  const flip = (key) => setFolded((prev) => Object.assign({}, prev, { [key]: !prev[key] }));
358
433
  /**
359
434
  * Collapse or expand every section and channel at once.
@@ -460,7 +535,47 @@ window.__ModuleLoader__.load({
460
535
  })
461
536
  ]
462
537
  }));
463
- children.push(jsx("div", { key: "hint", style: listHintStyle, children: t("models.hint") }));
538
+ // The box. Kept outside the settings form for the same reason the roster is:
539
+ // it is a view control over a live roster, not a value the page stages and
540
+ // saves, and typing in it must never look like an unsaved edit.
541
+ children.push(jsxs("div", {
542
+ key: "search",
543
+ style: { display: "flex", alignItems: "center", gap: "8px", margin: "10px 0 6px" },
544
+ children: [
545
+ jsx("input", {
546
+ key: "box",
547
+ type: "search",
548
+ value: query,
549
+ placeholder: t("models.search"),
550
+ "aria-label": t("models.search"),
551
+ onChange: (event) => setQuery(event && event.target ? event.target.value : ""),
552
+ style: searchInputStyle
553
+ }),
554
+ searching
555
+ ? jsx("button", {
556
+ key: "clear",
557
+ type: "button",
558
+ style: smallButtonStyle,
559
+ onClick: () => setQuery(""),
560
+ children: t("models.searchClear")
561
+ })
562
+ : null
563
+ ].filter((child) => child !== null)
564
+ }));
565
+ if (searching) {
566
+ children.push(jsx("div", {
567
+ key: "hits",
568
+ style: { fontSize: "12px", opacity: 0.75, marginBottom: "6px" },
569
+ children: matchedCount() === 0
570
+ ? t("models.searchNone")
571
+ : fill(t("models.searchHits"), {
572
+ n: matchedCount(),
573
+ total: state.data === null || state.data === undefined ? 0 : state.data.total
574
+ })
575
+ }));
576
+ } else {
577
+ children.push(jsx("div", { key: "hint", style: listHintStyle, children: t("models.hint") }));
578
+ }
464
579
 
465
580
  /**
466
581
  * Health tally for one channel or one section, counted from its own rows.
@@ -486,6 +601,14 @@ window.__ModuleLoader__.load({
486
601
  return { total: total, ok: ok, resting: resting, off: off };
487
602
  };
488
603
 
604
+ /**
605
+ * The rows a channel would render under the current query, and the two
606
+ * counters the folded line needs so a collapsed channel never hides whether
607
+ * the match is inside it.
608
+ */
609
+ const visibleModels = (channel) => channel.models.filter(matches);
610
+
611
+
489
612
  /** One collapsible line: chevron, name, counts. */
490
613
  const disclosure = (key, label, meta, hint) => jsxs("button", {
491
614
  key: key + "#head",
@@ -567,22 +690,30 @@ window.__ModuleLoader__.load({
567
690
  // Recorded for the expand/collapse-all buttons: they must cover exactly
568
691
  // the rows this render produced, whatever shape the payload had.
569
692
  for (const channel of group.channels) channelIds.push(group.key + "/" + channel.provider);
570
- const shown = folded[group.key] !== true;
693
+ // A query narrows the section to the channels that still have a match,
694
+ // and counts the matches rather than every row the channel owns - a
695
+ // folded line that reported the unfiltered tally would contradict the
696
+ // rows below it.
697
+ const matched = group.channels.filter((channel) => visibleModels(channel).length > 0);
698
+ if (matched.length === 0) continue;
699
+ // Searching should reveal, not hide: a hit inside a folded channel would
700
+ // otherwise be invisible, which is exactly what the box was opened to fix.
701
+ const shown = searching || folded[group.key] !== true;
571
702
  children.push(disclosure(
572
703
  group.key,
573
704
  t("models.group." + group.key),
574
- fill(t("models.counts"), Object.assign({ n: group.models }, tallyOf(group.channels)))
705
+ fill(t("models.counts"), Object.assign({ n: tallyOf(matched).total }, tallyOf(matched)))
575
706
  ));
576
707
  if (!shown) continue;
577
708
  children.push(jsx("div", { key: group.key + "#hint", style: groupHintStyle, children: t("models.groupHint." + group.key) }));
578
709
  const wrap = [];
579
- for (const channel of group.channels) {
710
+ for (const channel of matched) {
580
711
  const channelKey = group.key + "/" + channel.provider;
581
- const channelOpen = folded[channelKey] !== true;
712
+ const channelOpen = searching || folded[channelKey] !== true;
582
713
  // The tally is the point of a folded line: "18 models, all usable" or
583
714
  // "18 models, 2 cooling" answers the question the fold is hiding, so
584
715
  // collapsing never costs the reader the state.
585
- const tally = fill(t("models.counts"), Object.assign({ n: channel.models.length }, tallyOf([channel])));
716
+ const tally = fill(t("models.counts"), Object.assign({ n: visibleModels(channel).length }, tallyOf([channel])));
586
717
  // The channel's own switch. Its label follows the channel rather than
587
718
  // the individual rows, so a half-closed channel still offers the one
588
719
  // action that finishes the job: close the rest.
@@ -642,7 +773,7 @@ window.__ModuleLoader__.load({
642
773
  });
643
774
  const rows = [head];
644
775
  if (channelOpen) {
645
- for (const row of channel.models) rows.push(modelRow(row));
776
+ for (const row of visibleModels(channel)) rows.push(modelRow(row));
646
777
  }
647
778
  wrap.push(jsx("div", { key: channelKey, style: channelStyle, children: rows }));
648
779
  }
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.1",
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",