dsh-model-rotator 0.2.1 → 0.2.2

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.
Files changed (2) hide show
  1. package/client.js +87 -15
  2. package/package.json +1 -1
package/client.js CHANGED
@@ -74,7 +74,7 @@ window.__ModuleLoader__.load({
74
74
  "models.channelOff": "全部关闭",
75
75
  "models.expandAll": "全部展开",
76
76
  "models.collapseAll": "全部收起",
77
- "models.search": "搜索模型…(空格分隔多个关键词,需全部命中)",
77
+ "models.search": "搜索模型…(空格分词;忽略 - _ . / 与大小写)",
78
78
  "models.searchClear": "清除",
79
79
  "models.searchHits": "命中 {n} / {total} 个模型",
80
80
  "models.searchNone": "没有匹配的模型",
@@ -166,7 +166,7 @@ window.__ModuleLoader__.load({
166
166
  "models.channelOff": "all off",
167
167
  "models.expandAll": "Expand all",
168
168
  "models.collapseAll": "Collapse all",
169
- "models.search": "Search models… (space-separated terms must all match)",
169
+ "models.search": "Search models… (tokens; - _ . / and case are ignored)",
170
170
  "models.searchClear": "Clear",
171
171
  "models.searchHits": "{n} of {total} models match",
172
172
  "models.searchNone": "No model matches",
@@ -382,18 +382,74 @@ window.__ModuleLoader__.load({
382
382
  * Declared with the state, above every reader: the header, the counter and
383
383
  * the rows all call it during the same render pass.
384
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;
385
+ // ── search matching ───────────────────────────────────────────────────────
386
+ //
387
+ // Two ideas, both borrowed from how other DSH search UIs solved the same
388
+ // problem, and both measured against this machine's 950-model roster:
389
+ //
390
+ // 1. NORMALISE before comparing. "glm53" should find "GLM-5.3": dropping the
391
+ // separators (- _ . / and whitespace) from both sides costs nothing and
392
+ // took that query from 0 hits to 58. Normalised containment is a superset
393
+ // of raw containment, so nothing that matched before stops matching.
394
+ // 2. Rank matches by QUALITY rather than filtering to a flat list, so a loose
395
+ // fallback can be offered without making the list unusable.
396
+ //
397
+ // The fallback is subsequence (fzf-style: every query character in order, not
398
+ // necessarily adjacent), which is what makes an abbreviation like "ds" reach
399
+ // "DeepSeek". It is deliberately the weaker tier and guarded: without the
400
+ // length gate a single letter matched the entire roster, and even at two
401
+ // characters only 22% of its hits were actually DeepSeek-family models, so it
402
+ // is ranked below rather than mixed in with the substring hits.
403
+ const SEPARATORS = /[-_./\s]+/g;
404
+ const normSearch = (value) => String(value || "").toLowerCase().replace(SEPARATORS, "");
405
+
406
+ /** Every character of q appears in s, in order (not necessarily adjacent). */
407
+ const isSubsequence = (s, q) => {
408
+ let at = 0;
409
+ for (let i = 0; i < s.length && at < q.length; i++) if (s[i] === q[at]) at += 1;
410
+ return at === q.length;
396
411
  };
412
+
413
+ /**
414
+ * Match quality for one haystack/token pair.
415
+ *
416
+ * 2 = normalised substring, 1 = subsequence only, 0 = no match. Substring
417
+ * ranks above subsequence because it is the precise one: "flash" matching
418
+ * "flash" is a better answer than "ds" matching every model whose name happens
419
+ * to contain a d then an s.
420
+ */
421
+ const tierOf = (hay, token) => {
422
+ if (hay.includes(token)) return 2;
423
+ // Below two characters the fallback is far too loose to be useful: "a" is a
424
+ // subsequence of almost every name, so it would rank the roster at random.
425
+ if (token.length >= 2 && isSubsequence(hay, token)) return 1;
426
+ return 0;
427
+ };
428
+
429
+ /**
430
+ * The rank a row gets under the current query, or 0 to drop it.
431
+ *
432
+ * Every whitespace-separated token must match, and the row takes the WEAKEST
433
+ * token's tier: one imprecise token is enough to qualify the row, but it still
434
+ * sorts below a row where every token was precise.
435
+ */
436
+ const rankOf = (row) => {
437
+ const tokens = normSearch(query).length === 0
438
+ ? []
439
+ : query.trim().split(/\s+/).map(normSearch).filter((t) => t !== "");
440
+ if (tokens.length === 0) return query.trim() === "" ? 1 : 0;
441
+ const hay = normSearch(String(row.provider) + "/" + String(row.model));
442
+ let weakest = 2;
443
+ for (const token of tokens) {
444
+ const tier = tierOf(hay, token);
445
+ if (tier === 0) return 0;
446
+ if (tier < weakest) weakest = tier;
447
+ }
448
+ return weakest;
449
+ };
450
+
451
+ /** Kept as the boolean face of the same rule, for callers that only filter. */
452
+ const matches = (row) => rankOf(row) > 0;
397
453
  // True whenever the box holds something. Declared with the state, before
398
454
  // anything that reads it: the box renders before the payload is inspected,
399
455
  // so a declaration further down would be a temporal-dead-zone read.
@@ -606,7 +662,23 @@ window.__ModuleLoader__.load({
606
662
  * counters the folded line needs so a collapsed channel never hides whether
607
663
  * the match is inside it.
608
664
  */
609
- const visibleModels = (channel) => channel.models.filter(matches);
665
+ const visibleModels = (channel) => {
666
+ const ranked = [];
667
+ channel.models.forEach((row, index) => {
668
+ const rank = rankOf(row);
669
+ if (rank > 0) ranked.push({ row, rank, index });
670
+ });
671
+ // Only sort while a query is active: the unfiltered roster keeps the host's
672
+ // own order, which is a meaningful ranking (in-rotation first) that this
673
+ // page should not second-guess.
674
+ if (query.trim() !== "") {
675
+ ranked.sort((a, b) => (b.rank - a.rank) || (a.index - b.index));
676
+ }
677
+ return ranked.map((entry) => entry.row);
678
+ };
679
+
680
+ /** Whether the channel has any match at all - the filter the section needs. */
681
+ const hasMatch = (channel) => channel.models.some(matches);
610
682
 
611
683
 
612
684
  /** One collapsible line: chevron, name, counts. */
@@ -694,7 +766,7 @@ window.__ModuleLoader__.load({
694
766
  // and counts the matches rather than every row the channel owns - a
695
767
  // folded line that reported the unfiltered tally would contradict the
696
768
  // rows below it.
697
- const matched = group.channels.filter((channel) => visibleModels(channel).length > 0);
769
+ const matched = group.channels.filter(hasMatch);
698
770
  if (matched.length === 0) continue;
699
771
  // Searching should reveal, not hide: a hit inside a folded channel would
700
772
  // otherwise be invisible, which is exactly what the box was opened to fix.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-rotator",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
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",