mslxdff 0.1.56 → 0.1.60
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 +502 -56
- package/package.json +2 -2
- package/src/auto.js +1 -1
- package/src/chat/prompt.js +2 -0
- package/src/chat/repl.js +62 -5
- 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/routes/chat/index.js +44 -2
- package/src/routes/stream.js +58 -1
- package/src/state.js +83 -7
- package/src/sync-opencode.js +152 -0
- 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.60",
|
|
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
|
@@ -53,6 +53,8 @@ ${mini}
|
|
|
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
|
- 查“某供应商有哪些模型”:优先直接用上方“可用模型”按前缀过滤回答(如 bai/ 开头的即 bai 供应商),无需调工具;如需实时刷新,调 curl local/models(GET,自动带本机 token)看网关聚合列表,或 curl https://api.b.ai/v1/models(自动带 key)看上游全量。禁止为此调用 -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 本帮助
|
|
@@ -61,6 +62,8 @@ async function runAgentTurn(userText, messages) {
|
|
|
61
62
|
let lastLatency = 0;
|
|
62
63
|
const t0 = performance.now();
|
|
63
64
|
const turnStart = performance.now();
|
|
65
|
+
// 同轮去重:同一工具+参数只真正执行一次,重复直接复用并提示 LLM
|
|
66
|
+
const seenCalls = new Map(); // key -> { count, firstResult }
|
|
64
67
|
trace(`[turn] 开始 "${userText.slice(0, 60)}${userText.length > 60 ? "…" : ""}" · 历史 ${messages.length}条 约 ${estimateChars(messages)}字`);
|
|
65
68
|
while (loops < CHAT_MAX_TOOL_LOOPS) {
|
|
66
69
|
const tLoop = performance.now();
|
|
@@ -116,18 +119,51 @@ async function runAgentTurn(userText, messages) {
|
|
|
116
119
|
let args = {};
|
|
117
120
|
try { args = JSON.parse(c.function?.arguments || "{}"); } catch {}
|
|
118
121
|
const t1 = performance.now();
|
|
122
|
+
// 归一化 key:run_command 按命令去重(大小写+空白归一),curl 按 url+method+body,read_file 按 path
|
|
123
|
+
let dedupKey = `${name}:${JSON.stringify(args)}`;
|
|
124
|
+
if (name === "run_command") {
|
|
125
|
+
const cmd = String(args.command || "").trim().toLowerCase().replace(/\s+/g, " ");
|
|
126
|
+
// -provider list 与 -providers list 等价,归一
|
|
127
|
+
const norm = cmd.replace(/^-+providers\b/, "-provider").replace(/\s+/g, " ").trim();
|
|
128
|
+
dedupKey = `run_command:${norm}`;
|
|
129
|
+
} else if (name === "curl") {
|
|
130
|
+
const u = String(args.url || "").trim().toLowerCase();
|
|
131
|
+
const m = String(args.method || "GET").toUpperCase();
|
|
132
|
+
dedupKey = `curl:${m}:${u}:${String(args.body || "").slice(0, 200)}`;
|
|
133
|
+
} else if (name === "read_file") {
|
|
134
|
+
dedupKey = `read_file:${String(args.path || "").trim().toLowerCase()}`;
|
|
135
|
+
}
|
|
136
|
+
const seen = seenCalls.get(dedupKey);
|
|
137
|
+
if (seen) {
|
|
138
|
+
const dt = Math.round(performance.now() - t1);
|
|
139
|
+
trace(`[tool] ${name} 重复调用已跳过 · ${dt}ms · 之前 ${seen.count} 次`);
|
|
140
|
+
console.log(`\x1b[33m→ 跳过重复: ${name} ${JSON.stringify(args).slice(0, 120)}(本轮已执行过)\x1b[0m`);
|
|
141
|
+
return {
|
|
142
|
+
id: c.id,
|
|
143
|
+
content: `SKIPPED_DUP: 此工具调用在本轮已执行过 ${seen.count} 次,结果相同请直接基于已有信息回答用户,不要再重复调用。\n--- 首次结果复用 ---\n${seen.firstResult.slice(0, 6000)}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
119
146
|
let result;
|
|
120
147
|
if (name === "run_command") {
|
|
121
148
|
const cmd = String(args.command || "").trim();
|
|
122
149
|
console.log(`\x1b[90m→ 执行: mslxdff ${cmd}\x1b[0m`);
|
|
123
150
|
const r = await execCommand(cmd);
|
|
124
151
|
result = `${r.ok ? "OK" : "FAIL"}: ${r.output}`;
|
|
152
|
+
// 查询类命令直接在结果里植入“立即回答”锚点,降低 LLM 再发一次的概率
|
|
153
|
+
const lowCmd = cmd.toLowerCase().replace(/\s+/g, " ").trim();
|
|
154
|
+
const isOnceAndDone =
|
|
155
|
+
/^-+(showtoken|status|s|providers?\b|model\b|group\b|log\b|workbuddy\b|free\b|autostart\b|plugins\b)/.test(lowCmd) ||
|
|
156
|
+
lowCmd === "-provider list" || lowCmd === "-providers list";
|
|
157
|
+
if (isOnceAndDone && r.ok) {
|
|
158
|
+
result += `\n\n[系统提示:此查询已完成,结果即答案,请直接用中文回答用户,禁止再调用相同或同类查询工具]`;
|
|
159
|
+
}
|
|
125
160
|
const dt = Math.round(performance.now() - t1);
|
|
126
161
|
trace(`[tool] run_command "${cmd.slice(0, 40)}" · ${dt}ms · ${r.ok ? "OK" : "FAIL"} ${r.output.length}字`);
|
|
127
162
|
console.log(r.ok ? `\x1b[32m${r.output.slice(0, 800)}\x1b[0m` : `\x1b[31m${r.output.slice(0, 800)}\x1b[0m`);
|
|
128
163
|
} else if (name === "read_file") {
|
|
129
164
|
const r = await readFileTool(args);
|
|
130
165
|
result = `${r.ok ? "OK" : "FAIL"}: ${r.output.slice(0, 6000)}`;
|
|
166
|
+
if (r.ok) result += `\n\n[系统提示:文件已读取,请直接基于内容回答,禁止重复读取同一文件]`;
|
|
131
167
|
const dt = Math.round(performance.now() - t1);
|
|
132
168
|
trace(`[tool] read_file ${args.path} · ${dt}ms · ${r.ok ? "OK" : "FAIL"} ${r.output.length}字`);
|
|
133
169
|
console.log(`\x1b[90m→ 读取: ${args.path} ${r.ok ? "OK" : "FAIL"}\x1b[0m`);
|
|
@@ -142,9 +178,18 @@ async function runAgentTurn(userText, messages) {
|
|
|
142
178
|
} else {
|
|
143
179
|
result = `unknown tool ${name}`;
|
|
144
180
|
}
|
|
181
|
+
// 记录首次结果供复用
|
|
182
|
+
if (!seenCalls.has(dedupKey)) seenCalls.set(dedupKey, { count: 1, firstResult: result });
|
|
183
|
+
else seenCalls.get(dedupKey).count++;
|
|
184
|
+
// 若本轮首次执行后已累积 2 次以上相同调用,下次 LLM 再试会直接命中上面的 SKIPPED_DUP
|
|
145
185
|
return { id: c.id, content: result };
|
|
146
186
|
}));
|
|
147
187
|
for (const tr of toolResults) messages.push({ role: "tool", tool_call_id: tr.id, content: tr.content });
|
|
188
|
+
// 若本轮有 SKIPPED_DUP,额外追加一条系统提示,强制 LLM 基于已有结果回答而不是再调工具
|
|
189
|
+
if (toolResults.some((tr) => String(tr.content).startsWith("SKIPPED_DUP"))) {
|
|
190
|
+
messages.push({ role: "system", content: "系统提示:你已重复调用相同工具,工具侧已复用首次结果并跳过执行。请直接基于以上工具结果用中文回答用户,不要再发起任何工具调用。" });
|
|
191
|
+
trace(`[dup] 检测到重复调用,已注入系统提示要求直接回答`);
|
|
192
|
+
}
|
|
148
193
|
const toolsMs = Math.round(performance.now() - tTools);
|
|
149
194
|
const loopMs = Math.round(performance.now() - tLoop);
|
|
150
195
|
trace(`[loop ${loops}] 工具 ${toolsMs}ms · 本轮总 ${loopMs}ms · 累计 ${Math.round(performance.now() - turnStart)}ms`);
|
|
@@ -164,13 +209,25 @@ function printFooter({ model, latency, usage, totalMs, fallback }) {
|
|
|
164
209
|
const tokLabel = usage ? ` · tokens ${usage.prompt_tokens ?? "?"}→${usage.completion_tokens ?? "?"}` : "";
|
|
165
210
|
const fbLabel = fallback ? " · fallback" : "";
|
|
166
211
|
let gwLabel = "";
|
|
212
|
+
let extra = "";
|
|
167
213
|
if (gw) {
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
|
|
214
|
+
const full = (() => { try { return normalizeFullId(model); } catch { return model; } })();
|
|
215
|
+
const stat = gw.modelStats?.[full] || gw.modelStats?.[model] || null;
|
|
216
|
+
const cnt = stat?.count ?? gw.latencies?.[model]?.count;
|
|
217
|
+
const avgTtfb = stat?.avgTtfbMs ?? stat?.emaTtfbMs;
|
|
218
|
+
const avgTps = stat?.avgTps ?? stat?.emaTps;
|
|
219
|
+
const per = cnt ? ` · 网关该模型 ${cnt}次` : "";
|
|
220
|
+
const ttfbLabel = avgTtfb ? ` 平均首字 ${avgTtfb}ms` : (gw.latencies?.[model]?.emaMs ? ` EMA ${gw.latencies[model].emaMs}ms` : "");
|
|
221
|
+
const tpsLabel = avgTps ? ` · ${avgTps} tok/s` : "";
|
|
222
|
+
const verbose = stat?.avgCompTok ? ` · 啰嗦 ${stat.avgCompTok}tok/次` : "";
|
|
223
|
+
gwLabel = ` · 网关 总${gw.total} 成功${gw.success} 失败${gw.fail}${per}${ttfbLabel}${tpsLabel}${verbose}`;
|
|
224
|
+
// 本次首字/tps(若本次有 usage,估算本次 tps)
|
|
225
|
+
if (usage?.completion_tokens && latency) {
|
|
226
|
+
const tpsNow = Math.round(usage.completion_tokens / (latency / 1000));
|
|
227
|
+
if (Number.isFinite(tpsNow) && tpsNow > 0) extra = ` · 本次首字 ~${latency}ms · ${tpsNow} tok/s`;
|
|
228
|
+
}
|
|
172
229
|
}
|
|
173
|
-
console.log(`${dim}─ ${modelLabel} ${latLabel}${totalLabel}${tokLabel}${fbLabel}${gwLabel}${rst}`);
|
|
230
|
+
console.log(`${dim}─ ${modelLabel} ${latLabel}${totalLabel}${tokLabel}${fbLabel}${gwLabel}${extra}${rst}`);
|
|
174
231
|
}
|
|
175
232
|
|
|
176
233
|
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;
|
|
@@ -1,10 +1,77 @@
|
|
|
1
1
|
// 模型 ID 前缀解析:`<provider>/<rawId>`。默认供应商(opencode)保持裸 id(向后兼容),
|
|
2
2
|
// 其它供应商必须带前缀,请求按前缀路由到对应上游通道。
|
|
3
|
+
//
|
|
4
|
+
// WorkBuddy 别名系统:
|
|
5
|
+
// WorkBuddy 不支持模型 ID 中的 /,写入 models.json 时替换为 -
|
|
6
|
+
// mslxdff 在收到请求时通过固定映射表还原为 / 格式
|
|
7
|
+
// 映射表持久化在 ~/.config/mslxdff/model-aliases.json
|
|
8
|
+
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import os from "node:os";
|
|
3
12
|
|
|
4
13
|
export const DEFAULT_PROVIDER = "opencode";
|
|
5
14
|
|
|
6
15
|
const ALIASES = { oc: "opencode", opencode: "opencode" };
|
|
7
16
|
|
|
17
|
+
// WorkBuddy 别名映射(alias → canonical):clinebot-z-ai-glm-5.3-flash → clinebot/z-ai/glm-5.3-flash
|
|
18
|
+
const _modelAliases = new Map();
|
|
19
|
+
|
|
20
|
+
function aliasFile() {
|
|
21
|
+
const env = process.env.MSLXDFF_ALIASES_FILE;
|
|
22
|
+
if (typeof env === "string" && env.trim()) return env.trim();
|
|
23
|
+
return join(os.homedir(), ".config", "mslxdff", "model-aliases.json");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function loadModelAliases(file) {
|
|
27
|
+
const fp = file || aliasFile();
|
|
28
|
+
try {
|
|
29
|
+
const raw = JSON.parse(readFileSync(fp, "utf8"));
|
|
30
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
31
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
32
|
+
if (typeof v === "string" && v) _modelAliases.set(String(k), v);
|
|
33
|
+
}
|
|
34
|
+
_aliasLoaded = true;
|
|
35
|
+
}
|
|
36
|
+
} catch {}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function registerModelAlias(alias, canonical) {
|
|
40
|
+
const a = String(alias || "").trim();
|
|
41
|
+
const c = String(canonical || "").trim();
|
|
42
|
+
if (!a || !c || a === c) return;
|
|
43
|
+
_modelAliases.set(a, c);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function persistModelAliases(file) {
|
|
47
|
+
const fp = file || aliasFile();
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(dirname(fp), { recursive: true });
|
|
50
|
+
const obj = Object.fromEntries(_modelAliases);
|
|
51
|
+
writeFileSync(fp, JSON.stringify(obj, null, 2), "utf8");
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let _aliasLoaded = false;
|
|
56
|
+
export function getModelAlias(id) {
|
|
57
|
+
const key = String(id || "").trim();
|
|
58
|
+
let hit = _modelAliases.get(key);
|
|
59
|
+
if (hit) return hit;
|
|
60
|
+
// 未命中时尝试加载(支持 daemon 运行期间由 -setto 进程写入的新 alias)
|
|
61
|
+
try { loadModelAliases(); } catch {}
|
|
62
|
+
return _modelAliases.get(key) || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 反向查询:canonical id → alias(如 clinebot/z-ai/glm-5.3-flash → clinebot-z-ai-glm-5.3-flash)
|
|
66
|
+
export function getAliasForModel(canonicalId) {
|
|
67
|
+
const c = String(canonicalId || "").trim();
|
|
68
|
+
if (!_aliasLoaded) { try { loadModelAliases(); } catch {} }
|
|
69
|
+
for (const [alias, target] of _modelAliases) {
|
|
70
|
+
if (target === c) return alias;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
8
75
|
export function normalizeProviderId(p) {
|
|
9
76
|
const id = String(p || "").trim();
|
|
10
77
|
return ALIASES[id] || id;
|
|
@@ -33,4 +100,35 @@ export function joinModelId(provider, raw, { force = false } = {}) {
|
|
|
33
100
|
raw = String(raw || "");
|
|
34
101
|
if (provider === DEFAULT_PROVIDER && !force) return raw;
|
|
35
102
|
return `${provider}/${raw}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 观测用全称:始终带前缀(opencode 也带),用于 modelStats / calls.log 聚合主键
|
|
106
|
+
export function toFullId(provider, raw) {
|
|
107
|
+
provider = normalizeProviderId(provider || "");
|
|
108
|
+
raw = String(raw || "").trim();
|
|
109
|
+
if (!raw) return "";
|
|
110
|
+
if (!provider || provider === DEFAULT_PROVIDER) return `${DEFAULT_PROVIDER}/${raw}`;
|
|
111
|
+
return `${provider}/${raw}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 归一任意输入为全称:裸 id -> opencode/xxx,已带前缀保持不变(大小写保留,provider 归一)
|
|
115
|
+
// 优先查 WorkBuddy 别名表(固定映射),命中直接还原为 / 格式
|
|
116
|
+
export function normalizeFullId(id, knownProviders = []) {
|
|
117
|
+
const s = String(id || "").trim();
|
|
118
|
+
if (!s) return "";
|
|
119
|
+
// 1. 查别名表(clinebot-z-ai-glm-5.3-flash → clinebot/z-ai/glm-5.3-flash)
|
|
120
|
+
const aliased = getModelAlias(s);
|
|
121
|
+
if (aliased) return aliased;
|
|
122
|
+
// 2. 已含 /,走原有逻辑
|
|
123
|
+
const idx = s.indexOf("/");
|
|
124
|
+
if (idx > 0) {
|
|
125
|
+
const head = normalizeProviderId(s.slice(0, idx));
|
|
126
|
+
if (knownProviders.length) {
|
|
127
|
+
const known = new Set([DEFAULT_PROVIDER, ...knownProviders.map(normalizeProviderId)]);
|
|
128
|
+
if (known.has(head)) return `${head}/${s.slice(idx + 1)}`;
|
|
129
|
+
return `${DEFAULT_PROVIDER}/${s}`;
|
|
130
|
+
}
|
|
131
|
+
return `${head}/${s.slice(idx + 1)}`;
|
|
132
|
+
}
|
|
133
|
+
return `${DEFAULT_PROVIDER}/${s}`;
|
|
36
134
|
}
|