zapmyco 0.4.0 → 0.7.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/dist/cli/index.mjs +1321 -109
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +787 -344
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/index.mjs.map +1 -1
- package/dist/{loader-BK1Z72gI.mjs → loader-B5_elj6d.mjs} +903 -38
- package/dist/loader-B5_elj6d.mjs.map +1 -0
- package/package.json +5 -5
- package/dist/loader-BK1Z72gI.mjs.map +0 -1
package/dist/cli/index.mjs
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { B as APP_NAME, E as createLlmBasedAgent, F as eventBus, H as __VERSION__, I as buildSkillSnapshot, L as loadSkills, S as WebError, U as __require, V as SESSION_DIR_NAME, g as logger, h as configureLogger, i as AgentLlmFacade, n as loadConfig, p as SubAgentManager, t as HOME_CONFIG_PATH, w as ZapmycoErrorCode } from "../loader-B5_elj6d.mjs";
|
|
3
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
-
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
6
6
|
import * as os from "node:os";
|
|
7
|
-
import { homedir } from "node:os";
|
|
7
|
+
import { homedir, tmpdir } from "node:os";
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
|
|
10
10
|
import { EventEmitter } from "node:events";
|
|
11
|
+
import { getModels, getProviders } from "@mariozechner/pi-ai";
|
|
11
12
|
import chalk, { Chalk } from "chalk";
|
|
12
13
|
import { Command } from "commander";
|
|
13
|
-
import {
|
|
14
|
-
import { Container, Editor, Key, ProcessTerminal, TUI, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
15
|
-
import { spawn } from "node:child_process";
|
|
14
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
15
|
+
import { CombinedAutocompleteProvider, Container, Editor, Input, Key, ProcessTerminal, SelectList, TUI, getKeybindings, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
16
16
|
import TurndownService from "turndown";
|
|
17
17
|
import { lookup } from "node:dns/promises";
|
|
18
18
|
import { Client } from "@modelcontextprotocol/sdk/client";
|
|
19
19
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
20
20
|
|
|
21
21
|
//#region src/cli/repl/command-registry.ts
|
|
22
|
-
const log$
|
|
22
|
+
const log$6 = logger.child("repl:command-registry");
|
|
23
23
|
/**
|
|
24
24
|
* 命令注册表
|
|
25
25
|
*/
|
|
@@ -34,7 +34,7 @@ var CommandRegistry = class {
|
|
|
34
34
|
*/
|
|
35
35
|
register(cmd) {
|
|
36
36
|
const canonicalName = cmd.name.toLowerCase();
|
|
37
|
-
if (this.commands.has(canonicalName)) log$
|
|
37
|
+
if (this.commands.has(canonicalName)) log$6.warn(`命令 "${canonicalName}" 已存在,将被覆盖`);
|
|
38
38
|
this.commands.set(canonicalName, cmd);
|
|
39
39
|
for (const alias of cmd.aliases) {
|
|
40
40
|
const lowerAlias = alias.toLowerCase();
|
|
@@ -62,7 +62,7 @@ var CommandRegistry = class {
|
|
|
62
62
|
*/
|
|
63
63
|
async dispatch(parsed) {
|
|
64
64
|
if (parsed.kind !== "command") {
|
|
65
|
-
log$
|
|
65
|
+
log$6.warn("dispatch 收到了非 command 类型的输入");
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
68
68
|
const cmd = this.getCommand(parsed.name);
|
|
@@ -74,7 +74,7 @@ var CommandRegistry = class {
|
|
|
74
74
|
await cmd.handler(parsed.args, this.session);
|
|
75
75
|
} catch (error) {
|
|
76
76
|
const message = error instanceof Error ? error.message : String(error);
|
|
77
|
-
log$
|
|
77
|
+
log$6.error(`命令 /${cmd.name} 执行出错`, {}, error);
|
|
78
78
|
console.log(`\n 命令执行出错: ${message}\n`);
|
|
79
79
|
}
|
|
80
80
|
}
|
|
@@ -140,14 +140,73 @@ function createClearCommand() {
|
|
|
140
140
|
//#endregion
|
|
141
141
|
//#region src/cli/repl/commands/config-cmd.ts
|
|
142
142
|
/**
|
|
143
|
+
* /config 命令
|
|
144
|
+
*
|
|
145
|
+
* 查看和修改当前配置信息。
|
|
146
|
+
*/
|
|
147
|
+
/** 判断 key 是否可能触发原型污染 */
|
|
148
|
+
function isPrototypePollutionKey(key) {
|
|
149
|
+
return key === "__proto__" || key === "constructor" || key === "prototype";
|
|
150
|
+
}
|
|
151
|
+
function getByDotPath(obj, path) {
|
|
152
|
+
const keys = path.split(".");
|
|
153
|
+
let current = obj;
|
|
154
|
+
for (const key of keys) {
|
|
155
|
+
if (current === null || current === void 0 || typeof current !== "object") return;
|
|
156
|
+
if (isPrototypePollutionKey(key)) return;
|
|
157
|
+
current = current[key];
|
|
158
|
+
}
|
|
159
|
+
return current;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* 通过 dot-path 设置嵌套对象属性(自动创建中间对象)
|
|
163
|
+
*/
|
|
164
|
+
function setByDotPath(obj, path, value) {
|
|
165
|
+
const keys = path.split(".");
|
|
166
|
+
let current = obj;
|
|
167
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
168
|
+
const key = keys[i];
|
|
169
|
+
if (isPrototypePollutionKey(key)) return;
|
|
170
|
+
if (!(key in current) || typeof current[key] !== "object" || current[key] === null) current[key] = {};
|
|
171
|
+
current = current[key];
|
|
172
|
+
}
|
|
173
|
+
const lastKey = keys[keys.length - 1];
|
|
174
|
+
if (isPrototypePollutionKey(lastKey)) return;
|
|
175
|
+
current[lastKey] = value;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 更新 settings.json 中指定 dot-path 的值
|
|
179
|
+
*/
|
|
180
|
+
function updateSettingsFile(path, value) {
|
|
181
|
+
try {
|
|
182
|
+
const raw = readFileSync(HOME_CONFIG_PATH, "utf-8");
|
|
183
|
+
const config = JSON.parse(raw);
|
|
184
|
+
let parsedValue = value;
|
|
185
|
+
try {
|
|
186
|
+
parsedValue = JSON.parse(value);
|
|
187
|
+
} catch {}
|
|
188
|
+
setByDotPath(config, path, parsedValue);
|
|
189
|
+
writeFileSync(HOME_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
190
|
+
return {
|
|
191
|
+
success: true,
|
|
192
|
+
message: ""
|
|
193
|
+
};
|
|
194
|
+
} catch (err) {
|
|
195
|
+
return {
|
|
196
|
+
success: false,
|
|
197
|
+
message: err instanceof Error ? err.message : String(err)
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
143
202
|
* 创建 config 命令定义
|
|
144
203
|
*/
|
|
145
204
|
function createConfigCommand() {
|
|
146
205
|
return {
|
|
147
206
|
name: "config",
|
|
148
207
|
aliases: ["cfg"],
|
|
149
|
-
description: "
|
|
150
|
-
usage: "/config [show | get <key>]",
|
|
208
|
+
description: "查看或修改配置 [show | get <key> | set <key> <value>]",
|
|
209
|
+
usage: "/config [show | get <key> | set <key> <value>]",
|
|
151
210
|
handler(args, session) {
|
|
152
211
|
const config = session.config;
|
|
153
212
|
const renderer = session.getRenderer();
|
|
@@ -161,9 +220,9 @@ function createConfigCommand() {
|
|
|
161
220
|
if (value !== void 0) {
|
|
162
221
|
const displayValue = args[1].toLowerCase().includes("apikey") || args[1].toLowerCase().includes("api_key") ? "***已配置***" : JSON.stringify(value, null, 2);
|
|
163
222
|
session.appendOutput([
|
|
164
|
-
|
|
223
|
+
"",
|
|
165
224
|
` ${args[1]}: ${displayValue}`,
|
|
166
|
-
|
|
225
|
+
""
|
|
167
226
|
]);
|
|
168
227
|
} else session.appendOutput([
|
|
169
228
|
"",
|
|
@@ -173,28 +232,40 @@ function createConfigCommand() {
|
|
|
173
232
|
]);
|
|
174
233
|
return;
|
|
175
234
|
}
|
|
235
|
+
if (args[0] === "set" && args[1] && args[2] !== void 0) {
|
|
236
|
+
const key = args[1];
|
|
237
|
+
const value = args.slice(2).join(" ");
|
|
238
|
+
const result = updateSettingsFile(key, value);
|
|
239
|
+
if (result.success) {
|
|
240
|
+
let parsedValue = value;
|
|
241
|
+
try {
|
|
242
|
+
parsedValue = JSON.parse(value);
|
|
243
|
+
} catch {}
|
|
244
|
+
setByDotPath(session.config, key, parsedValue);
|
|
245
|
+
session.applyConfigUpdate(key);
|
|
246
|
+
const displayValue = key.toLowerCase().includes("apikey") || key.toLowerCase().includes("api_key") ? "***已配置***" : value;
|
|
247
|
+
session.appendOutput([
|
|
248
|
+
"",
|
|
249
|
+
` ✅ 配置已更新: ${key} = ${displayValue}`,
|
|
250
|
+
` 已持久化到 ${HOME_CONFIG_PATH}`,
|
|
251
|
+
""
|
|
252
|
+
]);
|
|
253
|
+
} else session.appendOutput([
|
|
254
|
+
"",
|
|
255
|
+
` ❌ 配置更新失败: ${result.message}`,
|
|
256
|
+
""
|
|
257
|
+
]);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
176
260
|
session.appendOutput([
|
|
177
261
|
"",
|
|
178
|
-
" 用法: /config [show | get <key>]",
|
|
262
|
+
" 用法: /config [show | get <key> | set <key> <value>]",
|
|
263
|
+
" 示例: /config set llm.providers.deepseek.apiKey sk-xxx",
|
|
179
264
|
""
|
|
180
265
|
]);
|
|
181
266
|
}
|
|
182
267
|
};
|
|
183
268
|
}
|
|
184
|
-
/**
|
|
185
|
-
* 通过 dot-path 获取嵌套对象属性
|
|
186
|
-
*
|
|
187
|
-
* 例如: getByPath(config, 'llm.provider') → config.llm.provider
|
|
188
|
-
*/
|
|
189
|
-
function getByDotPath(obj, path) {
|
|
190
|
-
const keys = path.split(".");
|
|
191
|
-
let current = obj;
|
|
192
|
-
for (const key of keys) {
|
|
193
|
-
if (current === null || current === void 0 || typeof current !== "object") return;
|
|
194
|
-
current = current[key];
|
|
195
|
-
}
|
|
196
|
-
return current;
|
|
197
|
-
}
|
|
198
269
|
|
|
199
270
|
//#endregion
|
|
200
271
|
//#region src/cli/repl/commands/help.ts
|
|
@@ -294,6 +365,840 @@ function createQuitCommand() {
|
|
|
294
365
|
};
|
|
295
366
|
}
|
|
296
367
|
|
|
368
|
+
//#endregion
|
|
369
|
+
//#region src/cli/repl/components/dialogs.ts
|
|
370
|
+
/**
|
|
371
|
+
* Shared dialog components for TUI overlays
|
|
372
|
+
*
|
|
373
|
+
* Extracted from settings-cmd.ts for reuse across the REPL.
|
|
374
|
+
* Provides SelectList and TextInput overlay dialogs.
|
|
375
|
+
*/
|
|
376
|
+
/** Overlay layout options for menus */
|
|
377
|
+
const OVERLAY_OPTIONS = {
|
|
378
|
+
width: "100%",
|
|
379
|
+
anchor: "top-left",
|
|
380
|
+
margin: {
|
|
381
|
+
top: 1,
|
|
382
|
+
bottom: 1
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
const SELECT_THEME = {
|
|
386
|
+
selectedPrefix: (text) => chalk.green(`❯ ${text}`),
|
|
387
|
+
selectedText: (text) => chalk.green.bold(text),
|
|
388
|
+
description: (text) => chalk.gray(text),
|
|
389
|
+
scrollInfo: (text) => chalk.gray(text),
|
|
390
|
+
noMatch: (text) => chalk.red(text)
|
|
391
|
+
};
|
|
392
|
+
/**
|
|
393
|
+
* Wraps SelectList with a footer hint showing available keybindings.
|
|
394
|
+
* This lets users discover navigation keys without relying on terminal conventions.
|
|
395
|
+
*/
|
|
396
|
+
var SelectListWithFooter = class {
|
|
397
|
+
selectList;
|
|
398
|
+
tui;
|
|
399
|
+
filterText = "";
|
|
400
|
+
isFiltering = false;
|
|
401
|
+
/** Callbacks stored at wrapper level, forwarded through inner SelectList */
|
|
402
|
+
onSelect;
|
|
403
|
+
onCancel;
|
|
404
|
+
/** Called when user presses q/esc — exits settings entirely */
|
|
405
|
+
onExit;
|
|
406
|
+
/** Called when user presses backspace/h — goes back one level */
|
|
407
|
+
onBack;
|
|
408
|
+
constructor(tui, items, maxVisible, theme) {
|
|
409
|
+
this.tui = tui;
|
|
410
|
+
this.selectList = new SelectList(items, maxVisible, theme);
|
|
411
|
+
this.selectList.onSelect = (item) => {
|
|
412
|
+
this.onSelect?.(item);
|
|
413
|
+
};
|
|
414
|
+
this.selectList.onCancel = () => {
|
|
415
|
+
this.onCancel?.();
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
handleInput(data) {
|
|
419
|
+
if (this.isFiltering) if (data.length === 1 && data >= " " && data <= "~") {
|
|
420
|
+
this.filterText += data;
|
|
421
|
+
this.applyFilter();
|
|
422
|
+
} else if (data === "backspace" || data === "" || data === "\b") if (this.filterText.length > 0) {
|
|
423
|
+
this.filterText = this.filterText.slice(0, -1);
|
|
424
|
+
this.applyFilter();
|
|
425
|
+
} else {
|
|
426
|
+
this.isFiltering = false;
|
|
427
|
+
this.resetFilteredItems();
|
|
428
|
+
this.selectList.invalidate();
|
|
429
|
+
}
|
|
430
|
+
else if (data === "escape") {
|
|
431
|
+
this.isFiltering = false;
|
|
432
|
+
this.filterText = "";
|
|
433
|
+
this.resetFilteredItems();
|
|
434
|
+
this.selectList.invalidate();
|
|
435
|
+
} else if (data === "enter") {
|
|
436
|
+
this.isFiltering = false;
|
|
437
|
+
this.selectList.handleInput("enter");
|
|
438
|
+
} else this.selectList.handleInput(data);
|
|
439
|
+
else if (data === "/") {
|
|
440
|
+
this.isFiltering = true;
|
|
441
|
+
this.filterText = "";
|
|
442
|
+
this.selectList.invalidate();
|
|
443
|
+
} else if (data === "escape" || data === "q") this.onExit?.();
|
|
444
|
+
else if (data === "backspace" || data === "" || data === "\b" || data === "h") this.onBack?.();
|
|
445
|
+
else this.selectList.handleInput(data);
|
|
446
|
+
}
|
|
447
|
+
/** Apply current filter text to the SelectList (uses includes, not startsWith) */
|
|
448
|
+
applyFilter() {
|
|
449
|
+
const sl = this.selectList;
|
|
450
|
+
if (!this.filterText) sl.filteredItems = [...sl.items];
|
|
451
|
+
else {
|
|
452
|
+
const lower = this.filterText.toLowerCase();
|
|
453
|
+
sl.filteredItems = sl.items.filter((item) => item.value.toLowerCase().includes(lower));
|
|
454
|
+
}
|
|
455
|
+
sl.selectedIndex = 0;
|
|
456
|
+
this.selectList.invalidate();
|
|
457
|
+
}
|
|
458
|
+
/** Restore SelectList to show all unfiltered items */
|
|
459
|
+
resetFilteredItems() {
|
|
460
|
+
const sl = this.selectList;
|
|
461
|
+
sl.filteredItems = [...sl.items];
|
|
462
|
+
sl.selectedIndex = 0;
|
|
463
|
+
}
|
|
464
|
+
invalidate() {
|
|
465
|
+
this.selectList.invalidate();
|
|
466
|
+
}
|
|
467
|
+
render(width) {
|
|
468
|
+
const lines = [];
|
|
469
|
+
if (this.isFiltering) {
|
|
470
|
+
lines.push("");
|
|
471
|
+
lines.push(chalk.cyan(` /${this.filterText}█`));
|
|
472
|
+
lines.push(chalk.gray(` ${"─".repeat(Math.max(0, width - 4))}`));
|
|
473
|
+
lines.push("");
|
|
474
|
+
}
|
|
475
|
+
const listLines = this.selectList.render(width);
|
|
476
|
+
lines.push(...listLines);
|
|
477
|
+
const termHeight = this.tui.terminal.rows;
|
|
478
|
+
const padding = Math.max(0, termHeight - 1 - lines.length - 3);
|
|
479
|
+
for (let i = 0; i < padding; i++) lines.push("");
|
|
480
|
+
if (width >= 50) {
|
|
481
|
+
lines.push(chalk.gray(` ${"─".repeat(Math.max(0, width - 4))}`));
|
|
482
|
+
if (this.isFiltering) lines.push(chalk.gray(" 输入文字搜索 · ↑↓ 导航 · Enter 确认 · Esc 取消"));
|
|
483
|
+
else lines.push(chalk.gray(" k/j ↑↓ 导航 · / 搜索 · Enter 选择 · Esc/q 退出 · BS/h 返回"));
|
|
484
|
+
} else if (this.isFiltering) lines.push(chalk.gray(" Enter 确认 · Esc 取消"));
|
|
485
|
+
else lines.push(chalk.gray(" ↑↓=k/j / Enter Esc/q BS/h"));
|
|
486
|
+
lines.push("");
|
|
487
|
+
return lines;
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
/**
|
|
491
|
+
* Show a SelectList overlay and wait for user selection
|
|
492
|
+
* @returns The selected item, or null if cancelled
|
|
493
|
+
*/
|
|
494
|
+
function showSelectList(tui, items, options) {
|
|
495
|
+
return new Promise((resolve) => {
|
|
496
|
+
const list = new SelectListWithFooter(tui, items, options?.maxVisible ?? 10, SELECT_THEME);
|
|
497
|
+
let handle = null;
|
|
498
|
+
list.onSelect = (item) => {
|
|
499
|
+
handle?.hide();
|
|
500
|
+
resolve(item);
|
|
501
|
+
};
|
|
502
|
+
list.onCancel = () => {
|
|
503
|
+
handle?.hide();
|
|
504
|
+
resolve(null);
|
|
505
|
+
};
|
|
506
|
+
list.onExit = () => {
|
|
507
|
+
handle?.hide();
|
|
508
|
+
resolve(null);
|
|
509
|
+
options?.onExit?.();
|
|
510
|
+
};
|
|
511
|
+
list.onBack = () => {
|
|
512
|
+
handle?.hide();
|
|
513
|
+
resolve(null);
|
|
514
|
+
};
|
|
515
|
+
handle = tui.showOverlay(list, OVERLAY_OPTIONS);
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
var TextInputComponent = class {
|
|
519
|
+
input;
|
|
520
|
+
label;
|
|
521
|
+
constructor(label, initialValue, placeholder, onSubmit, onCancel) {
|
|
522
|
+
this.label = label;
|
|
523
|
+
this.input = new Input();
|
|
524
|
+
if (initialValue) this.input.setValue(initialValue);
|
|
525
|
+
this.input.onSubmit = (value) => {
|
|
526
|
+
onSubmit(value === placeholder ? initialValue : value);
|
|
527
|
+
};
|
|
528
|
+
this.input.onEscape = () => {
|
|
529
|
+
onCancel();
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
get focused() {
|
|
533
|
+
return this.input.focused;
|
|
534
|
+
}
|
|
535
|
+
set focused(v) {
|
|
536
|
+
this.input.focused = v;
|
|
537
|
+
}
|
|
538
|
+
handleInput(data) {
|
|
539
|
+
this.input.handleInput(data);
|
|
540
|
+
}
|
|
541
|
+
invalidate() {
|
|
542
|
+
this.input.invalidate();
|
|
543
|
+
}
|
|
544
|
+
render(width) {
|
|
545
|
+
const c = chalk;
|
|
546
|
+
return [
|
|
547
|
+
"",
|
|
548
|
+
c.bold(` ${this.label}`),
|
|
549
|
+
"",
|
|
550
|
+
c.gray(` ${"─".repeat(Math.min(width - 4, 50))}`),
|
|
551
|
+
` ${this.input.render(width - 4)[0] ?? ""}`,
|
|
552
|
+
c.gray(` ${"─".repeat(Math.min(width - 4, 50))}`),
|
|
553
|
+
"",
|
|
554
|
+
c.gray(" Enter to confirm · Esc to cancel"),
|
|
555
|
+
""
|
|
556
|
+
];
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
/**
|
|
560
|
+
* Show a text input overlay
|
|
561
|
+
* @returns The entered text, or null if cancelled
|
|
562
|
+
*/
|
|
563
|
+
function showTextInput(tui, label, initialValue, placeholder) {
|
|
564
|
+
return new Promise((resolve) => {
|
|
565
|
+
let handle = null;
|
|
566
|
+
const component = new TextInputComponent(label, initialValue, placeholder ?? "", (value) => {
|
|
567
|
+
handle?.hide();
|
|
568
|
+
resolve(value);
|
|
569
|
+
}, () => {
|
|
570
|
+
handle?.hide();
|
|
571
|
+
resolve(null);
|
|
572
|
+
});
|
|
573
|
+
handle = tui.showOverlay(component, {
|
|
574
|
+
width: "60%",
|
|
575
|
+
minWidth: 50,
|
|
576
|
+
maxHeight: 12,
|
|
577
|
+
anchor: "top-left"
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* A simple overlay component that displays configuration text
|
|
583
|
+
* and dismisses on q/Esc/Enter/BS/h.
|
|
584
|
+
*/
|
|
585
|
+
var ConfigViewComponent = class {
|
|
586
|
+
lines;
|
|
587
|
+
onDismiss;
|
|
588
|
+
constructor(config, renderer) {
|
|
589
|
+
this.lines = renderer.renderConfig(config);
|
|
590
|
+
}
|
|
591
|
+
/** Set the dismiss callback from showConfigView */
|
|
592
|
+
setDismissCallback(cb) {
|
|
593
|
+
this.onDismiss = cb;
|
|
594
|
+
}
|
|
595
|
+
handleInput(data) {
|
|
596
|
+
if (data === "escape" || data === "q" || data === "enter" || data === "backspace" || data === "" || data === "\b" || data === "h") this.onDismiss?.();
|
|
597
|
+
}
|
|
598
|
+
invalidate() {}
|
|
599
|
+
render(width) {
|
|
600
|
+
if (width < 50) return [
|
|
601
|
+
...this.lines,
|
|
602
|
+
"",
|
|
603
|
+
chalk.gray(" q/Esc 返回")
|
|
604
|
+
];
|
|
605
|
+
return [
|
|
606
|
+
...this.lines,
|
|
607
|
+
"",
|
|
608
|
+
chalk.gray(` ${"─".repeat(Math.max(0, width - 4))}`),
|
|
609
|
+
chalk.gray(" q/Esc 返回 · Enter/BS/h 返回菜单"),
|
|
610
|
+
""
|
|
611
|
+
];
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
/**
|
|
615
|
+
* Show the current configuration as a dismissible overlay.
|
|
616
|
+
* Returns when the user dismisses it.
|
|
617
|
+
*/
|
|
618
|
+
function showConfigView(tui, config, renderer) {
|
|
619
|
+
return new Promise((resolve) => {
|
|
620
|
+
const component = new ConfigViewComponent(config, renderer);
|
|
621
|
+
const handle = tui.showOverlay(component, {
|
|
622
|
+
width: "100%",
|
|
623
|
+
anchor: "top-left",
|
|
624
|
+
margin: {
|
|
625
|
+
top: 1,
|
|
626
|
+
bottom: 1
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
component.setDismissCallback(() => {
|
|
630
|
+
handle.hide();
|
|
631
|
+
resolve();
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
//#endregion
|
|
637
|
+
//#region src/cli/repl/config-utils.ts
|
|
638
|
+
/**
|
|
639
|
+
* Configuration file utilities
|
|
640
|
+
*
|
|
641
|
+
* Shared read/write helpers for ~/.zapmyco/settings.json,
|
|
642
|
+
* used by settings-cmd.ts and session.ts.
|
|
643
|
+
*/
|
|
644
|
+
/** Read settings.json and return a mutable object */
|
|
645
|
+
function readSettings() {
|
|
646
|
+
try {
|
|
647
|
+
return JSON.parse(readFileSync(HOME_CONFIG_PATH, "utf-8"));
|
|
648
|
+
} catch {
|
|
649
|
+
return {};
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/** Write back to settings.json */
|
|
653
|
+
function writeSettings(settings) {
|
|
654
|
+
writeFileSync(HOME_CONFIG_PATH, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
655
|
+
}
|
|
656
|
+
/** Safely set a nested property (prototype-chain safe) */
|
|
657
|
+
function _setByDotPath(obj, path, value) {
|
|
658
|
+
const keys = path.split(".");
|
|
659
|
+
let current = obj;
|
|
660
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
661
|
+
const key = keys[i];
|
|
662
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") return;
|
|
663
|
+
if (!(key in current) || typeof current[key] !== "object" || current[key] === null) current[key] = {};
|
|
664
|
+
current = current[key];
|
|
665
|
+
}
|
|
666
|
+
const lastKey = keys[keys.length - 1];
|
|
667
|
+
if (lastKey === "__proto__" || lastKey === "constructor" || lastKey === "prototype") return;
|
|
668
|
+
current[lastKey] = value;
|
|
669
|
+
}
|
|
670
|
+
/** Get a nested property value via dot-path */
|
|
671
|
+
function _getByDotPath(obj, path) {
|
|
672
|
+
const keys = path.split(".");
|
|
673
|
+
let current = obj;
|
|
674
|
+
for (const key of keys) {
|
|
675
|
+
if (current === null || current === void 0 || typeof current !== "object") return;
|
|
676
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") return void 0;
|
|
677
|
+
current = current[key];
|
|
678
|
+
}
|
|
679
|
+
return current;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
//#endregion
|
|
683
|
+
//#region src/cli/repl/commands/settings-cmd.ts
|
|
684
|
+
/**
|
|
685
|
+
* /settings command — interactive configuration menu
|
|
686
|
+
*
|
|
687
|
+
* TUI overlay-based graphical configuration interface:
|
|
688
|
+
* - View and change the default model
|
|
689
|
+
* - Configure API keys, models, and base URLs for existing providers
|
|
690
|
+
* - Add new providers from a curated list
|
|
691
|
+
* - All changes sync to ~/.zapmyco/settings.json in real-time
|
|
692
|
+
*/
|
|
693
|
+
/** Curated list of known providers (sorted by popularity) */
|
|
694
|
+
const KNOWN_PROVIDERS = [
|
|
695
|
+
{
|
|
696
|
+
id: "anthropic",
|
|
697
|
+
label: "Anthropic",
|
|
698
|
+
apiFormat: "anthropic"
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
id: "openai",
|
|
702
|
+
label: "OpenAI"
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
id: "deepseek",
|
|
706
|
+
label: "DeepSeek"
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
id: "google",
|
|
710
|
+
label: "Google (Gemini)"
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
id: "mistral",
|
|
714
|
+
label: "Mistral AI"
|
|
715
|
+
},
|
|
716
|
+
{
|
|
717
|
+
id: "xai",
|
|
718
|
+
label: "xAI (Grok)"
|
|
719
|
+
},
|
|
720
|
+
{
|
|
721
|
+
id: "groq",
|
|
722
|
+
label: "Groq"
|
|
723
|
+
},
|
|
724
|
+
{
|
|
725
|
+
id: "openrouter",
|
|
726
|
+
label: "OpenRouter"
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
id: "cerebras",
|
|
730
|
+
label: "Cerebras"
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
id: "fireworks",
|
|
734
|
+
label: "Fireworks AI"
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
id: "github-copilot",
|
|
738
|
+
label: "GitHub Copilot"
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
id: "huggingface",
|
|
742
|
+
label: "Hugging Face"
|
|
743
|
+
},
|
|
744
|
+
{
|
|
745
|
+
id: "moonshotai",
|
|
746
|
+
label: "Moonshot AI (Kimi)"
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
id: "cloudflare-workers-ai",
|
|
750
|
+
label: "Cloudflare Workers AI"
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
id: "amazon-bedrock",
|
|
754
|
+
label: "Amazon Bedrock"
|
|
755
|
+
},
|
|
756
|
+
{
|
|
757
|
+
id: "zai",
|
|
758
|
+
label: "ZAI"
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
id: "minimax",
|
|
762
|
+
label: "MiniMax"
|
|
763
|
+
},
|
|
764
|
+
{
|
|
765
|
+
id: "opencode",
|
|
766
|
+
label: "OpenCode"
|
|
767
|
+
}
|
|
768
|
+
];
|
|
769
|
+
/** Set a dot-path value, persist to disk and hot-reload agent */
|
|
770
|
+
function setConfigValue(session, dotPath, value) {
|
|
771
|
+
const settings = readSettings();
|
|
772
|
+
_setByDotPath(settings, dotPath, value);
|
|
773
|
+
writeSettings(settings);
|
|
774
|
+
_setByDotPath(session.config, dotPath, value);
|
|
775
|
+
session.applyConfigUpdate(dotPath);
|
|
776
|
+
}
|
|
777
|
+
/** Check if a provider has an API key configured */
|
|
778
|
+
function hasApiKey(config, providerName) {
|
|
779
|
+
const key = _getByDotPath(config, `llm.providers.${providerName}.apiKey`);
|
|
780
|
+
if (!key) return false;
|
|
781
|
+
const keyStr = String(key);
|
|
782
|
+
return keyStr.length > 0 && keyStr !== "${}";
|
|
783
|
+
}
|
|
784
|
+
/** Get available model IDs for a provider (from config or pi-ai) */
|
|
785
|
+
function getProviderModels(config, providerName) {
|
|
786
|
+
const models = _getByDotPath(config, `llm.providers.${providerName}.models`);
|
|
787
|
+
if (models && typeof models === "object" && Object.keys(models).length > 0) return Object.keys(models);
|
|
788
|
+
try {
|
|
789
|
+
const piModels = getModels(providerName);
|
|
790
|
+
if (piModels && piModels.length > 0) return piModels.map((m) => m.id);
|
|
791
|
+
} catch {}
|
|
792
|
+
return [];
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Create the /settings command definition
|
|
796
|
+
*/
|
|
797
|
+
function createSettingsCommand() {
|
|
798
|
+
return {
|
|
799
|
+
name: "settings",
|
|
800
|
+
aliases: ["set"],
|
|
801
|
+
description: "Interactive configuration menu — manage model providers and API keys",
|
|
802
|
+
usage: "/settings [list-providers | list-models <provider>]",
|
|
803
|
+
async handler(args, session) {
|
|
804
|
+
const tui = session.getTui();
|
|
805
|
+
const config = readSettings();
|
|
806
|
+
if (args.length > 0) {
|
|
807
|
+
await handleCommandLine(args, session, tui, config);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
await handleInteractiveMode(tui, session, config);
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* CLI mode — quick operations via arguments
|
|
816
|
+
*/
|
|
817
|
+
async function handleCommandLine(args, session, _tui, config) {
|
|
818
|
+
switch (args[0]) {
|
|
819
|
+
case "list-providers": {
|
|
820
|
+
const providers = _getByDotPath(config, "llm.providers");
|
|
821
|
+
const names = providers ? Object.keys(providers) : [];
|
|
822
|
+
const lines = ["", "Known providers:"];
|
|
823
|
+
for (const p of KNOWN_PROVIDERS) {
|
|
824
|
+
const configured = names.includes(p.id) ? " ✓" : " ";
|
|
825
|
+
lines.push(` ${configured} ${p.label} (${p.id})`);
|
|
826
|
+
}
|
|
827
|
+
session.appendOutput(lines);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
case "list-models": {
|
|
831
|
+
if (!args[1]) {
|
|
832
|
+
session.appendOutput([
|
|
833
|
+
"",
|
|
834
|
+
"Usage: /settings list-models <provider>",
|
|
835
|
+
""
|
|
836
|
+
]);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const modelIds = getProviderModels(config, args[1]);
|
|
840
|
+
if (modelIds.length === 0) {
|
|
841
|
+
session.appendOutput([
|
|
842
|
+
"",
|
|
843
|
+
`Provider "${args[1]}" has no known models`,
|
|
844
|
+
"Hint: use /settings to configure this provider first",
|
|
845
|
+
""
|
|
846
|
+
]);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
session.appendOutput([
|
|
850
|
+
"",
|
|
851
|
+
`${args[1]} available models:`,
|
|
852
|
+
...modelIds.map((id) => ` - ${id}`),
|
|
853
|
+
""
|
|
854
|
+
]);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
default: session.appendOutput([
|
|
858
|
+
"",
|
|
859
|
+
"Usage:",
|
|
860
|
+
" /settings — Open interactive configuration menu",
|
|
861
|
+
" /settings list-providers — List all known providers",
|
|
862
|
+
" /settings list-models <name> — List available models for a provider",
|
|
863
|
+
""
|
|
864
|
+
]);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Interactive menu mode — main flow
|
|
869
|
+
*/
|
|
870
|
+
async function handleInteractiveMode(tui, session, config) {
|
|
871
|
+
const state = { current: config };
|
|
872
|
+
/**
|
|
873
|
+
* Handle API Key configuration
|
|
874
|
+
*/
|
|
875
|
+
const handleApiKeyConfig = async (providerName, _currentKey) => {
|
|
876
|
+
const envVarName = `${providerName.toUpperCase().replace(/-/g, "_")}_API_KEY`;
|
|
877
|
+
const choice = await showSelectList(tui, [
|
|
878
|
+
{
|
|
879
|
+
value: "env",
|
|
880
|
+
label: `Use env var ${"${" + envVarName + "}"}`,
|
|
881
|
+
description: "Recommended — more secure"
|
|
882
|
+
},
|
|
883
|
+
{
|
|
884
|
+
value: "manual",
|
|
885
|
+
label: "Enter manually",
|
|
886
|
+
description: "Type the key directly (stored in plaintext in settings.json)"
|
|
887
|
+
},
|
|
888
|
+
{
|
|
889
|
+
value: "clear",
|
|
890
|
+
label: "Clear",
|
|
891
|
+
description: "Remove the configured key"
|
|
892
|
+
}
|
|
893
|
+
], { onExit: exitAll });
|
|
894
|
+
if (!choice) return;
|
|
895
|
+
if (choice.value === "clear") {
|
|
896
|
+
setConfigValue(session, `llm.providers.${providerName}.apiKey`, "");
|
|
897
|
+
session.appendOutput([
|
|
898
|
+
"",
|
|
899
|
+
` [ok] Cleared API key for ${providerName}`,
|
|
900
|
+
""
|
|
901
|
+
]);
|
|
902
|
+
} else if (choice.value === "env") {
|
|
903
|
+
setConfigValue(session, `llm.providers.${providerName}.apiKey`, `\${${envVarName}}`);
|
|
904
|
+
session.appendOutput([
|
|
905
|
+
"",
|
|
906
|
+
` [ok] Set ${providerName} API key to env var \${${envVarName}}`,
|
|
907
|
+
` Make sure ${envVarName} is set in your shell`,
|
|
908
|
+
""
|
|
909
|
+
]);
|
|
910
|
+
} else if (choice.value === "manual") {
|
|
911
|
+
const key = await showTextInput(tui, `Enter ${providerName} API Key`, "", "sk-...");
|
|
912
|
+
if (key && key.length > 0) {
|
|
913
|
+
setConfigValue(session, `llm.providers.${providerName}.apiKey`, key);
|
|
914
|
+
session.appendOutput([
|
|
915
|
+
"",
|
|
916
|
+
` [ok] Configured API key for ${providerName}`,
|
|
917
|
+
""
|
|
918
|
+
]);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
};
|
|
922
|
+
/**
|
|
923
|
+
* Handle Base URL configuration
|
|
924
|
+
*/
|
|
925
|
+
const handleBaseUrlConfig = async (providerName, _currentUrl) => {
|
|
926
|
+
const url = await showTextInput(tui, `Enter ${providerName} Base URL`, "", "https://api.example.com");
|
|
927
|
+
if (url !== null) {
|
|
928
|
+
const configPath = `llm.providers.${providerName}.baseUrl`;
|
|
929
|
+
if (url.length === 0) {
|
|
930
|
+
const settings = readSettings();
|
|
931
|
+
_setByDotPath(settings, configPath, void 0);
|
|
932
|
+
const parent = _getByDotPath(settings, `llm.providers.${providerName}`);
|
|
933
|
+
if (parent) delete parent.baseUrl;
|
|
934
|
+
writeSettings(settings);
|
|
935
|
+
session.appendOutput([
|
|
936
|
+
"",
|
|
937
|
+
` [ok] Reset ${providerName} Base URL to default`,
|
|
938
|
+
""
|
|
939
|
+
]);
|
|
940
|
+
} else {
|
|
941
|
+
setConfigValue(session, configPath, url);
|
|
942
|
+
session.appendOutput([
|
|
943
|
+
"",
|
|
944
|
+
` [ok] Set ${providerName} Base URL: ${url}`,
|
|
945
|
+
""
|
|
946
|
+
]);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
/**
|
|
951
|
+
* Handle model selection
|
|
952
|
+
*/
|
|
953
|
+
const handleModelSelect = async (providerName) => {
|
|
954
|
+
const modelIds = getProviderModels(state.current, providerName);
|
|
955
|
+
if (modelIds.length === 0) {
|
|
956
|
+
session.appendOutput([
|
|
957
|
+
"",
|
|
958
|
+
` ${providerName} has no available model list`,
|
|
959
|
+
" Configure models manually in settings.json or check the provider name",
|
|
960
|
+
""
|
|
961
|
+
]);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
const selected = await showSelectList(tui, modelIds.map((id) => ({
|
|
965
|
+
value: id,
|
|
966
|
+
label: id,
|
|
967
|
+
description: ""
|
|
968
|
+
})), { onExit: exitAll });
|
|
969
|
+
if (selected && selected.value) {
|
|
970
|
+
const modelId = selected.value;
|
|
971
|
+
const settings = readSettings();
|
|
972
|
+
_setByDotPath(settings, `llm.providers.${providerName}.models.${modelId}`, { id: modelId });
|
|
973
|
+
writeSettings(settings);
|
|
974
|
+
_setByDotPath(session.config, `llm.providers.${providerName}.models`, _getByDotPath(settings, `llm.providers.${providerName}.models`) ?? {});
|
|
975
|
+
session.appendOutput([
|
|
976
|
+
"",
|
|
977
|
+
` [ok] Selected model: ${providerName}/${modelId}`,
|
|
978
|
+
""
|
|
979
|
+
]);
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
/**
|
|
983
|
+
* Handle setting a provider's model as the default
|
|
984
|
+
*/
|
|
985
|
+
const handleSetDefault = async (providerName) => {
|
|
986
|
+
const modelIds = getProviderModels(state.current, providerName);
|
|
987
|
+
if (modelIds.length === 0) {
|
|
988
|
+
session.appendOutput([
|
|
989
|
+
"",
|
|
990
|
+
" Configure a model first before setting it as default",
|
|
991
|
+
""
|
|
992
|
+
]);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const modelKey = `${providerName}/${modelIds[0]}`;
|
|
996
|
+
setConfigValue(session, "llm.defaultModel", modelKey);
|
|
997
|
+
session.appendOutput([
|
|
998
|
+
"",
|
|
999
|
+
` [ok] Default model set to: ${modelKey}`,
|
|
1000
|
+
""
|
|
1001
|
+
]);
|
|
1002
|
+
};
|
|
1003
|
+
let running = true;
|
|
1004
|
+
const exitAll = () => {
|
|
1005
|
+
running = false;
|
|
1006
|
+
};
|
|
1007
|
+
while (running) {
|
|
1008
|
+
state.current = readSettings();
|
|
1009
|
+
const providers = _getByDotPath(state.current, "llm.providers") ?? {};
|
|
1010
|
+
const providerCount = Object.keys(providers).length;
|
|
1011
|
+
const choice = await showSelectList(tui, [
|
|
1012
|
+
{
|
|
1013
|
+
value: "default-model",
|
|
1014
|
+
label: "Default Model",
|
|
1015
|
+
description: String(_getByDotPath(state.current, "llm.defaultModel") ?? "not configured")
|
|
1016
|
+
},
|
|
1017
|
+
{
|
|
1018
|
+
value: "manage-providers",
|
|
1019
|
+
label: "Manage Providers",
|
|
1020
|
+
description: providerCount > 0 ? `${providerCount} configured` : "none configured"
|
|
1021
|
+
},
|
|
1022
|
+
{
|
|
1023
|
+
value: "view-config",
|
|
1024
|
+
label: "View Config",
|
|
1025
|
+
description: "Display full configuration details"
|
|
1026
|
+
}
|
|
1027
|
+
], { onExit: exitAll });
|
|
1028
|
+
if (!choice) {
|
|
1029
|
+
running = false;
|
|
1030
|
+
break;
|
|
1031
|
+
}
|
|
1032
|
+
const value = choice.value;
|
|
1033
|
+
if (value === "default-model") {
|
|
1034
|
+
const configuredProviders = _getByDotPath(state.current, "llm.providers");
|
|
1035
|
+
const allProviders = getProviders();
|
|
1036
|
+
const enabledItems = [];
|
|
1037
|
+
const disabledItems = [];
|
|
1038
|
+
for (const providerName of allProviders) {
|
|
1039
|
+
const models = getProviderModels(state.current, providerName);
|
|
1040
|
+
if (models.length === 0) continue;
|
|
1041
|
+
const isEnabled = configuredProviders !== void 0 && providerName in configuredProviders && hasApiKey(state.current, providerName);
|
|
1042
|
+
for (const modelId of models) {
|
|
1043
|
+
const key = `${providerName}/${modelId}`;
|
|
1044
|
+
if (isEnabled) enabledItems.push({
|
|
1045
|
+
value: key,
|
|
1046
|
+
label: key,
|
|
1047
|
+
description: ""
|
|
1048
|
+
});
|
|
1049
|
+
else disabledItems.push({
|
|
1050
|
+
value: key,
|
|
1051
|
+
label: chalk.gray(key),
|
|
1052
|
+
description: chalk.gray("未配置 - Enter 设置 API Key")
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
const modelItems = [...enabledItems, ...disabledItems];
|
|
1057
|
+
if (modelItems.length === 0) {
|
|
1058
|
+
session.appendOutput([
|
|
1059
|
+
"",
|
|
1060
|
+
" No models available from pi-ai registry",
|
|
1061
|
+
""
|
|
1062
|
+
]);
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
const selected = await showSelectList(tui, modelItems, { onExit: exitAll });
|
|
1066
|
+
if (!selected || !selected.value) continue;
|
|
1067
|
+
const selectedKey = selected.value;
|
|
1068
|
+
const slashIndex = selectedKey.indexOf("/");
|
|
1069
|
+
const providerName = selectedKey.slice(0, slashIndex);
|
|
1070
|
+
if (configuredProviders !== void 0 && providerName in configuredProviders && hasApiKey(state.current, providerName)) {
|
|
1071
|
+
setConfigValue(session, "llm.defaultModel", selectedKey);
|
|
1072
|
+
session.appendOutput([
|
|
1073
|
+
"",
|
|
1074
|
+
` [ok] Default model set to: ${selectedKey}`,
|
|
1075
|
+
""
|
|
1076
|
+
]);
|
|
1077
|
+
} else {
|
|
1078
|
+
if (!configuredProviders || !(providerName in configuredProviders)) {
|
|
1079
|
+
const known = KNOWN_PROVIDERS.find((p) => p.id === providerName);
|
|
1080
|
+
const newProvider = { apiKey: "" };
|
|
1081
|
+
if (known?.apiFormat) newProvider.apiFormat = known.apiFormat;
|
|
1082
|
+
const settings = readSettings();
|
|
1083
|
+
_setByDotPath(settings, `llm.providers.${providerName}`, newProvider);
|
|
1084
|
+
writeSettings(settings);
|
|
1085
|
+
_setByDotPath(session.config, `llm.providers.${providerName}`, newProvider);
|
|
1086
|
+
state.current = readSettings();
|
|
1087
|
+
}
|
|
1088
|
+
await handleApiKeyConfig(providerName, "");
|
|
1089
|
+
state.current = readSettings();
|
|
1090
|
+
if (hasApiKey(state.current, providerName)) {
|
|
1091
|
+
setConfigValue(session, "llm.defaultModel", selectedKey);
|
|
1092
|
+
session.appendOutput([
|
|
1093
|
+
"",
|
|
1094
|
+
` [ok] Default model set to: ${selectedKey}`,
|
|
1095
|
+
""
|
|
1096
|
+
]);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
} else if (value === "manage-providers") {
|
|
1100
|
+
const providerChoice = await showSelectList(tui, [...Object.entries(providers).map(([name]) => {
|
|
1101
|
+
const hasK = hasApiKey(state.current, name);
|
|
1102
|
+
return {
|
|
1103
|
+
value: `provider:${name}`,
|
|
1104
|
+
label: name,
|
|
1105
|
+
description: hasK ? "key configured" : "no key"
|
|
1106
|
+
};
|
|
1107
|
+
}), {
|
|
1108
|
+
value: "add-provider",
|
|
1109
|
+
label: "Add Provider",
|
|
1110
|
+
description: "Select from a list of known providers"
|
|
1111
|
+
}], { onExit: exitAll });
|
|
1112
|
+
if (!providerChoice) continue;
|
|
1113
|
+
const providerValue = providerChoice.value;
|
|
1114
|
+
if (providerValue.startsWith("provider:")) {
|
|
1115
|
+
const providerName = providerValue.slice(9);
|
|
1116
|
+
const action = await showSelectList(tui, [
|
|
1117
|
+
{
|
|
1118
|
+
value: "api-key",
|
|
1119
|
+
label: "Configure API Key",
|
|
1120
|
+
description: ""
|
|
1121
|
+
},
|
|
1122
|
+
{
|
|
1123
|
+
value: "model",
|
|
1124
|
+
label: "Select Model",
|
|
1125
|
+
description: ""
|
|
1126
|
+
},
|
|
1127
|
+
{
|
|
1128
|
+
value: "base-url",
|
|
1129
|
+
label: "Base URL",
|
|
1130
|
+
description: ""
|
|
1131
|
+
},
|
|
1132
|
+
{
|
|
1133
|
+
value: "set-default",
|
|
1134
|
+
label: "Set as Default",
|
|
1135
|
+
description: ""
|
|
1136
|
+
}
|
|
1137
|
+
], { onExit: exitAll });
|
|
1138
|
+
if (!action) continue;
|
|
1139
|
+
switch (action.value) {
|
|
1140
|
+
case "api-key":
|
|
1141
|
+
await handleApiKeyConfig(providerName, String(_getByDotPath(state.current, `llm.providers.${providerName}.apiKey`) ?? ""));
|
|
1142
|
+
break;
|
|
1143
|
+
case "model":
|
|
1144
|
+
await handleModelSelect(providerName);
|
|
1145
|
+
break;
|
|
1146
|
+
case "base-url":
|
|
1147
|
+
await handleBaseUrlConfig(providerName, String(_getByDotPath(state.current, `llm.providers.${providerName}.baseUrl`) ?? ""));
|
|
1148
|
+
break;
|
|
1149
|
+
case "set-default":
|
|
1150
|
+
await handleSetDefault(providerName);
|
|
1151
|
+
break;
|
|
1152
|
+
}
|
|
1153
|
+
} else if (providerValue === "add-provider") {
|
|
1154
|
+
const selected = await showSelectList(tui, KNOWN_PROVIDERS.map((p) => ({
|
|
1155
|
+
value: p.id,
|
|
1156
|
+
label: `${p.label} (${p.id})`,
|
|
1157
|
+
description: p.apiFormat ? `API format: ${p.apiFormat}` : "OpenAI compatible"
|
|
1158
|
+
})), {
|
|
1159
|
+
maxVisible: 12,
|
|
1160
|
+
onExit: exitAll
|
|
1161
|
+
});
|
|
1162
|
+
if (!selected || !selected.value) continue;
|
|
1163
|
+
const providerName = selected.value;
|
|
1164
|
+
const existingProviders = _getByDotPath(state.current, "llm.providers");
|
|
1165
|
+
if (existingProviders && providerName in existingProviders) {
|
|
1166
|
+
session.appendOutput([
|
|
1167
|
+
"",
|
|
1168
|
+
` ${providerName} already exists, use the provider entry to configure it`,
|
|
1169
|
+
""
|
|
1170
|
+
]);
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
const known = KNOWN_PROVIDERS.find((p) => p.id === providerName);
|
|
1174
|
+
const newProvider = { apiKey: "" };
|
|
1175
|
+
if (known?.apiFormat) newProvider.apiFormat = known.apiFormat;
|
|
1176
|
+
const settings = readSettings();
|
|
1177
|
+
_setByDotPath(settings, `llm.providers.${providerName}`, newProvider);
|
|
1178
|
+
writeSettings(settings);
|
|
1179
|
+
_setByDotPath(session.config, `llm.providers.${providerName}`, newProvider);
|
|
1180
|
+
session.appendOutput([
|
|
1181
|
+
"",
|
|
1182
|
+
` [ok] Added provider: ${providerName}`,
|
|
1183
|
+
""
|
|
1184
|
+
]);
|
|
1185
|
+
if ((await showSelectList(tui, [{
|
|
1186
|
+
value: "yes",
|
|
1187
|
+
label: "Yes, configure API Key now",
|
|
1188
|
+
description: "Go to API Key settings"
|
|
1189
|
+
}, {
|
|
1190
|
+
value: "no",
|
|
1191
|
+
label: "Later",
|
|
1192
|
+
description: "Return to main menu"
|
|
1193
|
+
}], { onExit: exitAll }))?.value === "yes") await handleApiKeyConfig(providerName, "");
|
|
1194
|
+
}
|
|
1195
|
+
} else if (value === "view-config") {
|
|
1196
|
+
const renderer = session.getRenderer();
|
|
1197
|
+
await showConfigView(tui, session.config, renderer);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
297
1202
|
//#endregion
|
|
298
1203
|
//#region src/cli/repl/commands/status.ts
|
|
299
1204
|
/**
|
|
@@ -321,6 +1226,7 @@ function createStatusCommand() {
|
|
|
321
1226
|
* 继承自 pi-tui 的 Editor,添加 zapmyco 特有的快捷键处理:
|
|
322
1227
|
* - Ctrl+C: 取消任务 / 二次退出
|
|
323
1228
|
* - Ctrl+D: 退出
|
|
1229
|
+
* - Ctrl+O: 打开外部编辑器编辑输入
|
|
324
1230
|
* - Escape: 取消当前输入
|
|
325
1231
|
*
|
|
326
1232
|
* 同时 override render() 以:
|
|
@@ -357,8 +1263,12 @@ var ZapmycoEditor = class extends Editor {
|
|
|
357
1263
|
onCtrlC;
|
|
358
1264
|
/** Ctrl+D 回调 */
|
|
359
1265
|
onCtrlD;
|
|
1266
|
+
/** Ctrl+O 回调(打开外部编辑器) */
|
|
1267
|
+
onOpenEditor;
|
|
360
1268
|
/** 是否正在执行(用于显示 loading) */
|
|
361
1269
|
#executing = false;
|
|
1270
|
+
/** 是否显示 spinner(执行期间禁用输入但不一定显示 spinner) */
|
|
1271
|
+
#showSpinner = true;
|
|
362
1272
|
/** loading 动画帧索引 */
|
|
363
1273
|
#loadingFrame = 0;
|
|
364
1274
|
/** loading 动画定时器 */
|
|
@@ -376,15 +1286,22 @@ var ZapmycoEditor = class extends Editor {
|
|
|
376
1286
|
if (this.getText().length === 0 && this.onCtrlD) this.onCtrlD();
|
|
377
1287
|
return;
|
|
378
1288
|
}
|
|
1289
|
+
if (matchesKey(data, Key.ctrl("o")) && this.onOpenEditor) {
|
|
1290
|
+
this.onOpenEditor();
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
379
1293
|
super.handleInput(data);
|
|
380
1294
|
}
|
|
381
1295
|
/**
|
|
382
1296
|
* 设置执行状态(控制 loading spinner 显示)
|
|
1297
|
+
* @param executing 是否正在执行
|
|
1298
|
+
* @param showSpinner 是否显示 spinner(默认 true)。设为 false 时仅禁用输入,不显示动画
|
|
383
1299
|
*/
|
|
384
|
-
setExecuting(executing) {
|
|
385
|
-
if (this.#executing === executing) return;
|
|
1300
|
+
setExecuting(executing, showSpinner = true) {
|
|
1301
|
+
if (this.#executing === executing && this.#showSpinner === showSpinner) return;
|
|
386
1302
|
this.#executing = executing;
|
|
387
|
-
|
|
1303
|
+
this.#showSpinner = showSpinner;
|
|
1304
|
+
if (executing && showSpinner) {
|
|
388
1305
|
this.#loadingFrame = 0;
|
|
389
1306
|
this.#loadingTimer = setInterval(() => {
|
|
390
1307
|
this.#loadingFrame = (this.#loadingFrame + 1) % LOADING_FRAMES.length;
|
|
@@ -417,7 +1334,7 @@ var ZapmycoEditor = class extends Editor {
|
|
|
417
1334
|
for (let i = 0; i < contentLines.length; i++) {
|
|
418
1335
|
const prefix = i === 0 ? PROMPT_PREFIX : " ".repeat(promptWidth);
|
|
419
1336
|
let line;
|
|
420
|
-
if (i === 0 && this.#executing) line = `${prefix}${LOADING_FRAMES[this.#loadingFrame]} ${contentLines[i]}`;
|
|
1337
|
+
if (i === 0 && this.#executing && this.#showSpinner) line = `${prefix}${LOADING_FRAMES[this.#loadingFrame]} ${contentLines[i]}`;
|
|
421
1338
|
else line = prefix + contentLines[i];
|
|
422
1339
|
contentLines[i] = truncateToWidth(line, width);
|
|
423
1340
|
}
|
|
@@ -718,7 +1635,7 @@ const CRON_CONSTANTS = {
|
|
|
718
1635
|
*
|
|
719
1636
|
* @module cli/repl/cron/cron-scheduler
|
|
720
1637
|
*/
|
|
721
|
-
const log$
|
|
1638
|
+
const log$5 = logger.child("cron:scheduler");
|
|
722
1639
|
var CronScheduler = class extends EventEmitter {
|
|
723
1640
|
store;
|
|
724
1641
|
jobs = [];
|
|
@@ -739,7 +1656,7 @@ var CronScheduler = class extends EventEmitter {
|
|
|
739
1656
|
if (this.running) return;
|
|
740
1657
|
const loadedJobs = await this.store.load();
|
|
741
1658
|
this.jobs = loadedJobs;
|
|
742
|
-
log$
|
|
1659
|
+
log$5.info(`调度器启动,加载 ${loadedJobs.length} 个 durable 任务`);
|
|
743
1660
|
await this.handleMissedJobs();
|
|
744
1661
|
this.checkAutoExpiry();
|
|
745
1662
|
this.running = true;
|
|
@@ -755,7 +1672,7 @@ var CronScheduler = class extends EventEmitter {
|
|
|
755
1672
|
clearInterval(this.timer);
|
|
756
1673
|
this.timer = null;
|
|
757
1674
|
}
|
|
758
|
-
log$
|
|
1675
|
+
log$5.info("调度器已停止");
|
|
759
1676
|
}
|
|
760
1677
|
/** 添加任务 */
|
|
761
1678
|
async addJob(job) {
|
|
@@ -767,7 +1684,7 @@ var CronScheduler = class extends EventEmitter {
|
|
|
767
1684
|
this.jobs.push(job);
|
|
768
1685
|
await this.store.persist(this.jobs);
|
|
769
1686
|
} else this.sessionJobs.push(job);
|
|
770
|
-
log$
|
|
1687
|
+
log$5.info("任务已添加", {
|
|
771
1688
|
id: job.id,
|
|
772
1689
|
cron: job.cron,
|
|
773
1690
|
durable: job.durable
|
|
@@ -897,7 +1814,7 @@ var CronScheduler = class extends EventEmitter {
|
|
|
897
1814
|
}, delay);
|
|
898
1815
|
}
|
|
899
1816
|
if (toDelete.length > 0) {
|
|
900
|
-
log$
|
|
1817
|
+
log$5.info(`跳过 ${toDelete.length} 个错过的一次性任务(超出补发上限)`);
|
|
901
1818
|
this.emit("missed-overflow", {
|
|
902
1819
|
count: toDelete.length,
|
|
903
1820
|
jobIds: toDelete.map((m) => m.id)
|
|
@@ -915,7 +1832,7 @@ var CronScheduler = class extends EventEmitter {
|
|
|
915
1832
|
job.lastFiredAt = now;
|
|
916
1833
|
job.fireCount++;
|
|
917
1834
|
this.removeJob(job.id);
|
|
918
|
-
log$
|
|
1835
|
+
log$5.info("任务已过期并触发最后一次", { id: job.id });
|
|
919
1836
|
}
|
|
920
1837
|
}
|
|
921
1838
|
}
|
|
@@ -976,7 +1893,7 @@ function applyOneShotJitter(jobId, rawNext) {
|
|
|
976
1893
|
*
|
|
977
1894
|
* @module cli/repl/cron/cron-store
|
|
978
1895
|
*/
|
|
979
|
-
const log$
|
|
1896
|
+
const log$4 = logger.child("cron:store");
|
|
980
1897
|
const STORE_FILE = join(join(homedir(), ".zapmyco", "cron"), "scheduled_tasks.json");
|
|
981
1898
|
var CronStore = class {
|
|
982
1899
|
filePath;
|
|
@@ -1000,13 +1917,13 @@ var CronStore = class {
|
|
|
1000
1917
|
const raw = await readFile(this.filePath, "utf-8");
|
|
1001
1918
|
const data = JSON.parse(raw);
|
|
1002
1919
|
if (!Array.isArray(data)) {
|
|
1003
|
-
log$
|
|
1920
|
+
log$4.warn("存储文件格式无效(非数组),将使用空列表");
|
|
1004
1921
|
return [];
|
|
1005
1922
|
}
|
|
1006
1923
|
return this.validateJobs(data);
|
|
1007
1924
|
} catch (err) {
|
|
1008
1925
|
if (err.code === "ENOENT") return [];
|
|
1009
|
-
log$
|
|
1926
|
+
log$4.warn("加载定时任务文件失败,将使用空列表", { error: err instanceof Error ? err.message : String(err) });
|
|
1010
1927
|
return [];
|
|
1011
1928
|
}
|
|
1012
1929
|
}
|
|
@@ -1053,7 +1970,7 @@ var CronStore = class {
|
|
|
1053
1970
|
if (typeof obj.maxFires === "number") job.maxFires = obj.maxFires;
|
|
1054
1971
|
valid.push(job);
|
|
1055
1972
|
}
|
|
1056
|
-
if (valid.length < raw.length) log$
|
|
1973
|
+
if (valid.length < raw.length) log$4.warn(`跳过 ${raw.length - valid.length} 个无效任务条目`);
|
|
1057
1974
|
return valid;
|
|
1058
1975
|
}
|
|
1059
1976
|
};
|
|
@@ -1065,8 +1982,19 @@ function getCronStore() {
|
|
|
1065
1982
|
|
|
1066
1983
|
//#endregion
|
|
1067
1984
|
//#region src/cli/repl/history-store.ts
|
|
1985
|
+
/**
|
|
1986
|
+
* 会话历史存储
|
|
1987
|
+
*
|
|
1988
|
+
* 基于内存的环形缓冲区,记录 REPL 会话中的用户输入和执行结果。
|
|
1989
|
+
* 支持文件持久化到 ~/.zapmyco/history.json,跨会话恢复。
|
|
1990
|
+
*/
|
|
1991
|
+
const log$3 = logger.child("history:store");
|
|
1068
1992
|
/** 默认最大历史条数 */
|
|
1069
1993
|
const DEFAULT_MAX_SIZE = 100;
|
|
1994
|
+
/** 历史文件存储路径 */
|
|
1995
|
+
function getHistoryFilePath() {
|
|
1996
|
+
return join(homedir(), SESSION_DIR_NAME, "history.json");
|
|
1997
|
+
}
|
|
1070
1998
|
/**
|
|
1071
1999
|
* 历史存储类
|
|
1072
2000
|
*/
|
|
@@ -1074,8 +2002,11 @@ var HistoryStore = class {
|
|
|
1074
2002
|
entries = [];
|
|
1075
2003
|
nextId = 1;
|
|
1076
2004
|
maxSize;
|
|
2005
|
+
filePath;
|
|
1077
2006
|
constructor(maxSize = DEFAULT_MAX_SIZE) {
|
|
1078
2007
|
this.maxSize = maxSize;
|
|
2008
|
+
this.filePath = getHistoryFilePath();
|
|
2009
|
+
this.load();
|
|
1079
2010
|
}
|
|
1080
2011
|
/** 添加条目 */
|
|
1081
2012
|
push(entry) {
|
|
@@ -1085,6 +2016,7 @@ var HistoryStore = class {
|
|
|
1085
2016
|
};
|
|
1086
2017
|
this.entries.push(newEntry);
|
|
1087
2018
|
if (this.entries.length > this.maxSize) this.entries.shift();
|
|
2019
|
+
this.save();
|
|
1088
2020
|
return newEntry;
|
|
1089
2021
|
}
|
|
1090
2022
|
/** 获取所有条目 */
|
|
@@ -1096,16 +2028,52 @@ var HistoryStore = class {
|
|
|
1096
2028
|
const count = Math.min(n, this.entries.length);
|
|
1097
2029
|
return this.entries.slice(-count);
|
|
1098
2030
|
}
|
|
1099
|
-
/**
|
|
2031
|
+
/** 清空所有条目(同时清除持久化文件) */
|
|
1100
2032
|
clear() {
|
|
1101
2033
|
this.entries = [];
|
|
2034
|
+
this.save();
|
|
1102
2035
|
}
|
|
1103
2036
|
/** 搜索条目(按输入内容模糊匹配) */
|
|
1104
2037
|
search(query) {
|
|
1105
2038
|
const lowerQuery = query.toLowerCase();
|
|
1106
2039
|
return this.entries.filter((entry) => entry.input.toLowerCase().includes(lowerQuery));
|
|
1107
2040
|
}
|
|
2041
|
+
/** 从文件加载历史记录 */
|
|
2042
|
+
load() {
|
|
2043
|
+
try {
|
|
2044
|
+
ensureDir(dirname(this.filePath));
|
|
2045
|
+
const raw = readFileSync(this.filePath, "utf-8");
|
|
2046
|
+
const data = JSON.parse(raw);
|
|
2047
|
+
if (Array.isArray(data.entries)) {
|
|
2048
|
+
this.entries = data.entries.slice(-this.maxSize);
|
|
2049
|
+
this.nextId = typeof data.nextId === "number" ? data.nextId : 1;
|
|
2050
|
+
log$3.debug("历史记录已加载", {
|
|
2051
|
+
count: this.entries.length,
|
|
2052
|
+
nextId: this.nextId
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
} catch {
|
|
2056
|
+
log$3.debug("无历史文件或加载失败,使用空历史");
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
/** 持久化历史记录到文件 */
|
|
2060
|
+
save() {
|
|
2061
|
+
try {
|
|
2062
|
+
ensureDir(dirname(this.filePath));
|
|
2063
|
+
const data = JSON.stringify({
|
|
2064
|
+
entries: this.entries,
|
|
2065
|
+
nextId: this.nextId
|
|
2066
|
+
}, null, 2);
|
|
2067
|
+
writeFileSync(this.filePath, data, "utf-8");
|
|
2068
|
+
} catch (err) {
|
|
2069
|
+
log$3.warn("历史记录保存失败", { error: err instanceof Error ? err.message : String(err) });
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
1108
2072
|
};
|
|
2073
|
+
/** 确保目录存在 */
|
|
2074
|
+
function ensureDir(dir) {
|
|
2075
|
+
mkdirSync(dir, { recursive: true });
|
|
2076
|
+
}
|
|
1109
2077
|
|
|
1110
2078
|
//#endregion
|
|
1111
2079
|
//#region src/cli/repl/input-parser.ts
|
|
@@ -1344,13 +2312,17 @@ var OutputFormatter = class {
|
|
|
1344
2312
|
c.bold(" LLM:")
|
|
1345
2313
|
];
|
|
1346
2314
|
lines.push(` 默认模型: ${config.llm.defaultModel}`);
|
|
1347
|
-
const
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
const
|
|
1353
|
-
lines.push(`
|
|
2315
|
+
const defaultModelKey = config.llm.defaultModel;
|
|
2316
|
+
const slashIdx = defaultModelKey.indexOf("/");
|
|
2317
|
+
const defaultProvider = slashIdx > 0 ? defaultModelKey.slice(0, slashIdx) : "anthropic";
|
|
2318
|
+
const defaultModelName = slashIdx > 0 ? defaultModelKey.slice(slashIdx + 1) : defaultModelKey;
|
|
2319
|
+
const providerConfig = config.llm.providers[defaultProvider];
|
|
2320
|
+
const modelConfig = providerConfig?.models?.[defaultModelName];
|
|
2321
|
+
lines.push(` 提供商: ${defaultProvider}`);
|
|
2322
|
+
lines.push(` 模型 ID: ${modelConfig?.id ?? defaultModelName}`);
|
|
2323
|
+
if (modelConfig?.input && modelConfig.input.length > 0) lines.push(` 输入类型: ${modelConfig.input.join(", ")}`);
|
|
2324
|
+
lines.push(` API Key: ${providerConfig?.apiKey ? c.gray("***已配置***") : c.red("(未配置)")}`);
|
|
2325
|
+
if (providerConfig?.apiFormat) lines.push(` API 格式: ${providerConfig.apiFormat}`);
|
|
1354
2326
|
lines.push(c.bold(" 调度器:"));
|
|
1355
2327
|
lines.push(` 最大并行: ${config.scheduler.maxConcurrency}`);
|
|
1356
2328
|
lines.push(` 单 Agent 最大并发: ${config.scheduler.maxPerAgent}`);
|
|
@@ -6557,26 +7529,32 @@ var TaskStore = class {
|
|
|
6557
7529
|
};
|
|
6558
7530
|
|
|
6559
7531
|
//#endregion
|
|
6560
|
-
//#region src/
|
|
7532
|
+
//#region src/cli/repl/session.ts
|
|
6561
7533
|
/**
|
|
6562
|
-
*
|
|
7534
|
+
* REPL 会话核心(pi-tui 版)
|
|
6563
7535
|
*
|
|
6564
|
-
*
|
|
6565
|
-
*
|
|
7536
|
+
* 使用 @mariozechner/pi-tui 框架替代 readline,
|
|
7537
|
+
* 实现完整的 TUI 交互式 REPL:
|
|
7538
|
+
* - Editor 组件自带上下边框
|
|
7539
|
+
* - 差量渲染,无闪烁
|
|
7540
|
+
* - 组件化布局,可扩展
|
|
6566
7541
|
*/
|
|
6567
|
-
function parseModelKey(key) {
|
|
6568
|
-
const slashIndex = key.indexOf("/");
|
|
6569
|
-
if (slashIndex <= 0 || slashIndex >= key.length - 1) return null;
|
|
6570
|
-
return {
|
|
6571
|
-
provider: key.slice(0, slashIndex),
|
|
6572
|
-
modelId: key.slice(slashIndex + 1)
|
|
6573
|
-
};
|
|
6574
|
-
}
|
|
6575
|
-
|
|
6576
|
-
//#endregion
|
|
6577
|
-
//#region src/cli/repl/session.ts
|
|
6578
7542
|
const log = logger.child("repl:session");
|
|
6579
7543
|
/**
|
|
7544
|
+
* 检查错误消息是否匹配 "No API key for provider",返回解决指引行
|
|
7545
|
+
*/
|
|
7546
|
+
function getApiKeyErrorHelp(errorMessage) {
|
|
7547
|
+
const match = errorMessage.match(/No API key for provider: (\w+)/);
|
|
7548
|
+
if (!match) return [];
|
|
7549
|
+
const providerName = match[1];
|
|
7550
|
+
const envVarName = `${providerName.toUpperCase().replace(/-/g, "_")}_API_KEY`;
|
|
7551
|
+
return [
|
|
7552
|
+
"",
|
|
7553
|
+
chalk.yellow(` 请设置环境变量: export ${envVarName}=<your-api-key>`),
|
|
7554
|
+
chalk.yellow(` 或在 REPL 中使用: /config set llm.providers.${providerName}.apiKey <your-key>`)
|
|
7555
|
+
];
|
|
7556
|
+
}
|
|
7557
|
+
/**
|
|
6580
7558
|
* 输出区域组件
|
|
6581
7559
|
*
|
|
6582
7560
|
* 管理所有输出内容的行缓冲,实现 pi-tui 的 render 接口。
|
|
@@ -6599,6 +7577,12 @@ var OutputArea = class extends Container {
|
|
|
6599
7577
|
else this.lines[this.lines.length - 1] += text;
|
|
6600
7578
|
this.invalidate();
|
|
6601
7579
|
}
|
|
7580
|
+
/** 替换最后一行的完整内容(用于 spinner 动画和首 chunk 替换) */
|
|
7581
|
+
replaceLastLine(text) {
|
|
7582
|
+
if (this.lines.length > 0) this.lines[this.lines.length - 1] = text;
|
|
7583
|
+
else this.lines.push(text);
|
|
7584
|
+
this.invalidate();
|
|
7585
|
+
}
|
|
6602
7586
|
/** 清空所有内容 */
|
|
6603
7587
|
clear() {
|
|
6604
7588
|
this.lines = [];
|
|
@@ -6655,6 +7639,12 @@ var ReplSession = class {
|
|
|
6655
7639
|
const theme = createTheme(this.options.color);
|
|
6656
7640
|
const terminal = new ProcessTerminal();
|
|
6657
7641
|
this.tui = new TUI(terminal);
|
|
7642
|
+
getKeybindings().setUserBindings({
|
|
7643
|
+
"tui.select.up": ["up", "k"],
|
|
7644
|
+
"tui.select.down": ["down", "j"],
|
|
7645
|
+
"tui.select.cancel": ["escape", "h"],
|
|
7646
|
+
"tui.select.confirm": ["enter", "l"]
|
|
7647
|
+
});
|
|
6658
7648
|
this.outputArea = new OutputArea();
|
|
6659
7649
|
this.editor = new ZapmycoEditor(this.tui, theme.editorTheme);
|
|
6660
7650
|
const root = new Container();
|
|
@@ -6666,6 +7656,7 @@ var ReplSession = class {
|
|
|
6666
7656
|
this.registry = new CommandRegistry(this);
|
|
6667
7657
|
this.renderer = new Renderer(this.options);
|
|
6668
7658
|
this.history = new HistoryStore(this.options.maxHistorySize);
|
|
7659
|
+
for (const entry of this.history.getAll()) this.editor.addToHistory(entry.input);
|
|
6669
7660
|
this.agent = this.createReplAgent();
|
|
6670
7661
|
this.taskStore = new TaskStore();
|
|
6671
7662
|
this.taskStore.load();
|
|
@@ -6720,6 +7711,10 @@ var ReplSession = class {
|
|
|
6720
7711
|
getRenderer() {
|
|
6721
7712
|
return this.renderer;
|
|
6722
7713
|
}
|
|
7714
|
+
/** 获取 TUI 实例(用于显示 overlay 菜单) */
|
|
7715
|
+
getTui() {
|
|
7716
|
+
return this.tui;
|
|
7717
|
+
}
|
|
6723
7718
|
/** 获取历史存储引用 */
|
|
6724
7719
|
getHistoryStore() {
|
|
6725
7720
|
return this.history;
|
|
@@ -6749,10 +7744,19 @@ var ReplSession = class {
|
|
|
6749
7744
|
const startTime = Date.now();
|
|
6750
7745
|
let historyEntry;
|
|
6751
7746
|
const taskId = `task-${Date.now()}`;
|
|
7747
|
+
const ZAPMYCO_PREFIX = "ZapMyco: ";
|
|
7748
|
+
const THINKING_PREFIX = " 💭 ";
|
|
7749
|
+
const colorEnabled = this.options.color;
|
|
7750
|
+
const userStyle = (s) => colorEnabled ? chalk.bold.cyan(s) : s;
|
|
7751
|
+
const responseStyle = (s) => s;
|
|
7752
|
+
const toolStyle = (s) => colorEnabled ? chalk.yellow(s) : s;
|
|
7753
|
+
const thinkingStyle = (s) => colorEnabled ? chalk.gray(s) : s;
|
|
7754
|
+
let spinnerActive = true;
|
|
7755
|
+
let spinnerInterval;
|
|
6752
7756
|
try {
|
|
6753
7757
|
this._state = "executing";
|
|
6754
7758
|
this.updateStatsState();
|
|
6755
|
-
this.editor.setExecuting(true);
|
|
7759
|
+
this.editor.setExecuting(true, false);
|
|
6756
7760
|
this.currentTaskAbort = new AbortController();
|
|
6757
7761
|
historyEntry = this.history.push({
|
|
6758
7762
|
timestamp: Date.now(),
|
|
@@ -6762,24 +7766,64 @@ var ReplSession = class {
|
|
|
6762
7766
|
goalId: `goal-${startTime}`,
|
|
6763
7767
|
rawInput
|
|
6764
7768
|
});
|
|
6765
|
-
|
|
6766
|
-
|
|
7769
|
+
this.outputArea.append([userStyle(`Me: ${rawInput}`), responseStyle(ZAPMYCO_PREFIX + LOADING_FRAMES[0])]);
|
|
7770
|
+
let spinnerFrame = 0;
|
|
7771
|
+
spinnerActive = true;
|
|
7772
|
+
spinnerInterval = setInterval(() => {
|
|
7773
|
+
if (!spinnerActive) return;
|
|
7774
|
+
spinnerFrame = (spinnerFrame + 1) % LOADING_FRAMES.length;
|
|
7775
|
+
this.outputArea.replaceLastLine(responseStyle(ZAPMYCO_PREFIX + LOADING_FRAMES[spinnerFrame]));
|
|
7776
|
+
this.tui.requestRender();
|
|
7777
|
+
}, 100);
|
|
7778
|
+
let firstOutputReceived = false;
|
|
7779
|
+
let outputAccumulator = "";
|
|
7780
|
+
let thinkingAccumulator = "";
|
|
7781
|
+
let streamMode = "response";
|
|
6767
7782
|
const outputHandler = (event) => {
|
|
6768
|
-
if (event.taskId
|
|
6769
|
-
|
|
6770
|
-
|
|
7783
|
+
if (event.taskId !== taskId || !event.text) return;
|
|
7784
|
+
if (!firstOutputReceived) {
|
|
7785
|
+
firstOutputReceived = true;
|
|
7786
|
+
spinnerActive = false;
|
|
7787
|
+
clearInterval(spinnerInterval);
|
|
7788
|
+
streamMode = "response";
|
|
7789
|
+
thinkingAccumulator = "";
|
|
7790
|
+
outputAccumulator = event.text;
|
|
7791
|
+
this.outputArea.replaceLastLine(responseStyle(ZAPMYCO_PREFIX + outputAccumulator));
|
|
7792
|
+
} else if (streamMode !== "response") {
|
|
7793
|
+
streamMode = "response";
|
|
7794
|
+
thinkingAccumulator = "";
|
|
7795
|
+
outputAccumulator = event.text;
|
|
7796
|
+
this.outputArea.append([responseStyle(ZAPMYCO_PREFIX + outputAccumulator)]);
|
|
7797
|
+
} else {
|
|
7798
|
+
outputAccumulator += event.text;
|
|
7799
|
+
this.outputArea.replaceLastLine(responseStyle(ZAPMYCO_PREFIX + outputAccumulator));
|
|
6771
7800
|
}
|
|
7801
|
+
this.tui.requestRender();
|
|
7802
|
+
};
|
|
7803
|
+
const thinkingHandler = (event) => {
|
|
7804
|
+
if (event.taskId !== taskId || !event.text) return;
|
|
7805
|
+
if (streamMode !== "thinking") {
|
|
7806
|
+
streamMode = "thinking";
|
|
7807
|
+
outputAccumulator = "";
|
|
7808
|
+
thinkingAccumulator = event.text;
|
|
7809
|
+
this.outputArea.append([thinkingStyle(THINKING_PREFIX + thinkingAccumulator)]);
|
|
7810
|
+
} else {
|
|
7811
|
+
thinkingAccumulator += event.text;
|
|
7812
|
+
this.outputArea.replaceLastLine(thinkingStyle(THINKING_PREFIX + thinkingAccumulator));
|
|
7813
|
+
}
|
|
7814
|
+
this.tui.requestRender();
|
|
6772
7815
|
};
|
|
6773
7816
|
const errorHandler = (event) => {
|
|
6774
7817
|
if (event.taskId === taskId) log.error("Agent 执行中收到 error 事件", { error: event.error.message });
|
|
6775
7818
|
};
|
|
6776
7819
|
const progressHandler = (event) => {
|
|
6777
7820
|
if (event.taskId === taskId && event.percent === 0) {
|
|
6778
|
-
this.outputArea.append([` → ${event.message}`]);
|
|
7821
|
+
this.outputArea.append([toolStyle(` → ${event.message}`)]);
|
|
6779
7822
|
this.tui.requestRender();
|
|
6780
7823
|
}
|
|
6781
7824
|
};
|
|
6782
7825
|
this.agent.on(this.agent.EVENT_OUTPUT, outputHandler);
|
|
7826
|
+
this.agent.on(this.agent.EVENT_THINKING, thinkingHandler);
|
|
6783
7827
|
this.agent.on(this.agent.EVENT_ERROR, errorHandler);
|
|
6784
7828
|
this.agent.on(this.agent.EVENT_PROGRESS, progressHandler);
|
|
6785
7829
|
this.currentTaskId = taskId;
|
|
@@ -6797,6 +7841,7 @@ var ReplSession = class {
|
|
|
6797
7841
|
}
|
|
6798
7842
|
});
|
|
6799
7843
|
this.agent.off(this.agent.EVENT_OUTPUT, outputHandler);
|
|
7844
|
+
this.agent.off(this.agent.EVENT_THINKING, thinkingHandler);
|
|
6800
7845
|
this.agent.off(this.agent.EVENT_ERROR, errorHandler);
|
|
6801
7846
|
this.agent.off(this.agent.EVENT_PROGRESS, progressHandler);
|
|
6802
7847
|
log.debug("Agent 执行完成", {
|
|
@@ -6806,9 +7851,53 @@ var ReplSession = class {
|
|
|
6806
7851
|
duration: Date.now() - startTime
|
|
6807
7852
|
});
|
|
6808
7853
|
const outputText = typeof taskResult.output === "string" ? taskResult.output : taskResult.output != null ? JSON.stringify(taskResult.output) : null;
|
|
7854
|
+
if (spinnerActive) {
|
|
7855
|
+
spinnerActive = false;
|
|
7856
|
+
clearInterval(spinnerInterval);
|
|
7857
|
+
if (outputText) this.outputArea.replaceLastLine(responseStyle(ZAPMYCO_PREFIX + outputText));
|
|
7858
|
+
else if (taskResult.status !== "success") {
|
|
7859
|
+
const errorMsg = taskResult.error?.message ?? "Agent 执行失败(无详细错误信息)";
|
|
7860
|
+
this.outputArea.replaceLastLine(chalk.red(`ZapMyco: [错误] ${errorMsg}`));
|
|
7861
|
+
const helpLines = getApiKeyErrorHelp(errorMsg);
|
|
7862
|
+
if (helpLines.length > 0) {
|
|
7863
|
+
this.outputArea.append(helpLines);
|
|
7864
|
+
const providerMatch = errorMsg.match(/No API key for provider: (\w+)/);
|
|
7865
|
+
if (providerMatch) {
|
|
7866
|
+
const providerName = providerMatch[1];
|
|
7867
|
+
this.outputArea.append([""]);
|
|
7868
|
+
if ((await showSelectList(this.tui, [{
|
|
7869
|
+
value: "yes",
|
|
7870
|
+
label: "好的,我来输入 API Key",
|
|
7871
|
+
description: `直接输入 ${providerName} 的 API Key,立即配置并重试`
|
|
7872
|
+
}, {
|
|
7873
|
+
value: "no",
|
|
7874
|
+
label: "稍后再说",
|
|
7875
|
+
description: "回到对话"
|
|
7876
|
+
}], { title: `需要配置 ${providerName} 的 API Key` }))?.value === "yes") {
|
|
7877
|
+
const apiKey = await showTextInput(this.tui, `请输入 ${providerName} 的 API Key:`, "", "sk-...");
|
|
7878
|
+
if (apiKey && apiKey.length > 0) {
|
|
7879
|
+
const dotPath = `llm.providers.${providerName}.apiKey`;
|
|
7880
|
+
const settings = readSettings();
|
|
7881
|
+
_setByDotPath(settings, dotPath, apiKey);
|
|
7882
|
+
writeSettings(settings);
|
|
7883
|
+
_setByDotPath(this.config, dotPath, apiKey);
|
|
7884
|
+
this.applyConfigUpdate(dotPath);
|
|
7885
|
+
this.outputArea.append([
|
|
7886
|
+
"",
|
|
7887
|
+
chalk.green(`已配置 ${providerName} 的 API Key,正在重试...`),
|
|
7888
|
+
""
|
|
7889
|
+
]);
|
|
7890
|
+
return await this.executeGoal(rawInput);
|
|
7891
|
+
}
|
|
7892
|
+
}
|
|
7893
|
+
this.outputArea.append([""]);
|
|
7894
|
+
}
|
|
7895
|
+
}
|
|
7896
|
+
} else this.outputArea.replaceLastLine(chalk.red("ZapMyco: [错误] 模型未返回任何内容,请检查 API Key 配置"));
|
|
7897
|
+
}
|
|
6809
7898
|
if (taskResult.status !== "success") {
|
|
6810
7899
|
const errorMsg = taskResult.error?.message ?? "Agent 执行失败(无详细错误信息)";
|
|
6811
|
-
this.outputArea.appendText(`[错误] ${errorMsg}`);
|
|
7900
|
+
if (!spinnerActive || outputText) this.outputArea.appendText(`[错误] ${errorMsg}`);
|
|
6812
7901
|
log.error("Agent 执行返回 failure", {
|
|
6813
7902
|
taskId,
|
|
6814
7903
|
error: taskResult.error,
|
|
@@ -6854,14 +7943,52 @@ var ReplSession = class {
|
|
|
6854
7943
|
} catch (error) {
|
|
6855
7944
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
6856
7945
|
log.error("目标执行失败", { input: rawInput }, err);
|
|
7946
|
+
spinnerActive = false;
|
|
7947
|
+
clearInterval(spinnerInterval);
|
|
6857
7948
|
this.stats.totalRequests++;
|
|
6858
7949
|
this.stats.failureCount++;
|
|
6859
7950
|
eventBus.emit("goal:failed", {
|
|
6860
7951
|
goalId: `goal-${startTime}`,
|
|
6861
7952
|
error: err
|
|
6862
7953
|
});
|
|
6863
|
-
|
|
6864
|
-
|
|
7954
|
+
this.outputArea.replaceLastLine(responseStyle(`${ZAPMYCO_PREFIX}[错误] ${err.message}`));
|
|
7955
|
+
const helpLines = getApiKeyErrorHelp(err.message);
|
|
7956
|
+
if (helpLines.length > 0) {
|
|
7957
|
+
this.outputArea.append(helpLines);
|
|
7958
|
+
const providerMatch = err.message.match(/No API key for provider: (\w+)/);
|
|
7959
|
+
if (providerMatch) {
|
|
7960
|
+
const providerName = providerMatch[1];
|
|
7961
|
+
this.outputArea.append([""]);
|
|
7962
|
+
if ((await showSelectList(this.tui, [{
|
|
7963
|
+
value: "yes",
|
|
7964
|
+
label: "好的,我来输入 API Key",
|
|
7965
|
+
description: `直接输入 ${providerName} 的 API Key,立即配置并重试`
|
|
7966
|
+
}, {
|
|
7967
|
+
value: "no",
|
|
7968
|
+
label: "稍后再说",
|
|
7969
|
+
description: "回到对话"
|
|
7970
|
+
}], { title: `需要配置 ${providerName} 的 API Key` }))?.value === "yes") {
|
|
7971
|
+
const apiKey = await showTextInput(this.tui, `请输入 ${providerName} 的 API Key:`, "", "sk-...");
|
|
7972
|
+
if (apiKey && apiKey.length > 0) {
|
|
7973
|
+
const dotPath = `llm.providers.${providerName}.apiKey`;
|
|
7974
|
+
const settings = readSettings();
|
|
7975
|
+
_setByDotPath(settings, dotPath, apiKey);
|
|
7976
|
+
writeSettings(settings);
|
|
7977
|
+
_setByDotPath(this.config, dotPath, apiKey);
|
|
7978
|
+
this.applyConfigUpdate(dotPath);
|
|
7979
|
+
this.outputArea.append([
|
|
7980
|
+
"",
|
|
7981
|
+
chalk.green(`已配置 ${providerName} 的 API Key,正在重试...`),
|
|
7982
|
+
""
|
|
7983
|
+
]);
|
|
7984
|
+
return await this.executeGoal(rawInput);
|
|
7985
|
+
}
|
|
7986
|
+
}
|
|
7987
|
+
this.outputArea.append([""]);
|
|
7988
|
+
}
|
|
7989
|
+
}
|
|
7990
|
+
const errorLines = this.renderer.renderError(err).slice(1);
|
|
7991
|
+
if (errorLines.length > 0) this.outputArea.append(errorLines);
|
|
6865
7992
|
const duration = Date.now() - startTime;
|
|
6866
7993
|
return {
|
|
6867
7994
|
goalId: `goal-${startTime}`,
|
|
@@ -6878,6 +8005,8 @@ var ReplSession = class {
|
|
|
6878
8005
|
}
|
|
6879
8006
|
};
|
|
6880
8007
|
} finally {
|
|
8008
|
+
spinnerActive = false;
|
|
8009
|
+
if (spinnerInterval) clearInterval(spinnerInterval);
|
|
6881
8010
|
this._state = "idle";
|
|
6882
8011
|
this.updateStatsState();
|
|
6883
8012
|
this.editor.setExecuting(false);
|
|
@@ -6906,9 +8035,11 @@ var ReplSession = class {
|
|
|
6906
8035
|
case "incomplete": break;
|
|
6907
8036
|
case "command":
|
|
6908
8037
|
await this.registry.dispatch(parsed);
|
|
8038
|
+
this.editor.addToHistory(line);
|
|
6909
8039
|
break;
|
|
6910
8040
|
case "goal":
|
|
6911
8041
|
await this.executeGoal(parsed.rawInput);
|
|
8042
|
+
this.editor.addToHistory(line);
|
|
6912
8043
|
break;
|
|
6913
8044
|
}
|
|
6914
8045
|
}
|
|
@@ -6921,6 +8052,27 @@ var ReplSession = class {
|
|
|
6921
8052
|
this.registry.register(createConfigCommand());
|
|
6922
8053
|
this.registry.register(createAgentsCommand());
|
|
6923
8054
|
this.registry.register(createStatusCommand());
|
|
8055
|
+
this.registry.register(createSettingsCommand());
|
|
8056
|
+
this.buildAutocompleteProvider();
|
|
8057
|
+
}
|
|
8058
|
+
/** 构建并设置 autocomplete provider,将命令注册表中的命令接入 pi-tui 补全系统 */
|
|
8059
|
+
buildAutocompleteProvider() {
|
|
8060
|
+
const slashCommands = [];
|
|
8061
|
+
for (const cmd of this.registry.listCommands()) {
|
|
8062
|
+
const base = {
|
|
8063
|
+
name: cmd.name,
|
|
8064
|
+
description: cmd.description
|
|
8065
|
+
};
|
|
8066
|
+
if (cmd.usage !== `/${cmd.name}`) base.argumentHint = cmd.usage;
|
|
8067
|
+
slashCommands.push(base);
|
|
8068
|
+
for (const alias of cmd.aliases) slashCommands.push({
|
|
8069
|
+
name: alias,
|
|
8070
|
+
description: `${cmd.description}(别名: /${cmd.name})`
|
|
8071
|
+
});
|
|
8072
|
+
}
|
|
8073
|
+
const provider = new CombinedAutocompleteProvider(slashCommands, process.cwd(), null);
|
|
8074
|
+
this.editor.setAutocompleteProvider(provider);
|
|
8075
|
+
this.editor.setAutocompleteMaxVisible(12);
|
|
6924
8076
|
}
|
|
6925
8077
|
/**
|
|
6926
8078
|
* 创建 REPL 专用的 Agent 实例
|
|
@@ -6940,39 +8092,25 @@ var ReplSession = class {
|
|
|
6940
8092
|
}],
|
|
6941
8093
|
runtimeConfig: this.config.agentRuntime ?? {}
|
|
6942
8094
|
});
|
|
6943
|
-
|
|
6944
|
-
agent.innerAgent.
|
|
6945
|
-
|
|
6946
|
-
|
|
6947
|
-
|
|
6948
|
-
|
|
6949
|
-
|
|
6950
|
-
|
|
6951
|
-
|
|
6952
|
-
|
|
6953
|
-
|
|
6954
|
-
|
|
6955
|
-
|
|
6956
|
-
|
|
6957
|
-
|
|
6958
|
-
|
|
6959
|
-
|
|
6960
|
-
if (!parsed) throw new Error(`无效的模型标识符: ${modelKey}`);
|
|
6961
|
-
const modelConfig = this.config.llm.models[modelKey];
|
|
6962
|
-
const provider = modelConfig?.provider ?? parsed.provider;
|
|
6963
|
-
const modelId = modelConfig?.modelId ?? parsed.modelId;
|
|
6964
|
-
const baseModelId = provider === "anthropic" ? "claude-sonnet-4-20250514" : modelId;
|
|
6965
|
-
let model;
|
|
6966
|
-
try {
|
|
6967
|
-
model = getModel(provider, baseModelId);
|
|
6968
|
-
} catch {
|
|
6969
|
-
model = getModel("anthropic", "claude-sonnet-4-20250514");
|
|
8095
|
+
const facade = new AgentLlmFacade(this.config.llm);
|
|
8096
|
+
agent.innerAgent.state.model = facade.resolvePiModel();
|
|
8097
|
+
agent.innerAgent.getApiKey = facade.createGetApiKeyFn();
|
|
8098
|
+
agent.llmFacade = facade;
|
|
8099
|
+
const defaultModelInfo = facade.getModelInfo();
|
|
8100
|
+
if (defaultModelInfo) {
|
|
8101
|
+
if (!facade.getApiKey(defaultModelInfo.provider)) {
|
|
8102
|
+
const providerName = defaultModelInfo.provider;
|
|
8103
|
+
const envVar = providerName.toUpperCase() + "_API_KEY";
|
|
8104
|
+
this.outputArea.append([
|
|
8105
|
+
chalk.red(`[!] 提供商 "${providerName}" 没有配置 API Key`),
|
|
8106
|
+
chalk.yellow(` 请设置环境变量: export ${envVar}=<your-key>`),
|
|
8107
|
+
chalk.yellow(` 或在 REPL 中使用: /config set llm.providers.${providerName}.apiKey <your-key>`),
|
|
8108
|
+
""
|
|
8109
|
+
]);
|
|
8110
|
+
log.warn("默认提供商缺少 API Key", { provider: providerName });
|
|
8111
|
+
}
|
|
6970
8112
|
}
|
|
6971
|
-
|
|
6972
|
-
model.name = modelKey;
|
|
6973
|
-
model.id = modelId;
|
|
6974
|
-
if (modelConfig?.baseUrl) model.baseUrl = modelConfig.baseUrl;
|
|
6975
|
-
return model;
|
|
8113
|
+
return agent;
|
|
6976
8114
|
}
|
|
6977
8115
|
/**
|
|
6978
8116
|
* 注册 REPL 场景下的基础工具
|
|
@@ -7010,6 +8148,7 @@ var ReplSession = class {
|
|
|
7010
8148
|
const snapshot = buildSkillSnapshot(entries, skillConfig.maxSkillsInPrompt);
|
|
7011
8149
|
this.agent.skillPrompt = snapshot.prompt;
|
|
7012
8150
|
this._registerSkillCommands(entries);
|
|
8151
|
+
this.buildAutocompleteProvider();
|
|
7013
8152
|
log.info("Skill 系统初始化完成", {
|
|
7014
8153
|
count: snapshot.count,
|
|
7015
8154
|
names: snapshot.names
|
|
@@ -7077,6 +8216,7 @@ var ReplSession = class {
|
|
|
7077
8216
|
this.editor.onCtrlD = () => {
|
|
7078
8217
|
this.shutdown("收到 EOF (Ctrl+D)");
|
|
7079
8218
|
};
|
|
8219
|
+
this.editor.onOpenEditor = () => this.openInEditor();
|
|
7080
8220
|
}
|
|
7081
8221
|
/** 设置信号处理 */
|
|
7082
8222
|
setupSignalHandlers() {
|
|
@@ -7099,6 +8239,71 @@ var ReplSession = class {
|
|
|
7099
8239
|
this.currentTaskAbort = null;
|
|
7100
8240
|
}
|
|
7101
8241
|
}
|
|
8242
|
+
/**
|
|
8243
|
+
* 打开外部编辑器(vim / $EDITOR)编辑当前输入内容
|
|
8244
|
+
*
|
|
8245
|
+
* 流程:
|
|
8246
|
+
* 1. 将编辑器当前文本写入临时文件
|
|
8247
|
+
* 2. 暂停 TUI(恢复终端 cooked 模式)
|
|
8248
|
+
* 3. 启动外部编辑器,用户编辑并保存退出
|
|
8249
|
+
* 4. 读取编辑后的内容并更新编辑器
|
|
8250
|
+
* 5. 恢复 TUI 并重绘
|
|
8251
|
+
*/
|
|
8252
|
+
openInEditor() {
|
|
8253
|
+
const tmpFile = join(tmpdir(), "zapmyco-editor-input.txt");
|
|
8254
|
+
let tuiStopped = false;
|
|
8255
|
+
try {
|
|
8256
|
+
const currentText = this.editor.getExpandedText();
|
|
8257
|
+
writeFileSync(tmpFile, currentText, "utf-8");
|
|
8258
|
+
this.tui.stop();
|
|
8259
|
+
tuiStopped = true;
|
|
8260
|
+
const editorCmd = process.env.VISUAL || process.env.EDITOR || "vim";
|
|
8261
|
+
const result = spawnSync(editorCmd, [tmpFile], { stdio: "inherit" });
|
|
8262
|
+
const newText = readFileSync(tmpFile, "utf-8");
|
|
8263
|
+
if (newText !== currentText) this.editor.setText(newText);
|
|
8264
|
+
if (result.error) {
|
|
8265
|
+
const err = result.error;
|
|
8266
|
+
if (err.code === "ENOENT") this.outputArea.append([
|
|
8267
|
+
"",
|
|
8268
|
+
`未找到编辑器: ${editorCmd},请设置 $EDITOR 环境变量`,
|
|
8269
|
+
""
|
|
8270
|
+
]);
|
|
8271
|
+
else this.outputArea.append([
|
|
8272
|
+
"",
|
|
8273
|
+
`编辑器启动失败: ${err.message}`,
|
|
8274
|
+
""
|
|
8275
|
+
]);
|
|
8276
|
+
}
|
|
8277
|
+
} catch (err) {
|
|
8278
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
8279
|
+
this.outputArea.append([
|
|
8280
|
+
"",
|
|
8281
|
+
`打开编辑器失败: ${message}`,
|
|
8282
|
+
""
|
|
8283
|
+
]);
|
|
8284
|
+
} finally {
|
|
8285
|
+
if (tuiStopped) {
|
|
8286
|
+
this.tui.start();
|
|
8287
|
+
this.tui.requestRender(true);
|
|
8288
|
+
}
|
|
8289
|
+
try {
|
|
8290
|
+
unlinkSync(tmpFile);
|
|
8291
|
+
} catch {}
|
|
8292
|
+
}
|
|
8293
|
+
}
|
|
8294
|
+
/**
|
|
8295
|
+
* 应用配置更新到运行中的 Agent(无需重启)
|
|
8296
|
+
*
|
|
8297
|
+
* 当前处理以 "llm." 开头的配置变更,重新创建 AgentLlmFacade
|
|
8298
|
+
* 并注入到运行中的 Agent 实例,使新 Key/模型立即生效。
|
|
8299
|
+
*/
|
|
8300
|
+
applyConfigUpdate(key) {
|
|
8301
|
+
if (!key.startsWith("llm.")) return;
|
|
8302
|
+
const newFacade = new AgentLlmFacade(this.config.llm);
|
|
8303
|
+
this.agent.innerAgent.state.model = newFacade.resolvePiModel();
|
|
8304
|
+
this.agent.innerAgent.getApiKey = newFacade.createGetApiKeyFn();
|
|
8305
|
+
this.agent.llmFacade = newFacade;
|
|
8306
|
+
}
|
|
7102
8307
|
/** 更新统计中的状态字段 */
|
|
7103
8308
|
updateStatsState() {
|
|
7104
8309
|
this.stats.state = this._state;
|
|
@@ -7118,7 +8323,14 @@ var ReplSession = class {
|
|
|
7118
8323
|
* 加载配置 → 创建会话 → 进入输入循环
|
|
7119
8324
|
*/
|
|
7120
8325
|
async function startRepl() {
|
|
7121
|
-
|
|
8326
|
+
configureLogger({
|
|
8327
|
+
logFilePath: join(homedir(), ".zapmyco", "logs", "zapmyco.log"),
|
|
8328
|
+
quiet: true
|
|
8329
|
+
});
|
|
8330
|
+
const config = await loadConfig();
|
|
8331
|
+
if (config.logging?.level) configureLogger({ level: config.logging.level });
|
|
8332
|
+
if (config.logging?.file) configureLogger({ logFilePath: config.logging.file });
|
|
8333
|
+
await new ReplSession(config).start();
|
|
7122
8334
|
}
|
|
7123
8335
|
|
|
7124
8336
|
//#endregion
|