mslxdff 0.1.57 → 0.1.62

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.57",
3
+ "version": "0.1.62",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/mslxdff.js",
11
- "test": "node --test test/*.test.js",
11
+ "test": "node --test --test-concurrency=1 test/*.test.js",
12
12
  "docs:check": "node scripts/docs-check.js"
13
13
  },
14
14
  "engines": {
package/src/auto.js CHANGED
@@ -27,8 +27,8 @@ export function getPreferredModel({ file = defaultStateFile() } = {}) {
27
27
 
28
28
  export const DEFAULT_AUTO_MODELS = [
29
29
  PREFERRED_MODEL,
30
- "deepseek-v4-flash-free",
31
30
  "mimo-v2.5-free",
31
+ "deepseek-v4-flash-free",
32
32
  "ling-3.0-flash-free",
33
33
  "nemotron-3-ultra-free",
34
34
  "north-mini-code-free",
@@ -52,7 +52,9 @@ ${mini}
52
52
  - 永远输出精确的命令与模型 id,大小写敏感。
53
53
  - 需要执行命令时调用 run_command,需要看文件时调用 read_file,需要检查网络/服务可用性时调用 curl。
54
54
  - curl 简写:upstream(=上游 https://opencode.ai/zen/v1/models)、local/health(=本机 /health)、local/models(=本机 /v1/models),也支持完整 http(s) URL;会自动补上游头、本机 token 与已配置供应商 key(直连 https://api.b.ai/v1/models 会自动带 bai 的 key,无需手动加头)。
55
- - 查“某供应商有哪些模型”:优先直接用上方“可用模型”按前缀过滤回答(如 bai/ 开头的即 bai 供应商),无需调工具;如需实时刷新,调 curl local/models(GET,自动带本机 token)看网关聚合列表,或 curl https://api.b.ai/v1/models(自动带 key)看上游全量。禁止为此调用 -showtoken(本机 token 已自动注入)。
55
+ - 查“某供应商有哪些模型”**禁止调用 run_command**:**直接用上方“可用模型”按前缀过滤回答**(如 workbuddy/ 开头的即 workbuddy 供应商,clinebot/ 开头即 clinebot),无需调工具;如需实时刷新,调 curl local/models(GET,自动带本机 token)看网关聚合列表。**错误示例**:用户问 workbuddy有哪些模型 → 调用 -provider workbuddy list(这是查配置,不是查模型!)→ 错。**正确**:直接列 workbuddy/ 前缀的可用模型。禁止为此调用 -showtoken(本机 token 已自动注入)。
56
+ - 严禁幻觉命令: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
+ - **禁止重复调用(最高优先级)**:同一 run_command/curl/read_file 在本轮只执行一次,重复会被工具侧 SKIPPED_DUP 拦截;查询类(-showtoken/-status/-provider list/-providers list/-model list/-group list/-log 等)**调用一次即答案**,拿到 OK 结果后必须**立即用中文直接回答用户**,禁止再发起任何工具调用。收到 SKIPPED_DUP 或“请直接回答/禁止再调用”提示时,必须 0 工具直接回答。
56
58
  - 禁止调用 -uninstall,包含即拒绝;-showtoken 仅在用户明确要求查看/调试本机 token 时才用,查模型/查供应商严禁调用。
57
59
  - 回复用中文,简洁友好,执行前后说明你在做什么。
58
60
  - 若用户只是闲聊/提问且可用模型列表已能回答,不调工具,直接回答。`;
package/src/chat/repl.js CHANGED
@@ -8,6 +8,7 @@ import { loadHistory, saveHistory, clearHistory, histPath, estimateChars, needsC
8
8
  import { CHAT_KEEP_RECENT, CHAT_MAX_TOOL_LOOPS, CHAT_PREFERRED, CHAT_FALLBACK } from "./config.js";
9
9
  import { formatBannerLines, formatStatsDetail, collectStats } from "./stats.js";
10
10
  import { createSpinner } from "./spinner.js";
11
+ import { normalizeFullId } from "../providers/model-id.js";
11
12
 
12
13
  const SLASH_HELP = `自然语言直接说,斜杠快捷:
13
14
  /help 本帮助
@@ -29,6 +30,36 @@ function trace(line) {
29
30
  console.log(`\x1b[90m· ${line}\x1b[0m`);
30
31
  }
31
32
 
33
+ function extractProvFromQuery(text) {
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
+ }
62
+
32
63
  async function maybeCompress(messages) {
33
64
  if (!needsCompress(messages)) return [...messages];
34
65
  const sys = messages[0];
@@ -54,6 +85,19 @@ async function maybeCompress(messages) {
54
85
  async function runAgentTurn(userText, messages) {
55
86
  const tools = getToolDefs();
56
87
  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
+ }
57
101
  let loops = 0;
58
102
  let lastModel = null;
59
103
  let lastUsage = null;
@@ -61,6 +105,10 @@ async function runAgentTurn(userText, messages) {
61
105
  let lastLatency = 0;
62
106
  const t0 = performance.now();
63
107
  const turnStart = performance.now();
108
+ // 同轮去重:同一工具+参数只真正执行一次,重复直接复用并提示 LLM
109
+ const seenCalls = new Map(); // key -> { count, firstResult }
110
+ let duplicateStrikes = 0;
111
+ let forceNoTools = false;
64
112
  trace(`[turn] 开始 "${userText.slice(0, 60)}${userText.length > 60 ? "…" : ""}" · 历史 ${messages.length}条 约 ${estimateChars(messages)}字`);
65
113
  while (loops < CHAT_MAX_TOOL_LOOPS) {
66
114
  const tLoop = performance.now();
@@ -71,12 +119,19 @@ async function runAgentTurn(userText, messages) {
71
119
  messages.length = 0;
72
120
  for (const m of cur) messages.push(m);
73
121
  const tCall = performance.now();
74
- const spinnerLabel = loops === 0 ? "已发送给 AI,等待回复中" : "AI 正在整理回复中";
122
+ const spinnerLabel = loops === 0 ? "已发送给 AI,等待回复中" : forceNoTools ? "AI 整理回答中(已禁工具)" : "AI 正在整理回复中";
75
123
  const spinner = createSpinner(spinnerLabel);
76
124
  spinner.start();
77
125
  let res;
78
126
  try {
79
- res = await chatWithFallback({ messages, tools });
127
+ const activeTools = forceNoTools ? [] : tools;
128
+ res = await chatWithFallback({ messages, tools: activeTools });
129
+ if (forceNoTools && res.ok && res.message?.tool_calls?.length) {
130
+ // LLM 在禁工具模式下仍尝试调工具,视为违规,直接转文本
131
+ trace(`[guard] 禁工具模式下仍收到 tool_calls,已拦截`);
132
+ res.message.tool_calls = [];
133
+ if (!res.message.content) res.message.content = "(已拦截违规工具调用,请基于已有结果直接回答)";
134
+ }
80
135
  } finally {
81
136
  const ms = Math.round(performance.now() - tCall);
82
137
  spinner.stop(`\x1b[90m✓ AI 已回复 · ${ms}ms\x1b[0m`);
@@ -116,18 +171,56 @@ async function runAgentTurn(userText, messages) {
116
171
  let args = {};
117
172
  try { args = JSON.parse(c.function?.arguments || "{}"); } catch {}
118
173
  const t1 = performance.now();
174
+ // 归一化 key:run_command 按命令去重(大小写+空白归一),curl 按 url+method+body,read_file 按 path
175
+ let dedupKey = `${name}:${JSON.stringify(args)}`;
176
+ if (name === "run_command") {
177
+ const cmd = String(args.command || "").trim().toLowerCase().replace(/\s+/g, " ");
178
+ // -provider list 与 -providers list 等价,归一
179
+ const norm = cmd.replace(/^-+providers\b/, "-provider").replace(/\s+/g, " ").trim();
180
+ dedupKey = `run_command:${norm}`;
181
+ } else if (name === "curl") {
182
+ const u = String(args.url || "").trim().toLowerCase();
183
+ const m = String(args.method || "GET").toUpperCase();
184
+ dedupKey = `curl:${m}:${u}:${String(args.body || "").slice(0, 200)}`;
185
+ } else if (name === "read_file") {
186
+ dedupKey = `read_file:${String(args.path || "").trim().toLowerCase()}`;
187
+ }
188
+ const seen = seenCalls.get(dedupKey);
189
+ if (seen) {
190
+ const dt = Math.round(performance.now() - t1);
191
+ trace(`[tool] ${name} 重复调用已跳过 · ${dt}ms · 之前 ${seen.count} 次`);
192
+ console.log(`\x1b[33m→ 跳过重复: ${name} ${JSON.stringify(args).slice(0, 120)}(本轮已执行过)\x1b[0m`);
193
+ return {
194
+ id: c.id,
195
+ content: `SKIPPED_DUP: 此工具调用在本轮已执行过 ${seen.count} 次,结果相同请直接基于已有信息回答用户,不要再重复调用。\n--- 首次结果复用 ---\n${seen.firstResult.slice(0, 6000)}`,
196
+ };
197
+ }
119
198
  let result;
120
199
  if (name === "run_command") {
121
200
  const cmd = String(args.command || "").trim();
122
201
  console.log(`\x1b[90m→ 执行: mslxdff ${cmd}\x1b[0m`);
123
202
  const r = await execCommand(cmd);
124
203
  result = `${r.ok ? "OK" : "FAIL"}: ${r.output}`;
204
+ // 查询类命令直接在结果里植入“立即回答”锚点,降低 LLM 再发一次的概率;若用户问模型,则 provider list 不算答案
205
+ const lowCmd = cmd.toLowerCase().replace(/\s+/g, " ").trim();
206
+ const asksModel = String(userText || "").toLowerCase().includes("模型");
207
+ const isOnceAndDone =
208
+ /^-+(showtoken|status|s|providers?\b|model\b|group\b|log\b|workbuddy\b|free\b|autostart\b|plugins\b)/.test(lowCmd) ||
209
+ lowCmd === "-provider list" || lowCmd === "-providers list";
210
+ if (isOnceAndDone && r.ok) {
211
+ if (asksModel && lowCmd.includes("-provider")) {
212
+ result += `\n\n[提示:此命令仅显示供应商配置,不包含模型列表。用户问的是“有哪些模型”,请用系统提示中的“可用模型”按前缀过滤回答,或调 curl local/models,不要再调 provider list]`;
213
+ } else {
214
+ result += `\n\n[系统提示:此查询已完成,结果即答案,请直接用中文回答用户,禁止再调用相同或同类查询工具]`;
215
+ }
216
+ }
125
217
  const dt = Math.round(performance.now() - t1);
126
218
  trace(`[tool] run_command "${cmd.slice(0, 40)}" · ${dt}ms · ${r.ok ? "OK" : "FAIL"} ${r.output.length}字`);
127
219
  console.log(r.ok ? `\x1b[32m${r.output.slice(0, 800)}\x1b[0m` : `\x1b[31m${r.output.slice(0, 800)}\x1b[0m`);
128
220
  } else if (name === "read_file") {
129
221
  const r = await readFileTool(args);
130
222
  result = `${r.ok ? "OK" : "FAIL"}: ${r.output.slice(0, 6000)}`;
223
+ if (r.ok) result += `\n\n[系统提示:文件已读取,请直接基于内容回答,禁止重复读取同一文件]`;
131
224
  const dt = Math.round(performance.now() - t1);
132
225
  trace(`[tool] read_file ${args.path} · ${dt}ms · ${r.ok ? "OK" : "FAIL"} ${r.output.length}字`);
133
226
  console.log(`\x1b[90m→ 读取: ${args.path} ${r.ok ? "OK" : "FAIL"}\x1b[0m`);
@@ -142,9 +235,28 @@ async function runAgentTurn(userText, messages) {
142
235
  } else {
143
236
  result = `unknown tool ${name}`;
144
237
  }
238
+ // 记录首次结果供复用
239
+ if (!seenCalls.has(dedupKey)) seenCalls.set(dedupKey, { count: 1, firstResult: result });
240
+ else seenCalls.get(dedupKey).count++;
241
+ // 若本轮首次执行后已累积 2 次以上相同调用,下次 LLM 再试会直接命中上面的 SKIPPED_DUP
145
242
  return { id: c.id, content: result };
146
243
  }));
147
244
  for (const tr of toolResults) messages.push({ role: "tool", tool_call_id: tr.id, content: tr.content });
245
+ // 若本轮有 SKIPPED_DUP,额外追加系统提示并禁用后续工具,强制直接回答
246
+ if (toolResults.some((tr) => String(tr.content).startsWith("SKIPPED_DUP"))) {
247
+ duplicateStrikes++;
248
+ forceNoTools = true;
249
+ messages.push({ role: "system", content: "系统提示:你已重复调用相同工具,工具侧已复用首次结果并跳过执行。你已被禁止再调用任何工具,必须立即基于以上工具结果用中文直接回答用户,0 工具调用。" });
250
+ trace(`[dup] 检测到重复调用 ${duplicateStrikes} 次,已禁用后续工具调用`);
251
+ if (duplicateStrikes >= 2) {
252
+ const seen = [...seenCalls.values()].map((v) => v.firstResult).join("\n---\n").slice(0, 6000);
253
+ const synth = `检测到重复调用已达 ${duplicateStrikes} 次,为避免空转,直接基于已有结果回答:\n\n${seen}`;
254
+ messages.push({ role: "assistant", content: synth });
255
+ const totalMs = Math.round(performance.now() - t0);
256
+ trace(`[turn] 提前结束(重复阈值) 总计 ${totalMs}ms`);
257
+ return { text: synth, model: lastModel || "local", latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: true, totalMs };
258
+ }
259
+ }
148
260
  const toolsMs = Math.round(performance.now() - tTools);
149
261
  const loopMs = Math.round(performance.now() - tLoop);
150
262
  trace(`[loop ${loops}] 工具 ${toolsMs}ms · 本轮总 ${loopMs}ms · 累计 ${Math.round(performance.now() - turnStart)}ms`);
@@ -164,13 +276,25 @@ function printFooter({ model, latency, usage, totalMs, fallback }) {
164
276
  const tokLabel = usage ? ` · tokens ${usage.prompt_tokens ?? "?"}→${usage.completion_tokens ?? "?"}` : "";
165
277
  const fbLabel = fallback ? " · fallback" : "";
166
278
  let gwLabel = "";
279
+ let extra = "";
167
280
  if (gw) {
168
- const cnt = gw.latencies?.[model]?.count;
169
- const ema = gw.latencies?.[model]?.emaMs;
170
- const per = cnt ? ` · 网关该模型 ${cnt}次 EMA ${ema}ms` : "";
171
- gwLabel = ` · 网关 总${gw.total} 成功${gw.success} 失败${gw.fail}${per}`;
281
+ const full = (() => { try { return normalizeFullId(model); } catch { return model; } })();
282
+ const stat = gw.modelStats?.[full] || gw.modelStats?.[model] || null;
283
+ const cnt = stat?.count ?? gw.latencies?.[model]?.count;
284
+ const avgTtfb = stat?.avgTtfbMs ?? stat?.emaTtfbMs;
285
+ const avgTps = stat?.avgTps ?? stat?.emaTps;
286
+ const per = cnt ? ` · 网关该模型 ${cnt}次` : "";
287
+ const ttfbLabel = avgTtfb ? ` 平均首字 ${avgTtfb}ms` : (gw.latencies?.[model]?.emaMs ? ` EMA ${gw.latencies[model].emaMs}ms` : "");
288
+ const tpsLabel = avgTps ? ` · ${avgTps} tok/s` : "";
289
+ const verbose = stat?.avgCompTok ? ` · 啰嗦 ${stat.avgCompTok}tok/次` : "";
290
+ gwLabel = ` · 网关 总${gw.total} 成功${gw.success} 失败${gw.fail}${per}${ttfbLabel}${tpsLabel}${verbose}`;
291
+ // 本次首字/tps(若本次有 usage,估算本次 tps)
292
+ if (usage?.completion_tokens && latency) {
293
+ const tpsNow = Math.round(usage.completion_tokens / (latency / 1000));
294
+ if (Number.isFinite(tpsNow) && tpsNow > 0) extra = ` · 本次首字 ~${latency}ms · ${tpsNow} tok/s`;
295
+ }
172
296
  }
173
- console.log(`${dim}─ ${modelLabel} ${latLabel}${totalLabel}${tokLabel}${fbLabel}${gwLabel}${rst}`);
297
+ console.log(`${dim}─ ${modelLabel} ${latLabel}${totalLabel}${tokLabel}${fbLabel}${gwLabel}${extra}${rst}`);
174
298
  }
175
299
 
176
300
  export async function startRepl({ singleShot } = {}) {
package/src/chat/stats.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { readFileSync, existsSync } from "node:fs";
2
2
  import { getPreferredModel } from "../auto.js";
3
- import { loadModelLatencies, loadModelErrors, loadModelPicks, getPort } from "../state.js";
3
+ import { loadModelLatencies, loadModelErrors, loadModelPicks, getPort, loadModelStats } from "../state.js";
4
4
  import { logDir, callsFile, errorsFile, recentCalls, lastError } from "../logs.js";
5
5
  import { fmtShanghai } from "../time.js";
6
6
  import { CHAT_PREFERRED, CHAT_FALLBACK } from "./config.js";
7
+ import { normalizeFullId } from "../providers/model-id.js";
7
8
 
8
9
  function readLinesCount(file) {
9
10
  try {
@@ -22,6 +23,17 @@ function fmtLatency(entry) {
22
23
  return `${ema}ms${cnt}${last}`;
23
24
  }
24
25
 
26
+ function fmtMs(v) {
27
+ if (v == null || !Number.isFinite(v)) return "—";
28
+ if (v < 1000) return `${v}ms`;
29
+ return `${(v / 1000).toFixed(1)}s`;
30
+ }
31
+
32
+ function fmtTps(v) {
33
+ if (v == null || !Number.isFinite(v)) return "—";
34
+ return `${v} tok/s`;
35
+ }
36
+
25
37
  function fmtStatus(entry) {
26
38
  if (!entry) return "normal";
27
39
  if (typeof entry === "number") return "error";
@@ -31,9 +43,16 @@ function fmtStatus(entry) {
31
43
  export function collectStats() {
32
44
  const gatewayModel = getPreferredModel();
33
45
  const latencies = loadModelLatencies();
46
+ const modelStats = loadModelStats();
34
47
  const errors = loadModelErrors();
35
48
  const picks = loadModelPicks();
36
49
  const port = getPort() ?? (Number(process.env.MSLXDFF_PORT) > 0 ? Number(process.env.MSLXDFF_PORT) : 8989);
50
+ const fullPref = normalizeFullId(CHAT_PREFERRED);
51
+ const fullFall = normalizeFullId(CHAT_FALLBACK);
52
+ const fullGate = normalizeFullId(gatewayModel);
53
+ const chatPrefStat = modelStats[fullPref] || modelStats[CHAT_PREFERRED] || null;
54
+ const chatFallStat = modelStats[fullFall] || modelStats[CHAT_FALLBACK] || null;
55
+ const gatewayStat = modelStats[fullGate] || modelStats[gatewayModel] || null;
37
56
  const chatPrefLat = latencies[CHAT_PREFERRED] || null;
38
57
  const chatFallLat = latencies[CHAT_FALLBACK] || null;
39
58
  const gatewayLat = latencies[gatewayModel] || null;
@@ -63,30 +82,45 @@ export function collectStats() {
63
82
  } catch {}
64
83
  const freeSet = new Set(freeIds);
65
84
 
66
- // per-model daemon detail (for /stats) — 只展示网关认识的 free 模型,避免测试假数据污染
67
- const allIds = Object.keys({ ...latencies, ...errors }).filter(Boolean);
85
+ // per-model daemon detail (for /stats) — 优先用 modelStats(全称),兼容旧 latencies
86
+ const statIds = Object.keys(modelStats);
87
+ const allIds = [...new Set([...Object.keys(latencies), ...Object.keys(errors), ...statIds])].filter(Boolean);
68
88
  const filteredIds = freeIds.length
69
- ? allIds.filter((id) => freeSet.has(id) || id === gatewayModel || id === CHAT_PREFERRED || id === CHAT_FALLBACK)
89
+ ? allIds.filter((id) => {
90
+ const full = normalizeFullId(id);
91
+ return freeSet.has(id) || freeSet.has(full) || id === gatewayModel || id === CHAT_PREFERRED || id === CHAT_FALLBACK || full === fullGate || full === fullPref || full === fullFall || statIds.includes(full);
92
+ })
70
93
  : allIds.filter((id) => !/^m-(one|two)-free$|^a-free$|^b-free$|^c-free$|^ghost-/.test(id) && !id.startsWith("test-"));
71
94
  const perModel = filteredIds
72
- .map((id) => ({
73
- id,
74
- lat: latencies[id] || null,
75
- status: fmtStatus(errors[id]),
76
- at: errors[id]?.at || latencies[id]?.at || 0,
77
- }))
78
- .sort((a, b) => (b.lat?.count || 0) - (a.lat?.count || 0) || (b.at - a.at));
95
+ .map((id) => {
96
+ const full = normalizeFullId(id);
97
+ const st = modelStats[full] || modelStats[id] || null;
98
+ const lat = latencies[id] || latencies[full] || null;
99
+ return {
100
+ id: st ? full : id,
101
+ fullId: full,
102
+ lat: lat || null,
103
+ st: st || null,
104
+ status: fmtStatus(errors[id] || errors[full]),
105
+ at: st?.lastAt || errors[id]?.at || errors[full]?.at || lat?.at || 0,
106
+ count: st?.count ?? lat?.count ?? 0,
107
+ };
108
+ })
109
+ .sort((a, b) => (b.count - a.count) || (b.at - a.at));
79
110
 
80
111
  return {
81
112
  gatewayModel,
82
113
  gatewayLat,
114
+ gatewayStat,
83
115
  chatPref: CHAT_PREFERRED,
84
116
  chatFall: CHAT_FALLBACK,
117
+ chatPrefStat,
118
+ chatFallStat,
85
119
  chatPrefLat,
86
120
  chatFallLat,
87
- chatPrefStatus: fmtStatus(errors[CHAT_PREFERRED]),
88
- chatFallStatus: fmtStatus(errors[CHAT_FALLBACK]),
89
- gatewayStatus: fmtStatus(errors[gatewayModel]),
121
+ chatPrefStatus: fmtStatus(errors[CHAT_PREFERRED] || errors[fullPref]),
122
+ chatFallStatus: fmtStatus(errors[CHAT_FALLBACK] || errors[fullFall]),
123
+ gatewayStatus: fmtStatus(errors[gatewayModel] || errors[fullGate]),
90
124
  picks,
91
125
  port,
92
126
  totalCalls,
@@ -101,6 +135,7 @@ export function collectStats() {
101
135
  healthUrl: `http://127.0.0.1:${port}/health`,
102
136
  endpointUrl: `http://127.0.0.1:${port}/v1`,
103
137
  latencies,
138
+ modelStats,
104
139
  errors,
105
140
  perModel,
106
141
  };
@@ -117,10 +152,13 @@ export function formatBannerLines() {
117
152
  lines.push(`${cyan}┌─ mslxdff chat · 数据来自 -d 网关进程(非本会话) ─────${rst}`);
118
153
  lines.push(`${cyan}│${rst} 对话模型 ${yellow}${s.chatPref}${rst} ${dim}→ ${s.chatFall}(自动降级)${rst} ${dim}[${s.chatPrefStatus}/${s.chatFallStatus}]${rst}`);
119
154
  lines.push(`${cyan}│${rst} 网关默认 ${green}${s.gatewayModel}${rst} ${dim}[${s.gatewayStatus}]${rst} · 端口 ${s.port} · ${dim}${s.endpointUrl}${rst}`);
120
- const prefLine = `mimo ${fmtLatency(s.chatPrefLat)}`;
121
- const fallLine = `pickle ${fmtLatency(s.chatFallLat)}`;
122
- const gateLine = s.gatewayModel !== s.chatPref && s.gatewayModel !== s.chatFall ? ` · 网关默认 ${fmtLatency(s.gatewayLat)}` : "";
123
- lines.push(`${cyan}│${rst} 网关延迟 ${prefLine} · ${fallLine}${gateLine}`);
155
+ const prefTtfb = s.chatPrefStat?.avgTtfbMs ?? s.chatPrefStat?.emaTtfbMs ?? s.chatPrefLat?.emaMs;
156
+ const fallTtfb = s.chatFallStat?.avgTtfbMs ?? s.chatFallStat?.emaTtfbMs ?? s.chatFallLat?.emaMs;
157
+ const gateTtfb = s.gatewayStat?.avgTtfbMs ?? s.gatewayStat?.emaTtfbMs ?? s.gatewayLat?.emaMs;
158
+ const prefLine = `mimo ${prefTtfb ? fmtMs(prefTtfb) + (s.chatPrefStat?.count ? `·${s.chatPrefStat.count}次` : "") : fmtLatency(s.chatPrefLat)}${s.chatPrefStat?.avgTps ? `·${fmtTps(s.chatPrefStat.avgTps)}` : ""}`;
159
+ const fallLine = `pickle ${fallTtfb ? fmtMs(fallTtfb) + (s.chatFallStat?.count ? `·${s.chatFallStat.count}次` : "") : fmtLatency(s.chatFallLat)}${s.chatFallStat?.avgTps ? `·${fmtTps(s.chatFallStat.avgTps)}` : ""}`;
160
+ const gateLine = s.gatewayModel !== s.chatPref && s.gatewayModel !== s.chatFall ? ` · 网关默认 ${gateTtfb ? fmtMs(gateTtfb) : fmtLatency(s.gatewayLat)}` : "";
161
+ lines.push(`${cyan}│${rst} 平均首字 ${prefLine} · ${fallLine}${gateLine}`);
124
162
  const errWhen = s.lastErr?.ts ? fmtShanghai(s.lastErr.ts) : "—";
125
163
  const errMsg = s.lastErr?.message ? String(s.lastErr.message).slice(0, 40) : (s.lastErr?.status ? `HTTP ${s.lastErr.status}` : "无");
126
164
  lines.push(`${cyan}│${rst} 网关请求 总 ${s.total} 成功 ${s.success} 失败 ${s.fail} ${dim}· 末错 ${errWhen} ${errMsg}${rst}`);
@@ -136,11 +174,13 @@ export function formatStatsDetail() {
136
174
  const s = collectStats();
137
175
  const dim = "\x1b[90m";
138
176
  const rst = "\x1b[0m";
139
- const cyan = "\x1b[36m";
140
177
  const out = [];
141
178
  out.push(`${dim}── 网关详细统计(-d 进程持久化数据) ──────────${rst}`);
142
- out.push(`网关默认: ${s.gatewayModel} [${s.gatewayStatus}] 延迟 ${fmtLatency(s.gatewayLat)} 端口 ${s.port}`);
143
- out.push(`对话模型: ${s.chatPref} [${s.chatPrefStatus}] ${fmtLatency(s.chatPrefLat)} · 兜底 ${s.chatFall} [${s.chatFallStatus}] ${fmtLatency(s.chatFallLat)}`);
179
+ const gateTtfb = s.gatewayStat ? fmtMs(s.gatewayStat.avgTtfbMs ?? s.gatewayStat.emaTtfbMs) : fmtLatency(s.gatewayLat);
180
+ const prefTtfb = s.chatPrefStat ? fmtMs(s.chatPrefStat.avgTtfbMs ?? s.chatPrefStat.emaTtfbMs) : fmtLatency(s.chatPrefLat);
181
+ const fallTtfb = s.chatFallStat ? fmtMs(s.chatFallStat.avgTtfbMs ?? s.chatFallStat.emaTtfbMs) : fmtLatency(s.chatFallLat);
182
+ out.push(`网关默认: ${s.gatewayModel} [${s.gatewayStatus}] 平均首字 ${gateTtfb} 端口 ${s.port}`);
183
+ out.push(`对话模型: ${s.chatPref} [${s.chatPrefStatus}] ${prefTtfb}${s.chatPrefStat?.avgTps ? ` · ${fmtTps(s.chatPrefStat.avgTps)}` : ""} · 兜底 ${s.chatFall} [${s.chatFallStatus}] ${fallTtfb}${s.chatFallStat?.avgTps ? ` · ${fmtTps(s.chatFallStat.avgTps)}` : ""}`);
144
184
  out.push(`健康: ${s.healthUrl} 端点: ${s.endpointUrl}`);
145
185
  out.push(`网关请求: 总 ${s.total} 成功 ${s.success} 失败 ${s.fail} ${dim}(calls.log + errors.log 持久化计数)${rst}`);
146
186
  if (s.lastErr) {
@@ -150,21 +190,34 @@ export function formatStatsDetail() {
150
190
  }
151
191
  out.push(`勾选集: ${s.picks.length ? s.picks.join(", ") : "(空=全量 auto)"} · 模型库: ${s.freeCount || 0} free 缓存: ${s.cachedAt ? fmtShanghai(s.cachedAt) : "—"}`);
152
192
  if (s.perModel.length) {
153
- out.push(`各模型网关统计(按成功次数排序):`);
154
- for (const r of s.perModel.slice(0, 10)) {
155
- const lat = r.lat ? `${r.lat.emaMs}ms·${r.lat.count}次` : "—";
156
- const at = r.at ? fmtShanghai(r.at) : "—";
157
- out.push(` ${r.id.padEnd(28)} ${r.status.padEnd(6)} ${lat.padEnd(16)} ${at}`);
193
+ out.push(`模型体检表(平均首字 / 平均总耗时 / 平均速度 / 啰嗦 / 样本,100次均值更稳):`);
194
+ out.push(` ${"模型".padEnd(30)} ${"首字".padEnd(8)} ${"总耗时".padEnd(8)} ${"速度".padEnd(12)} ${"啰嗦".padEnd(8)} ${"样本".padEnd(6)} 状态`);
195
+ for (const r of s.perModel.slice(0, 15)) {
196
+ const st = r.st;
197
+ const ttfb = st ? fmtMs(st.avgTtfbMs ?? st.emaTtfbMs) : (r.lat ? fmtMs(r.lat.emaMs) : "—");
198
+ const total = st ? fmtMs(st.avgTotalMs ?? st.emaTotalMs) : "—";
199
+ const tps = st ? fmtTps(st.avgTps ?? st.emaTps) : "—";
200
+ const verbose = st?.avgCompTok != null ? `${st.avgCompTok}tok` : "—";
201
+ const cnt = st?.count ?? r.lat?.count ?? 0;
202
+ const p95 = st?.p95Ttfb ? ` p95:${fmtMs(st.p95Ttfb)}` : "";
203
+ const line = ` ${r.id.padEnd(30)} ${ttfb.padEnd(8)} ${total.padEnd(8)} ${tps.padEnd(12)} ${verbose.padEnd(8)} ${String(cnt).padEnd(6)} ${r.status}${p95}`;
204
+ out.push(line);
158
205
  }
159
- if (s.perModel.length > 10) out.push(` … 还有 ${s.perModel.length - 10} 个模型`);
206
+ if (s.perModel.length > 15) out.push(` … 还有 ${s.perModel.length - 15} 个模型`);
207
+ if (!s.perModel.some((r) => r.st)) out.push(` ${dim}暂无新样本(新观测需发一次请求后出现),旧数据仅显示延迟 —${rst}`);
208
+ } else {
209
+ out.push(` ${dim}暂无样本,先用 mslxdff -chat 发一句,100次后均值更稳${rst}`);
160
210
  }
161
211
  if (s.recent.length) {
162
- out.push(`最近网关调用(calls.log 最近5条):`);
212
+ out.push(`最近网关调用(calls.log 最近5条,含首字/tps):`);
163
213
  for (const r of s.recent.slice(-5)) {
164
- out.push(` ${fmtShanghai(r.ts)} ${(r.model || "-").padEnd(22)} ${String(r.status || "-").padEnd(4)} ${r.durationMs ? r.durationMs + "ms" : ""} ${r.stream ? "stream" : ""}`);
214
+ const ttfb = r.ttfbMs != null ? ` 首字${r.ttfbMs}ms` : "";
215
+ const tps = r.tps != null ? ` ${r.tps}tok/s` : (r.charsPerSec ? ` ${r.charsPerSec}ch/s` : "");
216
+ const tok = r.usage?.completion_tokens != null ? ` tok${r.usage.completion_tokens}` : (r.chars ? ` ch${r.chars}` : "");
217
+ out.push(` ${fmtShanghai(r.ts)} ${(r.model || "-").padEnd(30)} ${String(r.status || "-").padEnd(4)} ${r.totalMs ? r.totalMs + "ms" : (r.durationMs ? r.durationMs + "ms" : "")}${ttfb}${tps}${tok} ${r.stream ? "stream" : ""}`);
165
218
  }
166
219
  }
167
- out.push(`${dim}提示:以上均为 -d 网关进程的持久化数据,非本 -chat 会话计数。看实时事件用 mslxdff -log 20${rst}`);
220
+ out.push(`${dim}提示:以上均为 -d 网关统计,-stats 展示为平均值(EMA0.3,100次窗口 p95),单次抖动已被平滑。看实时事件用 mslxdff -log 20${rst}`);
168
221
  out.push(`${dim}──────────────────────────────────────${rst}`);
169
222
  return out.join("\n");
170
223
  }
package/src/chat/tools.js CHANGED
@@ -50,11 +50,11 @@ export function getToolDefs() {
50
50
  type: "function",
51
51
  function: {
52
52
  name: "run_command",
53
- description: "执行一条 mslxdff CLI 命令(不含 mslxdff 前缀)。仅限 cli_help_mini 所列命令,禁止 -uninstall。",
53
+ description: "执行一条 mslxdff CLI 命令(不含 mslxdff 前缀)。仅限 cli_help_mini 所列命令,禁止 -uninstall;禁止 mslxdff \"hi\" --model X / --model X \"hi\" / -chat --model X 等幻觉命令,探活模型必须用 curl 工具 POST 本机 /v1/chat/completions。",
54
54
  parameters: {
55
55
  type: "object",
56
56
  properties: {
57
- command: { type: "string", description: "例如: -model set hy3-free 或 -group list 或 -log 20" },
57
+ command: { type: "string", description: "例如: -model set hy3-free 或 -group list 或 -log 20;模型探活禁止用此工具,必须用 curl POST http://localhost:8989/v1/chat/completions" },
58
58
  },
59
59
  required: ["command"],
60
60
  },
@@ -79,7 +79,7 @@ export function getToolDefs() {
79
79
  type: "function",
80
80
  function: {
81
81
  name: "curl",
82
- description: "网络/HTTP 探活,检测上游或本机服务可用性。支持任意 http(s) URL,返回状态码、耗时、响应头与前几千字符。常用: upstream(上游模型列表)、local/health(本机健康)、local/models(本机模型列表)。简写会自动补全为完整 URL。",
82
+ description: "网络/HTTP 探活,检测上游或本机服务可用性。支持任意 http(s) URL,返回状态码、耗时、响应头与前几千字符。常用: upstream(上游模型列表)、local/health(本机健康)、local/models(本机模型列表)。探活指定模型必须用 POST http://localhost:8989/v1/chat/completions body {\"model\":\"<provider/模型>\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}。简写会自动补全为完整 URL。",
83
83
  parameters: {
84
84
  type: "object",
85
85
  properties: {
@@ -0,0 +1,62 @@
1
+ const V2EX_LATEST = "https://www.v2ex.com/api/topics/latest.json";
2
+ const V2EX_HOT = "https://www.v2ex.com/api/topics/hot.json";
3
+
4
+ const INCLUDE_RE = /(白嫖|限免|免费额度|注册送|注册即送|羊毛|薅羊毛|免费\s*API|free\s*tier)/i;
5
+ const EXCLUDE_RE = /(代充|代购|倍率|0\.16|0\.1|0\.2|闲鱼|手续费|求职|物业|期望薪资)/i;
6
+
7
+ function isHit(title) {
8
+ const t = String(title || "");
9
+ if (!INCLUDE_RE.test(t)) return false;
10
+ if (EXCLUDE_RE.test(t)) return false;
11
+ return true;
12
+ }
13
+
14
+ async function fetchJson(url, timeoutMs = 6000) {
15
+ const ctrl = new AbortController();
16
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
17
+ try {
18
+ const res = await fetch(url, {
19
+ headers: { "User-Agent": "mslxdff/free-watcher", Accept: "application/json" },
20
+ signal: ctrl.signal,
21
+ });
22
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`);
23
+ return await res.json();
24
+ } finally {
25
+ clearTimeout(t);
26
+ }
27
+ }
28
+
29
+ export async function fetchV2exFree({ timeoutMs = 6000 } = {}) {
30
+ const [latest, hot] = await Promise.all([
31
+ fetchJson(V2EX_LATEST, timeoutMs).catch(() => []),
32
+ fetchJson(V2EX_HOT, timeoutMs).catch(() => []),
33
+ ]);
34
+ const all = [...(Array.isArray(latest) ? latest : []), ...(Array.isArray(hot) ? hot : [])];
35
+ const seen = new Set();
36
+ const hits = [];
37
+ for (const item of all) {
38
+ const id = item?.id;
39
+ if (!id || seen.has(id)) continue;
40
+ seen.add(id);
41
+ const title = item?.title || "";
42
+ if (!isHit(title)) continue;
43
+ hits.push({
44
+ id,
45
+ title,
46
+ url: `https://www.v2ex.com/t/${id}`,
47
+ node: item?.node?.title || item?.node?.name || "",
48
+ replies: item?.replies ?? 0,
49
+ created: item?.created || 0,
50
+ member: item?.member?.username || "",
51
+ });
52
+ }
53
+ hits.sort((a, b) => (b.created || 0) - (a.created || 0));
54
+ return hits;
55
+ }
56
+
57
+ export function formatHits(hits) {
58
+ if (!hits.length) return "暂无命中(关键词:白嫖|限免|免费额度|注册送|羊毛)";
59
+ return hits.map((h) => `- ${h.title} | ${h.url} | ${h.node} ${h.replies}回复`).join("\n");
60
+ }
61
+
62
+ export { INCLUDE_RE, EXCLUDE_RE, isHit };
@@ -1,5 +1,5 @@
1
1
  import { splitModelId, DEFAULT_PROVIDER, joinModelId } from "./model-id.js";
2
- import { isModelAllowed, loadProviderAllowedModels } from "../state.js";
2
+ import { isModelAllowed, loadProviderAllowedModels, loadProviderAllowAnyModels } from "../state.js";
3
3
 
4
4
  // 多供应商 dispatcher:把多个 Provider 聚合成一个 `upstream` 形状(chat/preheat/close),
5
5
  // 按 body.model 的前缀路由到对应供应商,转发上游前剥掉前缀只发原始 id。
@@ -59,7 +59,10 @@ export function createProviderDispatcher(providers = []) {
59
59
  list = [];
60
60
  }
61
61
  const allowed = loadProviderAllowedModels(p.id);
62
+ const allowAny = loadProviderAllowAnyModels(p.id);
62
63
  const allowedSet = allowed.length ? new Set(allowed) : null;
64
+ // 空名单且不允许任意模型 => 该供应商不暴露任何模型(安全默认)
65
+ if (!allowedSet && !allowAny) continue;
63
66
  for (const m of list) {
64
67
  if (!m || !m.id) continue;
65
68
  if (seen.has(m.id)) continue;