xiaozhou-chat 1.0.4 → 1.0.7
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/README.md +73 -118
- package/bin/cli.js +368 -483
- package/lib/chat.js +324 -0
- package/lib/config.js +127 -0
- package/lib/history.js +68 -0
- package/lib/mcp-lite.js +128 -0
- package/lib/tools.js +178 -0
- package/lib/ui.js +114 -0
- package/lib/utils.js +21 -0
- package/package.json +5 -2
- package/update_config.js +0 -19
package/lib/chat.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
|
|
2
|
+
import { Spinner, StreamPrinter } from "./ui.js";
|
|
3
|
+
import { sleep } from "./utils.js";
|
|
4
|
+
|
|
5
|
+
// 尝试加载 Markdown 渲染库
|
|
6
|
+
let marked;
|
|
7
|
+
try {
|
|
8
|
+
marked = (await import('marked')).marked;
|
|
9
|
+
const TerminalRenderer = (await import('marked-terminal')).default;
|
|
10
|
+
marked.setOptions({ renderer: new TerminalRenderer() });
|
|
11
|
+
} catch (e) {}
|
|
12
|
+
|
|
13
|
+
export async function requestWithRetry(url, options, maxRetries = 3) {
|
|
14
|
+
let lastError;
|
|
15
|
+
for (let i = 0; i <= maxRetries; i++) {
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetch(url, options);
|
|
18
|
+
if (!res.ok) {
|
|
19
|
+
const text = await res.text();
|
|
20
|
+
// 4xx errors: do not retry
|
|
21
|
+
if (res.status >= 400 && res.status < 500) {
|
|
22
|
+
throw new Error(`API Error (${res.status}): ${text}`);
|
|
23
|
+
}
|
|
24
|
+
// 5xx errors: retry
|
|
25
|
+
throw new Error(`Server Error (${res.status}): ${text}`);
|
|
26
|
+
}
|
|
27
|
+
return res;
|
|
28
|
+
} catch (e) {
|
|
29
|
+
lastError = e;
|
|
30
|
+
if (e.name === 'AbortError') throw e;
|
|
31
|
+
if (i === maxRetries) break;
|
|
32
|
+
|
|
33
|
+
const delay = 1000 * Math.pow(2, i);
|
|
34
|
+
// console.log(`⚠️ 请求失败,${delay}ms 后重试...`); // Optional: callback for logging?
|
|
35
|
+
|
|
36
|
+
if (options.signal?.aborted) throw new Error("Aborted during retry wait");
|
|
37
|
+
await sleep(delay);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw lastError;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function chatStream(context, userInput = null, options = {}) {
|
|
44
|
+
const {
|
|
45
|
+
messages,
|
|
46
|
+
config,
|
|
47
|
+
mcpClients,
|
|
48
|
+
toolHandlers, // Function: (name, args) => result
|
|
49
|
+
signal
|
|
50
|
+
} = context;
|
|
51
|
+
|
|
52
|
+
const { isRecursion = false } = options;
|
|
53
|
+
|
|
54
|
+
if (userInput) {
|
|
55
|
+
messages.push({ role: "user", content: userInput });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 构造 Tools 定义
|
|
59
|
+
// TODO: 从 mcpClients 和 builtInTools 获取
|
|
60
|
+
// 这里我们假设 context 传入了完整的 tools 列表,或者我们在外部组装
|
|
61
|
+
// 为了解耦,建议外部传入 tools array
|
|
62
|
+
const tools = context.tools || [];
|
|
63
|
+
|
|
64
|
+
const body = {
|
|
65
|
+
model: config.model,
|
|
66
|
+
messages: messages,
|
|
67
|
+
stream: true,
|
|
68
|
+
max_tokens: 8192
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
if (tools.length > 0) {
|
|
72
|
+
body.tools = tools;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (config.systemPrompt) {
|
|
76
|
+
// Ensure system prompt is at the beginning
|
|
77
|
+
if (messages.length === 0 || messages[0].role !== "system") {
|
|
78
|
+
// Check if we should insert or replace?
|
|
79
|
+
// Simplest: just don't mutate history permanently if it's transient?
|
|
80
|
+
// But usually system prompt is part of config.
|
|
81
|
+
// Let's assume messages already handles system prompt or we unshift it here for the request ONLY.
|
|
82
|
+
// But standard is: messages[0] is system.
|
|
83
|
+
// We'll let the caller handle message structure, or handle it here.
|
|
84
|
+
// For CLI, we usually want to prepend system prompt if not present.
|
|
85
|
+
const sysMsg = { role: "system", content: config.systemPrompt };
|
|
86
|
+
if (messages.length > 0 && messages[0].role === "system") {
|
|
87
|
+
// Replace existing? Or assume caller synced it?
|
|
88
|
+
// Let's use a copy for the request to be safe
|
|
89
|
+
body.messages = [sysMsg, ...messages.filter(m => m.role !== "system")];
|
|
90
|
+
} else {
|
|
91
|
+
body.messages = [sysMsg, ...messages];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const spinner = new Spinner(isRecursion ? "AI 正在分析工具结果..." : "AI 正在思考...");
|
|
97
|
+
spinner.start();
|
|
98
|
+
|
|
99
|
+
const printer = new StreamPrinter();
|
|
100
|
+
|
|
101
|
+
let requestUrl = `${config.baseUrl}/chat/completions`;
|
|
102
|
+
|
|
103
|
+
// 自动尝试逻辑
|
|
104
|
+
let shouldRetryWithV1 = false;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
let res = await requestWithRetry(requestUrl, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: {
|
|
110
|
+
"Content-Type": "application/json",
|
|
111
|
+
Authorization: `Bearer ${config.apiKey}`
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify(body),
|
|
114
|
+
signal: signal
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// 智能检测:如果返回的是 HTML (通常是 404 页或首页),且 URL 没带 v1,可能是用户配错了
|
|
118
|
+
const contentType = res.headers.get("content-type");
|
|
119
|
+
if (contentType && contentType.includes("text/html")) {
|
|
120
|
+
if (!config.baseUrl.endsWith("/v1") && !config.baseUrl.endsWith("/v1/")) {
|
|
121
|
+
console.log("\n⚠️ 检测到 API 返回 HTML,Base URL 可能缺少 '/v1'。");
|
|
122
|
+
console.log("🔄 正在尝试自动追加 '/v1' 重试...");
|
|
123
|
+
requestUrl = `${config.baseUrl}/v1/chat/completions`;
|
|
124
|
+
shouldRetryWithV1 = true;
|
|
125
|
+
|
|
126
|
+
// 重试请求
|
|
127
|
+
res = await requestWithRetry(requestUrl, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: {
|
|
130
|
+
"Content-Type": "application/json",
|
|
131
|
+
Authorization: `Bearer ${config.apiKey}`
|
|
132
|
+
},
|
|
133
|
+
body: JSON.stringify(body),
|
|
134
|
+
signal: signal
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 停止 Spinner,准备流式输出
|
|
140
|
+
spinner.stop();
|
|
141
|
+
|
|
142
|
+
if (shouldRetryWithV1 && res.ok && !res.headers.get("content-type")?.includes("text/html")) {
|
|
143
|
+
console.log(`✅ 自动修复成功!建议运行以下命令永久修改配置:`);
|
|
144
|
+
console.log(`👉 /config baseUrl ${config.baseUrl}/v1`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!res.body) throw new Error("Response body is empty");
|
|
148
|
+
|
|
149
|
+
const reader = res.body.getReader();
|
|
150
|
+
const decoder = new TextDecoder("utf-8");
|
|
151
|
+
let reply = "";
|
|
152
|
+
let buffer = "";
|
|
153
|
+
let currentToolCalls = {};
|
|
154
|
+
let usageInfo = null;
|
|
155
|
+
let hasReceivedContent = false;
|
|
156
|
+
|
|
157
|
+
while (true) {
|
|
158
|
+
const { done, value } = await reader.read();
|
|
159
|
+
if (done) break;
|
|
160
|
+
|
|
161
|
+
buffer += decoder.decode(value, { stream: true });
|
|
162
|
+
const lines = buffer.split("\n");
|
|
163
|
+
buffer = lines.pop() || "";
|
|
164
|
+
|
|
165
|
+
for (const line of lines) {
|
|
166
|
+
if (!line.trim()) continue;
|
|
167
|
+
|
|
168
|
+
if (!line.startsWith("data:")) {
|
|
169
|
+
// 尝试检测非流式错误返回
|
|
170
|
+
try {
|
|
171
|
+
const json = JSON.parse(line);
|
|
172
|
+
if (json.error) {
|
|
173
|
+
throw new Error(json.error.message || JSON.stringify(json.error));
|
|
174
|
+
}
|
|
175
|
+
} catch (e) {
|
|
176
|
+
if (e.message.includes("JSON")) {
|
|
177
|
+
// ignore json parse error, just bad format
|
|
178
|
+
} else {
|
|
179
|
+
throw e; // rethrow actual API error
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const data = line.slice(5).trim();
|
|
186
|
+
if (data === "[DONE]") break;
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const json = JSON.parse(data);
|
|
190
|
+
if (json.usage) {
|
|
191
|
+
usageInfo = json.usage;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const delta = json.choices?.[0]?.delta;
|
|
195
|
+
|
|
196
|
+
if (delta?.content) {
|
|
197
|
+
printer.add(delta.content);
|
|
198
|
+
reply += delta.content;
|
|
199
|
+
hasReceivedContent = true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (delta?.tool_calls) {
|
|
203
|
+
hasReceivedContent = true;
|
|
204
|
+
for (const tc of delta.tool_calls) {
|
|
205
|
+
if (!currentToolCalls[tc.index]) {
|
|
206
|
+
currentToolCalls[tc.index] = {
|
|
207
|
+
id: tc.id,
|
|
208
|
+
type: "function",
|
|
209
|
+
function: { name: "", arguments: "" }
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (tc.function?.name) currentToolCalls[tc.index].function.name += tc.function.name;
|
|
213
|
+
if (tc.function?.arguments) currentToolCalls[tc.index].function.arguments += tc.function.arguments;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} catch {}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 检查是否收到了有效内容
|
|
221
|
+
if (!hasReceivedContent && reply.length === 0) {
|
|
222
|
+
console.log(`\n🔍 调试信息: Status=${res.status}, Headers=${JSON.stringify([...res.headers.entries()])}`);
|
|
223
|
+
|
|
224
|
+
// 检查 buffer 中是否残留了错误信息(针对非 SSE 格式的错误返回)
|
|
225
|
+
if (buffer.trim()) {
|
|
226
|
+
try {
|
|
227
|
+
const json = JSON.parse(buffer);
|
|
228
|
+
if (json.error) throw new Error(json.error.message || JSON.stringify(json.error));
|
|
229
|
+
} catch {}
|
|
230
|
+
throw new Error(`API 响应无法解析 (Raw: ${buffer.slice(0, 100)}...)`);
|
|
231
|
+
}
|
|
232
|
+
// 如果连 buffer 都是空的,但 status 是 200
|
|
233
|
+
throw new Error("API 返回了空内容 (Content-Length: 0)。请检查 Base URL 是否正确,或尝试更换 Model。");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
await printer.waitIdle();
|
|
237
|
+
console.log(""); // newline
|
|
238
|
+
|
|
239
|
+
// Markdown Post-render
|
|
240
|
+
if (marked && (reply.includes("```") || reply.includes("**") || reply.includes("# "))) {
|
|
241
|
+
try { console.log(marked(reply)); } catch {}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Add assistant message
|
|
245
|
+
const assistantMsg = { role: "assistant", content: reply };
|
|
246
|
+
const toolCalls = Object.values(currentToolCalls);
|
|
247
|
+
if (toolCalls.length > 0) {
|
|
248
|
+
assistantMsg.tool_calls = toolCalls;
|
|
249
|
+
}
|
|
250
|
+
messages.push(assistantMsg);
|
|
251
|
+
|
|
252
|
+
// Usage stats
|
|
253
|
+
if (usageInfo) {
|
|
254
|
+
const { prompt_tokens, completion_tokens, total_tokens } = usageInfo;
|
|
255
|
+
console.log(`\x1b[90m(Tokens: ${prompt_tokens} + ${completion_tokens} = ${total_tokens})\x1b[0m`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Handle Tool Calls
|
|
259
|
+
if (toolCalls.length > 0) {
|
|
260
|
+
for (const tc of toolCalls) {
|
|
261
|
+
const funcName = tc.function.name;
|
|
262
|
+
const argsStr = tc.function.arguments;
|
|
263
|
+
console.log(`🛠️ 调用工具: ${funcName}(${argsStr})`);
|
|
264
|
+
|
|
265
|
+
let result = null;
|
|
266
|
+
try {
|
|
267
|
+
const args = JSON.parse(argsStr);
|
|
268
|
+
// 1. Try built-in
|
|
269
|
+
result = await toolHandlers(funcName, args);
|
|
270
|
+
|
|
271
|
+
// 2. Try MCP
|
|
272
|
+
if (!result && mcpClients) {
|
|
273
|
+
// Find which client has this tool?
|
|
274
|
+
// We assume caller knows or we iterate.
|
|
275
|
+
// For now, simplify: mcpClients is a Map<name, client>
|
|
276
|
+
// We need to know which client provides which tool.
|
|
277
|
+
// Or we iterate all clients (slow?).
|
|
278
|
+
// In original cli.js, it wasn't fully implemented for *execution* via MCP map iteration?
|
|
279
|
+
// Ah, original cli.js didn't iterate mcpClients for execution in the snippet I saw.
|
|
280
|
+
// It only had `builtInTools`.
|
|
281
|
+
// But the todo said "integrate MCP support".
|
|
282
|
+
// I should check if I missed MCP execution logic.
|
|
283
|
+
// Assuming mcpClients have a `callTool` method.
|
|
284
|
+
for (const client of mcpClients.values()) {
|
|
285
|
+
try {
|
|
286
|
+
const mcpRes = await client.callTool(funcName, args);
|
|
287
|
+
if (mcpRes) {
|
|
288
|
+
result = JSON.stringify(mcpRes);
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
} catch {}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} catch (e) {
|
|
295
|
+
result = `Error: ${e.message}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (result === null) {
|
|
299
|
+
result = "Error: Tool not found or failed.";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
messages.push({
|
|
303
|
+
role: "tool",
|
|
304
|
+
tool_call_id: tc.id,
|
|
305
|
+
content: result
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Recursive call
|
|
310
|
+
return await chatStream(context, null, { isRecursion: true });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
} catch (e) {
|
|
314
|
+
spinner.stop();
|
|
315
|
+
printer.stop();
|
|
316
|
+
if (e.name === 'AbortError' || e.message === "Aborted during retry wait") {
|
|
317
|
+
console.log("\n🛑 已中断生成");
|
|
318
|
+
} else {
|
|
319
|
+
// 确保错误信息换行打印
|
|
320
|
+
process.stdout.write("\n");
|
|
321
|
+
console.error("❌ 请求失败:", e.message || e);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
|
|
6
|
+
// 查找项目根目录
|
|
7
|
+
export function findProjectRoot(startDir = process.cwd()) {
|
|
8
|
+
let dir = startDir;
|
|
9
|
+
while (true) {
|
|
10
|
+
if (fs.existsSync(path.join(dir, "package.json"))) {
|
|
11
|
+
return dir;
|
|
12
|
+
}
|
|
13
|
+
const parent = path.dirname(dir);
|
|
14
|
+
if (parent === dir) break;
|
|
15
|
+
dir = parent;
|
|
16
|
+
}
|
|
17
|
+
return startDir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const projectRoot = findProjectRoot();
|
|
21
|
+
const homeConfigFile = path.join(os.homedir(), ".newapi-chat-config.json");
|
|
22
|
+
const projectConfigFile = path.join(projectRoot, ".newapi-chat-config.json");
|
|
23
|
+
const projectAltConfigFile = path.join(projectRoot, "newapi-chat.config.json");
|
|
24
|
+
|
|
25
|
+
// 初始化配置文件 (安全模式: 不写入默认 Key)
|
|
26
|
+
export function initConfigFile() {
|
|
27
|
+
if (fs.existsSync(homeConfigFile)) return;
|
|
28
|
+
|
|
29
|
+
const defaultConfig = {
|
|
30
|
+
apiKey: "", // 用户需手动填入
|
|
31
|
+
baseUrl: "https://paid.tribiosapi.top/v1",
|
|
32
|
+
model: "claude-sonnet-4-5-20250929",
|
|
33
|
+
profiles: {
|
|
34
|
+
default: {
|
|
35
|
+
apiKey: "",
|
|
36
|
+
baseUrl: "https://paid.tribiosapi.top/v1",
|
|
37
|
+
model: "claude-sonnet-4-5-20250929"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
currentProfile: "default"
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
fs.writeFileSync(homeConfigFile, JSON.stringify(defaultConfig, null, 2), "utf-8");
|
|
44
|
+
console.log(`✅ 已创建配置文件: ${homeConfigFile}`);
|
|
45
|
+
console.log("⚠️ 请务必编辑该文件,填入你的 apiKey (sk-...),否则无法使用。");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function loadConfigFrom(file) {
|
|
49
|
+
if (!fs.existsSync(file)) return {};
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
52
|
+
} catch {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 加载配置 (Home < Project)
|
|
58
|
+
export function loadConfig() {
|
|
59
|
+
const home = loadConfigFrom(homeConfigFile);
|
|
60
|
+
const project =
|
|
61
|
+
loadConfigFrom(projectConfigFile) ||
|
|
62
|
+
loadConfigFrom(projectAltConfigFile);
|
|
63
|
+
|
|
64
|
+
// Deep merge profiles if needed, but simple merge is okay for now
|
|
65
|
+
const config = { ...home, ...project };
|
|
66
|
+
|
|
67
|
+
// 确保 profiles 结构存在
|
|
68
|
+
if (!config.profiles) {
|
|
69
|
+
config.profiles = {
|
|
70
|
+
default: {
|
|
71
|
+
apiKey: config.apiKey || "",
|
|
72
|
+
baseUrl: config.baseUrl || "https://paid.tribiosapi.top/v1",
|
|
73
|
+
model: config.model || "claude-sonnet-4-5-20250929",
|
|
74
|
+
systemPrompt: config.systemPrompt
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
config.currentProfile = "default";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return config;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getWriteConfigFile() {
|
|
84
|
+
if (fs.existsSync(projectConfigFile)) return projectConfigFile;
|
|
85
|
+
if (fs.existsSync(projectAltConfigFile)) return projectAltConfigFile;
|
|
86
|
+
return homeConfigFile;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function updateConfig(key, value) {
|
|
90
|
+
const target = getWriteConfigFile();
|
|
91
|
+
let current = loadConfigFrom(target);
|
|
92
|
+
|
|
93
|
+
// 安全检查:如果是项目级配置,且正在写入 apiKey,给出警告或阻止
|
|
94
|
+
if ((target === projectConfigFile || target === projectAltConfigFile) && key === "apiKey") {
|
|
95
|
+
console.warn("⚠️ 警告: 你正在将 API Key 写入项目级配置文件。这可能会被提交到版本控制系统!");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
current[key] = value;
|
|
99
|
+
fs.writeFileSync(target, JSON.stringify(current, null, 2), "utf-8");
|
|
100
|
+
return current;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function setProfileValue(profileName, key, value) {
|
|
104
|
+
const target = getWriteConfigFile();
|
|
105
|
+
let current = loadConfigFrom(target);
|
|
106
|
+
|
|
107
|
+
if (!current.profiles) current.profiles = {};
|
|
108
|
+
if (!current.profiles[profileName]) current.profiles[profileName] = {};
|
|
109
|
+
|
|
110
|
+
current.profiles[profileName][key] = value;
|
|
111
|
+
|
|
112
|
+
fs.writeFileSync(target, JSON.stringify(current, null, 2), "utf-8");
|
|
113
|
+
return current;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function getActiveConfig(config) {
|
|
117
|
+
const profileName = config.currentProfile || "default";
|
|
118
|
+
const profile = config.profiles?.[profileName] || {};
|
|
119
|
+
|
|
120
|
+
// 优先级: Profile > Top Level > Env > Default
|
|
121
|
+
return {
|
|
122
|
+
apiKey: profile.apiKey || config.apiKey || process.env.NEWAPI_API_KEY || "",
|
|
123
|
+
baseUrl: profile.baseUrl || config.baseUrl || process.env.NEWAPI_BASE_URL || "https://paid.tribiosapi.top/v1",
|
|
124
|
+
model: profile.model || config.model || process.env.NEWAPI_MODEL || "claude-sonnet-4-5-20250929",
|
|
125
|
+
systemPrompt: profile.systemPrompt || config.systemPrompt || process.env.NEWAPI_SYSTEM_PROMPT || ""
|
|
126
|
+
};
|
|
127
|
+
}
|
package/lib/history.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { findProjectRoot } from "./config.js";
|
|
6
|
+
|
|
7
|
+
const projectRoot = findProjectRoot();
|
|
8
|
+
const homeHistoryFile = path.join(os.homedir(), ".newapi-chat-history.json");
|
|
9
|
+
const projectHistoryFile = path.join(projectRoot, ".newapi-chat-history.json");
|
|
10
|
+
|
|
11
|
+
function canWriteProjectDir() {
|
|
12
|
+
try {
|
|
13
|
+
fs.accessSync(projectRoot, fs.constants.W_OK);
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getReadHistoryFile() {
|
|
21
|
+
return fs.existsSync(projectHistoryFile)
|
|
22
|
+
? projectHistoryFile
|
|
23
|
+
: homeHistoryFile;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getWriteHistoryFile() {
|
|
27
|
+
return canWriteProjectDir()
|
|
28
|
+
? projectHistoryFile
|
|
29
|
+
: homeHistoryFile;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function loadHistory() {
|
|
33
|
+
const target = getReadHistoryFile();
|
|
34
|
+
if (!fs.existsSync(target)) return [];
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(fs.readFileSync(target, "utf-8"));
|
|
37
|
+
} catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function saveHistory(messages) {
|
|
43
|
+
const target = getWriteHistoryFile();
|
|
44
|
+
// 简单的自动截断保护 (保留最近 500 条)
|
|
45
|
+
// 如果需要更复杂的压缩,由外部调用 compress
|
|
46
|
+
const toSave = messages.length > 500 ? messages.slice(-500) : messages;
|
|
47
|
+
fs.writeFileSync(target, JSON.stringify(toSave, null, 2));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function clearHistory() {
|
|
51
|
+
const target = getWriteHistoryFile();
|
|
52
|
+
fs.writeFileSync(target, JSON.stringify([], null, 2));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function exportHistory(messages) {
|
|
56
|
+
if (!messages.length) return "(暂无历史)";
|
|
57
|
+
|
|
58
|
+
const md = messages.map((m) => {
|
|
59
|
+
const role = m.role === "user" ? "你" : "助手";
|
|
60
|
+
return `### ${role}\n\n${m.content}\n`;
|
|
61
|
+
}).join("\n");
|
|
62
|
+
|
|
63
|
+
const dir = canWriteProjectDir() ? projectRoot : os.homedir();
|
|
64
|
+
const file = path.join(dir, "newapi-chat-history.md");
|
|
65
|
+
|
|
66
|
+
fs.writeFileSync(file, md, "utf-8");
|
|
67
|
+
return file;
|
|
68
|
+
}
|
package/lib/mcp-lite.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import readline from 'readline';
|
|
3
|
+
|
|
4
|
+
export class MCPClient {
|
|
5
|
+
constructor(command, args, env = {}) {
|
|
6
|
+
this.command = command;
|
|
7
|
+
this.args = args;
|
|
8
|
+
this.env = env;
|
|
9
|
+
this.process = null;
|
|
10
|
+
this.requestId = 0;
|
|
11
|
+
this.pending = new Map();
|
|
12
|
+
this.rl = null;
|
|
13
|
+
this.initialized = false;
|
|
14
|
+
this.tools = [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async connect() {
|
|
18
|
+
try {
|
|
19
|
+
console.log(`🔌 连接 MCP Server: ${this.command} ${this.args.join(' ')}`);
|
|
20
|
+
this.process = spawn(this.command, this.args, {
|
|
21
|
+
env: { ...process.env, ...this.env },
|
|
22
|
+
stdio: ['pipe', 'pipe', 'inherit'] // stderr 继承以便调试
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
this.rl = readline.createInterface({
|
|
26
|
+
input: this.process.stdout,
|
|
27
|
+
terminal: false
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
this.rl.on('line', (line) => {
|
|
31
|
+
if (!line.trim()) return;
|
|
32
|
+
try {
|
|
33
|
+
const msg = JSON.parse(line);
|
|
34
|
+
// console.log("Received:", JSON.stringify(msg).slice(0, 100)); // Debug
|
|
35
|
+
|
|
36
|
+
if (msg.id !== undefined && this.pending.has(msg.id)) {
|
|
37
|
+
const { resolve, reject } = this.pending.get(msg.id);
|
|
38
|
+
this.pending.delete(msg.id);
|
|
39
|
+
if (msg.error) {
|
|
40
|
+
reject(new Error(msg.error.message || JSON.stringify(msg.error)));
|
|
41
|
+
} else {
|
|
42
|
+
resolve(msg.result);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.error("MCP JSON Parse Error:", e);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
this.process.on('error', (err) => {
|
|
51
|
+
console.error(`MCP Process Error (${this.command}):`, err);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
this.process.on('exit', (code) => {
|
|
55
|
+
if (code !== 0) console.log(`MCP Server exited with code ${code}`);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// 初始化握手
|
|
59
|
+
await this.initialize();
|
|
60
|
+
this.initialized = true;
|
|
61
|
+
console.log("✅ MCP Server 连接成功");
|
|
62
|
+
|
|
63
|
+
// 获取工具列表
|
|
64
|
+
await this.refreshTools();
|
|
65
|
+
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error("MCP Connection Failed:", error);
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async request(method, params) {
|
|
73
|
+
if (!this.process) throw new Error("MCP Client not connected");
|
|
74
|
+
|
|
75
|
+
const id = this.requestId++;
|
|
76
|
+
const msg = { jsonrpc: "2.0", id, method, params };
|
|
77
|
+
const jsonStr = JSON.stringify(msg);
|
|
78
|
+
// console.log("Sending:", jsonStr); // Debug
|
|
79
|
+
this.process.stdin.write(jsonStr + "\n");
|
|
80
|
+
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
// 30秒超时
|
|
83
|
+
const timeout = setTimeout(() => {
|
|
84
|
+
if (this.pending.has(id)) {
|
|
85
|
+
this.pending.delete(id);
|
|
86
|
+
reject(new Error("MCP Request Timeout"));
|
|
87
|
+
}
|
|
88
|
+
}, 30000);
|
|
89
|
+
|
|
90
|
+
this.pending.set(id, {
|
|
91
|
+
resolve: (res) => { clearTimeout(timeout); resolve(res); },
|
|
92
|
+
reject: (err) => { clearTimeout(timeout); reject(err); }
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async initialize() {
|
|
98
|
+
return this.request("initialize", {
|
|
99
|
+
protocolVersion: "2024-11-05",
|
|
100
|
+
clientInfo: { name: "xiaozhou-chat", version: "1.0.0" },
|
|
101
|
+
capabilities: { tools: {} }
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async refreshTools() {
|
|
106
|
+
const res = await this.request("tools/list", {});
|
|
107
|
+
this.tools = res.tools || [];
|
|
108
|
+
console.log(`🛠️ 加载了 ${this.tools.length} 个工具`);
|
|
109
|
+
return this.tools;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async callTool(name, args) {
|
|
113
|
+
console.log(`🤖 调用工具: ${name}`);
|
|
114
|
+
const res = await this.request("tools/call", { name, arguments: args });
|
|
115
|
+
return res.content; // MCP 返回的是 content 数组
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
getOpenAITools() {
|
|
119
|
+
return this.tools.map(tool => ({
|
|
120
|
+
type: "function",
|
|
121
|
+
function: {
|
|
122
|
+
name: tool.name,
|
|
123
|
+
description: tool.description,
|
|
124
|
+
parameters: tool.inputSchema
|
|
125
|
+
}
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
}
|