@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 +47 -2
- package/package.json +6 -4
- package/src/commands.js +66 -9
- package/src/consult.js +65 -0
- package/src/input.js +71 -7
- package/src/repl.js +11 -6
- package/src/tui/app.js +16 -8
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
|
-
|
|
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 查看命令 ·
|
|
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": "
|
|
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",
|
|
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: '[
|
|
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
|
|
35
|
+
const candidates = [];
|
|
25
36
|
let m;
|
|
26
|
-
while ((m = re.exec(text)) &&
|
|
37
|
+
while ((m = re.exec(text)) && candidates.length < MAX_MENTIONS) {
|
|
27
38
|
const raw = m[2].replace(/[.,;:。,;]+$/, ''); // 去句尾标点
|
|
28
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
return { text:
|
|
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('、')
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
return expandAtMentions(q, cwd).text;
|
|
149
|
+
return q == null ? null : q;
|
|
146
150
|
};
|
|
147
151
|
|
|
148
152
|
const runUserTurn = async (text) => {
|
|
149
|
-
|
|
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
|
-
|
|
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)))
|