mocode-ai 0.4.4 → 0.4.6
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 +219 -209
- package/README.zh-CN.md +10 -0
- package/dist/agent/index.js +30 -13
- package/dist/config/index.js +13 -11
- package/dist/config/presets.js +177 -0
- package/dist/llm/capabilities.js +4 -0
- package/dist/llm/index.js +5 -1
- package/dist/memory/reflect.js +5 -2
- package/dist/repl/index.js +268 -10
- package/dist/tools/builtins/ask-human.js +71 -43
- package/dist/tools/builtins/codegraph.js +1 -1
- package/dist/ui/batch.js +235 -0
- package/dist/ui/content.js +51 -0
- package/dist/ui/diff.js +41 -4
- package/dist/ui/intervention.js +59 -20
- package/dist/ui/layout.js +449 -66
- package/dist/ui/prompt.js +99 -5
- package/dist/ui/render.js +22 -0
- package/dist/ui/theme.js +22 -0
- package/package.json +1 -1
package/dist/repl/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { emitKeypressEvents } from 'node:readline';
|
|
|
3
3
|
import { stdin, stdout } from 'node:process';
|
|
4
4
|
import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, buildBasePrompt, getPlanModeSuffix, } from '../config/index.js';
|
|
5
5
|
import { updateConfigKey, writeConfigKeys, CONFIG_PATH } from '../config/file.js';
|
|
6
|
+
import { deletePreset, getPreset, isValidPresetName, listPresets, migrateCurrentToPreset, savePreset, } from '../config/presets.js';
|
|
6
7
|
import { runAgent } from '../agent/index.js';
|
|
7
8
|
import { getAgentMode, setAgentMode, onModeChange } from '../agent/mode.js';
|
|
8
9
|
import { togglePet, killPetProcess, listSkins, setSkin, sendState } from '../pet/bridge.js';
|
|
@@ -11,11 +12,13 @@ import { ui, setTheme, getTheme, listThemes, themeExists } from '../ui/theme.js'
|
|
|
11
12
|
import { bannerString, displayWidth, padEndDisplay, summarizeToolCall, summarizeToolResult } from '../ui/render.js';
|
|
12
13
|
import * as layout from '../ui/layout.js';
|
|
13
14
|
import * as mouse from '../ui/mouse.js';
|
|
15
|
+
import * as batch from '../ui/batch.js';
|
|
14
16
|
import { promptWithSlashMenu, promptTurnPicker, promptSessionPicker, promptThemePicker, promptRevertChoice, } from '../ui/prompt.js';
|
|
15
17
|
import { promptIntervention } from '../ui/intervention.js';
|
|
16
18
|
import { tools } from '../tools/registry.js';
|
|
17
19
|
import { estimateMessagesTokens, reconfigureClient, } from '../llm/index.js';
|
|
18
20
|
import { loadImageAttachment, renderChip, MAX_INLINE_BYTES_DEFAULT, } from '../attachments/image.js';
|
|
21
|
+
import { modelSupportsVision } from '../llm/capabilities.js';
|
|
19
22
|
import { manualCompact, contextState, newSessionId, saveSession, loadSession, listSessions, } from '../session/index.js';
|
|
20
23
|
import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, } from '../rollback/index.js';
|
|
21
24
|
import { listSkills, effectiveSystemPrompt, } from '../skills/index.js';
|
|
@@ -42,7 +45,10 @@ const SLASH_COMMANDS = [
|
|
|
42
45
|
{ name: '/reflect', desc: '手动触发后台记忆反思 pass(需先开启记忆)' },
|
|
43
46
|
{ name: '/init', desc: '扫描项目生成 MOCODE.md 项目记忆(需先开启记忆)' },
|
|
44
47
|
{ name: '/theme', desc: '切换颜色主题(↑↓·Enter)' },
|
|
45
|
-
{ name: '/model', desc: '
|
|
48
|
+
{ name: '/model', desc: '配置新模型(向导);/model switch·list·delete 管理已配置预设' },
|
|
49
|
+
{ name: '/model switch', desc: '↑↓·Enter 在已配置预设间切换' },
|
|
50
|
+
{ name: '/model list', desc: '列出已经配置的模型' },
|
|
51
|
+
{ name: '/model delete <name>', desc: '删除已配置的模型' },
|
|
46
52
|
{ name: '/plan', desc: '切到 plan 模式(只读探查+产出计划)' },
|
|
47
53
|
{ name: '/auto', desc: '切回 auto 模式(全工具执行)' },
|
|
48
54
|
{ name: '/pet', desc: '开关桌宠(独立悬浮窗,展示 agent 状态动画)' },
|
|
@@ -63,6 +69,9 @@ const MODEL_PRESETS = [
|
|
|
63
69
|
{ label: 'GLM(智谱)', baseURL: 'https://open.bigmodel.cn/api/v3', model: 'glm-4.6', window: 128000 },
|
|
64
70
|
{ label: 'DeepSeek', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat', window: 64000 },
|
|
65
71
|
{ label: 'Qwen(阿里)', baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', window: 128000 },
|
|
72
|
+
// MiniMax OpenAI 兼容端点(https://platform.minimax.io/docs/api-reference/text-openai-api)。
|
|
73
|
+
// MiniMax-M3 为唯一支持图片/视频输入的模型;M2 系列纯文本(见 llm/capabilities.ts KNOWN_TEXT_ONLY_PREFIXES)。
|
|
74
|
+
{ label: 'MiniMax', baseURL: 'https://api.minimax.io/v1', model: 'MiniMax-M3', window: 1000000 },
|
|
66
75
|
{ label: '本地 Ollama', baseURL: 'http://localhost:11434/v1', model: 'qwen2.5:7b', window: 32768 },
|
|
67
76
|
{ label: '本地 vLLM', baseURL: 'http://localhost:8000/v1', model: 'default', window: 32768 },
|
|
68
77
|
{ label: '自定义 base_url', baseURL: '', model: '', window: 128000 },
|
|
@@ -418,19 +427,33 @@ function textOf(c) {
|
|
|
418
427
|
}
|
|
419
428
|
/**
|
|
420
429
|
* 把会话历史渲染成静态文本进内容区(回滚 / 续接 / --resume 后复显上下文,仿 Claude Code):
|
|
421
|
-
* user→❯ 回显、assistant→正文(+ tool_calls
|
|
430
|
+
* user→❯ 回显、assistant→正文(+ tool_calls 折叠成 ● 摘要行)、tool→↳ 结果预览;system 跳过。
|
|
422
431
|
* 思考段不持久(history 只存正文),故无思考折叠。渲染后续写位在末尾,紧接 enterInputMode 画输入框。
|
|
423
432
|
* 内容长于屏时 viewport 显尾(最近轮次),PgUp 可看更早——与流式态一致。
|
|
424
433
|
* user 多模态:用 textOf 取 text parts;若侧 channel messageAttachments 有原文件名则追加 chip 行
|
|
425
434
|
* (避免 base64 解码不可逆,旧 session 没侧 channel 时只显文本,文件名 fallback 到 image/* mime)。
|
|
435
|
+
*
|
|
436
|
+
* 折叠策略:遇到 assistant + tool_calls 不立即打 ● 行,而是累积到 batchEntries;
|
|
437
|
+
* 跟随的连续 tool 消息按 tool_call_id 反查填 resultSummary;遇下一个非 tool 消息(或末尾)时,
|
|
438
|
+
* 用 batch.writeSummaryOnly 出单行摘要(与实时 runAgent 同一渲染器,UI 一致)。
|
|
439
|
+
* 回放默认全折叠;用户可鼠标点击摘要行展开(由 BatchRenderer 接管,见 ui/batch.ts)。
|
|
426
440
|
*/
|
|
427
441
|
export function renderHistory(history) {
|
|
428
442
|
const idToName = new Map();
|
|
443
|
+
// 当前累积的 batch(assistant.tool_calls + 后续 tool 消息);一旦遇到非 tool 消息即收尾出摘要
|
|
444
|
+
let pendingBatch = [];
|
|
445
|
+
const flushBatch = () => {
|
|
446
|
+
if (pendingBatch.length === 0)
|
|
447
|
+
return;
|
|
448
|
+
batch.writeSummaryOnly(pendingBatch, layout);
|
|
449
|
+
pendingBatch = [];
|
|
450
|
+
};
|
|
429
451
|
for (let idx = 0; idx < history.length; idx++) {
|
|
430
452
|
const m = history[idx];
|
|
431
453
|
if (m.role === 'system')
|
|
432
454
|
continue;
|
|
433
455
|
if (m.role === 'user') {
|
|
456
|
+
flushBatch(); // 上一轮 batch(若有)收尾
|
|
434
457
|
const lines = textOf(m.content).split('\n');
|
|
435
458
|
layout.contentWrite(formatUserMessage(lines));
|
|
436
459
|
const atts = messageAttachments.get(idx);
|
|
@@ -455,31 +478,53 @@ export function renderHistory(history) {
|
|
|
455
478
|
if (m.role === 'assistant') {
|
|
456
479
|
const text = textOf(m.content);
|
|
457
480
|
if (text) {
|
|
481
|
+
flushBatch(); // 文本前若有累积 batch 先收尾(罕见:连续两个 assistant tool_calls 文本间)
|
|
458
482
|
layout.contentWriteMdOnce(text);
|
|
459
483
|
if (!text.endsWith('\n'))
|
|
460
484
|
layout.contentWrite('\n');
|
|
461
485
|
}
|
|
462
486
|
const tcs = m.tool_calls;
|
|
463
|
-
if (Array.isArray(tcs)) {
|
|
487
|
+
if (Array.isArray(tcs) && tcs.length > 0) {
|
|
488
|
+
// 累积到 pendingBatch,顺序 = tool_calls 序
|
|
464
489
|
for (const tc of tcs) {
|
|
465
490
|
const name = tc?.function?.name ?? '';
|
|
466
491
|
const args = tc?.function?.arguments ?? '';
|
|
467
492
|
if (tc?.id && name)
|
|
468
493
|
idToName.set(tc.id, name);
|
|
469
|
-
|
|
494
|
+
pendingBatch.push({
|
|
495
|
+
name,
|
|
496
|
+
callSummary: summarizeToolCall(name, args),
|
|
497
|
+
resultSummary: '',
|
|
498
|
+
diffBlock: null,
|
|
499
|
+
});
|
|
470
500
|
}
|
|
501
|
+
continue; // 跳过后续 tool 消息处理循环(由下一分支填 result)
|
|
471
502
|
}
|
|
503
|
+
// 无 tool_calls:若有 pending batch(文本+无 tool_calls 的 assistant),不常见,先收尾
|
|
504
|
+
flushBatch();
|
|
472
505
|
continue;
|
|
473
506
|
}
|
|
474
507
|
if (m.role === 'tool') {
|
|
475
508
|
const id = m.tool_call_id ?? '';
|
|
476
509
|
const name = idToName.get(id) ?? '';
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
510
|
+
const output = textOf(m.content);
|
|
511
|
+
const preview = summarizeToolResult(name, output);
|
|
512
|
+
// 匹配 pendingBatch 中尚未填 result 的同名 entry;同名前缀 tool 较罕见(并行工具同 id 不同名)
|
|
513
|
+
let target;
|
|
514
|
+
for (let i = pendingBatch.length - 1; i >= 0; i--) {
|
|
515
|
+
const e = pendingBatch[i];
|
|
516
|
+
if (e.name === name && !e.resultSummary) {
|
|
517
|
+
target = e;
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (target)
|
|
522
|
+
target.resultSummary = preview;
|
|
523
|
+
// 不直接写屏——等 flushBatch 时出单行摘要
|
|
480
524
|
continue;
|
|
481
525
|
}
|
|
482
526
|
}
|
|
527
|
+
flushBatch(); // 末尾兜底
|
|
483
528
|
}
|
|
484
529
|
/**
|
|
485
530
|
* 交互式 REPL:全屏 TUI(alt screen + 固定底栏)。INPUT 态底栏=状态行+输入框(raw mode 等按键);
|
|
@@ -554,6 +599,26 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
554
599
|
// 未配置 baseURL/apiKey:醒目提示引导 /model(不退出,REPL 仍可用;发消息会失败但不崩)。
|
|
555
600
|
layout.contentWrite(`${ui.yellow} ⚠ 未配置大模型。输入 ${ui.cyan}/model${ui.yellow} 配置 baseURL / apiKey / model(即时生效),或退出后运行 ${ui.cyan}mocode config${ui.yellow} 走向导。${ui.reset}\n`);
|
|
556
601
|
}
|
|
602
|
+
else {
|
|
603
|
+
// 老用户兜底:若 ~/.mocode/models/ 空,自动把 ~/.mocode/config 的当前 LLM 四键迁成 'default' 预设。
|
|
604
|
+
// 这样 /model list / switch 立即可见,无需手动 /model 重存一遍。幂等:重启只生效一次。
|
|
605
|
+
if (listPresets().length === 0) {
|
|
606
|
+
try {
|
|
607
|
+
const migrated = migrateCurrentToPreset({
|
|
608
|
+
baseURL: config.baseURL,
|
|
609
|
+
apiKey: config.apiKey,
|
|
610
|
+
model: config.model,
|
|
611
|
+
contextWindow: config.contextWindowTokens,
|
|
612
|
+
});
|
|
613
|
+
if (migrated) {
|
|
614
|
+
layout.contentWrite(`${ui.dim} ↳ 检测到老配置,已自动迁为预设 “${migrated}”(${ui.cyan}/model list${ui.dim} 查看 · /model switch 切换)${ui.reset}\n`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
// 迁移失败不阻塞启动;用户后续 /model 时仍能手动配。
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
557
622
|
layout.contentWrite(`${ui.dim} /plan · /auto · Shift+Tab 切换模式(plan:只读探查 + 产出计划,审批后切 auto 执行)${ui.reset}\n`);
|
|
558
623
|
/**
|
|
559
624
|
* 切换 agent 模式(Shift+Tab 触发,经 prompt.ts 的 onCycleMode 回调)。
|
|
@@ -674,9 +739,13 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
674
739
|
? input
|
|
675
740
|
: [
|
|
676
741
|
{ type: 'text', text: input },
|
|
742
|
+
// detail 故意不设(留 undefined,JSON.stringify 时被丢弃):OpenAI 认 'auto'/'low'/'high',
|
|
743
|
+
// 但 MiniMax 只认 'low'/'default'/'high'——'auto' 不是合法枚举值,某些后端会 400。
|
|
744
|
+
// 不传 detail 让各 provider 用自己的默认值(OpenAI 默认视为 auto,MiniMax 默认 default),
|
|
745
|
+
// 是唯一在两边都不出错的写法。
|
|
677
746
|
...imgs.map((a) => ({
|
|
678
747
|
type: 'image_url',
|
|
679
|
-
image_url: { url: a.dataUrl
|
|
748
|
+
image_url: { url: a.dataUrl },
|
|
680
749
|
})),
|
|
681
750
|
];
|
|
682
751
|
const msgIndex = history.length; // runAgent push 后 = 这个 index
|
|
@@ -939,6 +1008,11 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
939
1008
|
pendingAttachments.push(r.att);
|
|
940
1009
|
}
|
|
941
1010
|
layout.contentWrite(` ${ui.dim}${renderChip(r.att)} — will attach to next message${ui.reset}\n`);
|
|
1011
|
+
// 提前警告(不阻断附加):当前模型已知不支持视觉(如 MiniMax M2.x / gpt-3.5 等)时,
|
|
1012
|
+
// 附加时就提示,而不是等发送后才在 catch 块里翻译 API 报错——减少一轮无意义请求。
|
|
1013
|
+
if (!modelSupportsVision(config.model)) {
|
|
1014
|
+
layout.contentWrite(` ${ui.yellow}⚠ 当前模型 ${config.model} 已知不支持视觉输入,发送图片可能会失败。可用 /model 切换。${ui.reset}\n`);
|
|
1015
|
+
}
|
|
942
1016
|
continue;
|
|
943
1017
|
}
|
|
944
1018
|
if (line === '/context') {
|
|
@@ -969,6 +1043,11 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
969
1043
|
continue;
|
|
970
1044
|
}
|
|
971
1045
|
if (line === '/reflect') {
|
|
1046
|
+
// 记忆子系统总开关关闭时反思无意义(kickoffReflection 内部也会短路),直接提示,不误导用户"已触发"。
|
|
1047
|
+
if (!isMemoryEnabled()) {
|
|
1048
|
+
layout.contentWrite(`${ui.dim}(记忆子系统已关闭,/reflect 无效。用 /memory_switch 打开后再试)${ui.reset}\n`);
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
972
1051
|
// 手动触发后台反思 pass(不等;完成后下次 INPUT 态显摘要)。
|
|
973
1052
|
kickoffReflection(snapshotTranscript(history, 20));
|
|
974
1053
|
layout.contentWrite(`${ui.dim}(反思已触发,后台进行;完成后下次输入态显示摘要。日志见 .mocode/memory.log)${ui.reset}\n`);
|
|
@@ -1160,8 +1239,114 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1160
1239
|
// 即时生效(updateModelConfig 改内存 + env,reconfigureClient 重建 OpenAI 实例)+ 持久化(writeConfigKeys 写 ~/.mocode/config)。
|
|
1161
1240
|
// 仿 /theme:promptIntervention 弹菜单/输入 → 改 config → refreshStatusBase 刷底栏 → clearContent+banner 重显横幅 → dim 警告(shell env 覆盖)。
|
|
1162
1241
|
const arg = line.startsWith('/model ') ? line.slice('/model '.length).trim() : '';
|
|
1163
|
-
// /model list
|
|
1164
|
-
|
|
1242
|
+
// ── /model 子命令(use/save/list/delete/rename)优先派发,免得被无参向导路径吞掉。
|
|
1243
|
+
// 共用:apply 一个预设到 config + 持久化 + 重建 client + 重显横幅。无参 /model 选菜单和 /model use 都走这里。
|
|
1244
|
+
const applyPresetAndPersist = (target) => {
|
|
1245
|
+
updateModelConfig({
|
|
1246
|
+
model: target.model,
|
|
1247
|
+
baseURL: target.baseURL,
|
|
1248
|
+
apiKey: target.apiKey,
|
|
1249
|
+
contextWindowTokens: target.contextWindow,
|
|
1250
|
+
});
|
|
1251
|
+
writeConfigKeys({
|
|
1252
|
+
LLM_BASE_URL: target.baseURL,
|
|
1253
|
+
LLM_API_KEY: target.apiKey,
|
|
1254
|
+
LLM_MODEL: target.model,
|
|
1255
|
+
CONTEXT_WINDOW_TOKENS: String(target.contextWindow),
|
|
1256
|
+
});
|
|
1257
|
+
reconfigureClient();
|
|
1258
|
+
refreshStatusBase(history);
|
|
1259
|
+
layout.clearContent();
|
|
1260
|
+
if (history.some((m) => m.role === 'user')) {
|
|
1261
|
+
renderHistory(history);
|
|
1262
|
+
}
|
|
1263
|
+
else {
|
|
1264
|
+
layout.contentWrite(bannerString(banner()));
|
|
1265
|
+
}
|
|
1266
|
+
layout.contentWrite(`${ui.dim}(已切换到预设 “${target.name}” → ${target.model} @ ${target.baseURL})${ui.reset}\n`);
|
|
1267
|
+
if (config.llmKeysFromShell.length > 0) {
|
|
1268
|
+
layout.contentWrite(`${ui.dim}(shell 环境变量已设 ${config.llmKeysFromShell.join(' / ')},文件写入下次启动被其覆盖)${ui.reset}\n`);
|
|
1269
|
+
}
|
|
1270
|
+
};
|
|
1271
|
+
// 决定自动存的预设名:用 desired(model 字段),若与已有预设四元组完全相同则不重复存(返 null);
|
|
1272
|
+
// 否则若 desired 已存在则追加 -2/-3/...。desired 含非法字符(如 glm-4.6 的 '.')时先 sanitize(. → -),
|
|
1273
|
+
// sanitize 后仍空才退化到 'preset'。
|
|
1274
|
+
const uniquePresetName = (desired, baseURL, apiKey, model, contextWindow) => {
|
|
1275
|
+
const existing = listPresets();
|
|
1276
|
+
const sameEntry = existing.find((p) => p.baseURL === baseURL && p.apiKey === apiKey && p.model === model && p.contextWindow === contextWindow);
|
|
1277
|
+
if (sameEntry)
|
|
1278
|
+
return null; // 完全相同,不重复存
|
|
1279
|
+
// sanitize:把非 [a-zA-Z0-9_-] 字符(如 glm-4.6 的 '.')替换为 -,压缩两端 -,裁 1-32。
|
|
1280
|
+
const sanitized = desired
|
|
1281
|
+
.replace(/[^a-zA-Z0-9_-]+/g, '-')
|
|
1282
|
+
.replace(/^-+|-+$/g, '')
|
|
1283
|
+
.slice(0, 32) || 'preset';
|
|
1284
|
+
const base = isValidPresetName(sanitized) ? sanitized : 'preset';
|
|
1285
|
+
if (!existing.some((p) => p.name === base))
|
|
1286
|
+
return base;
|
|
1287
|
+
for (let i = 2; i < 1000; i++) {
|
|
1288
|
+
const candidate = `${base}-${i}`;
|
|
1289
|
+
if (candidate.length > 32)
|
|
1290
|
+
return `${base.slice(0, 32 - String(i).length - 1)}-${i}`;
|
|
1291
|
+
if (!existing.some((p) => p.name === candidate))
|
|
1292
|
+
return candidate;
|
|
1293
|
+
}
|
|
1294
|
+
return `${base}-${Date.now()}`;
|
|
1295
|
+
};
|
|
1296
|
+
// /model switch:弹 ↑↓·Enter 菜单挑预设切换。无预设时给一行引导。
|
|
1297
|
+
if (arg === 'switch') {
|
|
1298
|
+
const presets = listPresets();
|
|
1299
|
+
if (presets.length === 0) {
|
|
1300
|
+
layout.contentWrite(`${ui.dim}(还没有预设;先跑 /model 添加一个)${ui.reset}\n`);
|
|
1301
|
+
continue;
|
|
1302
|
+
}
|
|
1303
|
+
const isCurrent = (p) => p.baseURL === config.baseURL &&
|
|
1304
|
+
p.apiKey === config.apiKey &&
|
|
1305
|
+
p.model === config.model &&
|
|
1306
|
+
p.contextWindow === config.contextWindowTokens;
|
|
1307
|
+
const cols = layout.getGeo().cols;
|
|
1308
|
+
const labelFor = (p) => {
|
|
1309
|
+
const tag = isCurrent(p) ? ' ★current' : '';
|
|
1310
|
+
const right = `${p.model} @ ${p.baseURL}`;
|
|
1311
|
+
const left = `${p.name}${tag}`;
|
|
1312
|
+
const sep = left.length + 1 + right.length;
|
|
1313
|
+
if (sep <= cols - 2)
|
|
1314
|
+
return `${left} ${ui.dim}${right}${ui.reset}`;
|
|
1315
|
+
return left;
|
|
1316
|
+
};
|
|
1317
|
+
const choice = await promptIntervention({
|
|
1318
|
+
type: 'choice',
|
|
1319
|
+
title: '切换模型预设',
|
|
1320
|
+
detail: `当前: ${config.model} @ ${config.baseURL}(★ = 已匹配)`,
|
|
1321
|
+
options: presets.map(labelFor),
|
|
1322
|
+
allowCustom: false, // 纯切换,不需要「其他」干扰
|
|
1323
|
+
});
|
|
1324
|
+
if (choice.action === 'selected' && choice.value) {
|
|
1325
|
+
// value 含 ANSI 序列(labelFor 用了 ui.dim);按 preset.name 前缀匹配。
|
|
1326
|
+
const idx = presets.findIndex((p) => choice.value.startsWith(p.name));
|
|
1327
|
+
const target = idx >= 0 ? presets[idx] : presets[0];
|
|
1328
|
+
applyPresetAndPersist(target);
|
|
1329
|
+
}
|
|
1330
|
+
continue;
|
|
1331
|
+
}
|
|
1332
|
+
// /model list:列已配置的预设(★ 标当前);无预设给一行引导。
|
|
1333
|
+
// /model presets 是同义别名(老用户习惯)。
|
|
1334
|
+
if (arg === 'list' || arg === 'presets') {
|
|
1335
|
+
const ps = listPresets();
|
|
1336
|
+
if (ps.length === 0) {
|
|
1337
|
+
layout.contentWrite(`${ui.dim}(还没有预设;先跑 /model 添加一个)${ui.reset}\n`);
|
|
1338
|
+
continue;
|
|
1339
|
+
}
|
|
1340
|
+
layout.contentWrite(`${ui.dim}已配置 ${ps.length} 个预设:${ui.reset}\n`);
|
|
1341
|
+
for (const p of ps) {
|
|
1342
|
+
const star = p.baseURL === config.baseURL && p.apiKey === config.apiKey && p.model === config.model ? ' ★' : '';
|
|
1343
|
+
layout.contentWrite(` ${ui.cyan}${p.name}${ui.reset}${star} ${ui.dim}${p.model} @ ${p.baseURL}${ui.reset}\n`);
|
|
1344
|
+
}
|
|
1345
|
+
layout.contentWrite(`${ui.dim}(★ = 与当前一致;切换用 /model switch)${ui.reset}\n`);
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
// /model show:显示当前四项配置(apiKey 脱敏)。
|
|
1349
|
+
if (arg === 'show') {
|
|
1165
1350
|
layout.contentWrite(`${ui.dim}当前模型配置:${ui.reset}\n`);
|
|
1166
1351
|
layout.contentWrite(` ${ui.cyan}baseURL${ui.reset} ${config.baseURL}\n`);
|
|
1167
1352
|
layout.contentWrite(` ${ui.cyan}apiKey ${ui.reset} ${maskKey(config.apiKey)}\n`);
|
|
@@ -1170,6 +1355,61 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1170
1355
|
layout.contentWrite(`${ui.dim}(配置文件: ${CONFIG_PATH})${ui.reset}\n`);
|
|
1171
1356
|
continue;
|
|
1172
1357
|
}
|
|
1358
|
+
// /model use <name>:一键把预设应用到 config + 持久化 + 重建 client。
|
|
1359
|
+
if (arg.startsWith('use ')) {
|
|
1360
|
+
const name = arg.slice(4).trim();
|
|
1361
|
+
if (!name) {
|
|
1362
|
+
layout.contentWrite(`${ui.yellow}用法: /model use <name>${ui.reset}\n`);
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
if (!isValidPresetName(name)) {
|
|
1366
|
+
layout.contentWrite(`${ui.yellow}非法名字: ${name}(仅允许 [a-zA-Z0-9_-]{1,32})${ui.reset}\n`);
|
|
1367
|
+
continue;
|
|
1368
|
+
}
|
|
1369
|
+
let preset;
|
|
1370
|
+
try {
|
|
1371
|
+
preset = getPreset(name);
|
|
1372
|
+
}
|
|
1373
|
+
catch (e) {
|
|
1374
|
+
if (e.code === 'ENOENT') {
|
|
1375
|
+
layout.contentWrite(`${ui.yellow}没有预设 “${name}”;先 /model save ${name}${ui.reset}\n`);
|
|
1376
|
+
}
|
|
1377
|
+
else {
|
|
1378
|
+
layout.contentWrite(`${ui.red}/model use 失败: ${e.message}${ui.reset}\n`);
|
|
1379
|
+
}
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1382
|
+
applyPresetAndPersist(preset);
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
// /model delete <name>:删一个预设。无参 / 重名 / 非法名字分别给不同提示。
|
|
1386
|
+
if (arg.startsWith('delete ')) {
|
|
1387
|
+
const name = arg.slice(7).trim();
|
|
1388
|
+
if (!name) {
|
|
1389
|
+
layout.contentWrite(`${ui.yellow}用法: /model delete <name>${ui.reset}\n`);
|
|
1390
|
+
continue;
|
|
1391
|
+
}
|
|
1392
|
+
if (!isValidPresetName(name)) {
|
|
1393
|
+
layout.contentWrite(`${ui.yellow}非法名字: ${name}(仅允许 [a-zA-Z0-9_-]{1,32})${ui.reset}\n`);
|
|
1394
|
+
continue;
|
|
1395
|
+
}
|
|
1396
|
+
if (deletePreset(name)) {
|
|
1397
|
+
layout.contentWrite(`${ui.dim}已删除预设 “${name}”${ui.reset}\n`);
|
|
1398
|
+
}
|
|
1399
|
+
else {
|
|
1400
|
+
layout.contentWrite(`${ui.yellow}没有预设 “${name}”${ui.reset}\n`);
|
|
1401
|
+
}
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
// /model 后跟了未知子命令 → 给一行简短用法提示,免得静默吞用户输入。
|
|
1405
|
+
// arg === '' → 直接进向导(下方 4 步链);这里只兜底非法子命令。
|
|
1406
|
+
if (arg !== '') {
|
|
1407
|
+
layout.contentWrite(`${ui.yellow}未知子命令: ${arg}${ui.reset}\n` +
|
|
1408
|
+
`${ui.dim}用法: /model(配置新模型向导) · /model switch · /model list · /model delete <name>${ui.reset}\n`);
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
// /model 无参 → 直接进入「配置新模型」4 步向导(不弹动作菜单)。
|
|
1412
|
+
// 切换 / 查看 / 删除已配置预设改用显式子命令:/model switch · /model list · /model delete <name>。
|
|
1173
1413
|
// 1) 选 provider 预设(预填 baseURL,后续仍可逐项改)。
|
|
1174
1414
|
let preset;
|
|
1175
1415
|
try {
|
|
@@ -1203,6 +1443,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1203
1443
|
title: `应用 ${preset.label}?`,
|
|
1204
1444
|
detail: `model ${preset.model}\nbaseURL ${preset.baseURL}\napiKey ${maskKey(config.apiKey)}(直接应用=保留当前)\n窗口 ${preset.window}`,
|
|
1205
1445
|
options: ['直接应用', '逐项修改'],
|
|
1446
|
+
allowCustom: false,
|
|
1206
1447
|
});
|
|
1207
1448
|
if (res.action === 'cancelled') {
|
|
1208
1449
|
continue;
|
|
@@ -1312,6 +1553,20 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1312
1553
|
CONTEXT_WINDOW_TOKENS: String(window),
|
|
1313
1554
|
});
|
|
1314
1555
|
reconfigureClient();
|
|
1556
|
+
// 3.5) 自动存为命名预设:用 model 字段,重名追加 -2/-3,完全相同的四元组不重复存。
|
|
1557
|
+
// 让 /model 跑一次就多一份可切换的预设,/model switch 切回去。
|
|
1558
|
+
let savedName = null;
|
|
1559
|
+
try {
|
|
1560
|
+
const finalName = uniquePresetName(model, baseURL, apiKey, model, window);
|
|
1561
|
+
if (finalName) {
|
|
1562
|
+
savePreset({ name: finalName, baseURL, apiKey, model, contextWindow: window });
|
|
1563
|
+
savedName = finalName;
|
|
1564
|
+
}
|
|
1565
|
+
// finalName === null 表示与某个已存在预设完全一致,不再重复保存。
|
|
1566
|
+
}
|
|
1567
|
+
catch (e) {
|
|
1568
|
+
layout.contentWrite(`${ui.red}保存预设失败: ${e.message}${ui.reset}\n`);
|
|
1569
|
+
}
|
|
1315
1570
|
// 4) 刷新 UI:底栏模型名 + 重显横幅(banner() 闭包实时读 config,自动反映新值)。
|
|
1316
1571
|
refreshStatusBase(history);
|
|
1317
1572
|
layout.clearContent();
|
|
@@ -1322,6 +1577,9 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1322
1577
|
layout.contentWrite(bannerString(banner()));
|
|
1323
1578
|
}
|
|
1324
1579
|
layout.contentWrite(`${ui.dim}(已切换模型 → ${model} @ ${baseURL})${ui.reset}\n`);
|
|
1580
|
+
if (savedName) {
|
|
1581
|
+
layout.contentWrite(`${ui.dim}(已保存为预设 “${savedName}”,下次 /model use ${savedName} 一键切回)${ui.reset}\n`);
|
|
1582
|
+
}
|
|
1325
1583
|
// 5) dim 警告:shell export 的 LLM 键下次启动会覆盖文件值。
|
|
1326
1584
|
if (config.llmKeysFromShell.length > 0) {
|
|
1327
1585
|
layout.contentWrite(`${ui.dim}(shell 环境变量已设 ${config.llmKeysFromShell.join(' / ')},文件写入下次启动被其覆盖;取消该 shell 设置后生效)${ui.reset}\n`);
|
|
@@ -1,72 +1,85 @@
|
|
|
1
1
|
import { promptIntervention } from '../../ui/intervention.js';
|
|
2
2
|
import { sendState } from '../../pet/bridge.js';
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
* 直接 String(obj) 会变成 "[object Object]"——这里智能提取可读字段。 */
|
|
6
|
-
function optionToString(o) {
|
|
3
|
+
/** 把单个选项元素安全地转成 {label, detail}。LLM 有时传对象而不是字符串,这里提取可读字段。 */
|
|
4
|
+
function optionToChoice(o) {
|
|
7
5
|
if (o === null || o === undefined)
|
|
8
|
-
return '';
|
|
6
|
+
return { label: '' };
|
|
9
7
|
if (typeof o === 'string')
|
|
10
|
-
return o;
|
|
8
|
+
return { label: o };
|
|
11
9
|
if (typeof o === 'number' || typeof o === 'boolean')
|
|
12
|
-
return String(o);
|
|
10
|
+
return { label: String(o) };
|
|
13
11
|
if (typeof o === 'object') {
|
|
14
12
|
const obj = o;
|
|
15
|
-
|
|
13
|
+
const pickString = (source, keys) => {
|
|
14
|
+
for (const k of keys) {
|
|
15
|
+
const v = source[k];
|
|
16
|
+
if (typeof v === 'string' && v.trim())
|
|
17
|
+
return v.trim();
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
};
|
|
16
21
|
const labelKeys = ['label', 'name', 'title', 'text', 'option', 'choice', 'value', 'key'];
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
let label = pickString(obj, labelKeys);
|
|
23
|
+
const nestedOptions = obj.options;
|
|
24
|
+
if (!label && nestedOptions && typeof nestedOptions === 'object' && !Array.isArray(nestedOptions)) {
|
|
25
|
+
label = pickString(nestedOptions, labelKeys);
|
|
21
26
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (
|
|
26
|
-
return
|
|
27
|
+
const desc = pickString(obj, ['description', 'desc', 'detail', 'details', 'reason']);
|
|
28
|
+
if (label && desc && label !== desc)
|
|
29
|
+
return { label, detail: desc };
|
|
30
|
+
if (label)
|
|
31
|
+
return { label };
|
|
32
|
+
if (desc)
|
|
33
|
+
return { label: desc };
|
|
34
|
+
const nestedKeys = ['options', 'option', 'choice', 'value'];
|
|
35
|
+
for (const k of nestedKeys) {
|
|
36
|
+
const nested = obj[k];
|
|
37
|
+
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
|
|
38
|
+
const c = optionToChoice(nested);
|
|
39
|
+
if (c.label && !c.label.startsWith('{'))
|
|
40
|
+
return c;
|
|
41
|
+
}
|
|
27
42
|
}
|
|
28
|
-
// 兜底:JSON 序列化(去掉大括号让它看起来不像代码)
|
|
29
43
|
try {
|
|
44
|
+
// 兜底:仍 stringify 原值,以便上游能看到真实内容做调试/日志;但若得到字面"{}"或"[]"
|
|
45
|
+
// (说明 obj 本身就是个空对象),返回空 label 让 coerceOptions 的 length>0 filter 自然过滤掉,
|
|
46
|
+
// 避免终端上显示「1. {}」这种对用户无意义的内容。
|
|
30
47
|
const s = JSON.stringify(obj);
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
if (s === '{}' || s === '[]')
|
|
49
|
+
return { label: '' };
|
|
50
|
+
return { label: s };
|
|
33
51
|
}
|
|
34
52
|
catch {
|
|
35
|
-
return String(o);
|
|
53
|
+
return { label: String(o) };
|
|
36
54
|
}
|
|
37
55
|
}
|
|
38
|
-
return String(o);
|
|
56
|
+
return { label: String(o) };
|
|
39
57
|
}
|
|
40
58
|
/** 公开以便 check-ask-human-options.ts 单元测试。 */
|
|
41
59
|
export function coerceOptions(raw) {
|
|
42
|
-
// 路径 1:本身就是数组,map 成字符串。
|
|
43
60
|
if (Array.isArray(raw)) {
|
|
44
|
-
// 子路径 1a:LLM 把真数组包成字符串塞在单元素里(本次 bug 现场)
|
|
45
61
|
if (raw.length === 1 && typeof raw[0] === 'string') {
|
|
46
62
|
const t = raw[0].trim();
|
|
47
63
|
if (t.startsWith('[') && t.endsWith(']')) {
|
|
48
64
|
try {
|
|
49
65
|
const parsed = JSON.parse(t);
|
|
50
66
|
if (Array.isArray(parsed))
|
|
51
|
-
return parsed.map(
|
|
67
|
+
return parsed.map(optionToChoice).filter((o) => o.label.length > 0);
|
|
52
68
|
}
|
|
53
69
|
catch {
|
|
54
70
|
// 不是合法 JSON 数组,降级原值
|
|
55
71
|
}
|
|
56
72
|
}
|
|
57
73
|
}
|
|
58
|
-
return raw.map(
|
|
74
|
+
return raw.map(optionToChoice).filter((o) => o.label.length > 0);
|
|
59
75
|
}
|
|
60
|
-
// 路径 2:LLM 直接把整个数组 stringify 成单字符串塞 options 字段(JSON.parse 出来是字符串)
|
|
61
|
-
// 例如 GLM 系经常这么做,arg h['options']='["A","B"]' → args.options='["A","B"]'
|
|
62
|
-
// 这里解开成真数组再转。
|
|
63
76
|
if (typeof raw === 'string') {
|
|
64
77
|
const t = raw.trim();
|
|
65
78
|
if (t.startsWith('[') && t.endsWith(']')) {
|
|
66
79
|
try {
|
|
67
80
|
const parsed = JSON.parse(t);
|
|
68
81
|
if (Array.isArray(parsed))
|
|
69
|
-
return parsed.map(
|
|
82
|
+
return parsed.map(optionToChoice).filter((o) => o.label.length > 0);
|
|
70
83
|
}
|
|
71
84
|
catch {
|
|
72
85
|
// 不是合法 JSON,保留为单元素数组(对应 input 模式)
|
|
@@ -79,11 +92,12 @@ export function coerceOptions(raw) {
|
|
|
79
92
|
export const askHumanTool = {
|
|
80
93
|
name: 'ask_human',
|
|
81
94
|
description: [
|
|
82
|
-
'
|
|
83
|
-
'
|
|
84
|
-
'
|
|
85
|
-
'
|
|
86
|
-
|
|
95
|
+
'Present the user with a menu of choices to pick from.',
|
|
96
|
+
' DEFAULT: pass 2-4 concrete options via `options`; each is a string, or { label, description } when the choice has a non-obvious tradeoff.',
|
|
97
|
+
' STRICT: object form requires a non-empty `label` — never emit {} or omit label (renders as a literal "{}" menu item). Only `label` and `description` are recognized — no extra fields.',
|
|
98
|
+
' FREE-TEXT (omit `options`): only when the answer truly cannot be reduced to a few choices (e.g. "paste the error message").',
|
|
99
|
+
' DO NOT call when the task is clear and you can pick a sensible default.',
|
|
100
|
+
].join(' '),
|
|
87
101
|
parameters: {
|
|
88
102
|
type: 'object',
|
|
89
103
|
properties: {
|
|
@@ -93,25 +107,39 @@ export const askHumanTool = {
|
|
|
93
107
|
},
|
|
94
108
|
options: {
|
|
95
109
|
type: 'array',
|
|
96
|
-
items: {
|
|
97
|
-
|
|
110
|
+
items: {
|
|
111
|
+
anyOf: [
|
|
112
|
+
{ type: 'string' },
|
|
113
|
+
{
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: {
|
|
116
|
+
label: {
|
|
117
|
+
type: 'string',
|
|
118
|
+
minLength: 1,
|
|
119
|
+
description: 'Short option title (1-5 words), shown as the choice itself.',
|
|
120
|
+
},
|
|
121
|
+
description: {
|
|
122
|
+
type: 'string',
|
|
123
|
+
description: 'What picking this option means or implies; explain the tradeoff when the label alone leaves the user unsure.',
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
required: ['label'],
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
description: '2-4 concrete choices. Each is a plain string, or { label, description } when the tradeoff is non-obvious. Required in most cases; omit only for free-form input.',
|
|
98
131
|
},
|
|
99
132
|
context: {
|
|
100
133
|
type: 'string',
|
|
101
|
-
description: 'Background explanation
|
|
134
|
+
description: 'Background explanation shown under the title; may be multiline.',
|
|
102
135
|
},
|
|
103
136
|
},
|
|
104
137
|
required: ['question'],
|
|
105
138
|
},
|
|
106
139
|
async execute(args) {
|
|
107
140
|
const question = String(args.question ?? '');
|
|
108
|
-
// 容错:部分 LLM(尤其 GLM 系)把数组/对象 stringify 后塞进来,这里识别「长得很像 JSON
|
|
109
|
-
// 数组的单字符串元素」并解开,避免菜单只剩一行 [object Object]、逼用户手动输入。
|
|
110
|
-
// 任何一步失败 / 解出非数组:降级 input,与原代码语义一致。
|
|
111
141
|
const options = coerceOptions(args.options);
|
|
112
142
|
const context = args.context ? String(args.context) : undefined;
|
|
113
|
-
// 桌宠:面板弹出期间广播 waiting_human(红灯闪烁,提示需要人工介入);拿到响应后 sendState 会被
|
|
114
|
-
// 下一个 hook 事件(如 onToolDone→tool_call)覆盖,这里不用手动切回——与其它工具状态转移逻辑一致。
|
|
115
143
|
sendState('waiting_human');
|
|
116
144
|
const result = await promptIntervention({
|
|
117
145
|
type: options.length > 0 ? 'choice' : 'input',
|
|
@@ -53,7 +53,7 @@ export const codegraphTool = {
|
|
|
53
53
|
description: 'FIRST CHOICE for understanding/locating code, tracing call chains, or assessing the impact of changes — use this before read_file/grep when a .codegraph/ index exists.' +
|
|
54
54
|
' Returns symbol source + call paths in one shot — more accurate and economical than piecing together read_file/grep.' +
|
|
55
55
|
' When to use: starting any code exploration; locating a symbol; understanding how a feature works; seeing what calls a function or what a change affects.' +
|
|
56
|
-
' When NOT to use: no .codegraph/ index (build it first with `codegraph init`); reading a file you just edited; editing a single known small file — in those cases go straight to read_file/edit_file.' +
|
|
56
|
+
' When NOT to use: no .codegraph/ index (build it first with `codegraph init`); reading a file you just edited; editing a single known small file — in those cases go straight to read_file/edit_file; or the symbol/area was already retrieved earlier in this session — reuse that result instead of calling again.' +
|
|
57
57
|
' Fallback: if codegraph misses or the result is incomplete, then use read_file/grep/glob to fill the gaps.',
|
|
58
58
|
parameters: {
|
|
59
59
|
type: 'object',
|