mslxdff 0.1.55 → 0.1.56

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.
Files changed (39) hide show
  1. package/README.md +1 -160
  2. package/bin/mslxdff.js +666 -26
  3. package/package.json +3 -2
  4. package/src/chat/config.js +10 -0
  5. package/src/chat/index.js +6 -0
  6. package/src/chat/prompt.js +63 -0
  7. package/src/chat/repl.js +245 -0
  8. package/src/chat/spinner.js +38 -0
  9. package/src/chat/stats.js +170 -0
  10. package/src/chat/store.js +48 -0
  11. package/src/chat/tools.js +304 -0
  12. package/src/chat/upstream.js +90 -0
  13. package/src/models.js +90 -2
  14. package/src/providers/dispatcher.js +100 -0
  15. package/src/providers/generic.js +221 -0
  16. package/src/providers/index.js +6 -0
  17. package/src/providers/keyring.js +38 -0
  18. package/src/providers/model-id.js +36 -0
  19. package/src/providers/opencode.js +20 -0
  20. package/src/providers/openrouter.js +217 -0
  21. package/src/providers/share-keys.js +53 -0
  22. package/src/providers/workbuddy-balance.js +76 -0
  23. package/src/providers/workbuddy.js +563 -0
  24. package/src/routes/chat/index.js +20 -1
  25. package/src/routes/peers.js +12 -7
  26. package/src/routes/stream.js +9 -0
  27. package/src/state.js +299 -4
  28. package/src/time.js +73 -0
  29. package/src/upstream.js +126 -8
  30. package/docs/adr/0001-reasoning-content-injection.md +0 -14
  31. package/docs/adr/0002-models-free-filter.md +0 -12
  32. package/docs/adr/0003-zero-state-no-auth.md +0 -10
  33. package/docs/adr/0004-bearer-token.md +0 -18
  34. package/docs/adr/0005-peer-mesh.md +0 -53
  35. package/docs/adr/0006-broadband-member.md +0 -103
  36. package/docs/agents/domain.md +0 -51
  37. package/docs/agents/issue-tracker.md +0 -30
  38. package/docs/agents/triage-labels.md +0 -15
  39. package/docs/plugins.md +0 -185
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.55",
3
+ "version": "0.1.56",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/mslxdff.js",
11
- "test": "node --test test/*.test.js"
11
+ "test": "node --test test/*.test.js",
12
+ "docs:check": "node scripts/docs-check.js"
12
13
  },
13
14
  "engines": {
14
15
  "node": ">=20"
@@ -0,0 +1,10 @@
1
+ export const CHAT_PREFERRED = "mimo-v2.5-free";
2
+ export const CHAT_FALLBACK = "big-pickle";
3
+ // 128k 上下文 *95% ≈ 121.6k tokens,按 ~3.3 字符/token 估算 ≈ 400k 字符
4
+ // 接近 95% 再压缩,避免频繁摘要
5
+ export const CHAT_HISTORY_MAX_CHARS = 400000;
6
+ export const CHAT_KEEP_RECENT = 40;
7
+ export const CHAT_SUMMARY_TRIGGER = 400000;
8
+ export const CHAT_MAX_TOOL_LOOPS = 6;
9
+ export const CHAT_TIMEOUT_MS = 30000;
10
+ export const FORBIDDEN = ["-uninstall", "--uninstall"];
@@ -0,0 +1,6 @@
1
+ import { startRepl } from "./repl.js";
2
+
3
+ export async function startChat({ singleShot } = {}) {
4
+ // 独立进程:本函数在用户终端前台运行,与 daemon 无父子关系,daemon 重启不影响
5
+ await startRepl({ singleShot });
6
+ }
@@ -0,0 +1,63 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { logDir } from "../logs.js";
5
+ import { nowShanghaiYMDHM } from "../time.js";
6
+
7
+ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
8
+
9
+ function readMini() {
10
+ try {
11
+ const p = join(pkgRoot, "docs/cli_help_mini.md");
12
+ return readFileSync(p, "utf8");
13
+ } catch { return ""; }
14
+ }
15
+
16
+ function readModels() {
17
+ try {
18
+ const cache = join(logDir(), "models.json");
19
+ if (existsSync(cache)) {
20
+ const j = JSON.parse(readFileSync(cache, "utf8"));
21
+ const ids = (j.data || []).map((m) => m.id).filter(Boolean);
22
+ if (ids.length) return ids;
23
+ }
24
+ } catch {}
25
+ // 兜底:硬编码常见 free 模型,供离线时参考
26
+ return ["mimo-v2.5-free", "big-pickle", "deepseek-v4-flash-free", "hy3-free", "laguna-free", "kimi-k2-free", "nemotron-3-nano-free", "big-pickle"];
27
+ }
28
+
29
+ export function buildSystemPrompt({ modelsOverride } = {}) {
30
+ const mini = readMini();
31
+ const models = modelsOverride || readModels();
32
+ const now = nowShanghaiYMDHM();
33
+ // 按供应商分组,便于“bai有哪些模型”这类问题直接回答
34
+ const byProv = {};
35
+ for (const id of models) {
36
+ const slash = id.indexOf("/");
37
+ const prov = slash > 0 ? id.slice(0, slash) : "opencode";
38
+ if (!byProv[prov]) byProv[prov] = [];
39
+ byProv[prov].push(id);
40
+ }
41
+ const provSummary = Object.entries(byProv).map(([p, arr]) => `${p}(${arr.length}): ${arr.slice(0, 12).join(", ")}${arr.length > 12 ? " …" : ""}`).join(" | ");
42
+ return `你是 mslxdff 的终端助手,运行在用户本机,帮用户把自然语言翻译成精确的 mslxdff CLI 命令并执行。
43
+
44
+ 当前时间:${now}
45
+ 可用模型(你必须从中精确选择,禁止自创,共 ${models.length} 个):${models.join(", ")}
46
+ 按供应商:${provSummary}
47
+
48
+ ${mini}
49
+
50
+ 规则:
51
+ - 用户说简称你必须自行查“可用模型”找到全称,例如 hy3→hy3-free,mimo→mimo-v2.5-free,bigpickle→big-pickle。
52
+ - 永远输出精确的命令与模型 id,大小写敏感。
53
+ - 需要执行命令时调用 run_command,需要看文件时调用 read_file,需要检查网络/服务可用性时调用 curl。
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 已自动注入)。
56
+ - 禁止调用 -uninstall,包含即拒绝;-showtoken 仅在用户明确要求查看/调试本机 token 时才用,查模型/查供应商严禁调用。
57
+ - 回复用中文,简洁友好,执行前后说明你在做什么。
58
+ - 若用户只是闲聊/提问且可用模型列表已能回答,不调工具,直接回答。`;
59
+ }
60
+
61
+ export function getModelsForPrompt() {
62
+ return readModels();
63
+ }
@@ -0,0 +1,245 @@
1
+ import readline from "node:readline/promises";
2
+ import { stdin, stdout } from "node:process";
3
+ import { performance } from "node:perf_hooks";
4
+ import { buildSystemPrompt, getModelsForPrompt } from "./prompt.js";
5
+ import { getToolDefs, execCommand, readFileTool, curlTool } from "./tools.js";
6
+ import { chatWithFallback, summarizeHistory } from "./upstream.js";
7
+ import { loadHistory, saveHistory, clearHistory, histPath, estimateChars, needsCompress } from "./store.js";
8
+ import { CHAT_KEEP_RECENT, CHAT_MAX_TOOL_LOOPS, CHAT_PREFERRED, CHAT_FALLBACK } from "./config.js";
9
+ import { formatBannerLines, formatStatsDetail, collectStats } from "./stats.js";
10
+ import { createSpinner } from "./spinner.js";
11
+
12
+ const SLASH_HELP = `自然语言直接说,斜杠快捷:
13
+ /help 本帮助
14
+ /stats 详细统计(网关 -d 的请求/延迟/模型)
15
+ /history 查看对话历史
16
+ /clear 清空历史
17
+ /exit 退出
18
+ 示例:设置 hy3 为默认模型 / 查看组列表 / 看最近20条日志 / 读一下 src/logs.js`;
19
+
20
+ function printBanner() {
21
+ const { lines } = formatBannerLines();
22
+ for (const l of lines) console.log(l);
23
+ console.log(`\x1b[90m输入自然语言即可执行;/help 帮助,/stats 看网关统计,/exit 退出\x1b[0m`);
24
+ console.log(`\x1b[90m历史:${histPath()} · 仅拦截 -uninstall · 数据来自网关 -d,非本会话计数\x1b[0m`);
25
+ }
26
+
27
+ function trace(line) {
28
+ if (process.env.MSLXDFF_CHAT_TRACE === "0") return;
29
+ console.log(`\x1b[90m· ${line}\x1b[0m`);
30
+ }
31
+
32
+ async function maybeCompress(messages) {
33
+ if (!needsCompress(messages)) return [...messages];
34
+ const sys = messages[0];
35
+ const rest = messages.slice(1);
36
+ if (rest.length <= CHAT_KEEP_RECENT + 2) return [...messages];
37
+ const t0 = performance.now();
38
+ const toSummarize = rest.slice(0, -CHAT_KEEP_RECENT);
39
+ const keep = rest.slice(-CHAT_KEEP_RECENT);
40
+ const chars = estimateChars(messages);
41
+ trace(`[压缩] 触发 ${chars}字 > ${400000}阈值 · 待压 ${toSummarize.length}条 保留 ${keep.length}条`);
42
+ const summary = await summarizeHistory(toSummarize);
43
+ const dt = Math.round(performance.now() - t0);
44
+ if (!summary) {
45
+ trace(`[压缩] 失败/空 · ${dt}ms → 截断`);
46
+ return [sys, ...keep];
47
+ }
48
+ const summaryMsg = { role: "system", content: summary };
49
+ const next = [sys, summaryMsg, ...keep];
50
+ trace(`[压缩] 完成 ${toSummarize.length}条→${summary.length}字 · ${dt}ms · 新总量约 ${estimateChars(next)}字`);
51
+ return next;
52
+ }
53
+
54
+ async function runAgentTurn(userText, messages) {
55
+ const tools = getToolDefs();
56
+ messages.push({ role: "user", content: userText });
57
+ let loops = 0;
58
+ let lastModel = null;
59
+ let lastUsage = null;
60
+ let lastFallback = false;
61
+ let lastLatency = 0;
62
+ const t0 = performance.now();
63
+ const turnStart = performance.now();
64
+ trace(`[turn] 开始 "${userText.slice(0, 60)}${userText.length > 60 ? "…" : ""}" · 历史 ${messages.length}条 约 ${estimateChars(messages)}字`);
65
+ while (loops < CHAT_MAX_TOOL_LOOPS) {
66
+ const tLoop = performance.now();
67
+ const tComp = performance.now();
68
+ const cur = await maybeCompress(messages);
69
+ const compressMs = Math.round(performance.now() - tComp);
70
+ if (compressMs > 50) trace(`[loop ${loops}] 压缩耗时 ${compressMs}ms`);
71
+ messages.length = 0;
72
+ for (const m of cur) messages.push(m);
73
+ const tCall = performance.now();
74
+ const spinnerLabel = loops === 0 ? "已发送给 AI,等待回复中" : "AI 正在整理回复中";
75
+ const spinner = createSpinner(spinnerLabel);
76
+ spinner.start();
77
+ let res;
78
+ try {
79
+ res = await chatWithFallback({ messages, tools });
80
+ } finally {
81
+ const ms = Math.round(performance.now() - tCall);
82
+ spinner.stop(`\x1b[90m✓ AI 已回复 · ${ms}ms\x1b[0m`);
83
+ }
84
+ const llmMs = Math.round(performance.now() - tCall);
85
+ trace(`[loop ${loops}] LLM ${llmMs}ms${compressMs > 50 ? ` (含压缩 ${compressMs}ms)` : ""} · ${estimateChars(messages)}字上下文`);
86
+ lastLatency = llmMs;
87
+ if (!res.ok) {
88
+ const err = `大模型暂不可用:${res.error}`;
89
+ messages.push({ role: "assistant", content: err });
90
+ return { text: err, model: null, latency: lastLatency, usage: null, fallback: false, ok: false };
91
+ }
92
+ lastModel = res.model;
93
+ lastUsage = res.usage || null;
94
+ lastFallback = !!res.fallback;
95
+ const msg = res.message;
96
+ const toolCalls = msg.tool_calls || [];
97
+ let fallbackCmd = null;
98
+ if (!toolCalls.length && msg.content) {
99
+ const m = String(msg.content).match(/\{[^}]*"command"\s*:\s*"([^"]+)"[^}]*\}/);
100
+ if (m) fallbackCmd = m[1];
101
+ }
102
+ if (!toolCalls.length && !fallbackCmd) {
103
+ const text = String(msg.content || "").trim() || "(空回复)";
104
+ messages.push({ role: "assistant", content: text });
105
+ const totalMs = Math.round(performance.now() - t0);
106
+ const note = lastFallback ? "\n\x1b[90m[注:mimo 不可用,已用 big-pickle]\x1b[0m" : "";
107
+ trace(`[turn] 完成 总计 ${totalMs}ms · LLM ${lastLatency}ms · 0 工具`);
108
+ return { text: text + note, model: lastModel, latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: true, totalMs };
109
+ }
110
+ const calls = toolCalls.length ? toolCalls : [{ id: "fallback-1", function: { name: "run_command", arguments: JSON.stringify({ command: fallbackCmd }) } }];
111
+ messages.push({ role: "assistant", content: msg.content || "", tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: c.function })) });
112
+ trace(`[tools] 本轮 ${calls.length} 个调用 ${calls.map((c) => c.function?.name).join(",")} · 并发执行`);
113
+ const tTools = performance.now();
114
+ const toolResults = await Promise.all(calls.map(async (c) => {
115
+ const name = c.function?.name;
116
+ let args = {};
117
+ try { args = JSON.parse(c.function?.arguments || "{}"); } catch {}
118
+ const t1 = performance.now();
119
+ let result;
120
+ if (name === "run_command") {
121
+ const cmd = String(args.command || "").trim();
122
+ console.log(`\x1b[90m→ 执行: mslxdff ${cmd}\x1b[0m`);
123
+ const r = await execCommand(cmd);
124
+ result = `${r.ok ? "OK" : "FAIL"}: ${r.output}`;
125
+ const dt = Math.round(performance.now() - t1);
126
+ trace(`[tool] run_command "${cmd.slice(0, 40)}" · ${dt}ms · ${r.ok ? "OK" : "FAIL"} ${r.output.length}字`);
127
+ console.log(r.ok ? `\x1b[32m${r.output.slice(0, 800)}\x1b[0m` : `\x1b[31m${r.output.slice(0, 800)}\x1b[0m`);
128
+ } else if (name === "read_file") {
129
+ const r = await readFileTool(args);
130
+ result = `${r.ok ? "OK" : "FAIL"}: ${r.output.slice(0, 6000)}`;
131
+ const dt = Math.round(performance.now() - t1);
132
+ trace(`[tool] read_file ${args.path} · ${dt}ms · ${r.ok ? "OK" : "FAIL"} ${r.output.length}字`);
133
+ console.log(`\x1b[90m→ 读取: ${args.path} ${r.ok ? "OK" : "FAIL"}\x1b[0m`);
134
+ } else if (name === "curl") {
135
+ const u = String(args.url || "").trim();
136
+ console.log(`\x1b[90m→ 探活: ${u} ${args.method || "GET"}\x1b[0m`);
137
+ const r = await curlTool(args);
138
+ result = `${r.ok ? "OK" : "FAIL"}: ${r.output.slice(0, 6000)}`;
139
+ const dt = Math.round(performance.now() - t1);
140
+ trace(`[tool] curl ${u} · ${dt}ms`);
141
+ console.log(r.ok ? `\x1b[32m${r.output.slice(0, 800)}\x1b[0m` : `\x1b[31m${r.output.slice(0, 800)}\x1b[0m`);
142
+ } else {
143
+ result = `unknown tool ${name}`;
144
+ }
145
+ return { id: c.id, content: result };
146
+ }));
147
+ for (const tr of toolResults) messages.push({ role: "tool", tool_call_id: tr.id, content: tr.content });
148
+ const toolsMs = Math.round(performance.now() - tTools);
149
+ const loopMs = Math.round(performance.now() - tLoop);
150
+ trace(`[loop ${loops}] 工具 ${toolsMs}ms · 本轮总 ${loopMs}ms · 累计 ${Math.round(performance.now() - turnStart)}ms`);
151
+ loops++;
152
+ }
153
+ return { text: "(工具调用次数已达上限,已停止)", model: lastModel, latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: false };
154
+ }
155
+
156
+ function printFooter({ model, latency, usage, totalMs, fallback }) {
157
+ const dim = "\x1b[90m";
158
+ const rst = "\x1b[0m";
159
+ let gw = null;
160
+ try { gw = collectStats(); } catch {}
161
+ const modelLabel = model || "—";
162
+ const latLabel = latency ? `${latency}ms` : "—";
163
+ const totalLabel = totalMs ? ` · 总耗时 ${totalMs}ms` : "";
164
+ const tokLabel = usage ? ` · tokens ${usage.prompt_tokens ?? "?"}→${usage.completion_tokens ?? "?"}` : "";
165
+ const fbLabel = fallback ? " · fallback" : "";
166
+ let gwLabel = "";
167
+ 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}`;
172
+ }
173
+ console.log(`${dim}─ ${modelLabel} ${latLabel}${totalLabel}${tokLabel}${fbLabel}${gwLabel}${rst}`);
174
+ }
175
+
176
+ export async function startRepl({ singleShot } = {}) {
177
+ const models = getModelsForPrompt();
178
+ const system = buildSystemPrompt({ modelsOverride: models });
179
+ let messages = [{ role: "system", content: system }];
180
+ const hist = loadHistory();
181
+ if (hist.length) {
182
+ for (const h of hist) messages.push(h);
183
+ console.log(`\x1b[90m[恢复] 已载入 ${hist.length} 条历史\x1b[0m`);
184
+ }
185
+ if (singleShot) {
186
+ const text = String(singleShot).trim();
187
+ if (!text) return;
188
+ const r = await runAgentTurn(text, messages);
189
+ console.log(r.text);
190
+ if (r.model) printFooter(r);
191
+ saveHistory(messages.slice(1));
192
+ return;
193
+ }
194
+ printBanner();
195
+ const rl = readline.createInterface({ input: stdin, output: stdout, prompt: `\x1b[36m${CHAT_PREFERRED.split("-")[0]}>\x1b[0m ` });
196
+ rl.prompt();
197
+ for await (const line of rl) {
198
+ const raw = String(line || "").trim();
199
+ if (!raw) { rl.prompt(); continue; }
200
+ const low = raw.toLowerCase();
201
+ if (["/exit", "/quit", "exit", "quit", "退出"].includes(low)) {
202
+ console.log("再见");
203
+ saveHistory(messages.slice(1));
204
+ rl.close();
205
+ return;
206
+ }
207
+ if (["/help", "help", "/h", "?"].includes(low)) {
208
+ console.log(SLASH_HELP);
209
+ rl.prompt();
210
+ continue;
211
+ }
212
+ if (["/stats", "/status", "stats", "status"].includes(low)) {
213
+ console.log(formatStatsDetail());
214
+ rl.prompt();
215
+ continue;
216
+ }
217
+ if (["/clear", "clear"].includes(low)) {
218
+ clearHistory();
219
+ messages = [{ role: "system", content: system }];
220
+ console.log("\x1b[90m[已清空历史]\x1b[0m");
221
+ rl.prompt();
222
+ continue;
223
+ }
224
+ if (["/history"].includes(low)) {
225
+ console.log(`\x1b[90m历史 ${messages.length - 1} 条,约 ${estimateChars(messages)} 字符 · ${histPath()}\x1b[0m`);
226
+ for (const m of messages.slice(1).slice(-10)) console.log(`- ${m.role}: ${(m.content || "").slice(0, 120)}`);
227
+ rl.prompt();
228
+ continue;
229
+ }
230
+ try {
231
+ const r = await runAgentTurn(raw, messages);
232
+ if (r.text && !r.text.startsWith("OK") && !r.text.startsWith("FAIL")) console.log(r.text);
233
+ if (r.model || r.latency) printFooter(r);
234
+ } catch (e) {
235
+ console.log(`\x1b[31m[错误] ${String(e.message || e).slice(0, 800)}\x1b[0m`);
236
+ messages.push({ role: "assistant", content: `error: ${String(e.message || e)}` });
237
+ }
238
+ saveHistory(messages.slice(1));
239
+ if (needsCompress(messages)) {
240
+ messages = await maybeCompress(messages);
241
+ saveHistory(messages.slice(1));
242
+ }
243
+ rl.prompt();
244
+ }
245
+ }
@@ -0,0 +1,38 @@
1
+ import { stdout } from "node:process";
2
+ import { performance } from "node:perf_hooks";
3
+
4
+ export function createSpinner(label) {
5
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
+ let i = 0;
7
+ let t0 = performance.now();
8
+ let timer = null;
9
+ let active = false;
10
+ const isTTY = stdout.isTTY;
11
+ function start() {
12
+ if (active) return;
13
+ active = true;
14
+ t0 = performance.now();
15
+ if (!isTTY) {
16
+ console.log(`\x1b[90m${label}…\x1b[0m`);
17
+ return;
18
+ }
19
+ timer = setInterval(() => {
20
+ const sec = ((performance.now() - t0) / 1000).toFixed(1);
21
+ const f = frames[i++ % frames.length];
22
+ stdout.write(`\r\x1b[90m${f} ${label}… ${sec}s\x1b[0m`);
23
+ }, 180);
24
+ timer.unref?.();
25
+ }
26
+ function stop(finalMsg) {
27
+ if (!active) return;
28
+ active = false;
29
+ if (timer) { clearInterval(timer); timer = null; }
30
+ if (isTTY) {
31
+ stdout.write("\r\x1b[K");
32
+ if (finalMsg) console.log(finalMsg);
33
+ } else if (finalMsg) {
34
+ console.log(finalMsg);
35
+ }
36
+ }
37
+ return { start, stop };
38
+ }
@@ -0,0 +1,170 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { getPreferredModel } from "../auto.js";
3
+ import { loadModelLatencies, loadModelErrors, loadModelPicks, getPort } from "../state.js";
4
+ import { logDir, callsFile, errorsFile, recentCalls, lastError } from "../logs.js";
5
+ import { fmtShanghai } from "../time.js";
6
+ import { CHAT_PREFERRED, CHAT_FALLBACK } from "./config.js";
7
+
8
+ function readLinesCount(file) {
9
+ try {
10
+ if (!existsSync(file)) return 0;
11
+ const txt = readFileSync(file, "utf8");
12
+ if (!txt.trim()) return 0;
13
+ return txt.split("\n").filter(Boolean).length;
14
+ } catch { return 0; }
15
+ }
16
+
17
+ function fmtLatency(entry) {
18
+ if (!entry || !entry.emaMs) return "—";
19
+ const ema = entry.emaMs;
20
+ const cnt = entry.count ? ` · ${entry.count}次` : "";
21
+ const last = entry.lastMs ? ` (末次 ${entry.lastMs}ms)` : "";
22
+ return `${ema}ms${cnt}${last}`;
23
+ }
24
+
25
+ function fmtStatus(entry) {
26
+ if (!entry) return "normal";
27
+ if (typeof entry === "number") return "error";
28
+ return entry.status || "normal";
29
+ }
30
+
31
+ export function collectStats() {
32
+ const gatewayModel = getPreferredModel();
33
+ const latencies = loadModelLatencies();
34
+ const errors = loadModelErrors();
35
+ const picks = loadModelPicks();
36
+ const port = getPort() ?? (Number(process.env.MSLXDFF_PORT) > 0 ? Number(process.env.MSLXDFF_PORT) : 8989);
37
+ const chatPrefLat = latencies[CHAT_PREFERRED] || null;
38
+ const chatFallLat = latencies[CHAT_FALLBACK] || null;
39
+ const gatewayLat = latencies[gatewayModel] || null;
40
+
41
+ // gateway totals — 来自 -d 进程的持久化日志(非本 -chat 会话)
42
+ const cf = callsFile();
43
+ const ef = errorsFile();
44
+ const totalCalls = readLinesCount(cf);
45
+ const totalErrs = readLinesCount(ef);
46
+ const recent = recentCalls(5);
47
+ const lastErr = lastError();
48
+
49
+ // cache model counts + ids
50
+ let freeCount = 0;
51
+ let cachedAt = null;
52
+ let freeIds = [];
53
+ try {
54
+ const cacheFile = `${logDir()}/models.json`;
55
+ if (existsSync(cacheFile)) {
56
+ const j = JSON.parse(readFileSync(cacheFile, "utf8"));
57
+ if (Array.isArray(j.data)) {
58
+ freeIds = j.data.map((m) => m.id).filter(Boolean);
59
+ freeCount = freeIds.length;
60
+ }
61
+ cachedAt = j.cachedAt || null;
62
+ }
63
+ } catch {}
64
+ const freeSet = new Set(freeIds);
65
+
66
+ // per-model daemon detail (for /stats) — 只展示网关认识的 free 模型,避免测试假数据污染
67
+ const allIds = Object.keys({ ...latencies, ...errors }).filter(Boolean);
68
+ const filteredIds = freeIds.length
69
+ ? allIds.filter((id) => freeSet.has(id) || id === gatewayModel || id === CHAT_PREFERRED || id === CHAT_FALLBACK)
70
+ : allIds.filter((id) => !/^m-(one|two)-free$|^a-free$|^b-free$|^c-free$|^ghost-/.test(id) && !id.startsWith("test-"));
71
+ 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));
79
+
80
+ return {
81
+ gatewayModel,
82
+ gatewayLat,
83
+ chatPref: CHAT_PREFERRED,
84
+ chatFall: CHAT_FALLBACK,
85
+ chatPrefLat,
86
+ chatFallLat,
87
+ chatPrefStatus: fmtStatus(errors[CHAT_PREFERRED]),
88
+ chatFallStatus: fmtStatus(errors[CHAT_FALLBACK]),
89
+ gatewayStatus: fmtStatus(errors[gatewayModel]),
90
+ picks,
91
+ port,
92
+ totalCalls,
93
+ totalErrs,
94
+ total: totalCalls + totalErrs,
95
+ success: totalCalls,
96
+ fail: totalErrs,
97
+ recent,
98
+ lastErr,
99
+ freeCount,
100
+ cachedAt,
101
+ healthUrl: `http://127.0.0.1:${port}/health`,
102
+ endpointUrl: `http://127.0.0.1:${port}/v1`,
103
+ latencies,
104
+ errors,
105
+ perModel,
106
+ };
107
+ }
108
+
109
+ export function formatBannerLines() {
110
+ const s = collectStats();
111
+ const dim = "\x1b[90m";
112
+ const rst = "\x1b[0m";
113
+ const cyan = "\x1b[36m";
114
+ const yellow = "\x1b[33m";
115
+ const green = "\x1b[32m";
116
+ const lines = [];
117
+ lines.push(`${cyan}┌─ mslxdff chat · 数据来自 -d 网关进程(非本会话) ─────${rst}`);
118
+ lines.push(`${cyan}│${rst} 对话模型 ${yellow}${s.chatPref}${rst} ${dim}→ ${s.chatFall}(自动降级)${rst} ${dim}[${s.chatPrefStatus}/${s.chatFallStatus}]${rst}`);
119
+ 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}`);
124
+ const errWhen = s.lastErr?.ts ? fmtShanghai(s.lastErr.ts) : "—";
125
+ const errMsg = s.lastErr?.message ? String(s.lastErr.message).slice(0, 40) : (s.lastErr?.status ? `HTTP ${s.lastErr.status}` : "无");
126
+ lines.push(`${cyan}│${rst} 网关请求 总 ${s.total} 成功 ${s.success} 失败 ${s.fail} ${dim}· 末错 ${errWhen} ${errMsg}${rst}`);
127
+ const cacheInfo = s.freeCount ? `${s.freeCount} free` : "—";
128
+ const picksInfo = s.picks.length ? `${s.picks.length} 已选` : "未筛选";
129
+ lines.push(`${cyan}│${rst} 模型库 ${cacheInfo} · 勾选 ${picksInfo} ${dim}${s.picks.slice(0, 3).join(", ") || ""}${s.picks.length > 3 ? " …" : ""}${rst}`);
130
+ lines.push(`${cyan}│${rst} 健康 ${dim}${s.healthUrl}${rst} ${dim}· 日志 ${recentCalls(1).length ? "有" : "暂无"} · 上海时间${rst}`);
131
+ lines.push(`${cyan}└──────────────────────────────────────────────────${rst}`);
132
+ return { lines, stats: s };
133
+ }
134
+
135
+ export function formatStatsDetail() {
136
+ const s = collectStats();
137
+ const dim = "\x1b[90m";
138
+ const rst = "\x1b[0m";
139
+ const cyan = "\x1b[36m";
140
+ const out = [];
141
+ 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)}`);
144
+ out.push(`健康: ${s.healthUrl} 端点: ${s.endpointUrl}`);
145
+ out.push(`网关请求: 总 ${s.total} 成功 ${s.success} 失败 ${s.fail} ${dim}(calls.log + errors.log 持久化计数)${rst}`);
146
+ if (s.lastErr) {
147
+ out.push(`末次错误: ${fmtShanghai(s.lastErr.ts)} ${s.lastErr.model || ""} ${s.lastErr.status || ""} ${String(s.lastErr.message || "").slice(0, 80)}`);
148
+ } else {
149
+ out.push(`末次错误: 无`);
150
+ }
151
+ out.push(`勾选集: ${s.picks.length ? s.picks.join(", ") : "(空=全量 auto)"} · 模型库: ${s.freeCount || 0} free 缓存: ${s.cachedAt ? fmtShanghai(s.cachedAt) : "—"}`);
152
+ 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}`);
158
+ }
159
+ if (s.perModel.length > 10) out.push(` … 还有 ${s.perModel.length - 10} 个模型`);
160
+ }
161
+ if (s.recent.length) {
162
+ out.push(`最近网关调用(calls.log 最近5条):`);
163
+ 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" : ""}`);
165
+ }
166
+ }
167
+ out.push(`${dim}提示:以上均为 -d 网关进程的持久化数据,非本 -chat 会话计数。看实时事件用 mslxdff -log 20${rst}`);
168
+ out.push(`${dim}──────────────────────────────────────${rst}`);
169
+ return out.join("\n");
170
+ }
@@ -0,0 +1,48 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import { defaultStateFile } from "../state.js";
4
+ import { CHAT_HISTORY_MAX_CHARS } from "./config.js";
5
+
6
+ function histFile() {
7
+ const base = process.env.MSLXDFF_CHAT_HISTORY || join(dirname(defaultStateFile()), "chat-history.json");
8
+ return base;
9
+ }
10
+
11
+ export function loadHistory() {
12
+ try {
13
+ if (!existsSync(histFile())) return [];
14
+ const raw = readFileSync(histFile(), "utf8");
15
+ const arr = JSON.parse(raw);
16
+ return Array.isArray(arr) ? arr : [];
17
+ } catch { return []; }
18
+ }
19
+
20
+ export function saveHistory(arr) {
21
+ try {
22
+ const f = histFile();
23
+ mkdirSync(dirname(f), { recursive: true });
24
+ // 128k 上下文下保留更多历史,配合 400k 阈值
25
+ const slim = arr.slice(-120);
26
+ writeFileSync(f, JSON.stringify(slim, null, 2));
27
+ } catch {}
28
+ }
29
+
30
+ export function clearHistory() {
31
+ saveHistory([]);
32
+ }
33
+
34
+ export function histPath() { return histFile(); }
35
+
36
+ export function estimateChars(messages) {
37
+ let n = 0;
38
+ for (const m of messages) {
39
+ n += String(m.content || "").length;
40
+ if (m.tool_calls) n += JSON.stringify(m.tool_calls).length;
41
+ if (m.tool_call_id) n += 200;
42
+ }
43
+ return n;
44
+ }
45
+
46
+ export function needsCompress(messages) {
47
+ return estimateChars(messages) > CHAT_HISTORY_MAX_CHARS;
48
+ }