haoyuan 1.0.0 → 1.1.0
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 +30 -0
- package/bin/haoyuan.js +702 -48
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -109,6 +109,36 @@ Kernel(会话 / 事件 / 工具注册 / 策略 / 模型网关 / 审批)
|
|
|
109
109
|
- Node.js 18+
|
|
110
110
|
- Windows / Linux / macOS
|
|
111
111
|
|
|
112
|
+
### npm 安装(推荐)
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
# 前提:Node.js 18+、Python 3.10+
|
|
116
|
+
npm install -g haoyuan
|
|
117
|
+
|
|
118
|
+
# 国内加速:
|
|
119
|
+
npm install -g haoyuan --registry=https://registry.npmmirror.com
|
|
120
|
+
|
|
121
|
+
# 终端对话(首次自动创建 venv、安装依赖)
|
|
122
|
+
haoyuan
|
|
123
|
+
|
|
124
|
+
# 启动服务并自动打开网页
|
|
125
|
+
haoyuan web
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
| 命令 | 作用 |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `haoyuan` | 终端对话(流式输出、工具调用、审批确认) |
|
|
131
|
+
| `haoyuan web` | 启动服务并自动打开网页 |
|
|
132
|
+
| `haoyuan web --no-open` | 启动服务但不打开浏览器 |
|
|
133
|
+
| `haoyuan --version` / `-V` | 查看版本号 |
|
|
134
|
+
| `haoyuan login` / `haoyuan logout` | 账号保护开启时登录 / 清除本地令牌 |
|
|
135
|
+
| `haoyuan setup` | 仅预装依赖 |
|
|
136
|
+
| `haoyuan help` | 显示帮助 |
|
|
137
|
+
|
|
138
|
+
终端内命令:`/new` 新建会话、`/web` 在浏览器打开当前会话、`/cancel` 取消当前任务、`/exit` 退出(Ctrl+C 同样有效)。
|
|
139
|
+
|
|
140
|
+
网页默认地址 `http://127.0.0.1:8030`,端口被占用自动 +1。未配置模型 Key 时使用内置 `mock` 模型,会话、工具调用、审批与前端均可完整运行。
|
|
141
|
+
|
|
112
142
|
### Windows 一键启动
|
|
113
143
|
|
|
114
144
|
```bat
|
package/bin/haoyuan.js
CHANGED
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
const { spawn, spawnSync } = require("child_process");
|
|
5
5
|
const fs = require("fs");
|
|
6
|
+
const http = require("http");
|
|
6
7
|
const path = require("path");
|
|
8
|
+
const readline = require("readline");
|
|
7
9
|
|
|
8
10
|
const ROOT = path.resolve(__dirname, "..");
|
|
11
|
+
let VERSION = "0.0.0";
|
|
12
|
+
try {
|
|
13
|
+
VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version || VERSION;
|
|
14
|
+
} catch {
|
|
15
|
+
/* ignore */
|
|
16
|
+
}
|
|
9
17
|
const IS_WIN = process.platform === "win32";
|
|
10
18
|
const NPM = IS_WIN ? "npm.cmd" : "npm";
|
|
11
19
|
const VENV = path.join(ROOT, "api", "env1");
|
|
@@ -16,6 +24,48 @@ const WEB = path.join(ROOT, "web");
|
|
|
16
24
|
const API = path.join(ROOT, "api");
|
|
17
25
|
const RUNTIME_FILE = path.join(API, "data", "runtime.json");
|
|
18
26
|
const DIST_INDEX = path.join(WEB, "dist", "index.html");
|
|
27
|
+
const TOKEN_FILE = path.join(API, "data", "cli-token");
|
|
28
|
+
let TOKEN = (process.env.HAOYUAN_TOKEN || "").trim();
|
|
29
|
+
if (!TOKEN) {
|
|
30
|
+
try {
|
|
31
|
+
TOKEN = fs.readFileSync(TOKEN_FILE, "utf8").trim();
|
|
32
|
+
} catch {
|
|
33
|
+
TOKEN = "";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function saveToken(token) {
|
|
38
|
+
TOKEN = token;
|
|
39
|
+
try {
|
|
40
|
+
fs.mkdirSync(path.dirname(TOKEN_FILE), { recursive: true });
|
|
41
|
+
fs.writeFileSync(TOKEN_FILE, token);
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function clearToken() {
|
|
49
|
+
TOKEN = "";
|
|
50
|
+
try {
|
|
51
|
+
fs.unlinkSync(TOKEN_FILE);
|
|
52
|
+
} catch {
|
|
53
|
+
/* ignore */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
58
|
+
const C = COLOR
|
|
59
|
+
? {
|
|
60
|
+
reset: "\x1b[0m",
|
|
61
|
+
dim: "\x1b[2m",
|
|
62
|
+
bold: "\x1b[1m",
|
|
63
|
+
cyan: "\x1b[36m",
|
|
64
|
+
green: "\x1b[32m",
|
|
65
|
+
red: "\x1b[31m",
|
|
66
|
+
yellow: "\x1b[33m",
|
|
67
|
+
}
|
|
68
|
+
: { reset: "", dim: "", bold: "", cyan: "", green: "", red: "", yellow: "" };
|
|
19
69
|
|
|
20
70
|
function log(msg) {
|
|
21
71
|
process.stdout.write(`[HaoYuan] ${msg}\n`);
|
|
@@ -38,8 +88,7 @@ function shellQuote(s) {
|
|
|
38
88
|
function run(cmd, args, opts = {}) {
|
|
39
89
|
const lenient = opts.lenient === true;
|
|
40
90
|
const useShell = IS_WIN && /\.(cmd|bat)$/i.test(cmd);
|
|
41
|
-
|
|
42
|
-
log(display);
|
|
91
|
+
log(`${cmd} ${args.join(" ")}`);
|
|
43
92
|
const r = spawnSync(
|
|
44
93
|
cmd,
|
|
45
94
|
useShell ? args.map(shellQuote) : args,
|
|
@@ -121,20 +170,23 @@ function readApiPort() {
|
|
|
121
170
|
}
|
|
122
171
|
}
|
|
123
172
|
|
|
173
|
+
async function healthyPort(port) {
|
|
174
|
+
if (!port) return false;
|
|
175
|
+
try {
|
|
176
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
|
177
|
+
signal: AbortSignal.timeout(2000),
|
|
178
|
+
});
|
|
179
|
+
return res.ok;
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
124
185
|
async function waitUntilHealthy(timeoutMs = 180000) {
|
|
125
186
|
const deadline = Date.now() + timeoutMs;
|
|
126
187
|
while (Date.now() < deadline) {
|
|
127
188
|
const port = readApiPort();
|
|
128
|
-
if (port)
|
|
129
|
-
try {
|
|
130
|
-
const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
|
131
|
-
signal: AbortSignal.timeout(1500),
|
|
132
|
-
});
|
|
133
|
-
if (res.ok) return port;
|
|
134
|
-
} catch {
|
|
135
|
-
/* 服务未就绪,继续等待 */
|
|
136
|
-
}
|
|
137
|
-
}
|
|
189
|
+
if (await healthyPort(port)) return port;
|
|
138
190
|
await new Promise((r) => setTimeout(r, 500));
|
|
139
191
|
}
|
|
140
192
|
return 0;
|
|
@@ -169,7 +221,16 @@ function postinstall() {
|
|
|
169
221
|
}
|
|
170
222
|
}
|
|
171
223
|
|
|
172
|
-
async function
|
|
224
|
+
async function web(options = {}) {
|
|
225
|
+
const running = await healthyPort(readApiPort());
|
|
226
|
+
if (running) {
|
|
227
|
+
const url = `http://127.0.0.1:${running}`;
|
|
228
|
+
log(`服务已在运行: ${url}`);
|
|
229
|
+
if (options.noOpen) log(`(--no-open 模式,请手动打开 ${url})`);
|
|
230
|
+
else openBrowser(url);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
173
234
|
if (!fs.existsSync(PYTHON)) {
|
|
174
235
|
log("首次运行,安装后端依赖 ...");
|
|
175
236
|
setup({ build: !fs.existsSync(DIST_INDEX) });
|
|
@@ -203,44 +264,637 @@ async function start(options = {}) {
|
|
|
203
264
|
}
|
|
204
265
|
}
|
|
205
266
|
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
267
|
+
async function ensureServer() {
|
|
268
|
+
const running = await healthyPort(readApiPort());
|
|
269
|
+
if (running) return { port: running, child: null };
|
|
270
|
+
|
|
271
|
+
if (!fs.existsSync(PYTHON)) {
|
|
272
|
+
log("首次运行,安装后端依赖 ...");
|
|
273
|
+
setup({ build: !fs.existsSync(DIST_INDEX) });
|
|
274
|
+
}
|
|
275
|
+
if (!fs.existsSync(DIST_INDEX)) buildFrontend();
|
|
276
|
+
|
|
277
|
+
log("启动 API(端口 8030,被占用时自动顺延)...");
|
|
278
|
+
let tail = "";
|
|
279
|
+
const child = spawn(PYTHON, ["main.py"], {
|
|
280
|
+
cwd: API,
|
|
281
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
282
|
+
env: process.env,
|
|
283
|
+
});
|
|
284
|
+
const keep = (chunk) => {
|
|
285
|
+
tail = (tail + chunk.toString()).slice(-4000);
|
|
286
|
+
};
|
|
287
|
+
child.stdout.on("data", keep);
|
|
288
|
+
child.stderr.on("data", keep);
|
|
289
|
+
child.on("error", (err) => {
|
|
290
|
+
warn(`服务启动失败: ${err.message}`);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const port = await waitUntilHealthy();
|
|
295
|
+
if (!port) {
|
|
296
|
+
child.kill();
|
|
297
|
+
warn("服务启动超时。服务日志:");
|
|
298
|
+
if (tail) process.stderr.write(tail + "\n");
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
log(`服务已就绪: http://127.0.0.1:${port}`);
|
|
302
|
+
return { port, child };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function apiHeaders() {
|
|
306
|
+
const headers = { "content-type": "application/json" };
|
|
307
|
+
if (TOKEN) {
|
|
308
|
+
headers["authorization"] = `Bearer ${TOKEN}`;
|
|
309
|
+
headers["x-haoyuan-token"] = TOKEN;
|
|
310
|
+
}
|
|
311
|
+
return headers;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function apiJson(port, method, apiPath, body) {
|
|
315
|
+
const res = await fetch(`http://127.0.0.1:${port}${apiPath}`, {
|
|
316
|
+
method,
|
|
317
|
+
headers: apiHeaders(),
|
|
318
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
319
|
+
signal: AbortSignal.timeout(30000),
|
|
320
|
+
});
|
|
321
|
+
let payload = null;
|
|
322
|
+
try {
|
|
323
|
+
payload = await res.json();
|
|
324
|
+
} catch {
|
|
325
|
+
/* ignore */
|
|
326
|
+
}
|
|
327
|
+
if (!payload || payload.code !== 200) {
|
|
328
|
+
throw new Error((payload && payload.message) || `HTTP ${res.status}`);
|
|
329
|
+
}
|
|
330
|
+
return payload.data;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function shortJson(value, limit = 120) {
|
|
334
|
+
let text;
|
|
335
|
+
try {
|
|
336
|
+
text = JSON.stringify(value);
|
|
337
|
+
} catch {
|
|
338
|
+
text = String(value);
|
|
339
|
+
}
|
|
340
|
+
if (!text) text = "";
|
|
341
|
+
return text.length > limit ? `${text.slice(0, limit)}…` : text;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function ask(rl, promptText, { mute = false } = {}) {
|
|
345
|
+
return new Promise((resolve) => {
|
|
346
|
+
if (!mute) {
|
|
347
|
+
rl.question(promptText, resolve);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
process.stdout.write(promptText);
|
|
351
|
+
const original = rl._writeToOutput;
|
|
352
|
+
rl._writeToOutput = () => {};
|
|
353
|
+
rl.question("", (answer) => {
|
|
354
|
+
rl._writeToOutput = original;
|
|
355
|
+
process.stdout.write("\n");
|
|
356
|
+
resolve(answer);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function loginAt(port, rl) {
|
|
362
|
+
const statusRes = await fetch(`http://127.0.0.1:${port}/api/auth/status`, {
|
|
363
|
+
signal: AbortSignal.timeout(10000),
|
|
364
|
+
});
|
|
365
|
+
const status = await statusRes.json().catch(() => null);
|
|
366
|
+
const data = (status && status.data) || {};
|
|
367
|
+
if (!data.configured) {
|
|
368
|
+
log("该实例未开启账号保护,无需登录。");
|
|
369
|
+
return "";
|
|
370
|
+
}
|
|
371
|
+
const username = (await ask(rl, `用户名${data.username ? `(${data.username})` : ""}: `)).trim();
|
|
372
|
+
const password = await ask(rl, "密码: ", { mute: true });
|
|
373
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/auth/login`, {
|
|
374
|
+
method: "POST",
|
|
375
|
+
headers: { "content-type": "application/json" },
|
|
376
|
+
body: JSON.stringify({ username, password }),
|
|
377
|
+
signal: AbortSignal.timeout(15000),
|
|
378
|
+
});
|
|
379
|
+
const payload = await res.json().catch(() => null);
|
|
380
|
+
if (!payload || payload.code !== 200) {
|
|
381
|
+
throw new Error((payload && payload.message) || `HTTP ${res.status}`);
|
|
382
|
+
}
|
|
383
|
+
saveToken(payload.data.token);
|
|
384
|
+
log(`已登录: ${payload.data.username || username}`);
|
|
385
|
+
return payload.data.token;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function loginCommand() {
|
|
389
|
+
const { port, child } = await ensureServer();
|
|
390
|
+
const rl = readline.createInterface({
|
|
391
|
+
input: process.stdin,
|
|
392
|
+
output: process.stdout,
|
|
393
|
+
terminal: process.stdin.isTTY === true,
|
|
394
|
+
});
|
|
395
|
+
try {
|
|
396
|
+
await loginAt(port, rl);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
warn(`登录失败: ${err.message}`);
|
|
399
|
+
} finally {
|
|
400
|
+
try {
|
|
401
|
+
rl.close();
|
|
402
|
+
} catch {
|
|
403
|
+
/* ignore */
|
|
404
|
+
}
|
|
405
|
+
if (child) {
|
|
406
|
+
try {
|
|
407
|
+
child.kill();
|
|
408
|
+
} catch {
|
|
409
|
+
/* ignore */
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function logoutCommand() {
|
|
416
|
+
clearToken();
|
|
417
|
+
log("已清除本地 CLI 令牌。");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function chat() {
|
|
421
|
+
const { port, child: serverChild } = await ensureServer();
|
|
422
|
+
|
|
423
|
+
const die = (msg) => {
|
|
424
|
+
if (serverChild) {
|
|
425
|
+
try {
|
|
426
|
+
serverChild.kill();
|
|
427
|
+
} catch {
|
|
428
|
+
/* ignore */
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
fail(msg);
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const state = {
|
|
435
|
+
sessionId: "",
|
|
436
|
+
busy: false,
|
|
437
|
+
approval: null,
|
|
438
|
+
inText: false,
|
|
439
|
+
inReasoning: false,
|
|
440
|
+
streamReq: null,
|
|
441
|
+
lastEventId: 0,
|
|
442
|
+
closing: false,
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
let rl = null;
|
|
446
|
+
|
|
447
|
+
function prompt() {
|
|
448
|
+
if (state.closing) return;
|
|
449
|
+
rl.setPrompt(`${C.cyan}>${C.reset} `);
|
|
450
|
+
rl.prompt();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function endLine() {
|
|
454
|
+
if (state.inText || state.inReasoning) {
|
|
455
|
+
process.stdout.write(`${C.reset}\n`);
|
|
456
|
+
state.inText = false;
|
|
457
|
+
state.inReasoning = false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function shutdown(code = 0) {
|
|
462
|
+
state.closing = true;
|
|
463
|
+
closeStream();
|
|
464
|
+
try {
|
|
465
|
+
rl.close();
|
|
466
|
+
} catch {
|
|
467
|
+
/* ignore */
|
|
468
|
+
}
|
|
469
|
+
if (serverChild) {
|
|
470
|
+
try {
|
|
471
|
+
serverChild.kill();
|
|
472
|
+
} catch {
|
|
473
|
+
/* ignore */
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
process.exit(code);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function closeStream() {
|
|
480
|
+
if (state.streamReq) {
|
|
481
|
+
try {
|
|
482
|
+
state.streamReq.destroy();
|
|
483
|
+
} catch {
|
|
484
|
+
/* ignore */
|
|
485
|
+
}
|
|
486
|
+
state.streamReq = null;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function openStream() {
|
|
491
|
+
if (state.streamReq || state.closing) return;
|
|
492
|
+
const qs = `after_id=${state.lastEventId}${TOKEN ? `&token=${encodeURIComponent(TOKEN)}` : ""}`;
|
|
493
|
+
const headers = { Accept: "text/event-stream" };
|
|
494
|
+
if (TOKEN) headers["x-haoyuan-token"] = TOKEN;
|
|
495
|
+
const req = http.get(
|
|
496
|
+
{
|
|
497
|
+
host: "127.0.0.1",
|
|
498
|
+
port,
|
|
499
|
+
path: `/api/sessions/${encodeURIComponent(state.sessionId)}/stream?${qs}`,
|
|
500
|
+
headers,
|
|
501
|
+
},
|
|
502
|
+
(res) => {
|
|
503
|
+
if (res.statusCode !== 200) {
|
|
504
|
+
res.resume();
|
|
505
|
+
state.streamReq = null;
|
|
506
|
+
if (state.busy) setTimeout(openStream, 500);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
let buf = "";
|
|
510
|
+
res.setEncoding("utf8");
|
|
511
|
+
res.on("data", (chunk) => {
|
|
512
|
+
buf += chunk;
|
|
513
|
+
let idx;
|
|
514
|
+
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
|
515
|
+
const block = buf.slice(0, idx);
|
|
516
|
+
buf = buf.slice(idx + 2);
|
|
517
|
+
const data = block
|
|
518
|
+
.split("\n")
|
|
519
|
+
.filter((line) => line.startsWith("data:"))
|
|
520
|
+
.map((line) => line.slice(5).trim())
|
|
521
|
+
.join("");
|
|
522
|
+
if (!data) continue;
|
|
523
|
+
let event;
|
|
524
|
+
try {
|
|
525
|
+
event = JSON.parse(data);
|
|
526
|
+
} catch {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
handleEvent(event);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
res.on("end", () => {
|
|
533
|
+
state.streamReq = null;
|
|
534
|
+
if (state.busy && !state.closing) setTimeout(openStream, 600);
|
|
535
|
+
});
|
|
536
|
+
res.on("error", () => {
|
|
537
|
+
state.streamReq = null;
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
);
|
|
541
|
+
req.on("error", () => {
|
|
542
|
+
state.streamReq = null;
|
|
543
|
+
if (state.busy && !state.closing) setTimeout(openStream, 800);
|
|
544
|
+
});
|
|
545
|
+
state.streamReq = req;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function handleEvent(event) {
|
|
549
|
+
if (event && event.id !== undefined) {
|
|
550
|
+
const id = Number(event.id);
|
|
551
|
+
if (Number.isFinite(id) && id > state.lastEventId) state.lastEventId = id;
|
|
552
|
+
}
|
|
553
|
+
const type = event.type || event.event;
|
|
554
|
+
const payload = event.payload || {};
|
|
555
|
+
|
|
556
|
+
if (type === "stream.end") {
|
|
557
|
+
state.streamReq = null;
|
|
558
|
+
if (state.busy) {
|
|
559
|
+
state.busy = false;
|
|
560
|
+
endLine();
|
|
561
|
+
prompt();
|
|
562
|
+
}
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (type === "delta") {
|
|
567
|
+
if (state.inReasoning) {
|
|
568
|
+
process.stdout.write(`${C.reset}\n`);
|
|
569
|
+
state.inReasoning = false;
|
|
570
|
+
}
|
|
571
|
+
if (!state.inText) {
|
|
572
|
+
process.stdout.write(`${C.bold}[助手]${C.reset} `);
|
|
573
|
+
state.inText = true;
|
|
574
|
+
}
|
|
575
|
+
if (payload.text) process.stdout.write(payload.text);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (type === "reasoning") {
|
|
580
|
+
if (payload.text) {
|
|
581
|
+
if (!state.inReasoning) {
|
|
582
|
+
process.stdout.write(`${C.dim}[思考] `);
|
|
583
|
+
state.inReasoning = true;
|
|
584
|
+
}
|
|
585
|
+
process.stdout.write(payload.text);
|
|
586
|
+
}
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (type === "tool.start") {
|
|
591
|
+
endLine();
|
|
592
|
+
process.stdout.write(`${C.cyan}[工具]${C.reset} ${payload.tool} ${C.dim}${shortJson(payload.args)}${C.reset}\n`);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (type === "tool.end") {
|
|
597
|
+
const mark = payload.ok ? `${C.green}[完成]${C.reset}` : `${C.red}[失败]${C.reset}`;
|
|
598
|
+
const ms = payload.ms != null ? ` ${C.dim}${payload.ms}ms${C.reset}` : "";
|
|
599
|
+
const detail = payload.ok ? payload.summary || "" : payload.error || "";
|
|
600
|
+
process.stdout.write(`${mark} ${payload.tool}${ms}${detail ? ` ${C.dim}${shortJson(detail, 160)}${C.reset}` : ""}\n`);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (type === "approval.required") {
|
|
605
|
+
endLine();
|
|
606
|
+
state.approval = payload;
|
|
607
|
+
process.stdout.write(
|
|
608
|
+
`${C.yellow}[审批]${C.reset} ${payload.tool} 需要确认 ${C.dim}${shortJson(payload.args, 200)}${C.reset}\n`
|
|
609
|
+
);
|
|
610
|
+
if (payload.preview) {
|
|
611
|
+
process.stdout.write(`${C.dim}${shortJson(payload.preview, 400)}${C.reset}\n`);
|
|
612
|
+
}
|
|
613
|
+
rl.setPrompt(`${C.yellow}批准执行? (y/N)${C.reset} `);
|
|
614
|
+
rl.prompt();
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (type === "approval.resolved") {
|
|
619
|
+
state.approval = null;
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (type === "error") {
|
|
624
|
+
endLine();
|
|
625
|
+
process.stdout.write(`${C.red}[错误] ${payload.message || "未知错误"}${C.reset}\n`);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (type === "done") {
|
|
630
|
+
if (!state.inText && payload.text) {
|
|
631
|
+
process.stdout.write(`${C.bold}[助手]${C.reset} ${payload.text}\n`);
|
|
632
|
+
}
|
|
633
|
+
endLine();
|
|
634
|
+
if (state.busy) {
|
|
635
|
+
state.busy = false;
|
|
636
|
+
prompt();
|
|
637
|
+
}
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (type === "session.status") {
|
|
642
|
+
if (payload.status === "error" && payload.error) {
|
|
643
|
+
endLine();
|
|
644
|
+
process.stdout.write(`${C.red}[错误] ${payload.error}${C.reset}\n`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function resolveApproval(approved) {
|
|
650
|
+
const approval = state.approval;
|
|
651
|
+
state.approval = null;
|
|
652
|
+
rl.setPrompt(`${C.cyan}>${C.reset} `);
|
|
653
|
+
if (!approval) return;
|
|
654
|
+
try {
|
|
655
|
+
await apiJson(port, "POST", `/api/approvals/${encodeURIComponent(approval.approval_id)}/resolve`, {
|
|
656
|
+
approved,
|
|
657
|
+
reason: approved ? "approved from CLI" : "rejected from CLI",
|
|
658
|
+
});
|
|
659
|
+
process.stdout.write(`${approved ? C.green : C.red}[${approved ? "已批准" : "已拒绝"}]${C.reset}\n`);
|
|
660
|
+
} catch (err) {
|
|
661
|
+
warn(`审批提交失败: ${err.message}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function newSession() {
|
|
666
|
+
const data = await apiJson(port, "POST", "/api/sessions", {
|
|
667
|
+
kind: "auto",
|
|
668
|
+
workspace: process.cwd(),
|
|
669
|
+
});
|
|
670
|
+
state.sessionId = data.id;
|
|
671
|
+
state.lastEventId = 0;
|
|
672
|
+
openStream();
|
|
673
|
+
return data;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async function sendMessage(text) {
|
|
677
|
+
state.busy = true;
|
|
678
|
+
try {
|
|
679
|
+
await apiJson(port, "POST", `/api/sessions/${encodeURIComponent(state.sessionId)}/messages`, {
|
|
680
|
+
content: text,
|
|
681
|
+
locale: "",
|
|
682
|
+
});
|
|
683
|
+
if (!state.streamReq) openStream();
|
|
684
|
+
} catch (err) {
|
|
685
|
+
state.busy = false;
|
|
686
|
+
warn(`发送失败: ${err.message}`);
|
|
687
|
+
prompt();
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function cancelRun() {
|
|
692
|
+
try {
|
|
693
|
+
await apiJson(port, "POST", `/api/sessions/${encodeURIComponent(state.sessionId)}/cancel`, {});
|
|
694
|
+
process.stdout.write(`${C.yellow}[已取消当前任务]${C.reset}\n`);
|
|
695
|
+
} catch (err) {
|
|
696
|
+
warn(`取消失败: ${err.message}`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function chatHelp() {
|
|
227
701
|
process.stdout.write(
|
|
228
702
|
[
|
|
229
|
-
|
|
230
|
-
"",
|
|
231
|
-
"
|
|
232
|
-
"
|
|
233
|
-
"
|
|
234
|
-
" build 仅构建前端",
|
|
235
|
-
" help 显示帮助",
|
|
236
|
-
"",
|
|
237
|
-
"选项:",
|
|
238
|
-
" --no-open 启动后不自动打开浏览器",
|
|
239
|
-
" --no-build setup 时跳过前端构建(已有 dist 时自动跳过)",
|
|
703
|
+
`${C.bold}终端命令${C.reset}`,
|
|
704
|
+
" /new 新建会话",
|
|
705
|
+
" /web 在浏览器打开当前会话",
|
|
706
|
+
" /cancel 取消当前任务",
|
|
707
|
+
" /exit 退出(也可 Ctrl+C)",
|
|
240
708
|
"",
|
|
241
709
|
].join("\n")
|
|
242
710
|
);
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function handleLine(line) {
|
|
714
|
+
const text = line.trim();
|
|
715
|
+
if (!text) {
|
|
716
|
+
prompt();
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (state.approval) {
|
|
721
|
+
if (/^y(es)?$/i.test(text)) await resolveApproval(true);
|
|
722
|
+
else await resolveApproval(false);
|
|
723
|
+
prompt();
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (text.startsWith("/")) {
|
|
728
|
+
const cmd = text.split(/\s+/, 1)[0].toLowerCase();
|
|
729
|
+
if (cmd === "/exit" || cmd === "/quit") return shutdown(0);
|
|
730
|
+
if (cmd === "/help") {
|
|
731
|
+
chatHelp();
|
|
732
|
+
return prompt();
|
|
733
|
+
}
|
|
734
|
+
if (cmd === "/new") {
|
|
735
|
+
try {
|
|
736
|
+
const session = await newSession();
|
|
737
|
+
log(`新会话: ${session.id}${session.title ? ` · ${session.title}` : ""}`);
|
|
738
|
+
} catch (err) {
|
|
739
|
+
warn(`新建会话失败: ${err.message}`);
|
|
740
|
+
}
|
|
741
|
+
return prompt();
|
|
742
|
+
}
|
|
743
|
+
if (cmd === "/web") {
|
|
744
|
+
openBrowser(`http://127.0.0.1:${port}/#/s/${state.sessionId}`);
|
|
745
|
+
return prompt();
|
|
746
|
+
}
|
|
747
|
+
if (cmd === "/cancel") {
|
|
748
|
+
await cancelRun();
|
|
749
|
+
return prompt();
|
|
750
|
+
}
|
|
751
|
+
warn(`未知命令: ${cmd}(/help 查看帮助)`);
|
|
752
|
+
return prompt();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (state.busy) {
|
|
756
|
+
await sendMessage(text);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
await sendMessage(text);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
rl = readline.createInterface({
|
|
764
|
+
input: process.stdin,
|
|
765
|
+
output: process.stdout,
|
|
766
|
+
historySize: 300,
|
|
767
|
+
terminal: process.stdin.isTTY === true,
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
let session;
|
|
771
|
+
try {
|
|
772
|
+
session = await newSession();
|
|
773
|
+
} catch (err) {
|
|
774
|
+
if (!/auth/i.test(err.message)) die(`创建会话失败: ${err.message}`);
|
|
775
|
+
if (!process.stdin.isTTY) die(`需要登录:请先运行 haoyuan login(${err.message})`);
|
|
776
|
+
try {
|
|
777
|
+
await loginAt(port, rl);
|
|
778
|
+
session = await newSession();
|
|
779
|
+
} catch (loginErr) {
|
|
780
|
+
die(`登录失败: ${loginErr.message}`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const health = await fetch(`http://127.0.0.1:${port}/api/health`).then((r) => r.json());
|
|
785
|
+
const model = health && health.data ? `${health.data.default_provider}/${health.data.default_model}` : "unknown";
|
|
786
|
+
|
|
787
|
+
process.stdout.write(
|
|
788
|
+
[
|
|
789
|
+
"",
|
|
790
|
+
`${C.bold}豪源 HaoYuan v${VERSION}${C.reset} · 终端对话`,
|
|
791
|
+
`${C.dim}模型 ${model} · 工作区 ${process.cwd()}${C.reset}`,
|
|
792
|
+
`${C.dim}会话 ${session.id} · 网页 /web · 帮助 /help · 退出 /exit 或 Ctrl+C${C.reset}`,
|
|
793
|
+
"",
|
|
794
|
+
].join("\n")
|
|
795
|
+
);
|
|
796
|
+
|
|
797
|
+
rl.on("line", (line) => {
|
|
798
|
+
handleLine(line).catch((err) => {
|
|
799
|
+
warn(err.message);
|
|
800
|
+
prompt();
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
rl.on("close", () => shutdown(0));
|
|
805
|
+
|
|
806
|
+
const onSigint = async () => {
|
|
807
|
+
if (state.busy) {
|
|
808
|
+
await cancelRun();
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
shutdown(0);
|
|
812
|
+
};
|
|
813
|
+
rl.on("SIGINT", onSigint);
|
|
814
|
+
process.on("SIGINT", onSigint);
|
|
815
|
+
process.on("SIGTERM", () => shutdown(0));
|
|
816
|
+
|
|
817
|
+
prompt();
|
|
246
818
|
}
|
|
819
|
+
|
|
820
|
+
function printVersion() {
|
|
821
|
+
process.stdout.write(`${VERSION}\n`);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function printHelp() {
|
|
825
|
+
process.stdout.write(
|
|
826
|
+
[
|
|
827
|
+
`豪源 HaoYuan v${VERSION}`,
|
|
828
|
+
"",
|
|
829
|
+
"用法: haoyuan [命令] [选项]",
|
|
830
|
+
"",
|
|
831
|
+
"命令:",
|
|
832
|
+
" haoyuan 终端对话(默认)",
|
|
833
|
+
" haoyuan web 启动服务并打开网页",
|
|
834
|
+
" haoyuan version 显示版本号",
|
|
835
|
+
" haoyuan login 登录(账号保护开启时)",
|
|
836
|
+
" haoyuan logout 清除本地登录令牌",
|
|
837
|
+
" haoyuan setup 安装后端/前端依赖",
|
|
838
|
+
" haoyuan build 仅构建前端",
|
|
839
|
+
" haoyuan help 显示帮助",
|
|
840
|
+
"",
|
|
841
|
+
"选项:",
|
|
842
|
+
" -V, --version 显示版本号",
|
|
843
|
+
" --no-open web 启动后不打开浏览器",
|
|
844
|
+
" --no-build setup 时跳过前端构建",
|
|
845
|
+
"",
|
|
846
|
+
"环境变量:",
|
|
847
|
+
" HAOYUAN_TOKEN 账号保护开启后的访问令牌",
|
|
848
|
+
" HAOYUAN_PORT 后端基准端口(默认 8030)",
|
|
849
|
+
"",
|
|
850
|
+
].join("\n")
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const argv = process.argv.slice(2);
|
|
855
|
+
const command = argv[0] || "";
|
|
856
|
+
const flags = new Set(argv.slice(1));
|
|
857
|
+
const noOpen = flags.has("--no-open");
|
|
858
|
+
const noBuild = flags.has("--no-build");
|
|
859
|
+
|
|
860
|
+
async function main() {
|
|
861
|
+
if (command === "--version" || command === "-V" || command === "-v" || command === "version") {
|
|
862
|
+
printVersion();
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (command === "--help" || command === "-h" || command === "help") {
|
|
866
|
+
printHelp();
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (command === "" || command === "chat") {
|
|
870
|
+
await chat();
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
if (command === "web" || command === "start" || command === "serve") {
|
|
874
|
+
await web({ noOpen });
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (command === "setup") {
|
|
878
|
+
setup({ build: !noBuild });
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
if (command === "login") {
|
|
882
|
+
await loginCommand();
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (command === "logout") {
|
|
886
|
+
logoutCommand();
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
if (command === "build") {
|
|
890
|
+
buildFrontend();
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
if (command === "postinstall") {
|
|
894
|
+
postinstall();
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
fail(`未知命令: ${command}(运行 haoyuan help 查看用法)`);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
main().catch((err) => fail(err.message));
|