mslxdff 0.1.89 → 0.1.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.89",
3
+ "version": "0.1.91",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,160 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { estimateChars, needsCompress } from "./store.js";
3
+ import { CHAT_KEEP_RECENT, CHAT_MAX_TOOL_LOOPS } from "./config.js";
4
+ import { buildDedupKey, runTool } from "./tool-handlers.js";
5
+
6
+ // 纯引擎:无 readline/ANSI/spinner,单一 runTurn 可测
7
+ export function createEngine({
8
+ chatWithFallback,
9
+ summarizeHistory,
10
+ getToolDefs = () => [],
11
+ execCommand,
12
+ readFileTool,
13
+ curlTool,
14
+ onTrace = () => {},
15
+ config = {},
16
+ } = {}) {
17
+ const CWF = chatWithFallback || (async () => ({ ok: false, error: "no chat" }));
18
+ const SUM = summarizeHistory || (async () => null);
19
+ const GTOOLS = getToolDefs;
20
+ const EXEC = execCommand || (async () => ({ ok: false, output: "no exec" }));
21
+ const READ = readFileTool || (async () => ({ ok: false, output: "no read" }));
22
+ const CURL = curlTool || (async () => ({ ok: false, output: "no curl" }));
23
+
24
+ async function maybeCompress(messages) {
25
+ if (!needsCompress(messages)) return [...messages];
26
+ const sys = messages[0];
27
+ const rest = messages.slice(1);
28
+ if (rest.length <= CHAT_KEEP_RECENT + 2) return [...messages];
29
+ const t0 = performance.now();
30
+ const toSummarize = rest.slice(0, -CHAT_KEEP_RECENT);
31
+ const keep = rest.slice(-CHAT_KEEP_RECENT);
32
+ const chars = estimateChars(messages);
33
+ onTrace(`[压缩] 触发 ${chars}字 > 400000阈值 · 待压 ${toSummarize.length}条 保留 ${keep.length}条`);
34
+ const summary = await SUM(toSummarize);
35
+ const dt = Math.round(performance.now() - t0);
36
+ if (!summary) {
37
+ onTrace(`[压缩] 失败/空 · ${dt}ms → 截断`);
38
+ return [sys, ...keep];
39
+ }
40
+ const summaryMsg = { role: "system", content: summary };
41
+ const next = [sys, summaryMsg, ...keep];
42
+ onTrace(`[压缩] 完成 ${toSummarize.length}条→${summary.length}字 · ${dt}ms · 新总量约 ${estimateChars(next)}字`);
43
+ return next;
44
+ }
45
+
46
+ async function runTurn(userText, messages) {
47
+ const tools = GTOOLS();
48
+ messages.push({ role: "user", content: userText });
49
+ let loops = 0;
50
+ let lastModel = null;
51
+ let lastProvider = null;
52
+ let lastUsage = null;
53
+ let lastFallback = false;
54
+ let lastFallbackGateway = false;
55
+ let lastLatency = 0;
56
+ const t0 = performance.now();
57
+ const turnStart = performance.now();
58
+ const seenCalls = new Map();
59
+ let duplicateStrikes = 0;
60
+ let forceNoTools = false;
61
+ onTrace(`[turn] 开始 "${userText.slice(0, 60)}${userText.length > 60 ? "…" : ""}" · 历史 ${messages.length}条 约 ${estimateChars(messages)}字`);
62
+ while (loops < CHAT_MAX_TOOL_LOOPS) {
63
+ const tLoop = performance.now();
64
+ const tComp = performance.now();
65
+ const cur = await maybeCompress(messages);
66
+ const compressMs = Math.round(performance.now() - tComp);
67
+ if (compressMs > 50) onTrace(`[loop ${loops}] 压缩耗时 ${compressMs}ms`);
68
+ messages.length = 0;
69
+ for (const m of cur) messages.push(m);
70
+ const tCall = performance.now();
71
+ let res;
72
+ const activeTools = forceNoTools ? [] : tools;
73
+ res = await CWF({ messages, tools: activeTools });
74
+ if (forceNoTools && res.ok && res.message?.tool_calls?.length) {
75
+ onTrace(`[guard] 禁工具模式下仍收到 tool_calls,已拦截`);
76
+ res.message.tool_calls = [];
77
+ if (!res.message.content) res.message.content = "(已拦截违规工具调用,请基于已有结果直接回答)";
78
+ }
79
+ const llmMs = Math.round(performance.now() - tCall);
80
+ onTrace(`[loop ${loops}] LLM ${llmMs}ms${compressMs > 50 ? ` (含压缩 ${compressMs}ms)` : ""} · ${estimateChars(messages)}字上下文`);
81
+ lastLatency = llmMs;
82
+ if (!res.ok) {
83
+ const err = `大模型暂不可用:${res.error}`;
84
+ messages.push({ role: "assistant", content: err });
85
+ return { text: err, model: null, latency: lastLatency, usage: null, fallback: false, ok: false };
86
+ }
87
+ lastModel = res.model;
88
+ lastProvider = res.provider || null;
89
+ lastUsage = res.usage || null;
90
+ lastFallback = !!res.fallback;
91
+ lastFallbackGateway = !!res.fallbackGateway || !!res.viaGateway;
92
+ const msg = res.message;
93
+ const toolCalls = msg.tool_calls || [];
94
+ let fallbackCmd = null;
95
+ if (!toolCalls.length && msg.content) {
96
+ const m = String(msg.content).match(/\{[^}]*"command"\s*:\s*"([^"]+)"[^}]*\}/);
97
+ if (m) fallbackCmd = m[1];
98
+ }
99
+ if (!toolCalls.length && !fallbackCmd) {
100
+ const text = String(msg.content || "").trim() || "(空回复)";
101
+ messages.push({ role: "assistant", content: text });
102
+ const totalMs = Math.round(performance.now() - t0);
103
+ let note = "";
104
+ if (lastFallbackGateway) note = "\n[注:mimo/big-pickle 均不可用,已自动切本地网关 auto(:8989)]";
105
+ else if (lastFallback) note = "\n[注:mimo 不可用,已用 big-pickle]";
106
+ onTrace(`[turn] 完成 总计 ${totalMs}ms · LLM ${lastLatency}ms · 0 工具${lastFallbackGateway ? " · gateway-fallback" : ""}`);
107
+ return { text: text + note, model: lastModel, provider: lastProvider, latency: lastLatency, usage: lastUsage, fallback: lastFallback, fallbackGateway: lastFallbackGateway, ok: true, totalMs };
108
+ }
109
+ const calls = toolCalls.length ? toolCalls : [{ id: "fallback-1", function: { name: "run_command", arguments: JSON.stringify({ command: fallbackCmd }) } }];
110
+ messages.push({ role: "assistant", content: msg.content || "", tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: c.function })) });
111
+ onTrace(`[tools] 本轮 ${calls.length} 个调用 ${calls.map((c) => c.function?.name).join(",")} · 顺序执行`);
112
+ const tTools = performance.now();
113
+ const toolResults = [];
114
+ const toolDeps = { execCommand: EXEC, readFileTool: READ, curlTool: CURL, onTrace };
115
+ for (const c of calls) {
116
+ const name = c.function?.name;
117
+ let args = {};
118
+ try { args = JSON.parse(c.function?.arguments || "{}"); } catch {}
119
+ const t1 = performance.now();
120
+ const dedupKey = buildDedupKey(name, args);
121
+ const seen = seenCalls.get(dedupKey);
122
+ if (seen) {
123
+ const dt = Math.round(performance.now() - t1);
124
+ onTrace(`[tool] ${name} 重复调用已跳过 · ${dt}ms · 之前 ${seen.count} 次`);
125
+ toolResults.push({
126
+ id: c.id,
127
+ content: `SKIPPED_DUP: 此工具调用在本轮已执行过 ${seen.count} 次,结果相同请直接基于已有信息回答用户,不要再重复调用。\n--- 首次结果复用 ---\n${seen.firstResult.slice(0, 6000)}`,
128
+ });
129
+ continue;
130
+ }
131
+ const result = await runTool({ name, args, userText, ...toolDeps });
132
+ if (!seenCalls.has(dedupKey)) seenCalls.set(dedupKey, { count: 1, firstResult: result });
133
+ else seenCalls.get(dedupKey).count++;
134
+ toolResults.push({ id: c.id, content: result });
135
+ }
136
+ for (const tr of toolResults) messages.push({ role: "tool", tool_call_id: tr.id, content: tr.content });
137
+ if (toolResults.some((tr) => String(tr.content).startsWith("SKIPPED_DUP"))) {
138
+ duplicateStrikes++;
139
+ forceNoTools = true;
140
+ messages.push({ role: "system", content: "系统提示:你已重复调用相同工具,工具侧已复用首次结果并跳过执行。你已被禁止再调用任何工具,必须立即基于以上工具结果用中文直接回答用户,0 工具调用。" });
141
+ onTrace(`[dup] 检测到重复调用 ${duplicateStrikes} 次,已禁用后续工具调用`);
142
+ if (duplicateStrikes >= 2) {
143
+ const seen = [...seenCalls.values()].map((v) => v.firstResult).join("\n---\n").slice(0, 6000);
144
+ const synth = `检测到重复调用已达 ${duplicateStrikes} 次,为避免空转,直接基于已有结果回答:\n\n${seen}`;
145
+ messages.push({ role: "assistant", content: synth });
146
+ const totalMs = Math.round(performance.now() - t0);
147
+ onTrace(`[turn] 提前结束(重复阈值) 总计 ${totalMs}ms`);
148
+ return { text: synth, model: lastModel || "local", provider: lastProvider, latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: true, totalMs };
149
+ }
150
+ }
151
+ const toolsMs = Math.round(performance.now() - tTools);
152
+ const loopMs = Math.round(performance.now() - tLoop);
153
+ onTrace(`[loop ${loops}] 工具 ${toolsMs}ms · 本轮总 ${loopMs}ms · 累计 ${Math.round(performance.now() - turnStart)}ms`);
154
+ loops++;
155
+ }
156
+ return { text: "(工具调用次数已达上限,已停止)", model: lastModel, latency: lastLatency, usage: lastUsage, fallback: lastFallback, fallbackGateway: lastFallbackGateway, ok: false };
157
+ }
158
+
159
+ return { runTurn, maybeCompress };
160
+ }
package/src/chat/repl.js CHANGED
@@ -1,278 +1,16 @@
1
- import readline from "node:readline/promises";
2
- import { stdin, stdout } from "node:process";
3
- import { performance } from "node:perf_hooks";
4
1
  import { buildSystemPrompt, getModelsForPrompt } from "./prompt.js";
5
2
  import { getToolDefs, execCommand, readFileTool, curlTool } from "./tools.js";
6
3
  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, probeGateway } from "./stats.js";
10
- import { createSpinner } from "./spinner.js";
11
- import { normalizeFullId } from "../providers/model-id.js";
12
-
13
- const SLASH_HELP = `自然语言直接说,斜杠快捷:
14
- /help 本帮助
15
- /stats 详细统计(网关 -d 的请求/延迟/模型)
16
- /history 查看对话历史
17
- /clear 清空历史
18
- /exit 退出
19
- 示例:设置 hy3 为默认模型 / 查看组列表 / 看最近20条日志 / 读一下 src/logs.js`;
20
-
21
- async function printBanner() {
22
- let probe = null;
23
- try {
24
- const s = collectStats();
25
- probe = await probeGateway(s.port, 800);
26
- } catch {}
27
- const { lines } = formatBannerLines(probe);
28
- for (const l of lines) console.log(l);
29
- if (probe && !probe.alive) {
30
- console.log(`\x1b[31m本地服务没有启动无法使用auto模式,请mslxdff -d 启动\x1b[0m`);
31
- }
32
- console.log(`\x1b[90m输入自然语言即可执行;/help 帮助,/stats 看网关统计,/exit 退出\x1b[0m`);
33
- console.log(`\x1b[90m历史:${histPath()} · 仅拦截 -uninstall · 数据来自网关 -d,非本会话计数\x1b[0m`);
34
- }
4
+ import { loadHistory, saveHistory, histPath } from "./store.js";
5
+ import { CHAT_PREFERRED } from "./config.js";
6
+ import { createEngine } from "./engine.js";
7
+ import { printBanner, printFooter, handleSlash, createReadline, createSpinner, estimateChars } from "./terminal.js";
35
8
 
36
9
  function trace(line) {
37
10
  if (process.env.MSLXDFF_CHAT_TRACE === "0") return;
38
11
  console.log(`\x1b[90m· ${line}\x1b[0m`);
39
12
  }
40
13
 
41
-
42
-
43
- async function maybeCompress(messages) {
44
- if (!needsCompress(messages)) return [...messages];
45
- const sys = messages[0];
46
- const rest = messages.slice(1);
47
- if (rest.length <= CHAT_KEEP_RECENT + 2) return [...messages];
48
- const t0 = performance.now();
49
- const toSummarize = rest.slice(0, -CHAT_KEEP_RECENT);
50
- const keep = rest.slice(-CHAT_KEEP_RECENT);
51
- const chars = estimateChars(messages);
52
- trace(`[压缩] 触发 ${chars}字 > ${400000}阈值 · 待压 ${toSummarize.length}条 保留 ${keep.length}条`);
53
- const summary = await summarizeHistory(toSummarize);
54
- const dt = Math.round(performance.now() - t0);
55
- if (!summary) {
56
- trace(`[压缩] 失败/空 · ${dt}ms → 截断`);
57
- return [sys, ...keep];
58
- }
59
- const summaryMsg = { role: "system", content: summary };
60
- const next = [sys, summaryMsg, ...keep];
61
- trace(`[压缩] 完成 ${toSummarize.length}条→${summary.length}字 · ${dt}ms · 新总量约 ${estimateChars(next)}字`);
62
- return next;
63
- }
64
-
65
- async function runAgentTurn(userText, messages) {
66
- const tools = getToolDefs();
67
- messages.push({ role: "user", content: userText });
68
- let loops = 0;
69
- let lastModel = null;
70
- let lastProvider = null;
71
- let lastUsage = null;
72
- let lastFallback = false;
73
- let lastFallbackGateway = false;
74
- let lastLatency = 0;
75
- const t0 = performance.now();
76
- const turnStart = performance.now();
77
- // 同轮去重:同一工具+参数只真正执行一次,重复直接复用并提示 LLM
78
- const seenCalls = new Map(); // key -> { count, firstResult }
79
- let duplicateStrikes = 0;
80
- let forceNoTools = false;
81
- trace(`[turn] 开始 "${userText.slice(0, 60)}${userText.length > 60 ? "…" : ""}" · 历史 ${messages.length}条 约 ${estimateChars(messages)}字`);
82
- while (loops < CHAT_MAX_TOOL_LOOPS) {
83
- const tLoop = performance.now();
84
- const tComp = performance.now();
85
- const cur = await maybeCompress(messages);
86
- const compressMs = Math.round(performance.now() - tComp);
87
- if (compressMs > 50) trace(`[loop ${loops}] 压缩耗时 ${compressMs}ms`);
88
- messages.length = 0;
89
- for (const m of cur) messages.push(m);
90
- const tCall = performance.now();
91
- const spinnerLabel = loops === 0 ? "已发送给 AI,等待回复中" : forceNoTools ? "AI 整理回答中(已禁工具)" : "AI 正在整理回复中";
92
- const spinner = createSpinner(spinnerLabel);
93
- spinner.start();
94
- let res;
95
- try {
96
- const activeTools = forceNoTools ? [] : tools;
97
- res = await chatWithFallback({ messages, tools: activeTools });
98
- if (forceNoTools && res.ok && res.message?.tool_calls?.length) {
99
- // LLM 在禁工具模式下仍尝试调工具,视为违规,直接转文本
100
- trace(`[guard] 禁工具模式下仍收到 tool_calls,已拦截`);
101
- res.message.tool_calls = [];
102
- if (!res.message.content) res.message.content = "(已拦截违规工具调用,请基于已有结果直接回答)";
103
- }
104
- } finally {
105
- const ms = Math.round(performance.now() - tCall);
106
- spinner.stop(`\x1b[90m✓ AI 已回复 · ${ms}ms\x1b[0m`);
107
- }
108
- const llmMs = Math.round(performance.now() - tCall);
109
- trace(`[loop ${loops}] LLM ${llmMs}ms${compressMs > 50 ? ` (含压缩 ${compressMs}ms)` : ""} · ${estimateChars(messages)}字上下文`);
110
- lastLatency = llmMs;
111
- if (!res.ok) {
112
- const err = `大模型暂不可用:${res.error}`;
113
- messages.push({ role: "assistant", content: err });
114
- return { text: err, model: null, latency: lastLatency, usage: null, fallback: false, ok: false };
115
- }
116
- lastModel = res.model;
117
- lastProvider = res.provider || null;
118
- lastUsage = res.usage || null;
119
- lastFallback = !!res.fallback;
120
- lastFallbackGateway = !!res.fallbackGateway || !!res.viaGateway;
121
- const msg = res.message;
122
- const toolCalls = msg.tool_calls || [];
123
- let fallbackCmd = null;
124
- if (!toolCalls.length && msg.content) {
125
- const m = String(msg.content).match(/\{[^}]*"command"\s*:\s*"([^"]+)"[^}]*\}/);
126
- if (m) fallbackCmd = m[1];
127
- }
128
- if (!toolCalls.length && !fallbackCmd) {
129
- const text = String(msg.content || "").trim() || "(空回复)";
130
- messages.push({ role: "assistant", content: text });
131
- const totalMs = Math.round(performance.now() - t0);
132
- let note = "";
133
- if (lastFallbackGateway) note = "\n\x1b[90m[注:mimo/big-pickle 均不可用,已自动切本地网关 auto(:8989)]\x1b[0m";
134
- else if (lastFallback) note = "\n\x1b[90m[注:mimo 不可用,已用 big-pickle]\x1b[0m";
135
- trace(`[turn] 完成 总计 ${totalMs}ms · LLM ${lastLatency}ms · 0 工具${lastFallbackGateway ? " · gateway-fallback" : ""}`);
136
- return { text: text + note, model: lastModel, provider: lastProvider, latency: lastLatency, usage: lastUsage, fallback: lastFallback, fallbackGateway: lastFallbackGateway, ok: true, totalMs };
137
- }
138
- const calls = toolCalls.length ? toolCalls : [{ id: "fallback-1", function: { name: "run_command", arguments: JSON.stringify({ command: fallbackCmd }) } }];
139
- messages.push({ role: "assistant", content: msg.content || "", tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: c.function })) });
140
- trace(`[tools] 本轮 ${calls.length} 个调用 ${calls.map((c) => c.function?.name).join(",")} · 并发执行`);
141
- const tTools = performance.now();
142
- const toolResults = await Promise.all(calls.map(async (c) => {
143
- const name = c.function?.name;
144
- let args = {};
145
- try { args = JSON.parse(c.function?.arguments || "{}"); } catch {}
146
- const t1 = performance.now();
147
- // 归一化 key:run_command 按命令去重(大小写+空白归一),curl 按 url+method+body,read_file 按 path
148
- let dedupKey = `${name}:${JSON.stringify(args)}`;
149
- if (name === "run_command") {
150
- const cmd = String(args.command || "").trim().toLowerCase().replace(/\s+/g, " ");
151
- // -provider list 与 -providers list 等价,归一
152
- const norm = cmd.replace(/^-+providers\b/, "-provider").replace(/\s+/g, " ").trim();
153
- dedupKey = `run_command:${norm}`;
154
- } else if (name === "curl") {
155
- const u = String(args.url || "").trim().toLowerCase();
156
- const m = String(args.method || "GET").toUpperCase();
157
- dedupKey = `curl:${m}:${u}:${String(args.body || "").slice(0, 200)}`;
158
- } else if (name === "read_file") {
159
- dedupKey = `read_file:${String(args.path || "").trim().toLowerCase()}`;
160
- }
161
- const seen = seenCalls.get(dedupKey);
162
- if (seen) {
163
- const dt = Math.round(performance.now() - t1);
164
- trace(`[tool] ${name} 重复调用已跳过 · ${dt}ms · 之前 ${seen.count} 次`);
165
- console.log(`\x1b[33m→ 跳过重复: ${name} ${JSON.stringify(args).slice(0, 120)}(本轮已执行过)\x1b[0m`);
166
- return {
167
- id: c.id,
168
- content: `SKIPPED_DUP: 此工具调用在本轮已执行过 ${seen.count} 次,结果相同请直接基于已有信息回答用户,不要再重复调用。\n--- 首次结果复用 ---\n${seen.firstResult.slice(0, 6000)}`,
169
- };
170
- }
171
- let result;
172
- if (name === "run_command") {
173
- const cmd = String(args.command || "").trim();
174
- console.log(`\x1b[90m→ 执行: mslxdff ${cmd}\x1b[0m`);
175
- const r = await execCommand(cmd);
176
- result = `${r.ok ? "OK" : "FAIL"}: ${r.output}`;
177
- // 查询类命令直接在结果里植入“立即回答”锚点,降低 LLM 再发一次的概率;若用户问模型,则 provider list 不算答案
178
- const lowCmd = cmd.toLowerCase().replace(/\s+/g, " ").trim();
179
- const asksModel = String(userText || "").toLowerCase().includes("模型");
180
- const isOnceAndDone =
181
- /^-+(showtoken|status|s|providers?\b|model\b|group\b|log\b|workbuddy\b|free\b|autostart\b|plugins\b)/.test(lowCmd) ||
182
- lowCmd === "-provider list" || lowCmd === "-providers list";
183
- if (isOnceAndDone && r.ok) {
184
- if (asksModel && lowCmd.includes("-provider")) {
185
- result += `\n\n[提示:此命令仅显示供应商配置,不包含模型列表。用户问的是“有哪些模型”,请用系统提示中的“可用模型”按前缀过滤回答,或调 curl local/models,不要再调 provider list]`;
186
- } else {
187
- result += `\n\n[系统提示:此查询已完成,结果即答案,请直接用中文回答用户,禁止再调用相同或同类查询工具]`;
188
- }
189
- }
190
- const dt = Math.round(performance.now() - t1);
191
- trace(`[tool] run_command "${cmd.slice(0, 40)}" · ${dt}ms · ${r.ok ? "OK" : "FAIL"} ${r.output.length}字`);
192
- console.log(r.ok ? `\x1b[32m${r.output.slice(0, 800)}\x1b[0m` : `\x1b[31m${r.output.slice(0, 800)}\x1b[0m`);
193
- } else if (name === "read_file") {
194
- const r = await readFileTool(args);
195
- result = `${r.ok ? "OK" : "FAIL"}: ${r.output.slice(0, 6000)}`;
196
- if (r.ok) result += `\n\n[系统提示:文件已读取,请直接基于内容回答,禁止重复读取同一文件]`;
197
- const dt = Math.round(performance.now() - t1);
198
- trace(`[tool] read_file ${args.path} · ${dt}ms · ${r.ok ? "OK" : "FAIL"} ${r.output.length}字`);
199
- console.log(`\x1b[90m→ 读取: ${args.path} ${r.ok ? "OK" : "FAIL"}\x1b[0m`);
200
- } else if (name === "curl") {
201
- const u = String(args.url || "").trim();
202
- console.log(`\x1b[90m→ 探活: ${u} ${args.method || "GET"}\x1b[0m`);
203
- const r = await curlTool(args);
204
- result = `${r.ok ? "OK" : "FAIL"}: ${r.output.slice(0, 6000)}`;
205
- const dt = Math.round(performance.now() - t1);
206
- trace(`[tool] curl ${u} · ${dt}ms`);
207
- console.log(r.ok ? `\x1b[32m${r.output.slice(0, 800)}\x1b[0m` : `\x1b[31m${r.output.slice(0, 800)}\x1b[0m`);
208
- } else {
209
- result = `unknown tool ${name}`;
210
- }
211
- // 记录首次结果供复用
212
- if (!seenCalls.has(dedupKey)) seenCalls.set(dedupKey, { count: 1, firstResult: result });
213
- else seenCalls.get(dedupKey).count++;
214
- // 若本轮首次执行后已累积 2 次以上相同调用,下次 LLM 再试会直接命中上面的 SKIPPED_DUP
215
- return { id: c.id, content: result };
216
- }));
217
- for (const tr of toolResults) messages.push({ role: "tool", tool_call_id: tr.id, content: tr.content });
218
- // 若本轮有 SKIPPED_DUP,额外追加系统提示并禁用后续工具,强制直接回答
219
- if (toolResults.some((tr) => String(tr.content).startsWith("SKIPPED_DUP"))) {
220
- duplicateStrikes++;
221
- forceNoTools = true;
222
- messages.push({ role: "system", content: "系统提示:你已重复调用相同工具,工具侧已复用首次结果并跳过执行。你已被禁止再调用任何工具,必须立即基于以上工具结果用中文直接回答用户,0 工具调用。" });
223
- trace(`[dup] 检测到重复调用 ${duplicateStrikes} 次,已禁用后续工具调用`);
224
- if (duplicateStrikes >= 2) {
225
- const seen = [...seenCalls.values()].map((v) => v.firstResult).join("\n---\n").slice(0, 6000);
226
- const synth = `检测到重复调用已达 ${duplicateStrikes} 次,为避免空转,直接基于已有结果回答:\n\n${seen}`;
227
- messages.push({ role: "assistant", content: synth });
228
- const totalMs = Math.round(performance.now() - t0);
229
- trace(`[turn] 提前结束(重复阈值) 总计 ${totalMs}ms`);
230
- return { text: synth, model: lastModel || "local", provider: lastProvider, latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: true, totalMs };
231
- }
232
- }
233
- const toolsMs = Math.round(performance.now() - tTools);
234
- const loopMs = Math.round(performance.now() - tLoop);
235
- trace(`[loop ${loops}] 工具 ${toolsMs}ms · 本轮总 ${loopMs}ms · 累计 ${Math.round(performance.now() - turnStart)}ms`);
236
- loops++;
237
- }
238
- return { text: "(工具调用次数已达上限,已停止)", model: lastModel, latency: lastLatency, usage: lastUsage, fallback: lastFallback, fallbackGateway: lastFallbackGateway, ok: false };
239
- }
240
-
241
- function printFooter({ model, provider, latency, usage, totalMs, fallback, fallbackGateway, viaGateway }) {
242
- const dim = "\x1b[90m";
243
- const rst = "\x1b[0m";
244
- let gw = null;
245
- try { gw = collectStats(); } catch {}
246
- const prov = provider && provider !== "opencode" ? `${provider}/` : "";
247
- const baseLabel = model ? `${prov}${model}` : "—";
248
- const via = provider && !baseLabel.startsWith(`${provider}/`) ? `: ${provider}` : "";
249
- const modelLabel = model ? (fallbackGateway || viaGateway ? `${baseLabel} (gateway auto${via})` : baseLabel) : "—";
250
- const latLabel = latency ? `${latency}ms` : "—";
251
- const totalLabel = totalMs ? ` · 总耗时 ${totalMs}ms` : "";
252
- const tokLabel = usage ? ` · tokens ${usage.prompt_tokens ?? "?"}→${usage.completion_tokens ?? "?"}` : "";
253
- const fbLabel = fallbackGateway || viaGateway ? " · gateway-fallback" : fallback ? " · fallback" : "";
254
- let gwLabel = "";
255
- let extra = "";
256
- if (gw) {
257
- const full = (() => { try { return normalizeFullId(model); } catch { return model; } })();
258
- const stat = gw.modelStats?.[full] || gw.modelStats?.[model] || null;
259
- const cnt = stat?.count ?? gw.latencies?.[model]?.count;
260
- const avgTtfb = stat?.avgTtfbMs ?? stat?.emaTtfbMs;
261
- const avgTps = stat?.avgTps ?? stat?.emaTps;
262
- const per = cnt ? ` · 网关该模型 ${cnt}次` : "";
263
- const ttfbLabel = avgTtfb ? ` 平均首字 ${avgTtfb}ms` : (gw.latencies?.[model]?.emaMs ? ` EMA ${gw.latencies[model].emaMs}ms` : "");
264
- const tpsLabel = avgTps ? ` · ${avgTps} tok/s` : "";
265
- const verbose = stat?.avgCompTok ? ` · 啰嗦 ${stat.avgCompTok}tok/次` : "";
266
- gwLabel = ` · 网关 总${gw.total} 成功${gw.success} 失败${gw.fail}${per}${ttfbLabel}${tpsLabel}${verbose}`;
267
- // 本次首字/tps(若本次有 usage,估算本次 tps)
268
- if (usage?.completion_tokens && latency) {
269
- const tpsNow = Math.round(usage.completion_tokens / (latency / 1000));
270
- if (Number.isFinite(tpsNow) && tpsNow > 0) extra = ` · 本次首字 ~${latency}ms · ${tpsNow} tok/s`;
271
- }
272
- }
273
- console.log(`${dim}─ ${modelLabel} ${latLabel}${totalLabel}${tokLabel}${fbLabel}${gwLabel}${extra}${rst}`);
274
- }
275
-
276
14
  export async function startRepl({ singleShot } = {}) {
277
15
  const models = getModelsForPrompt();
278
16
  const system = buildSystemPrompt({ modelsOverride: models });
@@ -282,53 +20,48 @@ export async function startRepl({ singleShot } = {}) {
282
20
  for (const h of hist) messages.push(h);
283
21
  console.log(`\x1b[90m[恢复] 已载入 ${hist.length} 条历史\x1b[0m`);
284
22
  }
23
+ const engine = createEngine({
24
+ chatWithFallback,
25
+ summarizeHistory,
26
+ getToolDefs,
27
+ execCommand,
28
+ readFileTool,
29
+ curlTool,
30
+ onTrace: trace,
31
+ });
32
+
285
33
  if (singleShot) {
286
34
  const text = String(singleShot).trim();
287
35
  if (!text) return;
288
- const r = await runAgentTurn(text, messages);
36
+ const spinner = createSpinner("已发送给 AI,等待回复中");
37
+ spinner.start();
38
+ let r;
39
+ try { r = await engine.runTurn(text, messages); } finally { spinner.stop(`\x1b[90m✓ AI 已回复\x1b[0m`); }
289
40
  console.log(r.text);
290
41
  if (r.model) printFooter(r);
291
42
  saveHistory(messages.slice(1));
292
43
  return;
293
44
  }
45
+
294
46
  await printBanner();
295
- const rl = readline.createInterface({ input: stdin, output: stdout, prompt: `\x1b[36m${CHAT_PREFERRED.split("-")[0]}>\x1b[0m ` });
47
+ const rl = createReadline(`\x1b[36m${CHAT_PREFERRED.split("-")[0]}>\x1b[0m `);
296
48
  rl.prompt();
297
49
  for await (const line of rl) {
298
50
  const raw = String(line || "").trim();
299
51
  if (!raw) { rl.prompt(); continue; }
300
- const low = raw.toLowerCase();
301
- if (["/exit", "/quit", "exit", "quit", "退出"].includes(low)) {
302
- console.log("再见");
303
- saveHistory(messages.slice(1));
304
- rl.close();
305
- return;
306
- }
307
- if (["/help", "help", "/h", "?"].includes(low)) {
308
- console.log(SLASH_HELP);
309
- rl.prompt();
310
- continue;
311
- }
312
- if (["/stats", "/status", "stats", "status"].includes(low)) {
313
- console.log(formatStatsDetail());
314
- rl.prompt();
315
- continue;
316
- }
317
- if (["/clear", "clear"].includes(low)) {
318
- clearHistory();
319
- messages = [{ role: "system", content: system }];
320
- console.log("\x1b[90m[已清空历史]\x1b[0m");
321
- rl.prompt();
322
- continue;
323
- }
324
- if (["/history"].includes(low)) {
325
- console.log(`\x1b[90m历史 ${messages.length - 1} 条,约 ${estimateChars(messages)} 字符 · ${histPath()}\x1b[0m`);
326
- for (const m of messages.slice(1).slice(-10)) console.log(`- ${m.role}: ${(m.content || "").slice(0, 120)}`);
52
+ const slash = handleSlash(raw, { messages, system });
53
+ if (slash.handled) {
54
+ if (slash.exit) { console.log("再见"); saveHistory(messages.slice(1)); rl.close(); return; }
55
+ if (slash.messages) messages = slash.messages;
327
56
  rl.prompt();
328
57
  continue;
329
58
  }
330
59
  try {
331
- const r = await runAgentTurn(raw, messages);
60
+ const spinner = createSpinner("已发送给 AI,等待回复中");
61
+ spinner.start();
62
+ let r;
63
+ try { r = await engine.runTurn(raw, messages); }
64
+ finally { spinner.stop(`\x1b[90m✓ AI 已回复\x1b[0m`); }
332
65
  if (r.text && !r.text.startsWith("OK") && !r.text.startsWith("FAIL")) console.log(r.text);
333
66
  if (r.model || r.latency) printFooter(r);
334
67
  } catch (e) {
@@ -336,9 +69,9 @@ export async function startRepl({ singleShot } = {}) {
336
69
  messages.push({ role: "assistant", content: `error: ${String(e.message || e)}` });
337
70
  }
338
71
  saveHistory(messages.slice(1));
339
- if (needsCompress(messages)) {
340
- messages = await maybeCompress(messages);
341
- saveHistory(messages.slice(1));
72
+ if (messages.length > 2) {
73
+ const maybe = await engine.maybeCompress(messages);
74
+ if (maybe.length !== messages.length) { messages = maybe; saveHistory(messages.slice(1)); }
342
75
  }
343
76
  rl.prompt();
344
77
  }
@@ -0,0 +1,95 @@
1
+ import readline from "node:readline/promises";
2
+ import { stdin, stdout } from "node:process";
3
+ import { formatBannerLines, formatStatsDetail, collectStats, probeGateway } from "./stats.js";
4
+ import { createSpinner } from "./spinner.js";
5
+ import { normalizeFullId } from "../providers/model-id.js";
6
+ import { loadHistory, saveHistory, clearHistory, histPath, estimateChars } from "./store.js";
7
+
8
+ export const SLASH_HELP = `自然语言直接说,斜杠快捷:
9
+ /help 本帮助
10
+ /stats 详细统计(网关 -d 的请求/延迟/模型)
11
+ /history 查看对话历史
12
+ /clear 清空历史
13
+ /exit 退出
14
+ 示例:设置 hy3 为默认模型 / 查看组列表 / 看最近20条日志 / 读一下 src/logs.js`;
15
+
16
+ export async function printBanner() {
17
+ let probe = null;
18
+ try {
19
+ const s = collectStats();
20
+ probe = await probeGateway(s.port, 800);
21
+ } catch {}
22
+ const { lines } = formatBannerLines(probe);
23
+ for (const l of lines) console.log(l);
24
+ if (probe && !probe.alive) console.log(`\x1b[31m本地服务没有启动无法使用auto模式,请mslxdff -d 启动\x1b[0m`);
25
+ console.log(`\x1b[90m输入自然语言即可执行;/help 帮助,/stats 看网关统计,/exit 退出\x1b[0m`);
26
+ console.log(`\x1b[90m历史:${histPath()} · 仅拦截 -uninstall · 数据来自网关 -d,非本会话计数\x1b[0m`);
27
+ }
28
+
29
+ export function printFooter({ model, provider, latency, usage, totalMs, fallback, fallbackGateway, viaGateway }) {
30
+ const dim = "\x1b[90m";
31
+ const rst = "\x1b[0m";
32
+ let gw = null;
33
+ try { gw = collectStats(); } catch {}
34
+ const prov = provider && provider !== "opencode" ? `${provider}/` : "";
35
+ const baseLabel = model ? `${prov}${model}` : "—";
36
+ const via = provider && !baseLabel.startsWith(`${provider}/`) ? `: ${provider}` : "";
37
+ const modelLabel = model ? (fallbackGateway || viaGateway ? `${baseLabel} (gateway auto${via})` : baseLabel) : "—";
38
+ const latLabel = latency ? `${latency}ms` : "—";
39
+ const totalLabel = totalMs ? ` · 总耗时 ${totalMs}ms` : "";
40
+ const tokLabel = usage ? ` · tokens ${usage.prompt_tokens ?? "?"}→${usage.completion_tokens ?? "?"}` : "";
41
+ const fbLabel = fallbackGateway || viaGateway ? " · gateway-fallback" : fallback ? " · fallback" : "";
42
+ let gwLabel = "";
43
+ let extra = "";
44
+ if (gw) {
45
+ const full = (() => { try { return normalizeFullId(model); } catch { return model; } })();
46
+ const stat = gw.modelStats?.[full] || gw.modelStats?.[model] || null;
47
+ const cnt = stat?.count ?? gw.latencies?.[model]?.count;
48
+ const avgTtfb = stat?.avgTtfbMs ?? stat?.emaTtfbMs;
49
+ const avgTps = stat?.avgTps ?? stat?.emaTps;
50
+ const per = cnt ? ` · 网关该模型 ${cnt}次` : "";
51
+ const ttfbLabel = avgTtfb ? ` 平均首字 ${avgTtfb}ms` : (gw.latencies?.[model]?.emaMs ? ` EMA ${gw.latencies[model].emaMs}ms` : "");
52
+ const tpsLabel = avgTps ? ` · ${avgTps} tok/s` : "";
53
+ const verbose = stat?.avgCompTok ? ` · 啰嗦 ${stat.avgCompTok}tok/次` : "";
54
+ gwLabel = ` · 网关 总${gw.total} 成功${gw.success} 失败${gw.fail}${per}${ttfbLabel}${tpsLabel}${verbose}`;
55
+ if (usage?.completion_tokens && latency) {
56
+ const tpsNow = Math.round(usage.completion_tokens / (latency / 1000));
57
+ if (Number.isFinite(tpsNow) && tpsNow > 0) extra = ` · 本次首字 ~${latency}ms · ${tpsNow} tok/s`;
58
+ }
59
+ }
60
+ console.log(`${dim}─ ${modelLabel} ${latLabel}${totalLabel}${tokLabel}${fbLabel}${gwLabel}${extra}${rst}`);
61
+ }
62
+
63
+ export function handleSlash(line, ctx) {
64
+ const raw = String(line || "").trim();
65
+ const low = raw.toLowerCase();
66
+ if (["/help", "help", "/h", "?"].includes(low)) {
67
+ console.log(SLASH_HELP);
68
+ return { handled: true };
69
+ }
70
+ if (["/stats", "/status", "stats", "status"].includes(low)) {
71
+ console.log(formatStatsDetail());
72
+ return { handled: true };
73
+ }
74
+ if (["/clear", "clear"].includes(low)) {
75
+ clearHistory();
76
+ ctx.messages = [{ role: "system", content: ctx.system }];
77
+ console.log("\x1b[90m[已清空历史]\x1b[0m");
78
+ return { handled: true, messages: ctx.messages };
79
+ }
80
+ if (["/history"].includes(low)) {
81
+ console.log(`\x1b[90m历史 ${ctx.messages.length - 1} 条,约 ${estimateChars(ctx.messages)} 字符 · ${histPath()}\x1b[0m`);
82
+ for (const m of ctx.messages.slice(1).slice(-10)) console.log(`- ${m.role}: ${(m.content || "").slice(0, 120)}`);
83
+ return { handled: true };
84
+ }
85
+ if (["/exit", "/quit", "exit", "quit", "退出"].includes(low)) {
86
+ return { handled: true, exit: true };
87
+ }
88
+ return { handled: false };
89
+ }
90
+
91
+ export function createReadline(promptStr) {
92
+ return readline.createInterface({ input: stdin, output: stdout, prompt: promptStr });
93
+ }
94
+
95
+ export { createSpinner, loadHistory, saveHistory, histPath, estimateChars };