dsh-model-rotator 0.2.1 → 0.2.3

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 +109 -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,96 @@ 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
+ /**
407
+ * Whether a one-character token starts a word.
408
+ *
409
+ * Normalisation strips the very separators a word boundary is made of, so this
410
+ * reads the RAW name: "ds-flash" contributes the words ds and flash, so "f"
411
+ * starts a word there, while the "f" inside "flash" does not.
412
+ */
413
+ const startsWord = (raw, token) => String(raw)
414
+ .toLowerCase()
415
+ .split(/[-_./\s]+/)
416
+ .some((word) => word.length > 0 && word[0] === token);
417
+
418
+ /** Every character of q appears in s, in order (not necessarily adjacent). */
419
+ const isSubsequence = (s, q) => {
420
+ let at = 0;
421
+ for (let i = 0; i < s.length && at < q.length; i++) if (s[i] === q[at]) at += 1;
422
+ return at === q.length;
396
423
  };
424
+
425
+ /**
426
+ * Match quality for one haystack/token pair.
427
+ *
428
+ * 2 = normalised substring, 1 = subsequence only, 0 = no match. Substring
429
+ * ranks above subsequence because it is the precise one: "flash" matching
430
+ * "flash" is a better answer than "ds" matching every model whose name happens
431
+ * to contain a d then an s.
432
+ */
433
+ const tierOf = (hay, token) => {
434
+ if (hay.includes(token)) return 2;
435
+ // Below two characters the fallback is far too loose to be useful: "a" is a
436
+ // subsequence of almost every name, so it would rank the roster at random.
437
+ if (token.length >= 2 && isSubsequence(hay, token)) return 1;
438
+ return 0;
439
+ };
440
+
441
+ /**
442
+ * The rank a row gets under the current query, or 0 to drop it.
443
+ *
444
+ * Every whitespace-separated token must match, and the row takes the WEAKEST
445
+ * token's tier: one imprecise token is enough to qualify the row, but it still
446
+ * sorts below a row where every token was precise.
447
+ */
448
+ const rankOf = (row) => {
449
+ const tokens = normSearch(query).length === 0
450
+ ? []
451
+ : query.trim().split(/\s+/).map(normSearch).filter((t) => t !== "");
452
+ if (tokens.length === 0) return query.trim() === "" ? 1 : 0;
453
+ const raw = String(row.provider) + "/" + String(row.model);
454
+ const hay = normSearch(raw);
455
+ let weakest = 2;
456
+ for (const token of tokens) {
457
+ // A one-character token is only useful on its own. Measured on the live
458
+ // roster, 25 of 36 characters match more than 200 of 950 names ("o"
459
+ // matches 892), so as one term of a multi-term query it constrains
460
+ // nothing - "a b" still returned 264 rows. Narrow it to a word boundary,
461
+ // which is what a reader typing "ds f" or "q4 3" actually means, and keep
462
+ // the loose form only when the single letter IS the whole query.
463
+ const solo = tokens.length === 1;
464
+ const tier = token.length === 1 && !solo
465
+ ? (startsWord(raw, token) ? 2 : 0)
466
+ : tierOf(hay, token);
467
+ if (tier === 0) return 0;
468
+ if (tier < weakest) weakest = tier;
469
+ }
470
+ return weakest;
471
+ };
472
+
473
+ /** Kept as the boolean face of the same rule, for callers that only filter. */
474
+ const matches = (row) => rankOf(row) > 0;
397
475
  // True whenever the box holds something. Declared with the state, before
398
476
  // anything that reads it: the box renders before the payload is inspected,
399
477
  // so a declaration further down would be a temporal-dead-zone read.
@@ -606,7 +684,23 @@ window.__ModuleLoader__.load({
606
684
  * counters the folded line needs so a collapsed channel never hides whether
607
685
  * the match is inside it.
608
686
  */
609
- const visibleModels = (channel) => channel.models.filter(matches);
687
+ const visibleModels = (channel) => {
688
+ const ranked = [];
689
+ channel.models.forEach((row, index) => {
690
+ const rank = rankOf(row);
691
+ if (rank > 0) ranked.push({ row, rank, index });
692
+ });
693
+ // Only sort while a query is active: the unfiltered roster keeps the host's
694
+ // own order, which is a meaningful ranking (in-rotation first) that this
695
+ // page should not second-guess.
696
+ if (query.trim() !== "") {
697
+ ranked.sort((a, b) => (b.rank - a.rank) || (a.index - b.index));
698
+ }
699
+ return ranked.map((entry) => entry.row);
700
+ };
701
+
702
+ /** Whether the channel has any match at all - the filter the section needs. */
703
+ const hasMatch = (channel) => channel.models.some(matches);
610
704
 
611
705
 
612
706
  /** One collapsible line: chevron, name, counts. */
@@ -694,7 +788,7 @@ window.__ModuleLoader__.load({
694
788
  // and counts the matches rather than every row the channel owns - a
695
789
  // folded line that reported the unfiltered tally would contradict the
696
790
  // rows below it.
697
- const matched = group.channels.filter((channel) => visibleModels(channel).length > 0);
791
+ const matched = group.channels.filter(hasMatch);
698
792
  if (matched.length === 0) continue;
699
793
  // Searching should reveal, not hide: a hit inside a folded channel would
700
794
  // 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.3",
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",