mslxdff 0.1.60 → 0.1.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/chat/prompt.js +1 -1
- package/src/chat/repl.js +74 -7
- package/src/providers/workbuddy.js +11 -2
- package/src/state.js +12 -2
package/package.json
CHANGED
package/src/chat/prompt.js
CHANGED
|
@@ -52,7 +52,7 @@ ${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
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
57
|
- **禁止重复调用(最高优先级)**:同一 run_command/curl/read_file 在本轮只执行一次,重复会被工具侧 SKIPPED_DUP 拦截;查询类(-showtoken/-status/-provider list/-providers list/-model list/-group list/-log 等)**调用一次即答案**,拿到 OK 结果后必须**立即用中文直接回答用户**,禁止再发起任何工具调用。收到 SKIPPED_DUP 或“请直接回答/禁止再调用”提示时,必须 0 工具直接回答。
|
|
58
58
|
- 禁止调用 -uninstall,包含即拒绝;-showtoken 仅在用户明确要求查看/调试本机 token 时才用,查模型/查供应商严禁调用。
|
package/src/chat/repl.js
CHANGED
|
@@ -30,6 +30,36 @@ function trace(line) {
|
|
|
30
30
|
console.log(`\x1b[90m· ${line}\x1b[0m`);
|
|
31
31
|
}
|
|
32
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
|
+
|
|
33
63
|
async function maybeCompress(messages) {
|
|
34
64
|
if (!needsCompress(messages)) return [...messages];
|
|
35
65
|
const sys = messages[0];
|
|
@@ -55,6 +85,19 @@ async function maybeCompress(messages) {
|
|
|
55
85
|
async function runAgentTurn(userText, messages) {
|
|
56
86
|
const tools = getToolDefs();
|
|
57
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
|
+
}
|
|
58
101
|
let loops = 0;
|
|
59
102
|
let lastModel = null;
|
|
60
103
|
let lastUsage = null;
|
|
@@ -64,6 +107,8 @@ async function runAgentTurn(userText, messages) {
|
|
|
64
107
|
const turnStart = performance.now();
|
|
65
108
|
// 同轮去重:同一工具+参数只真正执行一次,重复直接复用并提示 LLM
|
|
66
109
|
const seenCalls = new Map(); // key -> { count, firstResult }
|
|
110
|
+
let duplicateStrikes = 0;
|
|
111
|
+
let forceNoTools = false;
|
|
67
112
|
trace(`[turn] 开始 "${userText.slice(0, 60)}${userText.length > 60 ? "…" : ""}" · 历史 ${messages.length}条 约 ${estimateChars(messages)}字`);
|
|
68
113
|
while (loops < CHAT_MAX_TOOL_LOOPS) {
|
|
69
114
|
const tLoop = performance.now();
|
|
@@ -74,12 +119,19 @@ async function runAgentTurn(userText, messages) {
|
|
|
74
119
|
messages.length = 0;
|
|
75
120
|
for (const m of cur) messages.push(m);
|
|
76
121
|
const tCall = performance.now();
|
|
77
|
-
const spinnerLabel = loops === 0 ? "已发送给 AI,等待回复中" : "AI 正在整理回复中";
|
|
122
|
+
const spinnerLabel = loops === 0 ? "已发送给 AI,等待回复中" : forceNoTools ? "AI 整理回答中(已禁工具)" : "AI 正在整理回复中";
|
|
78
123
|
const spinner = createSpinner(spinnerLabel);
|
|
79
124
|
spinner.start();
|
|
80
125
|
let res;
|
|
81
126
|
try {
|
|
82
|
-
|
|
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
|
+
}
|
|
83
135
|
} finally {
|
|
84
136
|
const ms = Math.round(performance.now() - tCall);
|
|
85
137
|
spinner.stop(`\x1b[90m✓ AI 已回复 · ${ms}ms\x1b[0m`);
|
|
@@ -149,13 +201,18 @@ async function runAgentTurn(userText, messages) {
|
|
|
149
201
|
console.log(`\x1b[90m→ 执行: mslxdff ${cmd}\x1b[0m`);
|
|
150
202
|
const r = await execCommand(cmd);
|
|
151
203
|
result = `${r.ok ? "OK" : "FAIL"}: ${r.output}`;
|
|
152
|
-
// 查询类命令直接在结果里植入“立即回答”锚点,降低 LLM
|
|
204
|
+
// 查询类命令直接在结果里植入“立即回答”锚点,降低 LLM 再发一次的概率;若用户问模型,则 provider list 不算答案
|
|
153
205
|
const lowCmd = cmd.toLowerCase().replace(/\s+/g, " ").trim();
|
|
206
|
+
const asksModel = String(userText || "").toLowerCase().includes("模型");
|
|
154
207
|
const isOnceAndDone =
|
|
155
208
|
/^-+(showtoken|status|s|providers?\b|model\b|group\b|log\b|workbuddy\b|free\b|autostart\b|plugins\b)/.test(lowCmd) ||
|
|
156
209
|
lowCmd === "-provider list" || lowCmd === "-providers list";
|
|
157
210
|
if (isOnceAndDone && r.ok) {
|
|
158
|
-
|
|
211
|
+
if (asksModel && lowCmd.includes("-provider")) {
|
|
212
|
+
result += `\n\n[提示:此命令仅显示供应商配置,不包含模型列表。用户问的是“有哪些模型”,请用系统提示中的“可用模型”按前缀过滤回答,或调 curl local/models,不要再调 provider list]`;
|
|
213
|
+
} else {
|
|
214
|
+
result += `\n\n[系统提示:此查询已完成,结果即答案,请直接用中文回答用户,禁止再调用相同或同类查询工具]`;
|
|
215
|
+
}
|
|
159
216
|
}
|
|
160
217
|
const dt = Math.round(performance.now() - t1);
|
|
161
218
|
trace(`[tool] run_command "${cmd.slice(0, 40)}" · ${dt}ms · ${r.ok ? "OK" : "FAIL"} ${r.output.length}字`);
|
|
@@ -185,10 +242,20 @@ async function runAgentTurn(userText, messages) {
|
|
|
185
242
|
return { id: c.id, content: result };
|
|
186
243
|
}));
|
|
187
244
|
for (const tr of toolResults) messages.push({ role: "tool", tool_call_id: tr.id, content: tr.content });
|
|
188
|
-
// 若本轮有 SKIPPED_DUP
|
|
245
|
+
// 若本轮有 SKIPPED_DUP,额外追加系统提示并禁用后续工具,强制直接回答
|
|
189
246
|
if (toolResults.some((tr) => String(tr.content).startsWith("SKIPPED_DUP"))) {
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
}
|
|
192
259
|
}
|
|
193
260
|
const toolsMs = Math.round(performance.now() - tTools);
|
|
194
261
|
const loopMs = Math.round(performance.now() - tLoop);
|
|
@@ -3,6 +3,7 @@ import { createKeyRing } from "./keyring.js";
|
|
|
3
3
|
import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, loadProviderShareKeys, saveProviderConfig, WORKBUDDY_DEFAULT_BASE_URL } from "../state.js";
|
|
4
4
|
import { writeFileSync, existsSync, mkdirSync, readdirSync, readFileSync, appendFileSync, statSync } from "node:fs";
|
|
5
5
|
import { join, dirname } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
6
7
|
import { getCachedBalance, setCachedBalance } from "./workbuddy-balance.js";
|
|
7
8
|
|
|
8
9
|
let UndiciAgent = null;
|
|
@@ -17,6 +18,14 @@ function envInt(name, fallback) {
|
|
|
17
18
|
const v = Number(process.env[name]);
|
|
18
19
|
return Number.isInteger(v) && v > 0 ? v : fallback;
|
|
19
20
|
}
|
|
21
|
+
function isTestEnv() {
|
|
22
|
+
if (process.env.NODE_ENV === "test") return true;
|
|
23
|
+
if (process.env.MSLXDFF_STATE_FILE && String(process.env.MSLXDFF_STATE_FILE).includes("mslxdff-test")) return true;
|
|
24
|
+
if (process.argv.some((a) => String(a).includes("--test") || String(a).endsWith(".test.js"))) return true;
|
|
25
|
+
if (Array.isArray(process.execArgv) && process.execArgv.some((a) => String(a).includes("--test"))) return true;
|
|
26
|
+
if (process.env.NODE_TEST_CONTEXT) return true;
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
20
29
|
|
|
21
30
|
function resolveBaseUrl(baseUrl) {
|
|
22
31
|
if (baseUrl) return String(baseUrl).trim().replace(/\/+$/, "");
|
|
@@ -143,7 +152,7 @@ export function createWorkbuddyProvider({
|
|
|
143
152
|
// fallback scan auths/workbuddy-*.json if still empty
|
|
144
153
|
if (!authList.length && !keys.length) {
|
|
145
154
|
try {
|
|
146
|
-
const authDir = process.env.WORKBUDDY_AUTH_DIR || (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths"));
|
|
155
|
+
const authDir = process.env.WORKBUDDY_AUTH_DIR || (isTestEnv() ? join(tmpdir(), "mslxdff-test-auths") : (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths")));
|
|
147
156
|
if (existsSync(authDir)) {
|
|
148
157
|
const files = readdirSync(authDir).filter((f) => f.startsWith("workbuddy-") && f.endsWith(".json"));
|
|
149
158
|
for (const f of files) {
|
|
@@ -223,7 +232,7 @@ export function createWorkbuddyProvider({
|
|
|
223
232
|
saveProviderConfig(id, { baseUrl: resolvedBase, keys: [...keys], auths: [...authList] }, file ? { file } : {});
|
|
224
233
|
} catch {}
|
|
225
234
|
try {
|
|
226
|
-
const authDir = process.env.WORKBUDDY_AUTH_DIR || (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths"));
|
|
235
|
+
const authDir = process.env.WORKBUDDY_AUTH_DIR || (isTestEnv() ? join(tmpdir(), "mslxdff-test-auths") : (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths")));
|
|
227
236
|
mkdirSync(authDir, { recursive: true });
|
|
228
237
|
const expAt = (() => { try { return JSON.parse(Buffer.from(newAt.split(".")[1], "base64").toString()).exp; } catch { return Math.floor(Date.now()/1000)+5184000; } })();
|
|
229
238
|
const doc = { account: { uid, enterpriseId: auth.enterpriseId || "", nickname: "" }, auth: { accessToken: newAt, refreshToken: newRt, expiresAt: expAt, domain: auth.domain || "www.codebuddy.cn" } };
|
package/src/state.js
CHANGED
|
@@ -6,9 +6,19 @@ import os from "node:os";
|
|
|
6
6
|
|
|
7
7
|
export const DEFAULT_PORT = 8989;
|
|
8
8
|
|
|
9
|
+
function isTestEnv() {
|
|
10
|
+
if (process.env.NODE_ENV === "test") return true;
|
|
11
|
+
if (process.env.MSLXDFF_STATE_FILE && String(process.env.MSLXDFF_STATE_FILE).includes("mslxdff-test")) return true;
|
|
12
|
+
if (process.argv.some((a) => String(a).includes("--test") || String(a).endsWith(".test.js"))) return true;
|
|
13
|
+
if (Array.isArray(process.execArgv) && process.execArgv.some((a) => String(a).includes("--test"))) return true;
|
|
14
|
+
// Node test runner sets this
|
|
15
|
+
if (process.env.NODE_TEST_CONTEXT) return true;
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
9
19
|
export function defaultStateFile() {
|
|
10
20
|
if (process.env.MSLXDFF_STATE_FILE) return process.env.MSLXDFF_STATE_FILE;
|
|
11
|
-
if (
|
|
21
|
+
if (isTestEnv()) {
|
|
12
22
|
return join(os.tmpdir(), "mslxdff-test-state.json");
|
|
13
23
|
}
|
|
14
24
|
return join(os.homedir(), ".config", "mslxdff", "state.json");
|
|
@@ -19,7 +29,7 @@ export function tokenFile(file) {
|
|
|
19
29
|
const sf = file || defaultStateFile();
|
|
20
30
|
const realDefault = join(os.homedir(), ".config", "mslxdff", "state.json");
|
|
21
31
|
if (sf !== realDefault) return join(dirname(sf), "token");
|
|
22
|
-
if (
|
|
32
|
+
if (isTestEnv()) {
|
|
23
33
|
return join(os.tmpdir(), "mslxdff-test-token");
|
|
24
34
|
}
|
|
25
35
|
return join(os.homedir(), ".config", "mslxdff", "token");
|