minimal-agent 0.5.2 → 0.5.4
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/package.json +1 -1
- package/skills/config/SKILL.md +3 -0
- package/src/cli/configWizard.js +19 -0
- package/src/loop.js +3 -0
- package/src/prompts/system.js +12 -0
- package/src/tools/edit/edit.js +26 -2
- package/src/tools/edit/multi-edit.js +24 -3
- package/src/tools/shared/fileState.js +2 -0
- package/src/tools/shared/fileUtils.js +61 -0
- package/src/ui/App.js +2 -2
- package/src/ui/MessageList.js +4 -2
- package/src/ui/hooks/useChat.js +64 -8
- package/src/utils/zodToJson.js +44 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minimal-agent",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "最小化 Agent 系统 —— 10 工具 + 插件系统 + workflow DSL + 自动压缩 + OpenAI 兼容 + Ink TUI;NodeNext + tsc 原地编译,dev 用 Bun .ts、install 用 Node .js(学习/教学用)",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Bill Wang <leiwang0359@gmail.com>",
|
package/skills/config/SKILL.md
CHANGED
|
@@ -53,8 +53,11 @@ Read({ file_path: "~/.minimal-agent/config.json" })
|
|
|
53
53
|
| moonshot | moonshot-v1-8k | 8000 |
|
|
54
54
|
| moonshot | moonshot-v1-32k | 32000 |
|
|
55
55
|
| moonshot | moonshot-v1-128k | 128000 |
|
|
56
|
+
| volces | doubao-seed-2.0-pro / glm-5.1 / kimi-k2.6 / deepseek-v3.2 / minimax-latest | 204800 |
|
|
56
57
|
| 其它 / custom | — | 128000 |
|
|
57
58
|
|
|
59
|
+
⚠️ 火山方舟 ARK Coding Plan:一个 endpoint (`https://ark.cn-beijing.volces.com/api/coding/v3`) 后挂多个 model,**model 字段大小写严格匹配**(`glm-5.1` 而非 `GLM-5.1`)。常见 model id 见 `src/cli/configWizard.tsx::PRESETS` 的 `volces` 项;部分 model(如 `glm-5.1`)走 draft-2020-12 严校验,由 `src/utils/zodToJson.ts::modernizeExclusiveBounds` 透明兼容。
|
|
60
|
+
|
|
58
61
|
⚠️ 询问 `contextWindow` 时,**先按上表给出推荐值并明确告诉用户**(例:「按你选的 `MiniMax-M2.7`,推荐 `contextWindow = 204800`,直接回车采纳」)。**不要让用户裸填 token 数**;用户主要关心 `baseURL` / `apiKey` / `model`,contextWindow 应优先按 provider+model 推断。
|
|
59
62
|
|
|
60
63
|
如果用户改了 model 但没改 contextWindow,**主动重新计算推荐值**并提示;用户旧 contextWindow 与新 model 不匹配时(如把 model 从 `moonshot-v1-8k` 改成 `moonshot-v1-128k`),明确建议同步更新。
|
package/src/cli/configWizard.js
CHANGED
|
@@ -37,6 +37,25 @@ const PRESETS = [
|
|
|
37
37
|
models: ['MiniMax-M2.7', 'MiniMax-M1', 'abab6.5s-chat'],
|
|
38
38
|
contextWindow: 204_800,
|
|
39
39
|
},
|
|
40
|
+
{
|
|
41
|
+
// 火山方舟 ARK Coding Plan —— 一个 endpoint 后挂多个 model id,
|
|
42
|
+
// model 字段大小写严格匹配('glm-5.1' 而非 'GLM-5.1')。
|
|
43
|
+
// 部分 model(如 glm-5.1)走 draft-2020-12 严校验,靠 zodToJson 的
|
|
44
|
+
// modernizeExclusiveBounds 兼容。
|
|
45
|
+
name: 'volces',
|
|
46
|
+
label: '火山方舟 Coding Plan (ARK)',
|
|
47
|
+
baseURL: 'https://ark.cn-beijing.volces.com/api/coding/v3',
|
|
48
|
+
models: [
|
|
49
|
+
'doubao-seed-2.0-pro',
|
|
50
|
+
'doubao-seed-2.0-code',
|
|
51
|
+
'doubao-seed-code',
|
|
52
|
+
'glm-5.1',
|
|
53
|
+
'minimax-latest',
|
|
54
|
+
'deepseek-v3.2',
|
|
55
|
+
'kimi-k2.6',
|
|
56
|
+
],
|
|
57
|
+
contextWindow: 204_800,
|
|
58
|
+
},
|
|
40
59
|
{
|
|
41
60
|
name: 'deepseek',
|
|
42
61
|
label: 'DeepSeek',
|
package/src/loop.js
CHANGED
|
@@ -107,6 +107,9 @@ export async function* runQuery(userInput, options) {
|
|
|
107
107
|
else if (ev.field === 'reasoning_details' && ev.items) {
|
|
108
108
|
reasoningDetails.push(...ev.items);
|
|
109
109
|
}
|
|
110
|
+
if (ev.delta) {
|
|
111
|
+
yield { type: 'reasoning_delta', field: ev.field, delta: ev.delta };
|
|
112
|
+
}
|
|
110
113
|
}
|
|
111
114
|
// ev.type === 'done' 时无需处理:循环自然结束
|
|
112
115
|
}
|
package/src/prompts/system.js
CHANGED
|
@@ -34,6 +34,17 @@ export async function getSystemPrompt(cwd, tools) {
|
|
|
34
34
|
|
|
35
35
|
# 思维与立场(核心:像专家一样独立思考)
|
|
36
36
|
- **你是工程协作者,不是奉承者**。不无条件夸赞、不为附和而附和、不同意时直说。不用"很好的问题"、"你说得对"、"完全同意"这类前缀——直接答内容。
|
|
37
|
+
- **先判断用户意图再行动**——这是最重要的第一步,区分以下三种情况:
|
|
38
|
+
- ✅ **明确执行(直接动手,不要犹豫)**:用户表述包含明确的行动指令。
|
|
39
|
+
动词特征:「修改/改/调整/修复/优化/更新/替换/删/移除/写/创建/实现/做/生成/新建/开发/执行/跑/运行/提交/部署/安装」
|
|
40
|
+
确认性指令也算:「对就这么改」「是的按这个方案实现」「好的麻烦改一下」
|
|
41
|
+
- ❌ **纯讨论(绝对不能动文件,只回答)**:用户只是提问、咨询、探讨、评估。
|
|
42
|
+
特征:问号结尾、「是不是/怎么样/为什么/好不好/能否/你觉得」、纯观点表达「我觉得...」「这里好像有...」
|
|
43
|
+
即使你发现明显问题,也只能给文字建议,**禁止调用 Edit/Write/MultiEdit/Bash 写操作等任何修改类工具**
|
|
44
|
+
- ❓ **模糊场景(仅一次确认,不要啰嗦)**:介于两者之间无法判断时。
|
|
45
|
+
例:「这个接口响应太慢了」「这个正则好像有问题」
|
|
46
|
+
只问一句:「你是需要我实际修改/修复,还是只是讨论这个问题?」—— 用户说改就立刻改,说讨论就停
|
|
47
|
+
- **兜底原则**:宁可多问一次也不要擅自修改;但只要用户明确说要改,必须立刻执行,不要二次确认。
|
|
37
48
|
- **基于证据,不基于揣测用户想听什么**。没读过的代码先 Read,没验证过的行为先验证,不凭印象答。回答前先问自己:这个判断有具体证据吗,还是我在猜?
|
|
38
49
|
- **不知道就说不知道**。明确边界:"这个版本号我不确定"、"这个 API 我没看过文档"、"这部分行为我没验证过"。胡编一个看起来对的答案,代价远高于承认不知道。
|
|
39
50
|
- **明示 confidence 分层**。结论按确定度分层表达——"已 verify / 高度确信"、"基于代码合理推测"、"未验证猜测"——让用户基于你的不确定程度决策,不要把推测说成事实。
|
|
@@ -62,6 +73,7 @@ ${toolList}
|
|
|
62
73
|
- 修改既有文件之前,必须用 Read 工具读取相关代码的最新内容。
|
|
63
74
|
"我记得"、"我之前看过"、"应该差不多"都不算读过——必须在本轮任务中实际调用 Read。
|
|
64
75
|
尤其当你要参考某个文件的写法来实现类似功能时,必须先重读该文件的关键部分(函数实现、判断逻辑、数据流),理解清楚后再动手。
|
|
76
|
+
- Edit / MultiEdit 拦截 fallback:如果你忘了先 Read 就直接调 Edit/MultiEdit,错误响应里会**自动附带当前文件前 200 行 / 32KB 以内的 snippet**(带行号)。这种情况下**直接基于 snippet 重新构造 old_string 再发一次 Edit 即可,不要再额外调 Read**。只有当 snippet 显示"未注入内容"(文件过大或二进制)时才需要显式 Read。Write 不附带 snippet——整文件覆盖语义下必须先显式 Read,避免基于片段重写导致截断范围外内容丢失。
|
|
65
77
|
- Edit 工具的 old_string 必须在文件中唯一;不唯一时请扩大上下文或显式 replace_all=true。
|
|
66
78
|
- 同一文件需要修改多处(3 处及以上)时优先用 MultiEdit:所有 edit 按顺序在内存中应用,**全部成功才落盘**;任一失败磁盘不动,避免中间状态污染。单点修改继续用 Edit。
|
|
67
79
|
- Write 覆盖既有文件前同样必须先 Read(与 Edit 对称,未先 Read 会被拒绝并提示"请先 Read");写新文件无此要求。
|
package/src/tools/edit/edit.js
CHANGED
|
@@ -17,7 +17,7 @@ import { dirname } from 'node:path';
|
|
|
17
17
|
import { z } from 'zod';
|
|
18
18
|
import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
|
|
19
19
|
import { toToolParameters } from '../../utils/zodToJson.js';
|
|
20
|
-
import { resolveToolPath, findActualString, preserveQuoteStyle, detectLineEndingsForString, applyLineEnding, countOccurrences, splitReplaceAll, } from '../shared/fileUtils.js';
|
|
20
|
+
import { resolveToolPath, findActualString, preserveQuoteStyle, detectLineEndingsForString, applyLineEnding, countOccurrences, splitReplaceAll, tryAutoSnapshot, } from '../shared/fileUtils.js';
|
|
21
21
|
import { filePathField } from '../shared/schemas.js';
|
|
22
22
|
import { assertFresh, recordRead, wasTruncated } from '../shared/fileState.js';
|
|
23
23
|
/** 编辑文件大小上限:1GB。防止模型试图 Edit 超大文件撑爆内存。 */
|
|
@@ -59,9 +59,33 @@ async function call(input) {
|
|
|
59
59
|
return pathResult;
|
|
60
60
|
const filePath = pathResult.resolvedPath;
|
|
61
61
|
// 5a-bis. Pre-read guard:文件已存在则必须先 Read(创建新文件场景下 assertFresh 返回 ok:true)
|
|
62
|
+
// 拦截路径自动注入 snippet:让模型在同一轮 tool result 里看到当前内容,无需多绕一次 Read。
|
|
63
|
+
// 安全语义保留:模型必须显式重发 Edit 才会落盘;snippet 只是给模型看的,不静默放行。
|
|
62
64
|
const freshness = assertFresh(filePath);
|
|
63
65
|
if (!freshness.ok) {
|
|
64
|
-
|
|
66
|
+
const snap = await tryAutoSnapshot(filePath);
|
|
67
|
+
if (snap.ok) {
|
|
68
|
+
// 关键:truncated=true 时 wasTruncated 会守住 Write 路径,防止基于残缺 snippet 重写整文件
|
|
69
|
+
recordRead(filePath, { truncated: snap.truncated });
|
|
70
|
+
const tail = snap.truncated
|
|
71
|
+
? `\n\n⚠️ 仅注入了前 200 行(文件共 ${snap.totalLines} 行 / 上限 32KB)。若要改的位置在范围外,请显式 Read offset=X limit=Y 后再 Edit。`
|
|
72
|
+
: '';
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
error: freshness.error +
|
|
76
|
+
`\n\n以下是当前文件${snap.truncated ? '前 200 行' : '全部'}内容(带行号),请基于此重新构造 old_string 后立即重试 Edit,不要再单独调 Read:\n\n${snap.snippet}` +
|
|
77
|
+
tail,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const why = snap.reason === 'too-large'
|
|
81
|
+
? `文件 ${((snap.sizeBytes ?? 0) / 1024).toFixed(1)} KB 超过 32KB 自动注入上限`
|
|
82
|
+
: snap.reason === 'binary'
|
|
83
|
+
? '文件命中二进制扩展名黑名单'
|
|
84
|
+
: '文件读取失败';
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: `${freshness.error}\n\n(${why},未注入内容快照,请用 Read 工具显式读取后再 Edit)`,
|
|
88
|
+
};
|
|
65
89
|
}
|
|
66
90
|
if (old_string === new_string) {
|
|
67
91
|
return { ok: false, error: 'old_string 与 new_string 完全相同,没有可改的内容。' };
|
|
@@ -18,7 +18,7 @@ import { existsSync } from 'node:fs';
|
|
|
18
18
|
import { z } from 'zod';
|
|
19
19
|
import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
|
|
20
20
|
import { toToolParameters } from '../../utils/zodToJson.js';
|
|
21
|
-
import { resolveToolPath, detectFileLineEndings, applyLineEnding, findActualString, preserveQuoteStyle, countOccurrences, splitReplaceAll, } from '../shared/fileUtils.js';
|
|
21
|
+
import { resolveToolPath, detectFileLineEndings, applyLineEnding, findActualString, preserveQuoteStyle, countOccurrences, splitReplaceAll, tryAutoSnapshot, } from '../shared/fileUtils.js';
|
|
22
22
|
import { filePathField } from '../shared/schemas.js';
|
|
23
23
|
import { assertFresh, recordRead, wasTruncated } from '../shared/fileState.js';
|
|
24
24
|
// ---------------- 1. Zod 输入 schema ----------------
|
|
@@ -58,10 +58,31 @@ async function call(input) {
|
|
|
58
58
|
if (!pathResult.ok)
|
|
59
59
|
return pathResult;
|
|
60
60
|
const filePath = pathResult.resolvedPath;
|
|
61
|
-
// 4b. Pre-read guard
|
|
61
|
+
// 4b. Pre-read guard —— 入口处一次性检查 + 注入 snippet(循环内多个 edit 不重复注入)
|
|
62
62
|
const freshness = assertFresh(filePath);
|
|
63
63
|
if (!freshness.ok) {
|
|
64
|
-
|
|
64
|
+
const snap = await tryAutoSnapshot(filePath);
|
|
65
|
+
if (snap.ok) {
|
|
66
|
+
recordRead(filePath, { truncated: snap.truncated });
|
|
67
|
+
const tail = snap.truncated
|
|
68
|
+
? `\n\n⚠️ 仅注入了前 200 行(文件共 ${snap.totalLines} 行 / 上限 32KB)。若要改的位置在范围外,请显式 Read offset=X limit=Y 后再 MultiEdit。`
|
|
69
|
+
: '';
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
error: freshness.error +
|
|
73
|
+
`\n\n以下是当前文件${snap.truncated ? '前 200 行' : '全部'}内容(带行号),请基于此重新构造各 edit 的 old_string 后立即重试 MultiEdit,不要再单独调 Read:\n\n${snap.snippet}` +
|
|
74
|
+
tail,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const why = snap.reason === 'too-large'
|
|
78
|
+
? `文件 ${((snap.sizeBytes ?? 0) / 1024).toFixed(1)} KB 超过 32KB 自动注入上限`
|
|
79
|
+
: snap.reason === 'binary'
|
|
80
|
+
? '文件命中二进制扩展名黑名单'
|
|
81
|
+
: '文件读取失败';
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
error: `${freshness.error}\n\n(${why},未注入内容快照,请用 Read 工具显式读取后再 MultiEdit)`,
|
|
85
|
+
};
|
|
65
86
|
}
|
|
66
87
|
// 4c. 文件必须存在(MultiEdit 不做"创建新文件"语义)
|
|
67
88
|
if (!existsSync(filePath)) {
|
|
@@ -39,6 +39,7 @@ export function assertFresh(absPath) {
|
|
|
39
39
|
if (existsSync(absPath)) {
|
|
40
40
|
return {
|
|
41
41
|
ok: false,
|
|
42
|
+
reason: 'never-read',
|
|
42
43
|
error: `文件 ${absPath} 已存在但未在本会话 Read 过。请先用 Read 工具读取,确认内容后再修改。`,
|
|
43
44
|
};
|
|
44
45
|
}
|
|
@@ -50,6 +51,7 @@ export function assertFresh(absPath) {
|
|
|
50
51
|
if (st.mtimeMs > entry.timestamp) {
|
|
51
52
|
return {
|
|
52
53
|
ok: false,
|
|
54
|
+
reason: 'mtime-drift',
|
|
53
55
|
error: `${absPath} 在 Read 后被外部修改(mtime 漂移)。请重新用 Read 工具读取最新内容。`,
|
|
54
56
|
};
|
|
55
57
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* 4. 引号风格保留(弯引号 ↔ 直引号归一化)
|
|
10
10
|
* ============================================================
|
|
11
11
|
*/
|
|
12
|
+
import { statSync } from 'node:fs';
|
|
12
13
|
import { open, readFile } from 'node:fs/promises';
|
|
13
14
|
import { homedir } from 'node:os';
|
|
14
15
|
import { extname, resolve } from 'node:path';
|
|
@@ -129,6 +130,66 @@ export function hasBinaryExtension(filePath) {
|
|
|
129
130
|
const ext = extname(filePath).toLowerCase();
|
|
130
131
|
return BINARY_EXTENSIONS.has(ext);
|
|
131
132
|
}
|
|
133
|
+
// -------------------- 3.7 自动 snapshot(Edit/MultiEdit 拦截路径用) --------------------
|
|
134
|
+
/**
|
|
135
|
+
* 自动读取文件前 N 行作为 snippet,给 Edit/MultiEdit 在 assertFresh 拦截时注入到错误响应里。
|
|
136
|
+
*
|
|
137
|
+
* 双限制:
|
|
138
|
+
* - SNAPSHOT_MAX_LINES = 200 行(防止小行长文件吃 context)
|
|
139
|
+
* - SNAPSHOT_MAX_BYTES = 32KB(防止超长单行 minified 文件撑爆 context)
|
|
140
|
+
* 两个阈值任一命中都拒绝读全量。
|
|
141
|
+
*
|
|
142
|
+
* 失败 reason:
|
|
143
|
+
* - 'binary' → 命中二进制扩展名黑名单
|
|
144
|
+
* - 'too-large' → 文件 > 32KB(不读全文,让模型走显式 Read)
|
|
145
|
+
* - 'unreadable' → stat 或 read 失败
|
|
146
|
+
*
|
|
147
|
+
* 调用方负责:
|
|
148
|
+
* 1. 在拿到 ok=true 后调 recordRead(filePath, { truncated: snap.truncated })
|
|
149
|
+
* 2. 把 snippet 拼到 error 字符串里
|
|
150
|
+
* 3. ok=false 时根据 reason 给模型可读的 fallback 消息
|
|
151
|
+
*/
|
|
152
|
+
const SNAPSHOT_MAX_LINES = 200;
|
|
153
|
+
const SNAPSHOT_MAX_BYTES = 32 * 1024;
|
|
154
|
+
export async function tryAutoSnapshot(filePath) {
|
|
155
|
+
// 1. 二进制扩展名拒绝
|
|
156
|
+
if (hasBinaryExtension(filePath)) {
|
|
157
|
+
return { ok: false, reason: 'binary' };
|
|
158
|
+
}
|
|
159
|
+
// 2. stat 拿大小
|
|
160
|
+
let sizeBytes;
|
|
161
|
+
try {
|
|
162
|
+
const st = statSync(filePath);
|
|
163
|
+
if (!st.isFile())
|
|
164
|
+
return { ok: false, reason: 'unreadable' };
|
|
165
|
+
sizeBytes = st.size;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return { ok: false, reason: 'unreadable' };
|
|
169
|
+
}
|
|
170
|
+
// 3. 字节阈值
|
|
171
|
+
if (sizeBytes > SNAPSHOT_MAX_BYTES) {
|
|
172
|
+
return { ok: false, reason: 'too-large', sizeBytes };
|
|
173
|
+
}
|
|
174
|
+
// 4. 读全文(已知 <= 32KB)
|
|
175
|
+
let content;
|
|
176
|
+
try {
|
|
177
|
+
content = await readFile(filePath, 'utf8');
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return { ok: false, reason: 'unreadable' };
|
|
181
|
+
}
|
|
182
|
+
// 5. 行数阈值:超过 200 行截断
|
|
183
|
+
const allLines = content.split('\n');
|
|
184
|
+
const totalLines = allLines.length;
|
|
185
|
+
const truncated = totalLines > SNAPSHOT_MAX_LINES;
|
|
186
|
+
const lines = truncated ? allLines.slice(0, SNAPSHOT_MAX_LINES) : allLines;
|
|
187
|
+
// 拼带行号的 snippet(与 Read 工具输出风格一致)
|
|
188
|
+
const numbered = lines
|
|
189
|
+
.map((line, i) => `${String(i + 1).padStart(6, ' ')}\t${line}`)
|
|
190
|
+
.join('\n');
|
|
191
|
+
return { ok: true, snippet: numbered, truncated, totalLines };
|
|
192
|
+
}
|
|
132
193
|
/**
|
|
133
194
|
* 读文件前 3 字节,返回 BOM 类型。
|
|
134
195
|
* - EF BB BF → utf8-bom
|
package/src/ui/App.js
CHANGED
|
@@ -8,7 +8,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
* ============================================================
|
|
9
9
|
*/
|
|
10
10
|
import React from 'react';
|
|
11
|
-
import { Box, Text } from 'ink';
|
|
11
|
+
import { Box, Text, Static } from 'ink';
|
|
12
12
|
import { saveContext } from '../context/persistContext.js';
|
|
13
13
|
import { InputBox } from './InputBox.js';
|
|
14
14
|
import { MessageList } from './MessageList.js';
|
|
@@ -33,5 +33,5 @@ export function App({ provider, initialHistory }) {
|
|
|
33
33
|
await chat.clearHistory();
|
|
34
34
|
process.stdout.write(CLEAR_SCREEN);
|
|
35
35
|
}, [chat]);
|
|
36
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: "minimal-agent" }), _jsx(Text, { color: "gray", children: ` · ${provider.name}/${provider.model} · /new 清空 · /compact 压缩 · /exit 退出 · Ctrl+C 中断` })] }), _jsx(MessageList, { history: chat.history, streamingText: chat.streamingText }), _jsx(ToolStatus, { status: chat.toolStatus, compacting: chat.compacting }), chat.error && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "red", children: ["\u26A0 ", chat.error] }) })), chat.interrupted && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "yellow", children: "\u26A1 \u64CD\u4F5C\u88AB\u7528\u6237\u4E2D\u65AD\uFF0C\u7B49\u5F85\u65B0\u7684\u4EFB\u52A1\u8F93\u5165..." }) })), _jsx(InputBox, { onSubmit: chat.submit, disabled: chat.isLoading, onAbort: chat.abort, onClear: handleClear, onCompact: chat.compactNow }), _jsx(StatusLine, { provider: provider, history: chat.history, pluginProgress: chat.pluginProgress })] }));
|
|
36
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: [null], children: () => (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: "minimal-agent" }), _jsx(Text, { color: "gray", children: ` · ${provider.name}/${provider.model} · /new 清空 · /compact 压缩 · /exit 退出 · Ctrl+C 中断` })] })) }), _jsx(MessageList, { history: chat.history, streamingText: chat.streamingText, streamingReasoning: chat.streamingReasoning }), _jsx(ToolStatus, { status: chat.toolStatus, compacting: chat.compacting }), chat.error && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "red", children: ["\u26A0 ", chat.error] }) })), chat.interrupted && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "yellow", children: "\u26A1 \u64CD\u4F5C\u88AB\u7528\u6237\u4E2D\u65AD\uFF0C\u7B49\u5F85\u65B0\u7684\u4EFB\u52A1\u8F93\u5165..." }) })), _jsx(InputBox, { onSubmit: chat.submit, disabled: chat.isLoading, onAbort: chat.abort, onClear: handleClear, onCompact: chat.compactNow }), _jsx(Static, { items: [chat.history.length], children: () => (_jsx(StatusLine, { provider: provider, history: chat.history, pluginProgress: chat.pluginProgress })) })] }));
|
|
37
37
|
}
|
package/src/ui/MessageList.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
const MAX_TOOL_PREVIEW_LINES = 3;
|
|
4
|
-
export function MessageList({ history, streamingText }) {
|
|
5
|
-
return (_jsxs(Box, { flexDirection: "column", children: [history.map((m, i) => (_jsx(MessageRow, { message: m }, i))),
|
|
4
|
+
export function MessageList({ history, streamingText, streamingReasoning }) {
|
|
5
|
+
return (_jsxs(Box, { flexDirection: "column", children: [history.map((m, i) => (_jsx(MessageRow, { message: m }, i))), streamingReasoning.length > 0 && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "gray", dimColor: true, children: ["\uD83D\uDCA1 ", streamingReasoning.length > 300
|
|
6
|
+
? `...(${Math.round(streamingReasoning.length / 1000)}K字)${streamingReasoning.slice(-200)}`
|
|
7
|
+
: streamingReasoning] }) })), streamingText.length > 0 && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: streamingText }) }))] }));
|
|
6
8
|
}
|
|
7
9
|
function MessageRow({ message }) {
|
|
8
10
|
switch (message.role) {
|
package/src/ui/hooks/useChat.js
CHANGED
|
@@ -29,6 +29,7 @@ export function useChat(args) {
|
|
|
29
29
|
const [version, setVersion] = useState(0);
|
|
30
30
|
const bump = useCallback(() => setVersion((v) => v + 1), []);
|
|
31
31
|
const [streamingText, setStreamingText] = useState('');
|
|
32
|
+
const [streamingReasoning, setStreamingReasoning] = useState('');
|
|
32
33
|
const [toolStatus, setToolStatus] = useState(null);
|
|
33
34
|
const [isLoading, setIsLoading] = useState(false);
|
|
34
35
|
const [error, setError] = useState(null);
|
|
@@ -55,6 +56,49 @@ export function useChat(args) {
|
|
|
55
56
|
setPluginProgress(null);
|
|
56
57
|
const ac = new AbortController();
|
|
57
58
|
abortRef.current = ac;
|
|
59
|
+
let streamBuf = '';
|
|
60
|
+
let reasoningBuf = '';
|
|
61
|
+
let streamTimer = null;
|
|
62
|
+
const throttledSetStreamingText = (value) => {
|
|
63
|
+
streamBuf = typeof value === 'function' ? value(streamBuf) : value;
|
|
64
|
+
if (!streamTimer) {
|
|
65
|
+
streamTimer = setInterval(() => {
|
|
66
|
+
if (streamBuf.length > 0) {
|
|
67
|
+
setStreamingText(streamBuf);
|
|
68
|
+
}
|
|
69
|
+
if (reasoningBuf.length > 0) {
|
|
70
|
+
setStreamingReasoning(reasoningBuf);
|
|
71
|
+
}
|
|
72
|
+
}, 200);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const throttledSetStreamingReasoning = (value) => {
|
|
76
|
+
reasoningBuf = typeof value === 'function' ? value(reasoningBuf) : value;
|
|
77
|
+
if (!streamTimer) {
|
|
78
|
+
streamTimer = setInterval(() => {
|
|
79
|
+
if (streamBuf.length > 0) {
|
|
80
|
+
setStreamingText(streamBuf);
|
|
81
|
+
}
|
|
82
|
+
if (reasoningBuf.length > 0) {
|
|
83
|
+
setStreamingReasoning(reasoningBuf);
|
|
84
|
+
}
|
|
85
|
+
}, 200);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const flushStream = () => {
|
|
89
|
+
if (streamTimer) {
|
|
90
|
+
clearInterval(streamTimer);
|
|
91
|
+
streamTimer = null;
|
|
92
|
+
}
|
|
93
|
+
if (streamBuf.length > 0) {
|
|
94
|
+
setStreamingText(streamBuf);
|
|
95
|
+
streamBuf = '';
|
|
96
|
+
}
|
|
97
|
+
if (reasoningBuf.length > 0) {
|
|
98
|
+
setStreamingReasoning(reasoningBuf);
|
|
99
|
+
reasoningBuf = '';
|
|
100
|
+
}
|
|
101
|
+
};
|
|
58
102
|
try {
|
|
59
103
|
for await (const ev of runWithPlugins(trimmed, {
|
|
60
104
|
provider: args.provider,
|
|
@@ -62,7 +106,8 @@ export function useChat(args) {
|
|
|
62
106
|
signal: ac.signal,
|
|
63
107
|
})) {
|
|
64
108
|
handleEvent(ev, {
|
|
65
|
-
setStreamingText,
|
|
109
|
+
setStreamingText: throttledSetStreamingText,
|
|
110
|
+
setStreamingReasoning: throttledSetStreamingReasoning,
|
|
66
111
|
setToolStatus,
|
|
67
112
|
setCompacting,
|
|
68
113
|
setError,
|
|
@@ -76,8 +121,10 @@ export function useChat(args) {
|
|
|
76
121
|
setError(`未捕获异常:${e.message}`);
|
|
77
122
|
}
|
|
78
123
|
finally {
|
|
124
|
+
flushStream();
|
|
79
125
|
setIsLoading(false);
|
|
80
126
|
setStreamingText('');
|
|
127
|
+
setStreamingReasoning('');
|
|
81
128
|
setToolStatus(null);
|
|
82
129
|
setCompacting(false);
|
|
83
130
|
setPluginProgress(null);
|
|
@@ -104,6 +151,7 @@ export function useChat(args) {
|
|
|
104
151
|
// 允许新 session 再次触发反应式压缩自救
|
|
105
152
|
resetReactiveCompactState();
|
|
106
153
|
setStreamingText('');
|
|
154
|
+
setStreamingReasoning('');
|
|
107
155
|
setToolStatus(null);
|
|
108
156
|
setError(null);
|
|
109
157
|
setInterrupted(false);
|
|
@@ -156,6 +204,7 @@ export function useChat(args) {
|
|
|
156
204
|
return {
|
|
157
205
|
history,
|
|
158
206
|
streamingText,
|
|
207
|
+
streamingReasoning,
|
|
159
208
|
toolStatus,
|
|
160
209
|
isLoading,
|
|
161
210
|
error,
|
|
@@ -179,11 +228,16 @@ function handleEvent(ev, setters) {
|
|
|
179
228
|
const e = ev;
|
|
180
229
|
switch (e.type) {
|
|
181
230
|
case 'text':
|
|
182
|
-
setters.setStreamingText(
|
|
231
|
+
setters.setStreamingText(prev => prev + e.delta);
|
|
232
|
+
break;
|
|
233
|
+
case 'reasoning_delta':
|
|
234
|
+
if (e.delta) {
|
|
235
|
+
setters.setStreamingReasoning(prev => prev + e.delta);
|
|
236
|
+
}
|
|
183
237
|
break;
|
|
184
238
|
case 'assistant_message':
|
|
185
|
-
|
|
186
|
-
setters.
|
|
239
|
+
setters.setStreamingText('');
|
|
240
|
+
setters.setStreamingReasoning('');
|
|
187
241
|
setters.bump();
|
|
188
242
|
break;
|
|
189
243
|
case 'tool_start':
|
|
@@ -199,10 +253,8 @@ function handleEvent(ev, setters) {
|
|
|
199
253
|
setters.bump();
|
|
200
254
|
break;
|
|
201
255
|
case 'compact_start':
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// autoCompact 场景下 streamingText 本来就是空,这里 no-op。
|
|
205
|
-
setters.setStreamingText(() => '');
|
|
256
|
+
setters.setStreamingText('');
|
|
257
|
+
setters.setStreamingReasoning('');
|
|
206
258
|
setters.setCompacting(true);
|
|
207
259
|
setters.bump();
|
|
208
260
|
break;
|
|
@@ -215,10 +267,14 @@ function handleEvent(ev, setters) {
|
|
|
215
267
|
break;
|
|
216
268
|
case 'interrupted':
|
|
217
269
|
setters.setInterrupted(true);
|
|
270
|
+
setters.setStreamingText('');
|
|
271
|
+
setters.setStreamingReasoning('');
|
|
218
272
|
setters.bump();
|
|
219
273
|
break;
|
|
220
274
|
case 'error':
|
|
221
275
|
setters.setError(e.error);
|
|
276
|
+
setters.setStreamingText('');
|
|
277
|
+
setters.setStreamingReasoning('');
|
|
222
278
|
setters.bump();
|
|
223
279
|
break;
|
|
224
280
|
case 'plugin_progress':
|
package/src/utils/zodToJson.js
CHANGED
|
@@ -11,12 +11,55 @@
|
|
|
11
11
|
* ============================================================
|
|
12
12
|
*/
|
|
13
13
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
14
|
+
/**
|
|
15
|
+
* 把 zodToJsonSchema 输出的 draft-04 boolean 形式 exclusiveMin/Max 升级为
|
|
16
|
+
* draft-2020-12 number 形式,让生成的 schema 在严校验 LLM provider 也能通过。
|
|
17
|
+
*
|
|
18
|
+
* 背景:`target: 'openApi3'` 走 OpenAPI 3.0 / JSON Schema draft-04 规范,
|
|
19
|
+
* `.positive()` 之类的 Zod 约束生成 `{minimum: 0, exclusiveMinimum: true}`。
|
|
20
|
+
* 严校验 provider(如火山 ARK Coding 后挂的 GLM-5.1)按 draft-2020-12 要求
|
|
21
|
+
* `exclusiveMinimum` 必须是 number,遇 boolean 直接 400 InvalidParameter。
|
|
22
|
+
*
|
|
23
|
+
* 转换规则:
|
|
24
|
+
* {minimum: N, exclusiveMinimum: true} → {exclusiveMinimum: N}
|
|
25
|
+
* {maximum: N, exclusiveMaximum: true} → {exclusiveMaximum: N}
|
|
26
|
+
*
|
|
27
|
+
* Fallback:boolean 形式但同级没有 minimum/maximum 时(理论 edge case),
|
|
28
|
+
* 静默删除 boolean 字段——避免污染 schema,运行时 Zod 兜底校验。
|
|
29
|
+
*/
|
|
30
|
+
function modernizeExclusiveBounds(node) {
|
|
31
|
+
if (Array.isArray(node))
|
|
32
|
+
return node.map(modernizeExclusiveBounds);
|
|
33
|
+
if (node === null || typeof node !== 'object')
|
|
34
|
+
return node;
|
|
35
|
+
const obj = node;
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
38
|
+
if (k === 'exclusiveMinimum' && typeof v === 'boolean') {
|
|
39
|
+
if (v && typeof obj.minimum === 'number')
|
|
40
|
+
out.exclusiveMinimum = obj.minimum;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (k === 'minimum' && obj.exclusiveMinimum === true)
|
|
44
|
+
continue;
|
|
45
|
+
if (k === 'exclusiveMaximum' && typeof v === 'boolean') {
|
|
46
|
+
if (v && typeof obj.maximum === 'number')
|
|
47
|
+
out.exclusiveMaximum = obj.maximum;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (k === 'maximum' && obj.exclusiveMaximum === true)
|
|
51
|
+
continue;
|
|
52
|
+
out[k] = modernizeExclusiveBounds(v);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
14
56
|
/**
|
|
15
57
|
* 从 Zod schema 派生出 OpenAI tools[] 兼容的 JSON Schema 对象。
|
|
16
58
|
*
|
|
17
59
|
* 做了以下清理:
|
|
18
60
|
* - 移除 `$schema` 元数据字段(LLM 不需要)
|
|
19
61
|
* - `additionalProperties` 默认 false(防止模型传多余字段)
|
|
62
|
+
* - `modernizeExclusiveBounds` 把 boolean exclusiveMin/Max 升级 draft-2020-12 number 形式
|
|
20
63
|
*/
|
|
21
64
|
export function toToolParameters(schema) {
|
|
22
65
|
const json = zodToJsonSchema(schema, {
|
|
@@ -25,5 +68,5 @@ export function toToolParameters(schema) {
|
|
|
25
68
|
});
|
|
26
69
|
// 移除 LLM 不需要的元数据
|
|
27
70
|
const { $schema, ...rest } = json;
|
|
28
|
-
return rest;
|
|
71
|
+
return modernizeExclusiveBounds(rest);
|
|
29
72
|
}
|