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.
- package/client.js +109 -15
- 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… (
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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) =>
|
|
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(
|
|
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.
|
|
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",
|