xiaozhou-chat 1.0.9 → 1.0.12

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 (2) hide show
  1. package/lib/chat.js +108 -52
  2. package/package.json +3 -2
package/lib/chat.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { Spinner, StreamPrinter } from "./ui.js";
3
3
  import { sleep } from "./utils.js";
4
4
  import { updateConfig, setProfileValue } from "./config.js";
5
+ import { builtInTools } from "./tools.js";
5
6
 
6
7
  // 尝试加载 Markdown 渲染库
7
8
  let marked;
@@ -57,43 +58,28 @@ export async function chatStream(context, userInput = null, options = {}) {
57
58
  }
58
59
 
59
60
  // 构造 Tools 定义
60
- // TODO: 从 mcpClients 和 builtInTools 获取
61
- // 这里我们假设 context 传入了完整的 tools 列表,或者我们在外部组装
62
- // 为了解耦,建议外部传入 tools array
63
61
  const tools = context.tools || [];
64
62
 
65
- const body = {
66
- model: config.model,
67
- messages: messages,
68
- stream: true,
69
- max_tokens: 8192
70
- };
71
-
72
- if (tools.length > 0) {
73
- body.tools = tools;
74
- }
75
-
63
+ // 处理 System Prompt
64
+ let requestMessages = messages;
76
65
  if (config.systemPrompt) {
77
- // Ensure system prompt is at the beginning
78
- if (messages.length === 0 || messages[0].role !== "system") {
79
- // Check if we should insert or replace?
80
- // Simplest: just don't mutate history permanently if it's transient?
81
- // But usually system prompt is part of config.
82
- // Let's assume messages already handles system prompt or we unshift it here for the request ONLY.
83
- // But standard is: messages[0] is system.
84
- // We'll let the caller handle message structure, or handle it here.
85
- // For CLI, we usually want to prepend system prompt if not present.
86
- const sysMsg = { role: "system", content: config.systemPrompt };
87
- if (messages.length > 0 && messages[0].role === "system") {
88
- // Replace existing? Or assume caller synced it?
89
- // Let's use a copy for the request to be safe
90
- body.messages = [sysMsg, ...messages.filter(m => m.role !== "system")];
91
- } else {
92
- body.messages = [sysMsg, ...messages];
93
- }
94
- }
66
+ const sysMsg = { role: "system", content: config.systemPrompt };
67
+ if (messages.length > 0 && messages[0].role === "system") {
68
+ requestMessages = [sysMsg, ...messages.filter(m => m.role !== "system")];
69
+ } else {
70
+ requestMessages = [sysMsg, ...messages];
71
+ }
95
72
  }
96
73
 
74
+ // 构造请求 Body
75
+ const createBody = (withTools = true) => ({
76
+ model: config.model,
77
+ messages: requestMessages,
78
+ stream: true,
79
+ max_tokens: 8192,
80
+ tools: (withTools && tools.length > 0) ? tools : undefined
81
+ });
82
+
97
83
  const spinner = new Spinner(isRecursion ? "AI 正在分析工具结果..." : "AI 正在思考...");
98
84
  spinner.start();
99
85
 
@@ -101,44 +87,82 @@ export async function chatStream(context, userInput = null, options = {}) {
101
87
 
102
88
  let requestUrl = `${config.baseUrl}/chat/completions`;
103
89
 
104
- // 自动尝试逻辑
105
- let shouldRetryWithV1 = false;
90
+ // 自动修正 Base URL (如果缺少 /v1)
91
+ if (!config.baseUrl.endsWith("/v1") && !config.baseUrl.endsWith("/v1/")) {
92
+ // console.log(`\n⚠️ 自动追加 /v1 到 Base URL`);
93
+ requestUrl = `${config.baseUrl}/v1/chat/completions`;
94
+ }
106
95
 
96
+ let shouldRetryWithV1 = false;
97
+
107
98
  try {
108
- let res = await requestWithRetry(requestUrl, {
109
- method: "POST",
110
- headers: {
111
- "Content-Type": "application/json",
112
- Authorization: `Bearer ${config.apiKey}`
113
- },
114
- body: JSON.stringify(body),
115
- signal: signal
116
- });
117
-
118
- // 智能检测:如果返回的是 HTML (通常是 404 页或首页),且 URL 没带 v1,可能是用户配错了
99
+ let res;
100
+ let usedTools = true;
101
+
102
+ // 第一次尝试 (默认带 Tools)
103
+ try {
104
+ res = await requestWithRetry(requestUrl, {
105
+ method: "POST",
106
+ headers: {
107
+ "Content-Type": "application/json",
108
+ Authorization: `Bearer ${config.apiKey}`
109
+ },
110
+ body: JSON.stringify(createBody(true)),
111
+ signal: signal
112
+ }, 1); // 减少内部重试,由外层控制
113
+ } catch (e) {
114
+ // 如果是 400 错误,且我们用了 Tools,尝试降级
115
+ if (e.message.includes("400") || e.message.includes("Improperly formed request")) {
116
+ console.log("\n⚠️ API 不支持工具调用或参数格式错误,尝试自动降级为纯聊天模式...");
117
+ usedTools = false;
118
+ res = await requestWithRetry(requestUrl, {
119
+ method: "POST",
120
+ headers: {
121
+ "Content-Type": "application/json",
122
+ Authorization: `Bearer ${config.apiKey}`
123
+ },
124
+ body: JSON.stringify(createBody(false)),
125
+ signal: signal
126
+ });
127
+ } else {
128
+ throw e;
129
+ }
130
+ }
131
+
132
+ // 智能检测 HTML (404/BaseUrl 错误)
119
133
  const contentType = res.headers.get("content-type");
120
134
  if (contentType && contentType.includes("text/html")) {
121
135
  if (!config.baseUrl.endsWith("/v1") && !config.baseUrl.endsWith("/v1/")) {
122
- // 静默重试,不打扰用户
123
136
  requestUrl = `${config.baseUrl}/v1/chat/completions`;
124
137
  shouldRetryWithV1 = true;
125
-
126
- // 重试请求
138
+ // 重试 (保持降级状态)
127
139
  res = await requestWithRetry(requestUrl, {
128
140
  method: "POST",
129
141
  headers: {
130
142
  "Content-Type": "application/json",
131
143
  Authorization: `Bearer ${config.apiKey}`
132
144
  },
133
- body: JSON.stringify(body),
145
+ body: JSON.stringify(createBody(usedTools)),
134
146
  signal: signal
135
147
  });
136
148
  }
137
149
  }
138
150
 
139
- // 停止 Spinner,准备流式输出
151
+ // 停止 Spinner
140
152
  spinner.stop();
141
153
 
154
+ // 检查是否是非流式响应 (JSON)
155
+ if (contentType && contentType.includes("application/json") && !createBody().stream) {
156
+ const data = await res.json();
157
+ const content = data.choices?.[0]?.message?.content || "";
158
+ printer.print(content);
159
+ printer.stop();
160
+ return { content };
161
+ }
162
+
163
+ // 准备流式输出
164
+ // spinner.stop(); // 移到上面了
165
+
142
166
  if (shouldRetryWithV1 && res.ok && !res.headers.get("content-type")?.includes("text/html")) {
143
167
  // 静默保存配置
144
168
  try {
@@ -272,14 +296,46 @@ export async function chatStream(context, userInput = null, options = {}) {
272
296
 
273
297
  // Handle Tool Calls
274
298
  if (toolCalls.length > 0) {
299
+ // 提取已知工具名
300
+ const knownToolNames = builtInTools.map(t => t.function.name);
301
+
275
302
  for (const tc of toolCalls) {
276
- const funcName = tc.function.name;
303
+ let funcName = tc.function.name;
304
+
305
+ // 自动修正工具名粘连 (例如: read_fileread_file -> read_file)
306
+ if (!knownToolNames.includes(funcName)) {
307
+ // 按长度降序排序,优先匹配更长的工具名
308
+ const matched = knownToolNames
309
+ .sort((a, b) => b.length - a.length)
310
+ .find(name => funcName.includes(name));
311
+
312
+ if (matched) {
313
+ console.log(`⚠️ 检测到工具名异常 "${funcName}",自动修正为 "${matched}"`);
314
+ funcName = matched;
315
+ tc.function.name = matched; // 修正原始对象,这对后续消息历史至关重要
316
+ }
317
+ }
318
+
277
319
  const argsStr = tc.function.arguments;
278
320
  console.log(`🛠️ 调用工具: ${funcName}(${argsStr})`);
279
321
 
280
322
  let result = null;
281
323
  try {
282
- const args = JSON.parse(argsStr);
324
+ let args;
325
+ try {
326
+ args = JSON.parse(argsStr);
327
+ } catch (e) {
328
+ // 尝试修复常见的 JSON 粘连问题 (例如: {"a":1}{"b":2})
329
+ if (argsStr.includes("}{")) {
330
+ console.log("⚠️ 检测到 JSON 粘连,尝试修复...");
331
+ // 简单策略:只取第一个 JSON
332
+ const fixStr = argsStr.split("}{")[0] + "}";
333
+ args = JSON.parse(fixStr);
334
+ } else {
335
+ throw e;
336
+ }
337
+ }
338
+
283
339
  // 1. Try built-in
284
340
  result = await toolHandlers(funcName, args);
285
341
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xiaozhou-chat",
3
- "version": "1.0.9",
3
+ "version": "1.0.12",
4
4
  "description": "CLI chatbot based on NewAPI",
5
5
  "bin": {
6
6
  "xiaozhou-chat": "bin/cli.js"
@@ -22,7 +22,8 @@
22
22
  "marked": "^15.0.12",
23
23
  "marked-terminal": "^7.3.0",
24
24
  "minimist": "^1.2.8",
25
- "moment": "^2.30.1"
25
+ "moment": "^2.30.1",
26
+ "xiaozhou-chat": "^1.0.1-0.1"
26
27
  },
27
28
  "pkg": {
28
29
  "assets": []