@huicai/cli 0.5.1 → 2026.710.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/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.710.0",
4
+ "versionDisplay": "2026.07.10",
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/banner.js ADDED
@@ -0,0 +1,83 @@
1
+ // 启动欢迎横幅:渐变 ASCII 字标 + 上下文信息 + 快捷提示(对标 Claude CLI 进入时的欢迎屏)。
2
+ // 零依赖;真彩(COLORTERM=truecolor)时走 24-bit 青→紫渐变,仅 16 色时退化为青色,NO_COLOR/非 TTY 输出朴素字标。
3
+
4
+ import { c, colorEnabled } from './colors.js';
5
+
6
+ // 5 行块字(每字形 5 列宽,字间 1 空格)。
7
+ const FONT = {
8
+ H: ['█ █', '█ █', '█████', '█ █', '█ █'],
9
+ U: ['█ █', '█ █', '█ █', '█ █', '█████'],
10
+ I: ['█████', ' █ ', ' █ ', ' █ ', '█████'],
11
+ C: ['█████', '█ ', '█ ', '█ ', '█████'],
12
+ A: ['█████', '█ █', '█████', '█ █', '█ █'],
13
+ ' ': [' ', ' ', ' ', ' ', ' '],
14
+ };
15
+
16
+ function wordmark(word) {
17
+ const rows = ['', '', '', '', ''];
18
+ [...word].forEach((ch, idx) => {
19
+ const g = FONT[ch] || FONT[' '];
20
+ for (let r = 0; r < 5; r++) rows[r] += (idx ? ' ' : '') + g[r];
21
+ });
22
+ return rows;
23
+ }
24
+
25
+ const truecolor = colorEnabled && /truecolor|24bit/i.test(process.env.COLORTERM || '');
26
+ const lerp = (a, b, t) => Math.round(a + (b - a) * t);
27
+
28
+ // 按列做水平渐变;无真彩退化为青色;无色原样返回。
29
+ function gradientRow(row, from, to) {
30
+ if (!colorEnabled) return row;
31
+ const chars = [...row];
32
+ const n = chars.length;
33
+ let out = '';
34
+ for (let i = 0; i < n; i++) {
35
+ const ch = chars[i];
36
+ if (ch === ' ') { out += ' '; continue; }
37
+ if (!truecolor) { out += `\x1b[36m${ch}`; continue; }
38
+ const t = n <= 1 ? 0 : i / (n - 1);
39
+ out += `\x1b[38;2;${lerp(from[0], to[0], t)};${lerp(from[1], to[1], t)};${lerp(from[2], to[2], t)}m${ch}`;
40
+ }
41
+ return out + '\x1b[0m';
42
+ }
43
+
44
+ const PAD = ' ';
45
+ const FROM = [0, 229, 255]; // 青
46
+ const TO = [168, 85, 247]; // 紫
47
+
48
+ /**
49
+ * 生成欢迎横幅字符串(不含结尾换行)。
50
+ * @param {object} p
51
+ * @param {string} p.model 当前模型 / 平台目标
52
+ * @param {string} p.cwd 工作目录
53
+ * @param {string} [p.version] 版本展示串
54
+ * @param {boolean} [p.platform] 是否平台伪模型(agent-/workflow-/datahuman-)
55
+ * @param {boolean} [p.yolo] 是否已开自动放行
56
+ * @param {string} [p.consult] 混合模式咨询目标展示名
57
+ * @param {boolean} [p.ink] 是否 ink 富终端(附注一行)
58
+ */
59
+ export function renderWelcome({ model, cwd, version, platform, yolo, consult, ink } = {}) {
60
+ const cols = process.stdout.columns || 60;
61
+ const sep = c.gray('─'.repeat(Math.min(Math.max(cols - 2, 24), 50)));
62
+ const L = [''];
63
+
64
+ if (colorEnabled) {
65
+ for (const row of wordmark('HUICAI')) L.push(PAD + gradientRow(row, FROM, TO));
66
+ } else {
67
+ L.push(PAD + 'H U I C A I');
68
+ }
69
+
70
+ L.push('');
71
+ L.push(PAD + c.bold('HuiCai AI') + c.gray(' · 终端智能体') + (version ? c.gray(' v' + version) : '')
72
+ + (ink ? c.gray(' · ink') : ''));
73
+ L.push(PAD + sep);
74
+ L.push(PAD + c.gray(platform ? '目标 ' : '模型 ') + c.cyan(model || c.gray('(未设置,/model 设置)'))
75
+ + (platform ? c.gray(' · 平台编排(无本机工具)') : ''));
76
+ L.push(PAD + c.gray('目录 ') + cwd);
77
+ if (consult) L.push(PAD + c.gray('咨询 ') + c.magenta(consult) + c.gray(' · 混合模式(本机工具 + 平台咨询)'));
78
+ if (yolo) L.push(PAD + c.yellow('⚠ 自动放行(yolo) 已开:写文件/执行命令不再逐次确认'));
79
+ L.push('');
80
+ L.push(PAD + c.magenta('✦ ') + c.gray('/help 命令 · @文件 引用 · !命令 直通 shell · Ctrl+C 中断'));
81
+
82
+ return L.join('\n');
83
+ }
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() {
@@ -160,6 +217,15 @@ const BUILTINS = [
160
217
  ctx.print(ctx.cwd);
161
218
  },
162
219
  },
220
+ {
221
+ name: 'markdown', desc: '切换助手回复的 Markdown 渲染(TUI 富文本;关闭则显示原始文本,便于复制)',
222
+ run(ctx) {
223
+ const next = ctx.config.markdown === false; // 当前关→开,否则→关
224
+ ctx.config.markdown = next;
225
+ saveConfig({ markdown: next });
226
+ ctx.print('Markdown 渲染:' + (next ? '开(下一条回复起生效)' : '关(显示原始 Markdown 文本)'));
227
+ },
228
+ },
163
229
  {
164
230
  name: 'init', desc: '让智能体调研项目并生成/完善 HUICAI.md',
165
231
  run() {
package/src/config.js CHANGED
@@ -43,6 +43,8 @@ export function loadConfig() {
43
43
  memoryEnabled: process.env.HUICAI_MEMORY === '0' ? false : file.memory !== false,
44
44
  // 思考过程流式展示:默认开;配置 showReasoning:false 或 HUICAI_REASONING=0 关闭
45
45
  showReasoning: process.env.HUICAI_REASONING === '0' ? false : file.showReasoning !== false,
46
+ // 助手回复 Markdown 渲染(TUI 富文本):默认开;配置 markdown:false 或 HUICAI_MARKDOWN=0 关闭(显示原始文本)
47
+ markdown: process.env.HUICAI_MARKDOWN === '0' ? false : file.markdown !== false,
46
48
  // 客户端来源标识(对话落库审计用):默认 cli;VSCode 扩展进程内置 HUICAI_CLIENT=vscode
47
49
  client: (process.env.HUICAI_CLIENT || 'cli').trim().toLowerCase(),
48
50
  };
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}>}
Binary file
package/src/repl.js CHANGED
@@ -8,9 +8,11 @@ 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';
15
+ import { renderWelcome } from './banner.js';
14
16
 
15
17
  /** 汇集动态工具:本地技能(load_skill)+ 本地 MCP 工具 + dispatch_agent 子智能体。返回 {extraTools, clients}。 */
16
18
  export async function buildExtraTools(config, cwd) {
@@ -24,22 +26,21 @@ export async function buildExtraTools(config, cwd) {
24
26
  const extraTools = [...makeSkillTools(cwd), ...mcp.tools];
25
27
  const dispatch = makeSubagentTool({ config, cwd, extraTools: mcp.tools, depth: 0 });
26
28
  if (dispatch) extraTools.push(dispatch);
29
+ // 混合模式:配置了 --consult 平台伪模型时,暴露 consult_platform 工具(本机驱动 + 平台编排咨询)
30
+ const consult = makeConsultTool({ config });
31
+ if (consult) extraTools.push(consult);
27
32
  return { extraTools, clients: mcp.clients };
28
33
  }
29
34
 
30
- function banner(config, cwd, permissions) {
31
- process.stdout.write(c.bold(c.magenta('\n huicai')) + c.gray(' · 终端智能体') + '\n');
32
- const platform = /^(agent|workflow|datahuman)-/.test(String(config.model || ''));
33
- process.stdout.write(
34
- c.gray(` ${platform ? '目标' : '模型'} ${config.model || '(未设置, /model 设置)'}`
35
- + `${platform ? ' · 平台编排(分享页同款对话,无本机工具)' : ''} · 目录 ${cwd}`) + '\n',
36
- );
37
- process.stdout.write(
38
- c.gray(
39
- ` ${permissions.isYolo() ? '⚠ 自动放行(yolo) 已开 · ' : ''}` +
40
- '/help 命令 · @文件 引用 · !命令 直通 shell · Ctrl+C 中断本回合',
41
- ) + '\n',
42
- );
35
+ function banner(config, cwd, permissions, version) {
36
+ process.stdout.write(renderWelcome({
37
+ model: config.model,
38
+ cwd,
39
+ version,
40
+ platform: /^(agent|workflow|datahuman)-/.test(String(config.model || '')),
41
+ yolo: permissions.isYolo(),
42
+ consult: config.consult ? (config.consultLabel || config.consult) : null,
43
+ }) + '\n');
43
44
  }
44
45
 
45
46
  export async function startRepl({ config, cwd, yolo, initialPrompt, session, version }) {
@@ -62,14 +63,15 @@ export async function startRepl({ config, cwd, yolo, initialPrompt, session, ver
62
63
  else process.stdout.write('\n' + c.gray('输入 /exit 退出。') + '\n');
63
64
  });
64
65
 
65
- banner(config, cwd, permissions);
66
+ banner(config, cwd, permissions, version);
66
67
  echoResumed(session);
67
68
  const { extraTools, clients } = await buildExtraTools(config, cwd); // 连接 MCP(若已配置)
68
69
 
69
70
  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 });
71
+ const { text: expanded, mentions, images } = await expandAtMentions(text, cwd, config);
72
+ if (mentions.length) process.stdout.write(c.gray(' · 已展开引用:' + mentions.join('、')
73
+ + (images.length ? `(含 ${images.length} 张图片)` : '')) + '\n');
74
+ ctx.session.messages.push({ role: 'user', content: buildUserContent(expanded, images) });
73
75
  currentAbort = new AbortController();
74
76
  if (!ctx.session.compaction) ctx.session.compaction = {};
75
77
  config.sessionId = ctx.session.id; // 对话落库审计:当前会话号(/clear 换新会话后随之更新)
@@ -169,8 +171,8 @@ export async function runOnce({ config, cwd, yolo, prompt, session, persist, out
169
171
  const json = outputFormat === 'json';
170
172
  const permissions = createPermissions({ yolo, ask: null, cwd });
171
173
  const { extraTools, clients } = await buildExtraTools(config, cwd);
172
- const { text: expanded } = expandAtMentions(prompt, cwd);
173
- session.messages.push({ role: 'user', content: expanded });
174
+ const { text: expanded, images } = await expandAtMentions(prompt, cwd, config);
175
+ session.messages.push({ role: 'user', content: buildUserContent(expanded, images) });
174
176
  if (!session.compaction) session.compaction = {};
175
177
  config.sessionId = session.id; // 对话落库审计:会话号
176
178
  const r = await runTurn({
package/src/tui/app.js CHANGED
@@ -10,15 +10,17 @@
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';
17
+ import { renderMarkdown, looksLikeMarkdown } from '../markdown.js';
18
+ import { renderWelcome } from '../banner.js';
17
19
  import { createSession, saveSession } from '../session.js';
18
20
  import { buildExtraTools } from '../repl.js';
19
21
  import { closeMcp } from '../mcp.js';
20
22
  import { runSlash } from '../commands.js';
21
- import { expandAtMentions, runBangCommand, loadHistory, appendHistory } from '../input.js';
23
+ import { expandAtMentions, buildUserContent, runBangCommand, loadHistory, appendHistory } from '../input.js';
22
24
  import { loadAllowRules, matchAllowRules, suggestRule, addProjectAllowRule, riskWarning } from '../settings.js';
23
25
 
24
26
  const h = React.createElement;
@@ -60,6 +62,10 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
60
62
  const draftRef = useRef('');
61
63
 
62
64
  const [, force] = useReducer((x) => x + 1, 0);
65
+ // 定稿记录(logRef)变更计数:ink <Static> 靠 items「引用变化」识别新项,就地 push 同一数组它不会刷新
66
+ // (会导致轮末助手回复无后续渲染触发而丢失)。故每次 addLog 自增 logVer,据此派生「全新数组引用」交给 Static。
67
+ const [logVer, bumpLog] = useReducer((x) => x + 1, 0);
68
+ const [staticKey, setStaticKey] = useState(0); // /clear 时自增以重挂 Static,复位其内部已渲染索引
63
69
  const [status, setStatus] = useState('idle'); // idle | busy
64
70
  const [inp, setInp] = useState({ text: '', cursor: 0 });
65
71
  const [pending, setPending] = useState(null);
@@ -71,11 +77,15 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
71
77
 
72
78
  const addLog = (kind, text) => {
73
79
  logRef.current.push({ id: idRef.current++, kind, text });
74
- force();
80
+ bumpLog(); // 变更定稿记录 → 触发 Static 用新数组引用刷新(见 logVer 说明)
75
81
  };
76
82
  const flushLive = () => {
77
83
  if (liveRef.current) {
78
- addLog('assistant', liveRef.current);
84
+ // 定稿时把助手正文渲染为 Markdown(ink <Text> 原样保留内嵌 ANSI);流式期间仍显示原文,完成即定稿为富文本。
85
+ // 纯散文(无 Markdown 结构)直接留原文,避免无谓处理与边角改动。
86
+ const raw = liveRef.current;
87
+ const md = config.markdown !== false && looksLikeMarkdown(raw);
88
+ addLog('assistant', md ? renderMarkdown(raw) : raw);
79
89
  liveRef.current = '';
80
90
  }
81
91
  };
@@ -139,14 +149,15 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
139
149
  };
140
150
 
141
151
  const pullQueued = () => {
152
+ // runTurn 同步回调:排队消息按原文注入(@ 文档/图片的异步展开只在主输入路径做,队列消息保留 @ 路径原文,模型可再 read_file)
142
153
  const q = queueRef.current.shift();
143
154
  setQueueLen(queueRef.current.length);
144
- if (q == null) return null;
145
- return expandAtMentions(q, cwd).text;
155
+ return q == null ? null : q;
146
156
  };
147
157
 
148
158
  const runUserTurn = async (text) => {
149
- sessionRef.current.messages.push({ role: 'user', content: expandAtMentions(text, cwd).text });
159
+ const { text: expanded, images } = await expandAtMentions(text, cwd, config);
160
+ sessionRef.current.messages.push({ role: 'user', content: buildUserContent(expanded, images) });
150
161
  setBusy(true);
151
162
  abortRef.current = new AbortController();
152
163
  if (!sessionRef.current.compaction) sessionRef.current.compaction = {};
@@ -203,6 +214,7 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
203
214
  setCtxTok(0);
204
215
  logRef.current = [];
205
216
  liveRef.current = '';
217
+ setStaticKey((k) => k + 1); // 重挂 Static:复位其内部已渲染索引,新会话记录从头正常渲染
206
218
  addLog('info', '已开启新会话(旧会话仍可 --resume 恢复)');
207
219
  }
208
220
  if (r.prompt) {
@@ -360,7 +372,9 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
360
372
  });
361
373
 
362
374
  // ---- 渲染 ----
363
- const logItems = logRef.current;
375
+ // 每当定稿记录变更(logVer)派生一个全新数组引用,Static 才能识别并渲染新追加项;
376
+ // 流式增量只调 force()(logVer 不变)→ 此处返回同一引用,Static 不重复刷历史记录。
377
+ const logItems = useMemo(() => logRef.current.slice(), [logVer]);
364
378
  const cwdName = cwd.split('/').filter(Boolean).pop() || cwd;
365
379
  const before = inp.text.slice(0, inp.cursor);
366
380
  const at = inp.text[inp.cursor] ?? ' ';
@@ -370,7 +384,7 @@ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPr
370
384
  Box,
371
385
  { flexDirection: 'column' },
372
386
  // 已定稿记录(Static:只渲染一次,天然滚动)
373
- h(Static, { items: logItems }, (item) => h(LogLine, { key: item.id, item })),
387
+ h(Static, { key: staticKey, items: logItems }, (item) => h(LogLine, { key: item.id, item })),
374
388
  // 思考过程(reasoning,流式、不入定稿记录)
375
389
  thinkRef.current
376
390
  ? h(Box, null, h(Text, { color: 'gray', italic: true }, '✦ 思考 ' + thinkRef.current.replace(/\n/g, ' ').slice(-160)))
@@ -458,7 +472,15 @@ function LogLine({ item }) {
458
472
  * 启动 ink TUI。先(在 ink 接管屏幕前)连接 MCP 并打印连接信息,再进入全屏界面;退出后关闭 MCP。
459
473
  */
460
474
  export async function runInk({ config, cwd, session, yolo, initialPrompt, version }) {
461
- process.stdout.write(`\n huicai · 终端智能体(ink TUI) · ${config.model}\n`);
475
+ process.stdout.write(renderWelcome({
476
+ model: config.model,
477
+ cwd,
478
+ version,
479
+ platform: /^(agent|workflow|datahuman)-/.test(String(config.model || '')),
480
+ yolo,
481
+ consult: config.consult ? (config.consultLabel || config.consult) : null,
482
+ ink: true,
483
+ }) + '\n');
462
484
  const { extraTools, clients } = await buildExtraTools(config, cwd);
463
485
  const { waitUntilExit } = render(
464
486
  h(App, { config, cwd, session, extraTools, yolo, initialPrompt, version, onExit: () => closeMcp(clients) }),