@huicai/cli 0.5.1 → 2026.709.53670

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/bin/huicai.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import readline from 'node:readline';
5
5
  import path from 'node:path';
6
+ import { createRequire } from 'node:module';
6
7
  import { c } from '../src/colors.js';
7
8
  import { loadConfig, saveConfig, CONFIG_PATH, DEFAULT_MODEL } from '../src/config.js';
8
9
  import { listModels, getAgentPersona, fetchUsage, listPlatformSkills, getPlatformSkill, pushPlatformSkill } from '../src/api.js';
@@ -12,7 +13,9 @@ import { buildSystemPrompt } from '../src/context.js';
12
13
  import * as sessions from '../src/session.js';
13
14
  import { loadLocalSkills, skillDirs, writeSkillToDir, readSkillDirForPush } from '../src/skills.js';
14
15
 
15
- const VERSION = '0.5.1';
16
+ // 版本从 package.json 读取(versionDisplay 年月日展示式,与 VSCode 扩展、IDEA 插件一致);勿手改,用 scripts/next-version.cjs
17
+ const _pkg = createRequire(import.meta.url)('../package.json');
18
+ const VERSION = _pkg.versionDisplay || _pkg.version;
16
19
 
17
20
  function parse(argv) {
18
21
  const flags = {};
@@ -51,6 +54,9 @@ function parse(argv) {
51
54
  case '--agent':
52
55
  flags.agent = argv[++i];
53
56
  break;
57
+ case '--consult':
58
+ flags.consult = argv[++i];
59
+ break;
54
60
  case '--project':
55
61
  flags.project = true;
56
62
  break;
@@ -119,6 +125,7 @@ ${c.bold('huicai')} — 终端智能体,以 HuiCai_AI 平台为模型后端,
119
125
  huicai sessions [-a] 列出历史会话(-a 跨目录全部)
120
126
  huicai login 浏览器授权登录(内置平台地址,无需输入网关)
121
127
  huicai login --paste 无浏览器环境:手动粘贴 API 令牌
128
+ huicai logout 退出登录(清除本机保存的令牌)
122
129
  huicai models 列出可用模型
123
130
  huicai usage 查看 Token 用量窗口(5小时/7天,按会员等级)
124
131
  huicai skills 列出本地 + 平台技能(Agent Skills)
@@ -129,6 +136,7 @@ ${c.bold('huicai')} — 终端智能体,以 HuiCai_AI 平台为模型后端,
129
136
  选项:
130
137
  -m, --model <id> 指定模型(覆盖配置)
131
138
  --agent <id> 采用平台智能体人格(分享令牌或 agent-xxx)
139
+ --consult <ref> 混合模式:本机驱动模型 + 平台智能体/工作流/数字人(agent-/workflow-/datahuman-xxx)作咨询工具
132
140
  -c, --continue 继续最近一次会话(当前目录)
133
141
  -r, --resume 选择并恢复历史会话(当前目录)
134
142
  --base <url> 覆盖内置平台地址(自建 / 联调用)
@@ -145,7 +153,8 @@ ${c.bold('huicai')} — 终端智能体,以 HuiCai_AI 平台为模型后端,
145
153
  -v, --version 版本
146
154
 
147
155
  交互技巧:
148
- /help 查看命令 · @文件路径 引用文件内容 · !命令 直通 shell(输出进上下文)
156
+ /help 查看命令 · @文件 引用文件(PDF/Word/Excel 等自动解析为文本、图片作为视觉输入) · !命令 直通 shell(输出进上下文)
157
+ 模型 -m auto 或 /model auto:按账号自动选择可用模型(与 VSCode 扩展一致)
149
158
  自定义命令:.huicai/commands/<名>.md($ARGUMENTS 为参数占位符)
150
159
  权限规则:.huicai/settings.json {"permissions":{"allow":["run_bash(git *)"]}}
151
160
 
@@ -217,6 +226,16 @@ function validateAuth(config) {
217
226
  return null;
218
227
  }
219
228
 
229
+ /** Auto 模型解析:按当前账号选一个可用的「原始渠道模型」(优先内置默认;伪模型不适合本机工具循环)。 */
230
+ async function resolveAutoModel(config) {
231
+ try {
232
+ const models = await listModels(config.baseUrl, config.apiKey);
233
+ const raw = models.map((m) => m.id).filter((id) => !/^(agent|workflow|datahuman)-/.test(id));
234
+ if (raw.length) return raw.includes(DEFAULT_MODEL) ? DEFAULT_MODEL : raw[0];
235
+ } catch { /* 网络失败:回退内置默认 */ }
236
+ return DEFAULT_MODEL;
237
+ }
238
+
220
239
  async function cmdModels(config) {
221
240
  const problem = validateAuth(config);
222
241
  if (problem) {
@@ -411,6 +430,11 @@ async function main() {
411
430
  await login(flags);
412
431
  return;
413
432
  }
433
+ if (sub === 'logout') {
434
+ saveConfig({ apiKey: '' });
435
+ process.stdout.write(c.green('已退出登录(已清除本机保存的令牌)。') + '\n');
436
+ return;
437
+ }
414
438
 
415
439
  const config = loadConfig();
416
440
  if (flags.base) config.baseUrl = flags.base.replace(/\/+$/, '');
@@ -460,6 +484,19 @@ async function main() {
460
484
  }
461
485
  }
462
486
 
487
+ // --consult:混合模式——把平台伪模型作为 consult_platform 工具(本机原始驱动 + 平台编排咨询)
488
+ if (flags.consult) {
489
+ if (!/^(agent|workflow|datahuman)-/.test(flags.consult)) {
490
+ process.stderr.write(c.red('--consult 需平台伪模型 id:agent-xxx / workflow-xxx / datahuman-xxx(分享码见分享窗口)') + '\n');
491
+ process.exit(1);
492
+ }
493
+ config.consult = flags.consult;
494
+ try {
495
+ const p = await getAgentPersona(config.baseUrl, config.apiKey, flags.consult);
496
+ if (p?.name) config.consultLabel = p.name;
497
+ } catch { /* 忽略:用 ref 作展示名 */ }
498
+ }
499
+
463
500
  // 恢复会话:resume <id> 子命令 / -r 选择器 / -c 最近一次
464
501
  let resumeId = null;
465
502
  let positionalPrompt = positional;
@@ -486,12 +523,20 @@ async function main() {
486
523
  config.model = session.model; // 恢复会话默认沿用其模型(-m/--agent 可覆盖)
487
524
  }
488
525
 
526
+ // Auto 模型(与 VSCode 扩展一致):model 为空/auto,或混合模式下 model 仍是伪模型时,解析为可用的原始驱动模型
527
+ if (!config.model || config.model === 'auto'
528
+ || (config.consult && /^(agent|workflow|datahuman)-/.test(config.model))) {
529
+ config.model = await resolveAutoModel(config);
530
+ }
489
531
  if (!config.model) {
490
532
  process.stderr.write(
491
533
  c.red('未设置模型。用 `huicai -m <模型>`、`huicai login` 设置,或先 `huicai models` 查看可用模型。') + '\n',
492
534
  );
493
535
  process.exit(1);
494
536
  }
537
+ if (config.consult) {
538
+ process.stdout.write(c.gray(`已启用混合模式:本机驱动 ${config.model} + 咨询平台 ${config.consultLabel || config.consult}`) + '\n');
539
+ }
495
540
 
496
541
  // 新会话:用当前 cwd/persona 构建系统提示词(--system-prompt 完全替换、--append-system-prompt 追加)
497
542
  if (!session) {
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@huicai/cli",
3
- "version": "0.5.1",
3
+ "version": "2026.709.53670",
4
+ "versionDisplay": "2026.07.09.53670",
4
5
  "description": "在终端运行的智能体 CLI:以 HuiCai_AI 平台的 OpenAI 兼容 /v1 为模型后端,在用户本机执行文件/命令工具。",
5
6
  "type": "module",
6
7
  "bin": {
@@ -19,13 +20,15 @@
19
20
  "start": "node bin/huicai.js",
20
21
  "check": "node --check bin/huicai.js",
21
22
  "test": "node --test --test-concurrency=1 test/*.test.mjs",
23
+ "version:next": "node scripts/next-version.cjs",
22
24
  "prepublishOnly": "node --check bin/huicai.js"
23
25
  },
24
26
  "keywords": [
25
27
  "cli",
26
28
  "agent",
27
29
  "ai",
28
- "terminal", "huicai",
30
+ "terminal",
31
+ "huicai",
29
32
  "openai-compatible",
30
33
  "coding-agent"
31
34
  ],
@@ -34,8 +37,7 @@
34
37
  "homepage": "https://github.com/SJHT-SDK/huicai-cli/tree/master#readme",
35
38
  "repository": {
36
39
  "type": "git",
37
- "url": "https://github.com/SJHT-SDK/huicai-cli.git/",
38
- "directory": "huicai-cli"
40
+ "url": "https://github.com/SJHT-SDK/huicai-cli.git"
39
41
  },
40
42
  "bugs": {
41
43
  "url": "https://github.com/SJHT-SDK/huicai-cli/issues"
package/src/commands.js CHANGED
@@ -7,8 +7,8 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import os from 'node:os';
9
9
  import { execSync } from 'node:child_process';
10
- import { saveConfig } from './config.js';
11
- import { listModels } from './api.js';
10
+ import { saveConfig, DEFAULT_MODEL } from './config.js';
11
+ import { listModels, fetchUsage } from './api.js';
12
12
  import { listSessions, relTime, turnCount } from './session.js';
13
13
  import { compactNow } from './compaction.js';
14
14
  import { loadAllowRules } from './settings.js';
@@ -42,15 +42,25 @@ const BUILTINS = [
42
42
  },
43
43
  },
44
44
  {
45
- name: 'model', args: '[名称]', desc: '查看或切换模型(带参数切换并保存)',
46
- run(ctx, arg) {
47
- if (arg) {
48
- ctx.config.model = arg;
49
- saveConfig({ model: arg });
50
- ctx.print('已切换模型:' + arg);
51
- } else {
45
+ name: 'model', args: '[名称|auto]', desc: '查看或切换模型(auto=按账号自动选可用模型,与 VSCode 一致)',
46
+ async run(ctx, arg) {
47
+ if (!arg) {
52
48
  ctx.print('当前模型:' + (ctx.config.model || '(未设置)'));
49
+ return;
50
+ }
51
+ if (arg === 'auto') {
52
+ try {
53
+ const models = await listModels(ctx.config.baseUrl, ctx.config.apiKey);
54
+ const raw = models.map((m) => m.id).filter((id) => !/^(agent|workflow|datahuman)-/.test(id));
55
+ ctx.config.model = (raw.includes(DEFAULT_MODEL) ? DEFAULT_MODEL : raw[0]) || ctx.config.model;
56
+ } catch { /* 拉取失败保持现值 */ }
57
+ saveConfig({ model: 'auto' }); // 保存 auto 偏好(下次启动重新解析),本会话用已解析的具体模型
58
+ ctx.print('Auto 已选:' + ctx.config.model);
59
+ return;
53
60
  }
61
+ ctx.config.model = arg;
62
+ saveConfig({ model: arg });
63
+ ctx.print('已切换模型:' + arg);
54
64
  },
55
65
  },
56
66
  {
@@ -64,6 +74,53 @@ const BUILTINS = [
64
74
  }
65
75
  },
66
76
  },
77
+ {
78
+ name: 'usage', desc: '查看 Token 用量窗口(5小时/7天,按会员等级)',
79
+ async run(ctx) {
80
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
81
+ const fmt = (used, limit) => {
82
+ used = num(used);
83
+ limit = num(limit);
84
+ if (limit < 0) return `${used.toLocaleString()} / 无限`;
85
+ if (limit === 0) return '该档未开放';
86
+ return `${used.toLocaleString()} / ${limit.toLocaleString()} (${Math.min(100, Math.round((used / limit) * 100))}%)`;
87
+ };
88
+ try {
89
+ const u = await fetchUsage(ctx.config.baseUrl, ctx.config.apiKey);
90
+ if (!u || (typeof u.used5h !== 'number' && typeof u.used7d !== 'number')) {
91
+ ctx.print('平台未返回用量数据(请确认平台已支持 /v1/usage)。');
92
+ return;
93
+ }
94
+ ctx.print(`Token 用量窗口\n 近 5 小时:${fmt(u.used5h, u.limit5h)}\n 近 7 天 :${fmt(u.used7d, u.limit7d)}`
95
+ + (!u.allowed && u.message ? '\n ' + u.message : ''));
96
+ } catch (e) {
97
+ ctx.print('拉取用量失败:' + e.message);
98
+ }
99
+ },
100
+ },
101
+ {
102
+ name: 'login', desc: '重新登录(浏览器授权;无浏览器请退出后 huicai login --paste)',
103
+ async run(ctx) {
104
+ try {
105
+ const { deviceLogin } = await import('./device.js');
106
+ ctx.print('正在打开浏览器授权…');
107
+ const key = await deviceLogin(ctx.config.baseUrl);
108
+ ctx.config.apiKey = key;
109
+ saveConfig({ apiKey: key });
110
+ ctx.print('登录成功。');
111
+ } catch (e) {
112
+ ctx.print('登录未完成:' + e.message + '(可退出后用 huicai login --paste 粘贴令牌)');
113
+ }
114
+ },
115
+ },
116
+ {
117
+ name: 'logout', desc: '退出登录(清除本机保存的令牌)',
118
+ run(ctx) {
119
+ ctx.config.apiKey = '';
120
+ saveConfig({ apiKey: '' });
121
+ ctx.print('已退出登录。后续对话需重新 /login 或 huicai login。');
122
+ },
123
+ },
67
124
  {
68
125
  name: 'clear', desc: '开启新会话(旧会话仍可 --resume 恢复)',
69
126
  run() {
package/src/consult.js ADDED
@@ -0,0 +1,65 @@
1
+ // 混合模式(对应 VSCode 扩展的「平台伪模型 + 工作区」):把平台智能体/工作流/数字人作为「咨询工具」
2
+ // 暴露给本机智能体循环。本机用原始驱动模型 + 本地工具(读写文件/执行命令/子智能体…),遇到平台目标擅长的问题
3
+ // 调用 consult_platform——由平台侧编排作答(服务端知识库 RAG / 工具 / 联网),再用本机工具把结论落地到项目。
4
+
5
+ import { streamChat } from './api.js';
6
+
7
+ const PSEUDO = /^(agent|workflow|datahuman)-/;
8
+
9
+ /** 平台伪模型 id → 中文类别名。 */
10
+ function kindOf(ref) {
11
+ if (ref.startsWith('workflow-')) return '工作流';
12
+ if (ref.startsWith('datahuman-')) return '业务数字人';
13
+ return '智能体';
14
+ }
15
+
16
+ /**
17
+ * 构造 consult_platform 工具;未配置或 ref 非平台伪模型时返回 null。
18
+ * @param {object} p
19
+ * @param {object} p.config 运行配置(读时取最新;含 baseUrl/apiKey/consult/consultLabel/client/sessionId)
20
+ */
21
+ export function makeConsultTool({ config }) {
22
+ const ref = config && config.consult;
23
+ if (!ref || !PSEUDO.test(String(ref))) return null;
24
+ const kind = kindOf(String(ref));
25
+ const label = config.consultLabel || ref;
26
+
27
+ const spec = {
28
+ type: 'function',
29
+ function: {
30
+ name: 'consult_platform',
31
+ description:
32
+ `咨询平台${kind}「${label}」:由平台侧编排作答(带其服务端知识库 RAG / 工具 / 联网检索)。`
33
+ + `遇到它擅长领域的问题优先调用它获取权威答复,再用本机工具把结论落地到项目。可多次调用。`,
34
+ parameters: {
35
+ type: 'object',
36
+ properties: {
37
+ query: { type: 'string', description: '自然语言的咨询 / 查询 / 运行输入' },
38
+ },
39
+ required: ['query'],
40
+ },
41
+ },
42
+ };
43
+
44
+ const run = async (args) => {
45
+ const q = String((args && args.query) || '').trim();
46
+ if (!q) return '需要提供 query(要咨询平台的自然语言问题 / 输入)';
47
+ try {
48
+ // 走平台伪模型 → 服务端编排;client/sessionId 使这轮咨询也计入对话审计
49
+ const r = await streamChat({
50
+ baseUrl: config.baseUrl,
51
+ apiKey: config.apiKey,
52
+ model: ref,
53
+ messages: [{ role: 'user', content: q }],
54
+ client: config.client,
55
+ sessionId: config.sessionId,
56
+ }, {});
57
+ return r.content || '(平台无返回)';
58
+ } catch (e) {
59
+ if (e && e.name === 'AbortError') throw e;
60
+ return `平台调用失败:${e.message}`;
61
+ }
62
+ };
63
+
64
+ return { readOnly: true, spec, run };
65
+ }
package/src/input.js CHANGED
@@ -8,24 +8,43 @@ import path from 'node:path';
8
8
  import os from 'node:os';
9
9
  import { CONFIG_DIR } from './config.js';
10
10
  import { runBash } from './executors.js';
11
+ import { extractDocument } from './api.js';
11
12
 
12
13
  const MAX_MENTIONS = 5;
13
14
  const MENTION_FILE_CHARS = 12_000;
14
15
  const HISTORY_PATH = path.join(CONFIG_DIR, 'history.json');
15
16
  const HISTORY_CAP = 1000;
16
17
 
18
+ /** 图片扩展名 → 作为多模态 image_url 附加。 */
19
+ const IMAGE_MIME = {
20
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
21
+ webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml',
22
+ };
23
+ /** 文档扩展名 → 经平台 /v1/files/extract 解析为纯文本(与 VSCode 扩展附件同源)。 */
24
+ const DOC_EXT = new Set(['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'rtf', 'odt', 'ods', 'odp', 'csv']);
25
+ const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
26
+ const MAX_DOC_BYTES = 20 * 1024 * 1024;
27
+
17
28
  /**
18
29
  * 展开消息中的 @文件/@目录 引用:原文保留,内容以引用块追加在末尾(模型可见全文)。
19
30
  * @returns {{text:string, mentions:string[]}}
20
31
  */
21
- export function expandAtMentions(text, cwd) {
32
+ export async function expandAtMentions(text, cwd, config = null) {
22
33
  const re = /(^|[\s("'`(【,,::])@([~A-Za-z0-9_./\\-]+)/g;
23
34
  const seen = new Set();
24
- const blocks = [];
35
+ const candidates = [];
25
36
  let m;
26
- while ((m = re.exec(text)) && seen.size < MAX_MENTIONS) {
37
+ while ((m = re.exec(text)) && candidates.length < MAX_MENTIONS) {
27
38
  const raw = m[2].replace(/[.,;:。,;]+$/, ''); // 去句尾标点
28
- if (!raw || seen.has(raw)) continue;
39
+ if (raw && !seen.has(raw)) {
40
+ seen.add(raw);
41
+ candidates.push(raw);
42
+ }
43
+ }
44
+ const blocks = [];
45
+ const images = []; // 多模态图片附件 {label, dataUri}
46
+ const mentions = [];
47
+ for (const raw of candidates) {
29
48
  const abs = raw.startsWith('~') ? path.join(os.homedir(), raw.slice(1)) : path.resolve(cwd, raw);
30
49
  let st;
31
50
  try {
@@ -33,7 +52,7 @@ export function expandAtMentions(text, cwd) {
33
52
  } catch {
34
53
  continue; // 不存在的路径按普通文本处理
35
54
  }
36
- seen.add(raw);
55
+ mentions.push(raw);
37
56
  if (st.isDirectory()) {
38
57
  try {
39
58
  const entries = fs.readdirSync(abs).slice(0, 100).join('\n');
@@ -43,6 +62,42 @@ export function expandAtMentions(text, cwd) {
43
62
  }
44
63
  continue;
45
64
  }
65
+ const ext = path.extname(abs).slice(1).toLowerCase();
66
+ // 图片:作为多模态 image_url 附加(需当前模型支持视觉,或走平台伪模型的服务端识图)
67
+ if (IMAGE_MIME[ext]) {
68
+ if (st.size > MAX_IMAGE_BYTES) {
69
+ blocks.push(`【图片 @${raw}】(超过 ${Math.round(MAX_IMAGE_BYTES / 1048576)}MB,未附加)`);
70
+ } else {
71
+ try {
72
+ const b64 = fs.readFileSync(abs).toString('base64');
73
+ images.push({ label: raw, dataUri: `data:${IMAGE_MIME[ext]};base64,${b64}` });
74
+ blocks.push(`【已附加图片 @${raw}】`);
75
+ } catch {
76
+ /* ignore */
77
+ }
78
+ }
79
+ continue;
80
+ }
81
+ // 文档(PDF/Word/Excel/PPT/RTF…):经平台 /v1/files/extract 解析为纯文本
82
+ if (DOC_EXT.has(ext)) {
83
+ if (!config || !config.baseUrl || !config.apiKey) {
84
+ blocks.push(`【文档 @${raw}】(需登录后才能解析为文本)`);
85
+ } else if (st.size > MAX_DOC_BYTES) {
86
+ blocks.push(`【文档 @${raw}】(超过 ${Math.round(MAX_DOC_BYTES / 1048576)}MB,未解析)`);
87
+ } else {
88
+ try {
89
+ const b64 = fs.readFileSync(abs).toString('base64');
90
+ const r = await extractDocument(config.baseUrl, config.apiKey,
91
+ { filename: path.basename(abs), data: b64, maxChars: 60_000 });
92
+ const meta = `${r.pages ? r.pages + '页·' : ''}${r.chars ?? 0}字${r.truncated ? '·已截断' : ''}`;
93
+ blocks.push(`【引用文档 @${raw}(${meta})】\n${r.text || '(空)'}`);
94
+ } catch (e) {
95
+ blocks.push(`【文档 @${raw}】(解析失败:${e.message})`);
96
+ }
97
+ }
98
+ continue;
99
+ }
100
+ // 其余:文本文件展开,二进制跳过
46
101
  if (st.size > 512 * 1024) {
47
102
  blocks.push(`【引用文件 @${raw}】\n(文件过大:${Math.round(st.size / 1024)}KB,未展开;可让我用 read_file 分段读取)`);
48
103
  continue;
@@ -60,8 +115,8 @@ export function expandAtMentions(text, cwd) {
60
115
  /* ignore */
61
116
  }
62
117
  }
63
- if (!blocks.length) return { text, mentions: [] };
64
- return { text: text + '\n\n' + blocks.join('\n\n'), mentions: [...seen] };
118
+ const outText = blocks.length ? text + '\n\n' + blocks.join('\n\n') : text;
119
+ return { text: outText, mentions, images };
65
120
  }
66
121
 
67
122
  function isBinary(buf) {
@@ -70,6 +125,15 @@ function isBinary(buf) {
70
125
  return false;
71
126
  }
72
127
 
128
+ /** 组装用户消息 content:有图片附件则用 OpenAI 多模态数组,否则纯文本字符串。 */
129
+ export function buildUserContent(text, images) {
130
+ if (!images || !images.length) return text;
131
+ return [
132
+ { type: 'text', text },
133
+ ...images.map((i) => ({ type: 'image_url', image_url: { url: i.dataUri } })),
134
+ ];
135
+ }
136
+
73
137
  /**
74
138
  * 执行 !命令 直通:在 cwd 下跑 shell 命令,返回展示文本与写入对话的记录文本。
75
139
  * @returns {Promise<{output:string, record:string}>}
package/src/repl.js CHANGED
@@ -8,8 +8,9 @@ import { runTurn } from './agent.js';
8
8
  import { createSession, saveSession, turnCount } from './session.js';
9
9
  import { loadMcpTools, closeMcp } from './mcp.js';
10
10
  import { makeSubagentTool } from './subagent.js';
11
+ import { makeConsultTool } from './consult.js';
11
12
  import { runSlash } from './commands.js';
12
- import { expandAtMentions, runBangCommand, loadHistory, appendHistory } from './input.js';
13
+ import { expandAtMentions, buildUserContent, runBangCommand, loadHistory, appendHistory } from './input.js';
13
14
  import { makeSkillTools } from './skills.js';
14
15
 
15
16
  /** 汇集动态工具:本地技能(load_skill)+ 本地 MCP 工具 + dispatch_agent 子智能体。返回 {extraTools, clients}。 */
@@ -24,6 +25,9 @@ export async function buildExtraTools(config, cwd) {
24
25
  const extraTools = [...makeSkillTools(cwd), ...mcp.tools];
25
26
  const dispatch = makeSubagentTool({ config, cwd, extraTools: mcp.tools, depth: 0 });
26
27
  if (dispatch) extraTools.push(dispatch);
28
+ // 混合模式:配置了 --consult 平台伪模型时,暴露 consult_platform 工具(本机驱动 + 平台编排咨询)
29
+ const consult = makeConsultTool({ config });
30
+ if (consult) extraTools.push(consult);
27
31
  return { extraTools, clients: mcp.clients };
28
32
  }
29
33
 
@@ -67,9 +71,10 @@ export async function startRepl({ config, cwd, yolo, initialPrompt, session, ver
67
71
  const { extraTools, clients } = await buildExtraTools(config, cwd); // 连接 MCP(若已配置)
68
72
 
69
73
  const doTurn = async (text) => {
70
- const { text: expanded, mentions } = expandAtMentions(text, cwd);
71
- if (mentions.length) process.stdout.write(c.gray(' · 已展开引用:' + mentions.join('、')) + '\n');
72
- ctx.session.messages.push({ role: 'user', content: expanded });
74
+ const { text: expanded, mentions, images } = await expandAtMentions(text, cwd, config);
75
+ if (mentions.length) process.stdout.write(c.gray(' · 已展开引用:' + mentions.join('、')
76
+ + (images.length ? `(含 ${images.length} 张图片)` : '')) + '\n');
77
+ ctx.session.messages.push({ role: 'user', content: buildUserContent(expanded, images) });
73
78
  currentAbort = new AbortController();
74
79
  if (!ctx.session.compaction) ctx.session.compaction = {};
75
80
  config.sessionId = ctx.session.id; // 对话落库审计:当前会话号(/clear 换新会话后随之更新)
@@ -169,8 +174,8 @@ export async function runOnce({ config, cwd, yolo, prompt, session, persist, out
169
174
  const json = outputFormat === 'json';
170
175
  const permissions = createPermissions({ yolo, ask: null, cwd });
171
176
  const { extraTools, clients } = await buildExtraTools(config, cwd);
172
- const { text: expanded } = expandAtMentions(prompt, cwd);
173
- session.messages.push({ role: 'user', content: expanded });
177
+ const { text: expanded, images } = await expandAtMentions(prompt, cwd, config);
178
+ session.messages.push({ role: 'user', content: buildUserContent(expanded, images) });
174
179
  if (!session.compaction) session.compaction = {};
175
180
  config.sessionId = session.id; // 对话落库审计:会话号
176
181
  const r = await runTurn({
package/src/tui/app.js CHANGED
@@ -10,7 +10,7 @@
10
10
  //
11
11
  // 仅在 TTY 且已安装 ink 时启用;未安装时 bin 回退到零依赖 readline REPL。
12
12
 
13
- import React, { useState, useRef, useReducer, useEffect } from 'react';
13
+ import React, { useState, useRef, useReducer, useEffect, useMemo } from 'react';
14
14
  import { render, Box, Text, Static, useInput, useApp } from 'ink';
15
15
  import { runTurn } from '../agent.js';
16
16
  import { toolLabel } from '../render.js';
@@ -18,7 +18,7 @@ import { createSession, saveSession } from '../session.js';
18
18
  import { buildExtraTools } from '../repl.js';
19
19
  import { closeMcp } from '../mcp.js';
20
20
  import { runSlash } from '../commands.js';
21
- import { expandAtMentions, runBangCommand, loadHistory, appendHistory } from '../input.js';
21
+ import { expandAtMentions, buildUserContent, runBangCommand, loadHistory, appendHistory } from '../input.js';
22
22
  import { loadAllowRules, matchAllowRules, suggestRule, addProjectAllowRule, riskWarning } from '../settings.js';
23
23
 
24
24
  const h = React.createElement;
@@ -60,6 +60,10 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
60
60
  const draftRef = useRef('');
61
61
 
62
62
  const [, force] = useReducer((x) => x + 1, 0);
63
+ // 定稿记录(logRef)变更计数:ink <Static> 靠 items「引用变化」识别新项,就地 push 同一数组它不会刷新
64
+ // (会导致轮末助手回复无后续渲染触发而丢失)。故每次 addLog 自增 logVer,据此派生「全新数组引用」交给 Static。
65
+ const [logVer, bumpLog] = useReducer((x) => x + 1, 0);
66
+ const [staticKey, setStaticKey] = useState(0); // /clear 时自增以重挂 Static,复位其内部已渲染索引
63
67
  const [status, setStatus] = useState('idle'); // idle | busy
64
68
  const [inp, setInp] = useState({ text: '', cursor: 0 });
65
69
  const [pending, setPending] = useState(null);
@@ -71,7 +75,7 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
71
75
 
72
76
  const addLog = (kind, text) => {
73
77
  logRef.current.push({ id: idRef.current++, kind, text });
74
- force();
78
+ bumpLog(); // 变更定稿记录 → 触发 Static 用新数组引用刷新(见 logVer 说明)
75
79
  };
76
80
  const flushLive = () => {
77
81
  if (liveRef.current) {
@@ -139,14 +143,15 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
139
143
  };
140
144
 
141
145
  const pullQueued = () => {
146
+ // runTurn 同步回调:排队消息按原文注入(@ 文档/图片的异步展开只在主输入路径做,队列消息保留 @ 路径原文,模型可再 read_file)
142
147
  const q = queueRef.current.shift();
143
148
  setQueueLen(queueRef.current.length);
144
- if (q == null) return null;
145
- return expandAtMentions(q, cwd).text;
149
+ return q == null ? null : q;
146
150
  };
147
151
 
148
152
  const runUserTurn = async (text) => {
149
- sessionRef.current.messages.push({ role: 'user', content: expandAtMentions(text, cwd).text });
153
+ const { text: expanded, images } = await expandAtMentions(text, cwd, config);
154
+ sessionRef.current.messages.push({ role: 'user', content: buildUserContent(expanded, images) });
150
155
  setBusy(true);
151
156
  abortRef.current = new AbortController();
152
157
  if (!sessionRef.current.compaction) sessionRef.current.compaction = {};
@@ -203,6 +208,7 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
203
208
  setCtxTok(0);
204
209
  logRef.current = [];
205
210
  liveRef.current = '';
211
+ setStaticKey((k) => k + 1); // 重挂 Static:复位其内部已渲染索引,新会话记录从头正常渲染
206
212
  addLog('info', '已开启新会话(旧会话仍可 --resume 恢复)');
207
213
  }
208
214
  if (r.prompt) {
@@ -360,7 +366,9 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
360
366
  });
361
367
 
362
368
  // ---- 渲染 ----
363
- const logItems = logRef.current;
369
+ // 每当定稿记录变更(logVer)派生一个全新数组引用,Static 才能识别并渲染新追加项;
370
+ // 流式增量只调 force()(logVer 不变)→ 此处返回同一引用,Static 不重复刷历史记录。
371
+ const logItems = useMemo(() => logRef.current.slice(), [logVer]);
364
372
  const cwdName = cwd.split('/').filter(Boolean).pop() || cwd;
365
373
  const before = inp.text.slice(0, inp.cursor);
366
374
  const at = inp.text[inp.cursor] ?? ' ';
@@ -370,7 +378,7 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
370
378
  Box,
371
379
  { flexDirection: 'column' },
372
380
  // 已定稿记录(Static:只渲染一次,天然滚动)
373
- h(Static, { items: logItems }, (item) => h(LogLine, { key: item.id, item })),
381
+ h(Static, { key: staticKey, items: logItems }, (item) => h(LogLine, { key: item.id, item })),
374
382
  // 思考过程(reasoning,流式、不入定稿记录)
375
383
  thinkRef.current
376
384
  ? h(Box, null, h(Text, { color: 'gray', italic: true }, '✦ 思考 ' + thinkRef.current.replace(/\n/g, ' ').slice(-160)))