mslxdff 0.1.63 → 0.1.65
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/bin/mslxdff.js +281 -45
- package/package.json +1 -1
- package/src/auto.js +13 -0
- package/src/chat/config.js +2 -1
- package/src/chat/prompt.js +7 -2
- package/src/chat/repl.js +17 -50
- package/src/chat/stats.js +1 -1
- package/src/chat/upstream.js +429 -16
- package/src/models.js +15 -5
- package/src/providers/keyring.js +19 -1
- package/src/providers/workbuddy.js +193 -99
- package/src/routes/chat/index.js +147 -2
package/bin/mslxdff.js
CHANGED
|
@@ -295,11 +295,54 @@ if (args.includes("-model") || args.includes("-models")) {
|
|
|
295
295
|
// provider filter: bare ids are opencode, prefixed are <provider>/...
|
|
296
296
|
if (modelListProvider) {
|
|
297
297
|
const prov = String(modelListProvider).toLowerCase();
|
|
298
|
-
|
|
298
|
+
const filtered = ids.filter((id) => {
|
|
299
299
|
const slash = String(id).indexOf("/");
|
|
300
300
|
const p = slash > 0 ? String(id).slice(0, slash).toLowerCase() : "opencode";
|
|
301
301
|
return p === prov;
|
|
302
302
|
});
|
|
303
|
+
// 非 opencode 供应商的模型不在 opencode 缓存中,改从 allowlist 展示(原名+别名)
|
|
304
|
+
if (prov !== "opencode" && filtered.length === 0) {
|
|
305
|
+
try {
|
|
306
|
+
const { loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } = await import("../src/state.js");
|
|
307
|
+
const { loadModelAliases, getAliasForModel } = await import("../src/providers/model-id.js");
|
|
308
|
+
try { loadModelAliases(); } catch {}
|
|
309
|
+
const allowed = loadProviderAllowedModels(prov);
|
|
310
|
+
const allowAny = loadProviderAllowAnyModels(prov);
|
|
311
|
+
const baseUrl = loadProviderBaseUrl(prov);
|
|
312
|
+
if (modelListJson) {
|
|
313
|
+
const data = allowed.length
|
|
314
|
+
? allowed.map((raw) => ({ id: `${prov}/${raw}`, object: "model" }))
|
|
315
|
+
: [];
|
|
316
|
+
console.log(JSON.stringify({ object: "list", data }, null, 2));
|
|
317
|
+
process.exit(0);
|
|
318
|
+
}
|
|
319
|
+
if (!allowed.length) {
|
|
320
|
+
if (allowAny) {
|
|
321
|
+
console.log(`provider "${prov}" allowAny ON (allowlist 空=放行全部)${baseUrl ? ` baseUrl=${baseUrl}` : ""}`);
|
|
322
|
+
console.log(` (未设 allowlist,全部模型放行) 查看 live 列表: mslxdff -provider ${prov} models`);
|
|
323
|
+
} else {
|
|
324
|
+
console.log(`no models for provider "${prov}" — allowlist 空 + allowAny OFF = 阻塞`);
|
|
325
|
+
console.log(` 设白名单: mslxdff -provider ${prov} allowlist set <model1> <model2> 或 mslxdff -provider ${prov} allowAny on`);
|
|
326
|
+
console.log(` live 查看: mslxdff -provider ${prov} models`);
|
|
327
|
+
}
|
|
328
|
+
process.exit(0);
|
|
329
|
+
}
|
|
330
|
+
const at2 = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
|
|
331
|
+
console.log(`${allowed.length} model(s) for ${prov}${at2} (allowlist,原名 + 别名):`);
|
|
332
|
+
const pickedIds2 = loadModelPicks();
|
|
333
|
+
for (const raw of allowed) {
|
|
334
|
+
const canonical = `${prov}/${raw}`;
|
|
335
|
+
let alias = null;
|
|
336
|
+
try { alias = getAliasForModel(canonical); } catch {}
|
|
337
|
+
if (!alias && String(canonical).includes("/")) alias = String(canonical).replace(/\//g, "-");
|
|
338
|
+
const aliasStr = alias && alias !== canonical ? ` (别名: ${alias})` : "";
|
|
339
|
+
const mark2 = pickedIds2.includes(canonical) || (alias && pickedIds2.includes(alias)) ? "*" : " ";
|
|
340
|
+
console.log(` ${mark2} ${canonical}${aliasStr}`);
|
|
341
|
+
}
|
|
342
|
+
process.exit(0);
|
|
343
|
+
} catch {}
|
|
344
|
+
}
|
|
345
|
+
ids = filtered;
|
|
303
346
|
if (modelListJson) {
|
|
304
347
|
console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
|
|
305
348
|
process.exit(0);
|
|
@@ -316,12 +359,42 @@ if (args.includes("-model") || args.includes("-models")) {
|
|
|
316
359
|
console.log("no models available — try: mslxdff -model refresh");
|
|
317
360
|
process.exit(0);
|
|
318
361
|
}
|
|
319
|
-
// TTY
|
|
320
|
-
|
|
362
|
+
// TTY 交互式:`mslxdff -models`(无 list)走交互;` -model list` 默认列表,但交互入口统一为 -models
|
|
363
|
+
// 为支持“opencode + 其他供应商 allowlist 原名/别名”一起勾选,交互池 = opencode 免费池 + 各供应商 allowlist + 已勾选的遗留 picks
|
|
364
|
+
if (sub === undefined && process.stdin.isTTY && process.stdout.isTTY) {
|
|
321
365
|
const statuses = loadModelErrors();
|
|
322
366
|
const current = getPreferredModel();
|
|
323
367
|
const pickedIds = loadModelPicks();
|
|
324
|
-
|
|
368
|
+
// 基础:opencode 免费池
|
|
369
|
+
const combinedIds = [...ids];
|
|
370
|
+
const seen = new Set(combinedIds);
|
|
371
|
+
try {
|
|
372
|
+
const { loadProviderConfigs, loadProviderAllowedModels } = await import("../src/state.js");
|
|
373
|
+
const configs = loadProviderConfigs();
|
|
374
|
+
for (const pid of Object.keys(configs).filter((k) => String(k).toLowerCase() !== "opencode")) {
|
|
375
|
+
const allowed = loadProviderAllowedModels(pid);
|
|
376
|
+
for (const raw of allowed) {
|
|
377
|
+
const canonical = `${pid}/${raw}`;
|
|
378
|
+
if (!seen.has(canonical)) {
|
|
379
|
+
seen.add(canonical);
|
|
380
|
+
combinedIds.push(canonical);
|
|
381
|
+
}
|
|
382
|
+
// 别名(dash 版)若已被 picks 选中,也确保可见(便于取消)
|
|
383
|
+
const aliasDash = String(canonical).replace(/\//g, "-");
|
|
384
|
+
if (aliasDash !== canonical && pickedIds.includes(aliasDash) && !seen.has(aliasDash)) {
|
|
385
|
+
// 不直接加入 alias 作为独立选项,保留 canonical 即可(canonical 与 alias 视为同一模型,勾选 canonical)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
} catch {}
|
|
390
|
+
// 已勾选但不在上述池中的(例如 workbuddy/* 当 allowAny ON 时无 allowlist,或历史 picks),补齐以便取消
|
|
391
|
+
for (const pid of pickedIds) {
|
|
392
|
+
if (!seen.has(pid)) {
|
|
393
|
+
seen.add(pid);
|
|
394
|
+
combinedIds.push(pid);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const items = combinedIds.map((id) => {
|
|
325
398
|
const e = statuses[id];
|
|
326
399
|
return {
|
|
327
400
|
id,
|
|
@@ -342,38 +415,158 @@ if (args.includes("-model") || args.includes("-models")) {
|
|
|
342
415
|
const at = cachedAt ? ` (cached ${fmtShanghaiYMDHM(cachedAt)})` : "";
|
|
343
416
|
const pickedIds = loadModelPicks();
|
|
344
417
|
const mark = (id) => (pickedIds.includes(id) ? "*" : " ");
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
418
|
+
// 分组展示:按供应商前缀分组,opencode 裸 id 归为 opencode
|
|
419
|
+
const groups = {};
|
|
420
|
+
for (const id of ids) {
|
|
421
|
+
const prov = String(id).includes("/") ? String(id).split("/")[0] : "opencode";
|
|
422
|
+
if (!groups[prov]) groups[prov] = [];
|
|
423
|
+
groups[prov].push(id);
|
|
424
|
+
}
|
|
425
|
+
const order = ["opencode", "workbuddy", "clinebot", "openrouter"];
|
|
426
|
+
const sortedProvs = Object.keys(groups).sort((a, b) => {
|
|
427
|
+
const ia = order.indexOf(a), ib = order.indexOf(b);
|
|
428
|
+
if (ia !== -1 || ib !== -1) {
|
|
429
|
+
if (ia === -1) return 1;
|
|
430
|
+
if (ib === -1) return -1;
|
|
431
|
+
return ia - ib;
|
|
355
432
|
}
|
|
356
|
-
|
|
433
|
+
return a.localeCompare(b);
|
|
434
|
+
});
|
|
435
|
+
if (modelListProvider) {
|
|
436
|
+
console.log(`${ids.length} model(s) for ${modelListProvider}${at} (${pickedIds.length} picked, * = picked):`);
|
|
437
|
+
// 加载别名映射,显示原始名+别名
|
|
438
|
+
let aliasMap = {};
|
|
357
439
|
try {
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
440
|
+
const { loadModelAliases, getAliasForModel } = await import("../src/providers/model-id.js");
|
|
441
|
+
loadModelAliases();
|
|
442
|
+
for (const id of ids) {
|
|
443
|
+
const alias = getAliasForModel(id);
|
|
444
|
+
if (alias) aliasMap[id] = alias;
|
|
445
|
+
else if (String(id).includes("/")) {
|
|
446
|
+
const dashAlias = String(id).replace(/\//g, "-");
|
|
447
|
+
if (dashAlias !== id) aliasMap[id] = dashAlias;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
361
450
|
} catch {}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
if (aliasEntries.length) {
|
|
371
|
-
console.log(`\nlocal aliases (${aliasEntries.length}):`);
|
|
372
|
-
for (const [alias, canonical] of aliasEntries) {
|
|
373
|
-
console.log(` ${canonical} => ${alias}`);
|
|
451
|
+
for (const prov of sortedProvs) {
|
|
452
|
+
const list = groups[prov];
|
|
453
|
+
console.log(`\n ── ${prov} (${list.length}) ──`);
|
|
454
|
+
for (const id of list) {
|
|
455
|
+
const alias = aliasMap[id];
|
|
456
|
+
const aliasStr = alias ? ` (别名: ${alias})` : "";
|
|
457
|
+
console.log(` ${mark(id)} ${id}${aliasStr}`);
|
|
458
|
+
}
|
|
374
459
|
}
|
|
460
|
+
console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
|
|
461
|
+
} else {
|
|
462
|
+
console.log(`${ids.length} free model(s)${at} (${pickedIds.length} picked, * = picked):`);
|
|
463
|
+
// 加载别名映射,显示原始名+别名
|
|
464
|
+
let aliasMap = {};
|
|
465
|
+
let fullAliases = {};
|
|
466
|
+
try {
|
|
467
|
+
const { loadModelAliases, getAliasForModel } = await import("../src/providers/model-id.js");
|
|
468
|
+
loadModelAliases();
|
|
469
|
+
for (const id of ids) {
|
|
470
|
+
const alias = getAliasForModel(id);
|
|
471
|
+
if (alias) aliasMap[id] = alias;
|
|
472
|
+
else if (String(id).includes("/")) {
|
|
473
|
+
const dashAlias = String(id).replace(/\//g, "-");
|
|
474
|
+
if (dashAlias !== id) aliasMap[id] = dashAlias;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
const aliasesFile = join(homedir(), ".config", "mslxdff", "model-aliases.json");
|
|
479
|
+
const raw = JSON.parse(readFileSync(aliasesFile, "utf8"));
|
|
480
|
+
if (raw && typeof raw === "object") fullAliases = raw;
|
|
481
|
+
} catch {}
|
|
482
|
+
} catch {}
|
|
483
|
+
for (const prov of sortedProvs) {
|
|
484
|
+
const list = groups[prov];
|
|
485
|
+
console.log(`\n ── ${prov} (${list.length}) ──`);
|
|
486
|
+
for (const id of list) {
|
|
487
|
+
const alias = aliasMap[id];
|
|
488
|
+
const aliasStr = alias ? ` (别名: ${alias})` : "";
|
|
489
|
+
console.log(` ${mark(id)} ${id}${aliasStr}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// ── 分隔 + 其他供应商 allowlist(原名 + 别名) ──
|
|
493
|
+
// 其余供应商的可用模型由 allowlist 决定(空 allowlist + allowAny OFF = 阻塞),此处直接展示 allowlist 条目与别名,不做网络拉取,速度快
|
|
494
|
+
try {
|
|
495
|
+
const { loadProviderConfigs, loadProviderAllowedModels, loadProviderAllowAnyModels, loadProviderBaseUrl } = await import("../src/state.js");
|
|
496
|
+
const { loadModelAliases: _la2, getAliasForModel: _gaf } = await import("../src/providers/model-id.js");
|
|
497
|
+
try { _la2(); } catch {}
|
|
498
|
+
const configs = loadProviderConfigs();
|
|
499
|
+
const otherIds = Object.keys(configs).filter((k) => String(k).toLowerCase() !== "opencode");
|
|
500
|
+
// 仅展示已配置过的供应商(有 baseUrl/keys/allowlist 任一),按固定顺序 + 字典序
|
|
501
|
+
const order2 = ["workbuddy", "clinebot", "openrouter", "bai"];
|
|
502
|
+
otherIds.sort((a, b) => {
|
|
503
|
+
const ia = order2.indexOf(a), ib = order2.indexOf(b);
|
|
504
|
+
if (ia !== -1 || ib !== -1) {
|
|
505
|
+
if (ia === -1) return 1;
|
|
506
|
+
if (ib === -1) return -1;
|
|
507
|
+
return ia - ib;
|
|
508
|
+
}
|
|
509
|
+
return a.localeCompare(b);
|
|
510
|
+
});
|
|
511
|
+
if (otherIds.length) {
|
|
512
|
+
console.log(`\n────────────────────────────────────────`);
|
|
513
|
+
console.log(`其他供应商 (allowlist,原名 + 别名) (${otherIds.length} providers):`);
|
|
514
|
+
for (const pid of otherIds) {
|
|
515
|
+
const allowed = loadProviderAllowedModels(pid);
|
|
516
|
+
const allowAny = loadProviderAllowAnyModels(pid);
|
|
517
|
+
const baseUrl = loadProviderBaseUrl(pid) || configs[pid]?.baseUrl || "";
|
|
518
|
+
const header = allowAny
|
|
519
|
+
? (allowed.length ? `allowlist ${allowed.length} (allowAny ON)` : `allowAny ON (allowlist 空=放行全部)`)
|
|
520
|
+
: (allowed.length ? `allowlist ${allowed.length} (allowAny OFF)` : `allowlist 空 + allowAny OFF = 阻塞`);
|
|
521
|
+
console.log(`\n ── ${pid} (${header})${baseUrl ? ` baseUrl=${baseUrl}` : ""} ──`);
|
|
522
|
+
if (!allowed.length) {
|
|
523
|
+
if (allowAny) {
|
|
524
|
+
console.log(` (未设 allowlist,全部模型放行) 查看 live 列表: mslxdff -provider ${pid} models`);
|
|
525
|
+
console.log(` 限制可用模型: mslxdff -provider ${pid} allowlist set <model1> <model2>`);
|
|
526
|
+
} else {
|
|
527
|
+
console.log(` 阻塞中:无可用模型 — 设白名单: mslxdff -provider ${pid} allowlist set <model1> <model2>`);
|
|
528
|
+
console.log(` 或放行全部: mslxdff -provider ${pid} allowAny on`);
|
|
529
|
+
}
|
|
530
|
+
} else {
|
|
531
|
+
for (const raw of allowed) {
|
|
532
|
+
const canonical = `${pid}/${raw}`;
|
|
533
|
+
let alias = null;
|
|
534
|
+
try { alias = _gaf(canonical); } catch {}
|
|
535
|
+
if (!alias && String(canonical).includes("/")) alias = String(canonical).replace(/\//g, "-");
|
|
536
|
+
const aliasStr = alias && alias !== canonical ? ` (别名: ${alias})` : "";
|
|
537
|
+
const pickedMark = pickedIds.includes(canonical) || pickedIds.includes(alias || "") ? "*" : " ";
|
|
538
|
+
console.log(` ${pickedMark} ${canonical}${aliasStr}`);
|
|
539
|
+
}
|
|
540
|
+
console.log(` 管理: mslxdff -provider ${pid} allowlist [list|add|remove|clear] | allowAny on|off`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
} else {
|
|
544
|
+
console.log(`\n────────────────────────────────────────`);
|
|
545
|
+
console.log(`其他供应商 (allowlist,原名 + 别名): (none — 尚未配置)`);
|
|
546
|
+
console.log(` 添加示例: mslxdff -provider add myapi https://api.example.com/v1 sk-xxx --models-path /v1/models`);
|
|
547
|
+
}
|
|
548
|
+
// 额外兜底:本地别名中不在任何 allowlist 里的,单独提示
|
|
549
|
+
const aliasEntries = Object.entries(fullAliases).filter(([alias, canonical]) => {
|
|
550
|
+
if (ids.includes(canonical)) return false;
|
|
551
|
+
// 已在 allowlist 中展示过的别名不重复
|
|
552
|
+
for (const pid of otherIds) {
|
|
553
|
+
const allowed = loadProviderAllowedModels(pid);
|
|
554
|
+
for (const raw of allowed) {
|
|
555
|
+
const can = `${pid}/${raw}`;
|
|
556
|
+
if (can === canonical) return false;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return true;
|
|
560
|
+
});
|
|
561
|
+
if (aliasEntries.length) {
|
|
562
|
+
console.log(`\n 本地别名 (不在 allowlist 里的遗留映射 ${aliasEntries.length}):`);
|
|
563
|
+
for (const [alias, canonical] of aliasEntries) {
|
|
564
|
+
console.log(` ${canonical} => ${alias}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
} catch {}
|
|
568
|
+
console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
|
|
375
569
|
}
|
|
376
|
-
console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
|
|
377
570
|
} catch (err) {
|
|
378
571
|
console.error(`could not fetch models: ${String(err?.message || err)}`);
|
|
379
572
|
process.exit(1);
|
|
@@ -764,17 +957,21 @@ if (args.includes("-provider") || args.includes("--provider")) {
|
|
|
764
957
|
const sub = args[idx + 2];
|
|
765
958
|
const rest = args.slice(idx + 2);
|
|
766
959
|
if (!id) {
|
|
767
|
-
console.error("usage: mslxdff -provider <id> [key...|add|remove|list|clear|share|set-url|allowlist|allowAny]");
|
|
960
|
+
console.error("usage: mslxdff -provider <id> [key...|add|remove|list|models|clear|share|set-url|allowlist|allowAny]");
|
|
768
961
|
console.error(" e.g. mslxdff -provider openrouter sk-1 sk-2 sk-3 set multiple keys (replaces all)");
|
|
769
962
|
console.error(" mslxdff -provider openrouter add sk-4 append one key");
|
|
770
963
|
console.error(" mslxdff -provider openrouter remove sk-1 remove a key by value");
|
|
771
964
|
console.error(" mslxdff -provider openrouter list list all keys (masked)");
|
|
965
|
+
console.error(" mslxdff -provider openrouter models [--json] list provider models (allowlist filtered)");
|
|
772
966
|
console.error(" mslxdff -provider openrouter share on|off share keys with peers on outgoing forward (ADR-0008)");
|
|
773
967
|
console.error(" mslxdff -provider openrouter set-url https://api.example.com/v1");
|
|
968
|
+
console.error(" mslxdff -provider openrouter set-models-path /v1/models");
|
|
969
|
+
console.error(" mslxdff -provider openrouter set-chat-path /v1/chat/completions");
|
|
774
970
|
console.error(" mslxdff -provider openrouter allowlist set gpt-4 gpt-3.5 manage allowed models (empty=block unless allowAny ON)");
|
|
775
971
|
console.error(" mslxdff -provider openrouter allowAny on|off empty allowlist = allow all or block all (default OFF, secure)");
|
|
776
972
|
console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx add generic OpenAI-compatible provider");
|
|
777
973
|
console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx gpt-4 add with allowlist");
|
|
974
|
+
console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx --models-path /v1/models --chat-path /v1/chat/completions");
|
|
778
975
|
console.error(" mslxdff -provider openrouter interactive hidden input (append)");
|
|
779
976
|
console.error(" mslxdff -provider openrouter clear remove all keys");
|
|
780
977
|
process.exit(1);
|
|
@@ -832,9 +1029,10 @@ if (args.includes("-provider") || args.includes("--provider")) {
|
|
|
832
1029
|
const gBase = rest[1];
|
|
833
1030
|
const gKey = rest[2];
|
|
834
1031
|
if (!gid || !gBase || !gKey) {
|
|
835
|
-
console.error("usage: mslxdff -provider add <id> <baseUrl> <key> [allowedModel...]");
|
|
1032
|
+
console.error("usage: mslxdff -provider add <id> <baseUrl> <key> [allowedModel...] [--models-path <path>] [--chat-path <path>]");
|
|
836
1033
|
console.error(" e.g. mslxdff -provider add myapi https://api.example.com/v1 sk-xxx");
|
|
837
1034
|
console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx gpt-4 gpt-3.5");
|
|
1035
|
+
console.error(" mslxdff -provider add myapi https://api.example.com/v1 sk-xxx --models-path /v1/models --chat-path /v1/chat/completions");
|
|
838
1036
|
process.exit(1);
|
|
839
1037
|
}
|
|
840
1038
|
if (gid === "opencode" || gid === "oc" || gid === "openrouter") {
|
|
@@ -1115,7 +1313,7 @@ if (args.includes("-provider") || args.includes("--provider")) {
|
|
|
1115
1313
|
console.error(` mslxdff -provider ${id} allowAny on|off (empty allowlist = block or allow all)`);
|
|
1116
1314
|
process.exit(1);
|
|
1117
1315
|
}
|
|
1118
|
-
if (sub === "models" || sub === "list-models" || sub === "ls") {
|
|
1316
|
+
if (sub === "models" || sub === "show-models" || sub === "list-models" || sub === "ls") {
|
|
1119
1317
|
const wantsJson = args.includes("--json") || args.includes("-json");
|
|
1120
1318
|
const cfg = loadProviderConfig(id);
|
|
1121
1319
|
// opencode: use aggregated cache + provider-specific? For opencode, show bare ids from cache
|
|
@@ -1156,20 +1354,57 @@ if (args.includes("-provider") || args.includes("--provider")) {
|
|
|
1156
1354
|
provider = createGenericProvider({ id, baseUrl, apiKeys: keys, file: defaultStateFile() });
|
|
1157
1355
|
}
|
|
1158
1356
|
const all = await provider.listModels();
|
|
1159
|
-
//
|
|
1160
|
-
const
|
|
1161
|
-
const raw = String(
|
|
1162
|
-
|
|
1163
|
-
const checkRaw = m.id.startsWith(`${id}/`) ? m.id.slice(id.length + 1) : raw;
|
|
1357
|
+
// show 全部上游模型,仅标注是否被 allowlist 放行(不拦截展示)
|
|
1358
|
+
const markAllowed = (mid) => {
|
|
1359
|
+
const raw = String(mid || "").includes("/") ? String(mid).split("/").slice(1).join("/") : String(mid);
|
|
1360
|
+
const checkRaw = mid.startsWith(`${id}/`) ? mid.slice(id.length + 1) : raw;
|
|
1164
1361
|
return isModelAllowed(id, checkRaw);
|
|
1165
|
-
}
|
|
1362
|
+
};
|
|
1166
1363
|
if (wantsJson) {
|
|
1364
|
+
// --json 仍按 allowlist 过滤(给脚本消费可用模型)
|
|
1365
|
+
const filtered = all.filter((m) => markAllowed(m.id));
|
|
1167
1366
|
console.log(JSON.stringify({ object: "list", data: filtered }, null, 2));
|
|
1168
1367
|
} else {
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1368
|
+
const allowedCount = all.filter((m) => markAllowed(m.id)).length;
|
|
1369
|
+
console.log(`${id} models (${all.length} total, ${allowedCount} ✓ allowed${allowedCount !== all.length ? `, ${all.length - allowedCount} x blocked by allowlist` : ""}):`);
|
|
1370
|
+
const fmtPrice = (m) => {
|
|
1371
|
+
const c = String(m.credits || "").trim();
|
|
1372
|
+
if (c) {
|
|
1373
|
+
// "x0.00 credits" / "x0.00" → 统一成 "x0.00"
|
|
1374
|
+
const m0 = c.match(/x\s*([\d.]+)/i);
|
|
1375
|
+
if (m0) return `x${m0[1]}`;
|
|
1376
|
+
return c.replace(/\s*credits\s*/gi, "").trim().replace(/\s+/g, " ");
|
|
1377
|
+
}
|
|
1378
|
+
if (m.pricing && typeof m.pricing === "object") {
|
|
1379
|
+
const p = m.pricing.prompt ?? m.pricing.input ?? m.pricing.completion ?? "";
|
|
1380
|
+
if (p) return String(p);
|
|
1381
|
+
}
|
|
1382
|
+
if (m.price != null && String(m.price).trim()) return String(m.price).trim();
|
|
1383
|
+
if (String(m.id).endsWith("/auto")) return "浮动";
|
|
1384
|
+
return "—";
|
|
1385
|
+
};
|
|
1386
|
+
const fmtBadge = (m) => {
|
|
1387
|
+
const tags = Array.isArray(m.tags) ? m.tags : [];
|
|
1388
|
+
const b = tags.find((t) => String(t).includes("限时免费") || String(t).toLowerCase().includes("free"));
|
|
1389
|
+
if (!b) return "";
|
|
1390
|
+
const part = String(b).split(":")[1];
|
|
1391
|
+
return part ? ` [${part}]` : ` [${b}]`;
|
|
1392
|
+
};
|
|
1393
|
+
// 按 credits 升序已在 provider 排好序,展示时对齐价格列便于分辨
|
|
1394
|
+
const idW = Math.max(22, ...all.map((m) => String(m.id).length)) + 2;
|
|
1395
|
+
const priceW = Math.max(6, ...all.map((m) => fmtPrice(m).length)) + 2;
|
|
1396
|
+
for (const m of all) {
|
|
1397
|
+
const ok = markAllowed(m.id);
|
|
1398
|
+
const price = fmtPrice(m);
|
|
1399
|
+
const badge = fmtBadge(m);
|
|
1400
|
+
const name = m.name ? ` ${m.name}` : "";
|
|
1401
|
+
const blocked = ok ? "" : " [blocked — allowlist]";
|
|
1402
|
+
const line = ` ${ok ? "✓" : "x"} ${String(m.id).padEnd(idW)}${String(price).padEnd(priceW)}${name}${badge}${blocked}`;
|
|
1403
|
+
console.log(line);
|
|
1404
|
+
}
|
|
1172
1405
|
if (!all.length) console.log(` (no models — check baseUrl/keys or try: curl ${baseUrl}/models)`);
|
|
1406
|
+
else if (allowedCount === 0) console.log(` tip: all blocked — mslxdff -provider ${id} allowAny on 或 allowlist set <model...>`);
|
|
1407
|
+
else if (allowedCount !== all.length) console.log(` tip: blocked 仅影响 /v1/chat 调用,展示已全量列出`);
|
|
1173
1408
|
}
|
|
1174
1409
|
try { await provider.close?.(); } catch {}
|
|
1175
1410
|
} catch (e) {
|
|
@@ -1317,7 +1552,8 @@ async function pickInteractive(items, startCursor = 0) {
|
|
|
1317
1552
|
let cursor = Math.min(Math.max(startCursor, 0), items.length - 1);
|
|
1318
1553
|
const draw = () => {
|
|
1319
1554
|
const lines = [...renderChooser(items, cursor), ...renderChooserHelp()];
|
|
1320
|
-
|
|
1555
|
+
// PowerShell 兼容:\x1b[2J 清屏 + \x1b[H 回到左上,比 \x1b[G\x1b[J 更可靠
|
|
1556
|
+
process.stdout.write("\x1b[2J\x1b[H" + lines.join("\n"));
|
|
1321
1557
|
};
|
|
1322
1558
|
draw();
|
|
1323
1559
|
return new Promise((resolve) => {
|
|
@@ -1359,7 +1595,7 @@ async function pickInteractiveMulti(items, initialPicked = new Set(), startCurso
|
|
|
1359
1595
|
const draw = () => {
|
|
1360
1596
|
const rows = items.map((it, i) => ({ ...it, picked: picked.has(it.id) }));
|
|
1361
1597
|
const lines = [...renderChooser(rows, cursor, { multi: true }), ...renderChooserHelp(true)];
|
|
1362
|
-
process.stdout.write("\x1b[
|
|
1598
|
+
process.stdout.write("\x1b[2J\x1b[H" + lines.join("\n"));
|
|
1363
1599
|
};
|
|
1364
1600
|
draw();
|
|
1365
1601
|
return new Promise((resolve) => {
|
package/package.json
CHANGED
package/src/auto.js
CHANGED
|
@@ -85,6 +85,7 @@ function effectiveCooldown(entry, slowCooldownMs, cooldownMs) {
|
|
|
85
85
|
function inCooldown(id, errors, now, cooldownMs, slowCooldownMs) {
|
|
86
86
|
const e = normEntry(errors[id]);
|
|
87
87
|
if (!e || !(e.at > 0)) return false;
|
|
88
|
+
if (e.status === MODEL_STATUS.NORMAL) return false;
|
|
88
89
|
const cd = effectiveCooldown(e, slowCooldownMs, cooldownMs);
|
|
89
90
|
return cd > 0 && now - e.at < cd;
|
|
90
91
|
}
|
|
@@ -98,6 +99,16 @@ function normLatency(e) {
|
|
|
98
99
|
|
|
99
100
|
export function rankModels(ids, errors = {}, { now = Date.now(), cooldownMs = 0, slowCooldownMs = 0, latencies = {}, preferred } = {}) {
|
|
100
101
|
const pref = preferred ?? getPreferredModel();
|
|
102
|
+
// 最近一次成功的模型(NORMAL 且非慢且 at 最大),用于“上次成功优先”——慢模型即使刚成功也不应钉死
|
|
103
|
+
let lastSuccessId = null;
|
|
104
|
+
let lastSuccessAt = 0;
|
|
105
|
+
for (const [id, e] of Object.entries(errors)) {
|
|
106
|
+
const ne = normEntry(e);
|
|
107
|
+
if (ne && ne.status === MODEL_STATUS.NORMAL && !ne.slow && ne.at > lastSuccessAt) {
|
|
108
|
+
lastSuccessAt = ne.at;
|
|
109
|
+
lastSuccessId = id;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
101
112
|
return [...new Set(ids)]
|
|
102
113
|
.filter(Boolean)
|
|
103
114
|
.map((id) => ({
|
|
@@ -105,12 +116,14 @@ export function rankModels(ids, errors = {}, { now = Date.now(), cooldownMs = 0,
|
|
|
105
116
|
e: normEntry(errors[id]),
|
|
106
117
|
err: normEntry(errors[id])?.at ?? 0,
|
|
107
118
|
isPreferred: id === pref,
|
|
119
|
+
isLastSuccess: id === lastSuccessId,
|
|
108
120
|
cooling: inCooldown(id, errors, now, cooldownMs, slowCooldownMs),
|
|
109
121
|
latency: normLatency(latencies[id]) ?? Number.MAX_SAFE_INTEGER,
|
|
110
122
|
}))
|
|
111
123
|
.sort(
|
|
112
124
|
(a, b) =>
|
|
113
125
|
(a.cooling ? 1 : 0) - (b.cooling ? 1 : 0) ||
|
|
126
|
+
(b.isLastSuccess ? 1 : 0) - (a.isLastSuccess ? 1 : 0) ||
|
|
114
127
|
(b.isPreferred ? 1 : 0) - (a.isPreferred ? 1 : 0) ||
|
|
115
128
|
a.latency - b.latency ||
|
|
116
129
|
a.err - b.err
|
package/src/chat/config.js
CHANGED
|
@@ -6,5 +6,6 @@ export const CHAT_HISTORY_MAX_CHARS = 400000;
|
|
|
6
6
|
export const CHAT_KEEP_RECENT = 40;
|
|
7
7
|
export const CHAT_SUMMARY_TRIGGER = 400000;
|
|
8
8
|
export const CHAT_MAX_TOOL_LOOPS = 6;
|
|
9
|
-
export const CHAT_TIMEOUT_MS =
|
|
9
|
+
export const CHAT_TIMEOUT_MS = 15000;
|
|
10
|
+
export const CHAT_GATEWAY_TIMEOUT_MS = 25000;
|
|
10
11
|
export const FORBIDDEN = ["-uninstall", "--uninstall"];
|
package/src/chat/prompt.js
CHANGED
|
@@ -47,6 +47,11 @@ export function buildSystemPrompt({ modelsOverride } = {}) {
|
|
|
47
47
|
|
|
48
48
|
${mini}
|
|
49
49
|
|
|
50
|
+
语言(最高优先级):
|
|
51
|
+
- 全部面向用户的自然语言回复**必须使用简体中文**(无论用户用英文/日文/拼音提问,都用中文回答)。
|
|
52
|
+
- 仅代码、命令、模型 id、路径、JSON 等技术标识保持原文,不做翻译。
|
|
53
|
+
- 禁止输出英文长段解释;中英文混排时中文为主。
|
|
54
|
+
|
|
50
55
|
规则:
|
|
51
56
|
- 用户说简称你必须自行查“可用模型”找到全称,例如 hy3→hy3-free,mimo→mimo-v2.5-free,bigpickle→big-pickle。
|
|
52
57
|
- 永远输出精确的命令与模型 id,大小写敏感。
|
|
@@ -56,8 +61,8 @@ ${mini}
|
|
|
56
61
|
- 严禁幻觉命令:mslxdff "hi" --model X / mslxdff --model X "hi" / mslxdff -chat --model X 都不存在,输出只会是 status 页。探活任意模型(含 clinebot/*、workbuddy/*、bai/*)必须用 curl POST http://localhost:8989/v1/chat/completions,body 为 {"model":"<前缀/模型>","messages":[{"role":"user","content":"hi"}],"stream":false},成功 200 + x-mslxdff-via:local 即通;401 代表本机 token 陈旧需提示 mslxdff -stop && mslxdff;403 + x-mslxdff-allowlist:1 代表白名单未放行需 allowlist add。
|
|
57
62
|
- **禁止重复调用(最高优先级)**:同一 run_command/curl/read_file 在本轮只执行一次,重复会被工具侧 SKIPPED_DUP 拦截;查询类(-showtoken/-status/-provider list/-providers list/-model list/-group list/-log 等)**调用一次即答案**,拿到 OK 结果后必须**立即用中文直接回答用户**,禁止再发起任何工具调用。收到 SKIPPED_DUP 或“请直接回答/禁止再调用”提示时,必须 0 工具直接回答。
|
|
58
63
|
- 禁止调用 -uninstall,包含即拒绝;-showtoken 仅在用户明确要求查看/调试本机 token 时才用,查模型/查供应商严禁调用。
|
|
59
|
-
-
|
|
60
|
-
-
|
|
64
|
+
- 回复风格:简洁友好,执行前后用中文说明你在做什么。
|
|
65
|
+
- 若用户只是闲聊/提问且可用模型列表已能回答,不调工具,直接用中文回答。`;
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
export function getModelsForPrompt() {
|
package/src/chat/repl.js
CHANGED
|
@@ -30,35 +30,7 @@ function trace(line) {
|
|
|
30
30
|
console.log(`\x1b[90m· ${line}\x1b[0m`);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
const low = String(text || "").toLowerCase();
|
|
35
|
-
const known = ["workbuddy", "clinebot", "opencode", "bai", "openrouter", "poolside", "z-ai", "deepseek"];
|
|
36
|
-
for (const k of known) if (low.includes(k)) return k;
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
function isModelListQuery(text) {
|
|
40
|
-
const low = String(text || "").toLowerCase();
|
|
41
|
-
if (!low.includes("模型")) return false;
|
|
42
|
-
return low.includes("哪些") || low.includes("可用") || low.includes("支持") || low.includes("列表") || low.includes("有啥") || low.includes("都有") || low.includes("可以") || low.includes("用");
|
|
43
|
-
}
|
|
44
|
-
function formatModelAnswer(prov, models) {
|
|
45
|
-
const byProv = {};
|
|
46
|
-
for (const id of models) {
|
|
47
|
-
const slash = id.indexOf("/");
|
|
48
|
-
const p = slash > 0 ? id.slice(0, slash) : "opencode";
|
|
49
|
-
if (!byProv[p]) byProv[p] = [];
|
|
50
|
-
byProv[p].push(id);
|
|
51
|
-
}
|
|
52
|
-
if (prov) {
|
|
53
|
-
const list = byProv[prov] || models.filter((m) => m.toLowerCase().startsWith(prov.toLowerCase() + "/"));
|
|
54
|
-
if (!list.length) return `**${prov}** 暂无可用模型(可能未配置或网关未聚合)。可用总量 ${models.length},按供应商:${Object.entries(byProv).map(([k, v]) => `${k}(${v.length})`).join(" | ")}`;
|
|
55
|
-
// 更友好:直接列 id 和调用方式
|
|
56
|
-
return `**${prov}** 可用模型(共 ${list.length} 个,网关已聚合):\n\n| 模型 id | 调用方式 |\n|:---|:---|\n${list.map((m) => `| \`${m}\` | \`${m}\` |`).join("\n")}\n\n> 提示:直接用 \`${prov}/<模型>\` 调用,例如 \`${list[0]}\``;
|
|
57
|
-
}
|
|
58
|
-
// 无指定供应商:按分组汇总
|
|
59
|
-
const summary = Object.entries(byProv).map(([p, arr]) => `**${p}**(${arr.length}):${arr.slice(0, 8).join(", ")}${arr.length > 8 ? " …" : ""}`).join("\n");
|
|
60
|
-
return `可用模型总计 ${models.length} 个,按供应商分组:\n\n${summary}\n\n> 查某供应商请说“workbuddy有哪些模型”`;
|
|
61
|
-
}
|
|
33
|
+
|
|
62
34
|
|
|
63
35
|
async function maybeCompress(messages) {
|
|
64
36
|
if (!needsCompress(messages)) return [...messages];
|
|
@@ -85,23 +57,12 @@ async function maybeCompress(messages) {
|
|
|
85
57
|
async function runAgentTurn(userText, messages) {
|
|
86
58
|
const tools = getToolDefs();
|
|
87
59
|
messages.push({ role: "user", content: userText });
|
|
88
|
-
// Fast-path:模型列表类问题本地直答,不走 LLM,避免 “provider list” 幻觉和 6 轮重复
|
|
89
|
-
if (isModelListQuery(userText)) {
|
|
90
|
-
const prov = extractProvFromQuery(userText);
|
|
91
|
-
try {
|
|
92
|
-
const models = getModelsForPrompt();
|
|
93
|
-
const answer = formatModelAnswer(prov, models);
|
|
94
|
-
if (answer) {
|
|
95
|
-
messages.push({ role: "assistant", content: answer });
|
|
96
|
-
trace(`[fast] 模型列表直答 prov=${prov || "all"} 共 ${models.length} 个`);
|
|
97
|
-
return { text: answer, model: "local", latency: 0, usage: null, fallback: false, ok: true, totalMs: 0 };
|
|
98
|
-
}
|
|
99
|
-
} catch {}
|
|
100
|
-
}
|
|
101
60
|
let loops = 0;
|
|
102
61
|
let lastModel = null;
|
|
62
|
+
let lastProvider = null;
|
|
103
63
|
let lastUsage = null;
|
|
104
64
|
let lastFallback = false;
|
|
65
|
+
let lastFallbackGateway = false;
|
|
105
66
|
let lastLatency = 0;
|
|
106
67
|
const t0 = performance.now();
|
|
107
68
|
const turnStart = performance.now();
|
|
@@ -145,8 +106,10 @@ async function runAgentTurn(userText, messages) {
|
|
|
145
106
|
return { text: err, model: null, latency: lastLatency, usage: null, fallback: false, ok: false };
|
|
146
107
|
}
|
|
147
108
|
lastModel = res.model;
|
|
109
|
+
lastProvider = res.provider || null;
|
|
148
110
|
lastUsage = res.usage || null;
|
|
149
111
|
lastFallback = !!res.fallback;
|
|
112
|
+
lastFallbackGateway = !!res.fallbackGateway || !!res.viaGateway;
|
|
150
113
|
const msg = res.message;
|
|
151
114
|
const toolCalls = msg.tool_calls || [];
|
|
152
115
|
let fallbackCmd = null;
|
|
@@ -158,9 +121,11 @@ async function runAgentTurn(userText, messages) {
|
|
|
158
121
|
const text = String(msg.content || "").trim() || "(空回复)";
|
|
159
122
|
messages.push({ role: "assistant", content: text });
|
|
160
123
|
const totalMs = Math.round(performance.now() - t0);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
124
|
+
let note = "";
|
|
125
|
+
if (lastFallbackGateway) note = "\n\x1b[90m[注:mimo/big-pickle 均不可用,已自动切本地网关 auto(:8989)]\x1b[0m";
|
|
126
|
+
else if (lastFallback) note = "\n\x1b[90m[注:mimo 不可用,已用 big-pickle]\x1b[0m";
|
|
127
|
+
trace(`[turn] 完成 总计 ${totalMs}ms · LLM ${lastLatency}ms · 0 工具${lastFallbackGateway ? " · gateway-fallback" : ""}`);
|
|
128
|
+
return { text: text + note, model: lastModel, provider: lastProvider, latency: lastLatency, usage: lastUsage, fallback: lastFallback, fallbackGateway: lastFallbackGateway, ok: true, totalMs };
|
|
164
129
|
}
|
|
165
130
|
const calls = toolCalls.length ? toolCalls : [{ id: "fallback-1", function: { name: "run_command", arguments: JSON.stringify({ command: fallbackCmd }) } }];
|
|
166
131
|
messages.push({ role: "assistant", content: msg.content || "", tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: c.function })) });
|
|
@@ -254,7 +219,7 @@ async function runAgentTurn(userText, messages) {
|
|
|
254
219
|
messages.push({ role: "assistant", content: synth });
|
|
255
220
|
const totalMs = Math.round(performance.now() - t0);
|
|
256
221
|
trace(`[turn] 提前结束(重复阈值) 总计 ${totalMs}ms`);
|
|
257
|
-
return { text: synth, model: lastModel || "local", latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: true, totalMs };
|
|
222
|
+
return { text: synth, model: lastModel || "local", provider: lastProvider, latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: true, totalMs };
|
|
258
223
|
}
|
|
259
224
|
}
|
|
260
225
|
const toolsMs = Math.round(performance.now() - tTools);
|
|
@@ -262,19 +227,21 @@ async function runAgentTurn(userText, messages) {
|
|
|
262
227
|
trace(`[loop ${loops}] 工具 ${toolsMs}ms · 本轮总 ${loopMs}ms · 累计 ${Math.round(performance.now() - turnStart)}ms`);
|
|
263
228
|
loops++;
|
|
264
229
|
}
|
|
265
|
-
return { text: "(工具调用次数已达上限,已停止)", model: lastModel, latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: false };
|
|
230
|
+
return { text: "(工具调用次数已达上限,已停止)", model: lastModel, latency: lastLatency, usage: lastUsage, fallback: lastFallback, fallbackGateway: lastFallbackGateway, ok: false };
|
|
266
231
|
}
|
|
267
232
|
|
|
268
|
-
function printFooter({ model, latency, usage, totalMs, fallback }) {
|
|
233
|
+
function printFooter({ model, provider, latency, usage, totalMs, fallback, fallbackGateway, viaGateway }) {
|
|
269
234
|
const dim = "\x1b[90m";
|
|
270
235
|
const rst = "\x1b[0m";
|
|
271
236
|
let gw = null;
|
|
272
237
|
try { gw = collectStats(); } catch {}
|
|
273
|
-
const
|
|
238
|
+
const prov = provider && provider !== "opencode" ? `${provider}/` : "";
|
|
239
|
+
const baseLabel = model ? `${prov}${model}` : "—";
|
|
240
|
+
const modelLabel = model ? (fallbackGateway || viaGateway ? `${baseLabel} (gateway auto)` : baseLabel) : "—";
|
|
274
241
|
const latLabel = latency ? `${latency}ms` : "—";
|
|
275
242
|
const totalLabel = totalMs ? ` · 总耗时 ${totalMs}ms` : "";
|
|
276
243
|
const tokLabel = usage ? ` · tokens ${usage.prompt_tokens ?? "?"}→${usage.completion_tokens ?? "?"}` : "";
|
|
277
|
-
const fbLabel = fallback ? " · fallback" : "";
|
|
244
|
+
const fbLabel = fallbackGateway || viaGateway ? " · gateway-fallback" : fallback ? " · fallback" : "";
|
|
278
245
|
let gwLabel = "";
|
|
279
246
|
let extra = "";
|
|
280
247
|
if (gw) {
|
package/src/chat/stats.js
CHANGED
|
@@ -150,7 +150,7 @@ export function formatBannerLines() {
|
|
|
150
150
|
const green = "\x1b[32m";
|
|
151
151
|
const lines = [];
|
|
152
152
|
lines.push(`${cyan}┌─ mslxdff chat · 数据来自 -d 网关进程(非本会话) ─────${rst}`);
|
|
153
|
-
lines.push(`${cyan}│${rst} 对话模型 ${yellow}${s.chatPref}${rst} ${dim}→ ${s.chatFall}
|
|
153
|
+
lines.push(`${cyan}│${rst} 对话模型 ${yellow}${s.chatPref}${rst} ${dim}→ ${s.chatFall} → gateway auto:8989${rst} ${dim}[${s.chatPrefStatus}/${s.chatFallStatus}]${rst} ${dim}三级兜底${rst}`);
|
|
154
154
|
lines.push(`${cyan}│${rst} 网关默认 ${green}${s.gatewayModel}${rst} ${dim}[${s.gatewayStatus}]${rst} · 端口 ${s.port} · ${dim}${s.endpointUrl}${rst}`);
|
|
155
155
|
const prefTtfb = s.chatPrefStat?.avgTtfbMs ?? s.chatPrefStat?.emaTtfbMs ?? s.chatPrefLat?.emaMs;
|
|
156
156
|
const fallTtfb = s.chatFallStat?.avgTtfbMs ?? s.chatFallStat?.emaTtfbMs ?? s.chatFallLat?.emaMs;
|