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/bin/mslxdff.js +427 -53
- package/package.json +2 -2
- package/src/auto.js +1 -1
- package/src/chat/prompt.js +3 -1
- package/src/chat/repl.js +131 -7
- package/src/chat/stats.js +83 -30
- package/src/chat/tools.js +3 -3
- package/src/free-watcher.js +62 -0
- package/src/providers/dispatcher.js +4 -1
- package/src/providers/model-id.js +98 -0
- package/src/providers/workbuddy.js +11 -2
- package/src/routes/chat/index.js +9 -2
- package/src/routes/stream.js +58 -1
- package/src/state.js +93 -7
- package/src/sync-workbuddy.js +38 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mslxdff",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
package/src/chat/prompt.js
CHANGED
|
@@ -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
|
-
-
|
|
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
|
-
|
|
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
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
|
|
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) —
|
|
67
|
-
const
|
|
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) =>
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
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 >
|
|
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
|
-
|
|
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
|
|
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;
|