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.
@@ -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: '配置大模型(baseURL/key/model/窗口)' },
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 )、tool→↳ 结果预览;system 跳过。
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
- layout.contentWrite(` ${ui.brightMagenta}●${ui.reset} ${ui.cyan}${name}${ui.reset} ${ui.dim}${summarizeToolCall(name, args)}${ui.reset}\n`);
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 preview = summarizeToolResult(name, textOf(m.content));
478
- if (preview)
479
- layout.contentWrite(` ${ui.gray}↳ ${preview}${ui.reset}\n`);
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, detail: 'auto' },
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:显示当前四项配置(apiKey 脱敏)
1164
- if (arg === 'list' || arg === 'show') {
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
- * LLM 有时会传对象( {name/label/title:"xxx", desc/description:"yyy"})而不是纯字符串,
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
- for (const k of labelKeys) {
18
- const v = obj[k];
19
- if (typeof v === 'string' && v.trim())
20
- return v;
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
- // 其次尝试 "label + description" 组合
23
- const label = obj.label ?? obj.name ?? obj.title;
24
- const desc = obj.description ?? obj.desc ?? obj.detail;
25
- if (typeof label === 'string' && typeof desc === 'string') {
26
- return `${label}: ${desc}`;
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
- return s;
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(optionToString);
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(optionToString);
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(optionToString);
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
- 'Ask the user for input at a decision point (blocks until they respond).',
83
- ' Use when: multiple approaches need a user decision, intent is unclear, or extra info is needed.',
84
- ' Don\'t call when the task is clear and you can decideit interrupts the user.',
85
- ' Options (2-6) let the user pick; omit/empty for free-text input. Their answer is returned as the result.',
86
- ].join(''),
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 recognizedno 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: { type: 'string' },
97
- description: 'Options for the user to choose from (2~6). May be omitted — when omitted or empty, it becomes free-text input instead',
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 for the question (optional; helps the user understand why their decision is needed; shown under the title, may be multiline)',
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',