minimal-agent 0.2.0 → 0.3.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/README.md +50 -72
- package/package.json +18 -13
- package/plugins/ralph-wiggum/plugin.js +205 -0
- package/plugins/ralph-wiggum/src/goalState.js +260 -0
- package/plugins/ralph-wiggum/src/{sentinels.ts → sentinels.js} +4 -7
- package/plugins/ralph-wiggum/src/stopHookRunner.js +104 -0
- package/plugins/ralph-wiggum/src/verificationGate.js +202 -0
- package/plugins/workflow-runner/{plugin.ts → plugin.js} +20 -26
- package/plugins/workflow-runner/src/expressions.js +369 -0
- package/plugins/workflow-runner/src/index.js +174 -0
- package/plugins/workflow-runner/src/loader.js +183 -0
- package/plugins/workflow-runner/src/runner.js +290 -0
- package/plugins/workflow-runner/src/stepExecutors/assert.js +28 -0
- package/plugins/workflow-runner/src/stepExecutors/llm.js +44 -0
- package/plugins/workflow-runner/src/stepExecutors/skill.js +103 -0
- package/plugins/workflow-runner/src/stepExecutors/{tool.ts → tool.js} +19 -25
- package/plugins/workflow-runner/src/types.js +59 -0
- package/plugins/workflow-runner/src/{workflowState.ts → workflowState.js} +21 -40
- package/src/bootstrap/cwdArg.js +22 -0
- package/src/bootstrap/workingDir.js +31 -0
- package/src/cli/configWizard.js +272 -0
- package/src/cli/print.js +192 -0
- package/src/config/configFile.js +78 -0
- package/src/config.js +118 -0
- package/src/context/compact.js +357 -0
- package/src/context/microCompactLite.js +151 -0
- package/src/context/persistContext.js +109 -0
- package/src/context/reactiveCompact.js +121 -0
- package/src/context/sessionPath.js +58 -0
- package/src/context/snipCompact.js +112 -0
- package/src/context/tokenCounter.js +66 -0
- package/src/llm/client.js +182 -0
- package/src/loop.js +230 -0
- package/src/main.js +116 -0
- package/src/plugin-sdk.js +24 -0
- package/src/plugins/commandRouter.js +169 -0
- package/src/plugins/hookEngine.js +258 -0
- package/src/plugins/pluginApi.js +23 -0
- package/src/plugins/pluginLoader.js +71 -0
- package/src/plugins/pluginRunner.js +65 -0
- package/src/plugins/transcript.js +171 -0
- package/src/prompts/projectInstructions.js +48 -0
- package/src/prompts/skillList.js +126 -0
- package/src/prompts/system.js +155 -0
- package/src/session/runTurn.js +41 -0
- package/src/session/sessionState.js +19 -0
- package/src/tools/bash/bash.js +352 -0
- package/src/tools/bash/semantics.js +85 -0
- package/src/tools/bash/warnings.js +98 -0
- package/src/tools/edit/edit.js +253 -0
- package/src/tools/edit/multi-edit.js +155 -0
- package/src/tools/glob/glob.js +97 -0
- package/src/tools/grep/grep.js +185 -0
- package/src/tools/grep/rgPath.js +173 -0
- package/src/tools/index.js +94 -0
- package/src/tools/read/read.js +209 -0
- package/src/tools/shared/fileState.js +61 -0
- package/src/tools/shared/fileUtils.js +281 -0
- package/src/tools/shared/schemas.js +16 -0
- package/src/tools/types.js +21 -0
- package/src/tools/webbrowser/browser.js +55 -0
- package/src/tools/webbrowser/webbrowser.js +194 -0
- package/src/tools/webfetch/preapproved.js +267 -0
- package/src/tools/webfetch/webfetch.js +317 -0
- package/src/tools/websearch/websearch.js +161 -0
- package/src/tools/write/write.js +125 -0
- package/src/types/turndown.d.ts +23 -0
- package/src/types.js +16 -0
- package/src/ui/App.js +37 -0
- package/src/ui/InputBox.js +240 -0
- package/src/ui/MessageList.js +28 -0
- package/src/ui/Root.js +70 -0
- package/src/ui/StatusLine.js +41 -0
- package/src/ui/ToolStatus.js +11 -0
- package/src/ui/hooks/useChat.js +234 -0
- package/src/ui/hooks/usePasteHandler.js +137 -0
- package/src/ui/hooks/useTextBuffer.js +55 -0
- package/src/ui/hooks/useTokenUsage.js +30 -0
- package/src/ui/textBuffer.js +217 -0
- package/src/utils/packageRoot.js +37 -0
- package/src/utils/resourcePaths.js +49 -0
- package/src/utils/zodToJson.js +29 -0
- package/dist/main.js +0 -5315
- package/plugins/ralph-wiggum/plugin.ts +0 -275
- package/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh +0 -203
- package/plugins/ralph-wiggum/src/goalState.ts +0 -310
- package/plugins/ralph-wiggum/src/stopHookRunner.ts +0 -136
- package/plugins/ralph-wiggum/src/verificationGate.ts +0 -252
- package/plugins/ralph-wiggum/test/goalState.test.ts +0 -410
- package/plugins/ralph-wiggum/test/verificationGate.test.ts +0 -122
- package/plugins/workflow-runner/src/expressions.ts +0 -371
- package/plugins/workflow-runner/src/index.ts +0 -194
- package/plugins/workflow-runner/src/loader.ts +0 -193
- package/plugins/workflow-runner/src/runner.ts +0 -313
- package/plugins/workflow-runner/src/stepExecutors/assert.ts +0 -30
- package/plugins/workflow-runner/src/stepExecutors/llm.ts +0 -54
- package/plugins/workflow-runner/src/stepExecutors/skill.ts +0 -115
- package/plugins/workflow-runner/src/types.ts +0 -183
- package/plugins/workflow-runner/test/cli.e2e.test.ts +0 -114
- package/plugins/workflow-runner/test/e2e.test.ts +0 -268
- package/plugins/workflow-runner/test/expressions.test.ts +0 -140
- package/plugins/workflow-runner/test/fixtures/cli-e2e.yaml +0 -27
- package/plugins/workflow-runner/test/fixtures/hello-workflow.yaml +0 -49
- package/plugins/workflow-runner/test/graceful.test.ts +0 -139
- package/plugins/workflow-runner/test/loader.test.ts +0 -216
- package/plugins/workflow-runner/test/pluginRunner.isolation.test.ts +0 -230
- package/plugins/workflow-runner/test/runner.test.ts +0 -511
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/tools/websearch.ts —— WebSearch 工具(联网搜索)
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 底层调用 Tavily Search API(https://tavily.com/)。
|
|
6
|
+
*
|
|
7
|
+
* 为什么选 Tavily:
|
|
8
|
+
* - LLM 友好:返回结构化 JSON,自带 content 摘要;不用我们再去抓 HTML
|
|
9
|
+
* - 接口简单:一个 POST + JSON body 搞定,不用 OAuth / 翻墙
|
|
10
|
+
* - 免费额度:每月 1000 次足够学习用
|
|
11
|
+
*
|
|
12
|
+
* Key 配置:
|
|
13
|
+
* 在项目根 .env 里加:
|
|
14
|
+
* TAVILY_API_KEY=tvly-xxxxxxxxxxxxxxxx
|
|
15
|
+
* 没设的话,工具会返回友好错误(不影响 agent 启动)。
|
|
16
|
+
*
|
|
17
|
+
* 输入:
|
|
18
|
+
* query 必填,搜索关键词
|
|
19
|
+
* max_results 可选,默认 5(Tavily 上限 20)
|
|
20
|
+
* search_depth 可选 'basic' | 'advanced',默认 basic
|
|
21
|
+
* topic 可选 'general' | 'news',默认 general
|
|
22
|
+
*
|
|
23
|
+
* 输出:
|
|
24
|
+
* 每条结果一段:
|
|
25
|
+
* [1] 标题
|
|
26
|
+
* URL: https://...
|
|
27
|
+
* 内容摘要(content)
|
|
28
|
+
* 如有 answer 字段(advanced 才返回),先列在最上面。
|
|
29
|
+
* ============================================================
|
|
30
|
+
*/
|
|
31
|
+
import { z } from 'zod';
|
|
32
|
+
import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
|
|
33
|
+
import { toToolParameters } from '../../utils/zodToJson.js';
|
|
34
|
+
// ---------------- 1. Zod 输入 schema ----------------
|
|
35
|
+
const inputSchema = z.object({
|
|
36
|
+
query: z
|
|
37
|
+
.string()
|
|
38
|
+
.min(1, '必须提供搜索关键词')
|
|
39
|
+
.max(400, '搜索关键词太长(>400 字)')
|
|
40
|
+
.describe('搜索关键词,建议自然语言描述需要查的信息'),
|
|
41
|
+
max_results: z
|
|
42
|
+
.number()
|
|
43
|
+
.int()
|
|
44
|
+
.min(1)
|
|
45
|
+
.max(20)
|
|
46
|
+
.optional()
|
|
47
|
+
.describe('返回结果数量,1-20,默认 5'),
|
|
48
|
+
search_depth: z
|
|
49
|
+
.enum(['basic', 'advanced'])
|
|
50
|
+
.optional()
|
|
51
|
+
.describe('basic 快但浅;advanced 慢但深(含 answer 摘要),默认 basic'),
|
|
52
|
+
topic: z
|
|
53
|
+
.enum(['general', 'news'])
|
|
54
|
+
.optional()
|
|
55
|
+
.describe('general=通用网页;news=偏新闻源;默认 general'),
|
|
56
|
+
});
|
|
57
|
+
// ---------------- 2. JSON Schema(由 Zod 自动派生) ----------------
|
|
58
|
+
const parameters = toToolParameters(inputSchema);
|
|
59
|
+
// ---------------- 3. Description(给 LLM 看的工具说明) ----------------
|
|
60
|
+
const description = `- Searches the public web via the Tavily Search API and returns structured results.
|
|
61
|
+
- Use this when you need up-to-date information that is not in your training data, or when the user asks for recent news / docs / API references.
|
|
62
|
+
- Returns the top N results, each with a title, URL, and content snippet. With \`search_depth: "advanced"\` Tavily also returns a synthesized answer at the top.
|
|
63
|
+
- Prefer specific natural-language queries over keyword soup (e.g. "how does Bun handle .env files in version 1.1").
|
|
64
|
+
- Requires the TAVILY_API_KEY environment variable to be set; if missing the tool returns a friendly error.`;
|
|
65
|
+
// ---------------- 5. call() ----------------
|
|
66
|
+
async function call(input, signal) {
|
|
67
|
+
// 5a. 取 key
|
|
68
|
+
const apiKey = process.env.TAVILY_API_KEY;
|
|
69
|
+
if (!apiKey) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
error: 'WebSearch 不可用:环境变量 TAVILY_API_KEY 未设置。\n' +
|
|
73
|
+
'请在项目根 .env 里加:\n' +
|
|
74
|
+
' TAVILY_API_KEY=tvly-你的key\n' +
|
|
75
|
+
'免费 key 申请:https://tavily.com/',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// 5b. 自动追加当前日期到 query(强制时间限制,防止搜索到过时信息)
|
|
79
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
80
|
+
const queryWithDate = input.query.includes(today)
|
|
81
|
+
? input.query
|
|
82
|
+
: `${input.query} (as of ${today})`;
|
|
83
|
+
const body = {
|
|
84
|
+
api_key: apiKey,
|
|
85
|
+
query: queryWithDate,
|
|
86
|
+
max_results: input.max_results ?? 5,
|
|
87
|
+
search_depth: input.search_depth ?? 'basic',
|
|
88
|
+
topic: input.topic ?? 'general',
|
|
89
|
+
// 让 advanced 模式自动给 answer;basic 不会有
|
|
90
|
+
include_answer: input.search_depth === 'advanced',
|
|
91
|
+
};
|
|
92
|
+
// 5c. 发请求
|
|
93
|
+
let res;
|
|
94
|
+
try {
|
|
95
|
+
res = await fetch('https://api.tavily.com/search', {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'content-type': 'application/json' },
|
|
98
|
+
body: JSON.stringify(body),
|
|
99
|
+
signal,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: `调用 Tavily 失败(网络):${e.message}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
const text = await res.text().catch(() => '');
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
error: `Tavily HTTP ${res.status}:${text.slice(0, 500)}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// 5d. 解析
|
|
116
|
+
let data;
|
|
117
|
+
try {
|
|
118
|
+
data = (await res.json());
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
error: `Tavily 响应不是合法 JSON:${e.message}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const results = data.results ?? [];
|
|
127
|
+
if (results.length === 0) {
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
content: `(no results for "${input.query}")`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
// 5e. 拼输出
|
|
134
|
+
const parts = [];
|
|
135
|
+
if (data.answer) {
|
|
136
|
+
parts.push(`【综合回答】\n${data.answer}\n`);
|
|
137
|
+
}
|
|
138
|
+
parts.push(`【共 ${results.length} 条结果】`);
|
|
139
|
+
results.forEach((r, i) => {
|
|
140
|
+
const date = r.published_date ? `(${r.published_date})` : '';
|
|
141
|
+
parts.push(`\n[${i + 1}] ${r.title}${date}\nURL: ${r.url}\n${(r.content ?? '').trim()}`);
|
|
142
|
+
});
|
|
143
|
+
let content = parts.join('\n');
|
|
144
|
+
if (content.length > DEFAULT_MAX_RESULT_SIZE_CHARS) {
|
|
145
|
+
content =
|
|
146
|
+
content.slice(0, DEFAULT_MAX_RESULT_SIZE_CHARS) +
|
|
147
|
+
`\n\n... (输出超过 ${DEFAULT_MAX_RESULT_SIZE_CHARS} 字符,已截断)`;
|
|
148
|
+
}
|
|
149
|
+
return { ok: true, content };
|
|
150
|
+
}
|
|
151
|
+
// ---------------- 6. 导出 ----------------
|
|
152
|
+
export const webSearchTool = {
|
|
153
|
+
name: 'WebSearch',
|
|
154
|
+
description,
|
|
155
|
+
inputSchema,
|
|
156
|
+
parameters,
|
|
157
|
+
isReadOnly: true,
|
|
158
|
+
isConcurrencySafe: true,
|
|
159
|
+
maxResultSizeChars: DEFAULT_MAX_RESULT_SIZE_CHARS,
|
|
160
|
+
call,
|
|
161
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/tools/write.ts —— Write 工具(整文件写入)
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 对应 kakadeai 主仓库的 FileWriteTool。
|
|
6
|
+
* 把 content 整段写到 file_path(覆盖既有内容)。
|
|
7
|
+
* 目录不存在时自动创建(递归)。
|
|
8
|
+
*
|
|
9
|
+
* v0.2 增强:
|
|
10
|
+
* - 路径安全校验(null byte / UNC 路径)
|
|
11
|
+
* - 内容大小上限(1GB)
|
|
12
|
+
* - 覆盖已有文件时在返回值中明确提示
|
|
13
|
+
* ============================================================
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { dirname } from 'node:path';
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
|
|
20
|
+
import { toToolParameters } from '../../utils/zodToJson.js';
|
|
21
|
+
import { applyLineEnding, detectFileBomEncoding, detectFileLineEndings, resolveToolPath, } from '../shared/fileUtils.js';
|
|
22
|
+
import { filePathField } from '../shared/schemas.js';
|
|
23
|
+
import { assertFresh, recordRead } from '../shared/fileState.js';
|
|
24
|
+
/** 写入内容上限:1GB。防止模型试图写入超大内容撑爆内存/磁盘。 */
|
|
25
|
+
const MAX_WRITE_SIZE_BYTES = 1024 * 1024 * 1024;
|
|
26
|
+
// ---------------- 1. Zod 输入 schema ----------------
|
|
27
|
+
const inputSchema = z.object({
|
|
28
|
+
file_path: filePathField('写入'),
|
|
29
|
+
content: z.string().describe('文件完整内容(会覆盖既有内容)'),
|
|
30
|
+
});
|
|
31
|
+
// ---------------- 2. JSON Schema(由 Zod 自动派生) ----------------
|
|
32
|
+
const parameters = toToolParameters(inputSchema);
|
|
33
|
+
// ---------------- 3. Description ----------------
|
|
34
|
+
const description = `Writes a file to the local filesystem.
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
- This tool will overwrite the existing file if there is one at the provided path.
|
|
38
|
+
- If you intend to overwrite an existing file, you MUST use your \`Read\` tool to read its current content at least once in the current session BEFORE calling Write. This tool will error with a "请先 Read" message if you attempt to overwrite a file that has not been read. To create a new file (path does not yet exist), you can call Write directly without a prior Read.
|
|
39
|
+
- If the parent directory does not exist, it will be created recursively.
|
|
40
|
+
- ALWAYS prefer editing existing files in the codebase via the Edit tool. NEVER write new files unless explicitly required.
|
|
41
|
+
- NEVER create documentation files (*.md) or README files unless explicitly requested by the User.`;
|
|
42
|
+
// ---------------- 5. call() ----------------
|
|
43
|
+
async function call(input) {
|
|
44
|
+
const pathResult = resolveToolPath(input.file_path);
|
|
45
|
+
if (!pathResult.ok)
|
|
46
|
+
return pathResult;
|
|
47
|
+
const filePath = pathResult.resolvedPath;
|
|
48
|
+
// Pre-read guard:覆盖已存在文件前必须先 Read 过;防止盲写覆盖外部修改。
|
|
49
|
+
const freshness = assertFresh(filePath);
|
|
50
|
+
if (!freshness.ok) {
|
|
51
|
+
return { ok: false, error: freshness.error };
|
|
52
|
+
}
|
|
53
|
+
const contentSize = Buffer.byteLength(input.content, 'utf8');
|
|
54
|
+
if (contentSize > MAX_WRITE_SIZE_BYTES) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
error: `写入内容过大(${contentSize} 字节 > ${MAX_WRITE_SIZE_BYTES} 字节 ≈ 1GB)。请拆分内容或使用 Edit 工具分块修改。`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
62
|
+
let originalSize = 0;
|
|
63
|
+
const fileExisted = existsSync(filePath);
|
|
64
|
+
if (fileExisted) {
|
|
65
|
+
try {
|
|
66
|
+
const st = await stat(filePath);
|
|
67
|
+
originalSize = st.size;
|
|
68
|
+
}
|
|
69
|
+
catch { }
|
|
70
|
+
}
|
|
71
|
+
let contentToWrite = input.content;
|
|
72
|
+
// bomEncoding 仅在覆盖既有文件时有意义;新建文件统一按 utf8 无 BOM 处理。
|
|
73
|
+
let bomEncoding = 'utf8';
|
|
74
|
+
if (fileExisted) {
|
|
75
|
+
const detected = await detectFileBomEncoding(filePath);
|
|
76
|
+
if (detected === 'utf16le') {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
error: '暂不支持改写 UTF-16 LE 文件(BOM=FF FE)。请改用 UTF-8 编码。',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
bomEncoding = detected;
|
|
83
|
+
const lineEnding = await detectFileLineEndings(filePath);
|
|
84
|
+
contentToWrite = applyLineEnding(input.content, lineEnding);
|
|
85
|
+
}
|
|
86
|
+
if (bomEncoding === 'utf8-bom') {
|
|
87
|
+
// 关键:BOM 必须走 Buffer 路径手动拼接 EF BB BF。
|
|
88
|
+
// 不能直接 '\uFEFF' + content 再让 writeFile 以 utf8 编码 ——
|
|
89
|
+
// 那样 utf8 编码器虽然也会输出 EF BB BF,但等价于"二次编码",且无法保证
|
|
90
|
+
// 与原文件字节级一致;显式 Buffer.concat 才能保证落盘字节稳定。
|
|
91
|
+
const bomBytes = Buffer.from([0xef, 0xbb, 0xbf]);
|
|
92
|
+
const bodyBytes = Buffer.from(contentToWrite, 'utf8');
|
|
93
|
+
// 传 Buffer 时不要再传 encoding 参数。
|
|
94
|
+
await writeFile(filePath, Buffer.concat([bomBytes, bodyBytes]));
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
await writeFile(filePath, contentToWrite, 'utf8');
|
|
98
|
+
}
|
|
99
|
+
recordRead(filePath);
|
|
100
|
+
const action = fileExisted ? '已覆盖' : '已创建新文件';
|
|
101
|
+
const newSize = Buffer.byteLength(contentToWrite, 'utf8');
|
|
102
|
+
const sizeInfo = fileExisted
|
|
103
|
+
? `(原文件 ${originalSize} 字节 → 新内容 ${newSize} 字节)`
|
|
104
|
+
: `(${newSize} 字节)`;
|
|
105
|
+
const bomInfo = bomEncoding === 'utf8-bom' ? ',已保留 UTF-8 BOM' : '';
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
content: `${action} ${filePath}${sizeInfo}${bomInfo}`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
return { ok: false, error: `写入失败:${e.message}` };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// ---------------- 6. 导出 ----------------
|
|
116
|
+
export const writeTool = {
|
|
117
|
+
name: 'Write',
|
|
118
|
+
description,
|
|
119
|
+
inputSchema,
|
|
120
|
+
parameters,
|
|
121
|
+
isReadOnly: false,
|
|
122
|
+
isConcurrencySafe: false,
|
|
123
|
+
maxResultSizeChars: DEFAULT_MAX_RESULT_SIZE_CHARS,
|
|
124
|
+
call,
|
|
125
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// 项目内补的最小 turndown 类型声明(社区 @types/turndown 不维护)。
|
|
2
|
+
// 只覆盖 webfetch.ts 用到的两个 API:constructor + turndown(html)。
|
|
3
|
+
declare module 'turndown' {
|
|
4
|
+
export interface TurndownOptions {
|
|
5
|
+
headingStyle?: 'setext' | 'atx';
|
|
6
|
+
hr?: string;
|
|
7
|
+
bulletListMarker?: '-' | '+' | '*';
|
|
8
|
+
codeBlockStyle?: 'indented' | 'fenced';
|
|
9
|
+
emDelimiter?: '_' | '*';
|
|
10
|
+
strongDelimiter?: '__' | '**';
|
|
11
|
+
linkStyle?: 'inlined' | 'referenced';
|
|
12
|
+
linkReferenceStyle?: 'full' | 'collapsed' | 'shortcut';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default class TurndownService {
|
|
16
|
+
constructor(options?: TurndownOptions);
|
|
17
|
+
turndown(html: string): string;
|
|
18
|
+
use(plugin: unknown): this;
|
|
19
|
+
addRule(name: string, rule: unknown): this;
|
|
20
|
+
keep(filter: unknown): this;
|
|
21
|
+
remove(filter: unknown): this;
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/types.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/types.ts —— 全局核心类型定义
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 这个文件是项目的"类型字典",所有模块都从这里 import 类型。
|
|
6
|
+
* 新手阅读建议:从上往下,每个类型上方有"何时用 / 为何这么设计"的注释。
|
|
7
|
+
*
|
|
8
|
+
* 设计原则:
|
|
9
|
+
* - 形状跟 OpenAI Chat Completions API 对齐(多 provider 兼容性最好)
|
|
10
|
+
* - 不用 class,全用 interface / type alias(更轻、更易读)
|
|
11
|
+
* - 一切尽量简单:能用基本类型就不引第三方
|
|
12
|
+
* ============================================================
|
|
13
|
+
*/
|
|
14
|
+
export {};
|
|
15
|
+
// 注:插件私有事件(如 workflow-runner 的 workflow_*)不再写在这里。
|
|
16
|
+
// 插件通过 PluginEvent 开放契约 yield 任意 type 字符串的事件,UI 静默忽略未识别项。
|
package/src/ui/App.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* ============================================================
|
|
4
|
+
* src/ui/App.tsx —— TUI 顶层组件
|
|
5
|
+
* ------------------------------------------------------------
|
|
6
|
+
* 组合所有 UI 子组件,把 useChat 暴露的状态映射到各组件 props。
|
|
7
|
+
* 不直接处理键盘输入,那是 InputBox 的事。
|
|
8
|
+
* ============================================================
|
|
9
|
+
*/
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import { Box, Text } from 'ink';
|
|
12
|
+
import { saveContext } from '../context/persistContext.js';
|
|
13
|
+
import { InputBox } from './InputBox.js';
|
|
14
|
+
import { MessageList } from './MessageList.js';
|
|
15
|
+
import { StatusLine } from './StatusLine.js';
|
|
16
|
+
import { ToolStatus } from './ToolStatus.js';
|
|
17
|
+
import { useChat } from './hooks/useChat.js';
|
|
18
|
+
/** ANSI escape: 清屏 + 光标归位 */
|
|
19
|
+
const CLEAR_SCREEN = '\x1b[2J\x1b[H';
|
|
20
|
+
export function App({ provider, initialHistory }) {
|
|
21
|
+
// 持久化挂钩:每次轮次结束 / 清空 / 压缩后由 useChat 触发;fire-and-forget。
|
|
22
|
+
const onPersist = React.useCallback((messages) => {
|
|
23
|
+
void saveContext(messages);
|
|
24
|
+
}, []);
|
|
25
|
+
const chat = useChat({
|
|
26
|
+
provider,
|
|
27
|
+
initialHistory,
|
|
28
|
+
onPersist,
|
|
29
|
+
onCompactDone: () => process.stdout.write(CLEAR_SCREEN),
|
|
30
|
+
});
|
|
31
|
+
// /new:内存 reset + 磁盘 clearContext 全部由 chat.clearHistory() 内部完成,await 之后再清屏。
|
|
32
|
+
const handleClear = React.useCallback(async () => {
|
|
33
|
+
await chat.clearHistory();
|
|
34
|
+
process.stdout.write(CLEAR_SCREEN);
|
|
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 })] }));
|
|
37
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* ============================================================
|
|
4
|
+
* src/ui/InputBox.tsx —— 用户输入框(v0.4 多行粘贴支持版)
|
|
5
|
+
* ------------------------------------------------------------
|
|
6
|
+
* v0.4 新增功能:
|
|
7
|
+
* - 多行粘贴支持:粘贴多行代码时完整保留所有行
|
|
8
|
+
* - 多行编辑模式:Enter 提交,Alt+Enter(Mac: Option+Enter)插入换行
|
|
9
|
+
* - 粘贴检测:自动识别长输入为粘贴操作
|
|
10
|
+
* - 跨平台兼容:Win / Mac / Linux
|
|
11
|
+
*
|
|
12
|
+
* v0.3 修复内容:
|
|
13
|
+
* - 移除过激进的全局防抖机制(导致 Backspace 完全失效)
|
|
14
|
+
* - 改为仅对 Delete 键做去重(解决 Windows 双事件问题)
|
|
15
|
+
* - 修复 BufferView 组件重建导致的不可见问题
|
|
16
|
+
* - 保留强制刷新但精准控制时机
|
|
17
|
+
* ============================================================
|
|
18
|
+
*/
|
|
19
|
+
import { useRef, useState, useCallback } from 'react';
|
|
20
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
21
|
+
import { useTextBuffer } from './hooks/useTextBuffer.js';
|
|
22
|
+
import { usePasteHandler } from './hooks/usePasteHandler.js';
|
|
23
|
+
export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
|
|
24
|
+
const buf = useTextBuffer();
|
|
25
|
+
const ctrlCCountRef = useRef(0);
|
|
26
|
+
const ctrlCTimerRef = useRef(null);
|
|
27
|
+
const { exit } = useApp();
|
|
28
|
+
// ✅ 精准刷新计数器:只在必要时触发重渲染
|
|
29
|
+
const [refreshCounter, setRefreshCounter] = useState(0);
|
|
30
|
+
// ✅ Delete 专用去重:只防止 Windows 双事件,不影响其他键
|
|
31
|
+
const lastDeleteTime = useRef(0);
|
|
32
|
+
const DELETE_DEBOUNCE_MS = 20; // 仅 Delete 键用极短防抖
|
|
33
|
+
const forceRefresh = useCallback(() => {
|
|
34
|
+
setRefreshCounter((c) => c + 1);
|
|
35
|
+
}, []);
|
|
36
|
+
// ✅ 粘贴处理 Hook:支持多行粘贴
|
|
37
|
+
// v0.3 设计:Enter 始终发送, Alt+Enter(Mac: Option+Enter)换行, 粘贴期间拦截 Enter
|
|
38
|
+
const { handleInput: handlePasteInput } = usePasteHandler({
|
|
39
|
+
onPasteComplete: (text) => {
|
|
40
|
+
buf.insert(text);
|
|
41
|
+
forceRefresh();
|
|
42
|
+
},
|
|
43
|
+
multiline: true,
|
|
44
|
+
});
|
|
45
|
+
useInput((input, key) => {
|
|
46
|
+
// ---- ✅ 粘贴检测(必须优先处理)----
|
|
47
|
+
// 这会在粘贴期间忽略 Enter 键,防止多行内容被截断
|
|
48
|
+
const pasteResult = handlePasteInput(input, key);
|
|
49
|
+
if (pasteResult === 'paste') {
|
|
50
|
+
// 粘贴内容已由 usePasteHandler 处理并插入
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (pasteResult === 'enter-in-paste') {
|
|
54
|
+
// 粘贴期间的 Enter 键被忽略(不插入换行,不提交)
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (pasteResult === 'enter-newline') {
|
|
58
|
+
// Alt+Enter:插入换行符(多行编辑用)
|
|
59
|
+
buf.insert('\n');
|
|
60
|
+
forceRefresh();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// pasteResult === 'continue',继续普通处理
|
|
64
|
+
// ---- ESC ----
|
|
65
|
+
if (key.escape) {
|
|
66
|
+
if (disabled)
|
|
67
|
+
onAbort();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// ---- Ctrl+C ----
|
|
71
|
+
if (key.ctrl && input === 'c') {
|
|
72
|
+
if (buf.state.text.length > 0) {
|
|
73
|
+
buf.clear();
|
|
74
|
+
forceRefresh();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (disabled) {
|
|
78
|
+
onAbort();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
ctrlCCountRef.current++;
|
|
82
|
+
if (ctrlCCountRef.current >= 2)
|
|
83
|
+
exit();
|
|
84
|
+
if (ctrlCTimerRef.current)
|
|
85
|
+
clearTimeout(ctrlCTimerRef.current);
|
|
86
|
+
ctrlCTimerRef.current = setTimeout(() => { ctrlCCountRef.current = 0; }, 1000);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// ---- ✅ Enter 提交(始终发送)----
|
|
90
|
+
// usePasteHandler v0.3 逻辑:
|
|
91
|
+
// - Enter(单独按):返回 'continue' → 这里执行**发送**
|
|
92
|
+
// - Alt+Enter(Mac: Option+Enter):返回 'enter-newline' → 上面已处理为**插入换行**
|
|
93
|
+
// - Ctrl+Enter:返回 'continue' → 这里也执行**强制发送**
|
|
94
|
+
// - 粘贴期间 + Enter:返回 'enter-in-paste' → **忽略**(防误发)
|
|
95
|
+
if (key.return) {
|
|
96
|
+
const text = buf.state.text.trim();
|
|
97
|
+
if (text.length === 0)
|
|
98
|
+
return;
|
|
99
|
+
if (text === '/exit' || text === '/quit')
|
|
100
|
+
exit();
|
|
101
|
+
else if (text === '/new' || text === '/clear') {
|
|
102
|
+
buf.clear();
|
|
103
|
+
forceRefresh();
|
|
104
|
+
onClear();
|
|
105
|
+
}
|
|
106
|
+
else if (text === '/compact') {
|
|
107
|
+
buf.clear();
|
|
108
|
+
forceRefresh();
|
|
109
|
+
onCompact();
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
buf.clear();
|
|
113
|
+
forceRefresh();
|
|
114
|
+
onSubmit(text);
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// ---- 移动光标(无防抖,响应优先)----
|
|
119
|
+
if (key.leftArrow) {
|
|
120
|
+
if (key.ctrl || key.meta)
|
|
121
|
+
buf.moveWordLeft();
|
|
122
|
+
else
|
|
123
|
+
buf.moveLeft();
|
|
124
|
+
forceRefresh();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (key.rightArrow) {
|
|
128
|
+
if (key.ctrl || key.meta)
|
|
129
|
+
buf.moveWordRight();
|
|
130
|
+
else
|
|
131
|
+
buf.moveRight();
|
|
132
|
+
forceRefresh();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (key.upArrow) {
|
|
136
|
+
buf.moveUp();
|
|
137
|
+
forceRefresh();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (key.downArrow) {
|
|
141
|
+
buf.moveDown();
|
|
142
|
+
forceRefresh();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// ---- ✅ Backspace(无防抖,保证每次按键都生效)----
|
|
146
|
+
if (key.backspace) {
|
|
147
|
+
buf.deleteBefore();
|
|
148
|
+
forceRefresh();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// ---- ✅ Delete(仅此键有防抖,解决 Windows 双事件)----
|
|
152
|
+
if (key.delete) {
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
if (now - lastDeleteTime.current < DELETE_DEBOUNCE_MS) {
|
|
155
|
+
return; // 忽略过快的重复 Delete(Windows 特有问题)
|
|
156
|
+
}
|
|
157
|
+
lastDeleteTime.current = now;
|
|
158
|
+
// Windows 下 key.delete 通常对应 Backspace,保持与原始行为一致
|
|
159
|
+
buf.deleteBefore();
|
|
160
|
+
forceRefresh();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// ---- Emacs 控制键(无防抖)----
|
|
164
|
+
if (key.ctrl && !key.meta) {
|
|
165
|
+
switch (input) {
|
|
166
|
+
case 'a':
|
|
167
|
+
buf.moveLineStart();
|
|
168
|
+
break;
|
|
169
|
+
case 'e':
|
|
170
|
+
buf.moveLineEnd();
|
|
171
|
+
break;
|
|
172
|
+
case 'b':
|
|
173
|
+
buf.moveLeft();
|
|
174
|
+
break;
|
|
175
|
+
case 'f':
|
|
176
|
+
buf.moveRight();
|
|
177
|
+
break;
|
|
178
|
+
case 'u':
|
|
179
|
+
buf.killToLineStart();
|
|
180
|
+
break;
|
|
181
|
+
case 'k':
|
|
182
|
+
buf.killToLineEnd();
|
|
183
|
+
break;
|
|
184
|
+
case 'w':
|
|
185
|
+
buf.deleteWordBefore();
|
|
186
|
+
break;
|
|
187
|
+
case 'h':
|
|
188
|
+
buf.deleteBefore();
|
|
189
|
+
break; // Ctrl+H = Backspace
|
|
190
|
+
default: return; // 忽略其他控制键
|
|
191
|
+
}
|
|
192
|
+
forceRefresh();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// ---- Alt 快捷键 ----
|
|
196
|
+
if (key.meta && input === '<') {
|
|
197
|
+
buf.moveBufferStart();
|
|
198
|
+
forceRefresh();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (key.meta && input === '>') {
|
|
202
|
+
buf.moveBufferEnd();
|
|
203
|
+
forceRefresh();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// ---- ✅ 字符输入(已排除粘贴,已排除裸 Enter)----
|
|
207
|
+
// Alt+Enter 在多数终端发 \x1b\r:Ink 把 \x1b 剥掉后 input='\r'、key.return=false、
|
|
208
|
+
// key.meta=false,从这里落地。把 \r/\r\n 统一成 \n,textBuffer 才能真切分逻辑行
|
|
209
|
+
// (否则 layout 永远 1 行 → ↑/↓ 失效、输入框不长高,光标只能左右)。
|
|
210
|
+
if (input && !key.meta) {
|
|
211
|
+
const normalized = input.replace(/\r\n?/g, '\n');
|
|
212
|
+
buf.insert(normalized);
|
|
213
|
+
forceRefresh();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
// ✅ 传递 refreshCounter 作为 prop(不放入 key)
|
|
217
|
+
return (_jsx(BufferView, { buf: buf, disabled: disabled, refreshCounter: refreshCounter }));
|
|
218
|
+
}
|
|
219
|
+
function BufferView({ buf, disabled, refreshCounter }) {
|
|
220
|
+
// ✅ 关键:用 useMemo 依赖 refreshCounter,而非改变 key
|
|
221
|
+
// 这样不会重建组件,只是强制重新计算 layout
|
|
222
|
+
const { lines, cursorRow, cursorCol } = buf.layout;
|
|
223
|
+
const promptColor = disabled ? 'gray' : 'cyan';
|
|
224
|
+
const prompt = disabled ? '⏳ ' : '> ';
|
|
225
|
+
return (_jsx(Box, { borderStyle: "round", borderColor: promptColor, paddingX: 1, flexDirection: "column", children: lines.map((line, row) => {
|
|
226
|
+
const isFirst = row === 0;
|
|
227
|
+
const isCursorRow = row === cursorRow && !disabled;
|
|
228
|
+
// ✅ key 保持稳定(只用 row),避免组件重建
|
|
229
|
+
// refreshCounter 通过 useMemo 依赖间接触发更新
|
|
230
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: promptColor, bold: true, children: isFirst ? prompt : ' ' }), isCursorRow ? _jsx(CursorLine, { line: line, col: cursorCol }) : _jsx(Text, { children: line || ' ' })] }, row));
|
|
231
|
+
}) }));
|
|
232
|
+
}
|
|
233
|
+
/** 光标行渲染(稳定,不会被重建) */
|
|
234
|
+
function CursorLine({ line, col }) {
|
|
235
|
+
const cps = Array.from(line);
|
|
236
|
+
const before = cps.slice(0, col).join('');
|
|
237
|
+
const cursorChar = cps[col] ?? ' ';
|
|
238
|
+
const after = cps.slice(col + 1).join('');
|
|
239
|
+
return (_jsxs(Text, { children: [before, _jsx(Text, { inverse: true, children: cursorChar }), after] }));
|
|
240
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
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))), streamingText.length > 0 && (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { children: streamingText }), _jsx(Text, { color: "gray", children: " \u258D" })] }))] }));
|
|
6
|
+
}
|
|
7
|
+
function MessageRow({ message }) {
|
|
8
|
+
switch (message.role) {
|
|
9
|
+
case 'system':
|
|
10
|
+
return null; // 不展示系统提示词
|
|
11
|
+
case 'user':
|
|
12
|
+
return (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: '> ' }), _jsx(Text, { children: message.content })] }));
|
|
13
|
+
case 'assistant': {
|
|
14
|
+
// assistant 可能只有 tool_calls 没有 content(content 为 null)
|
|
15
|
+
const text = message.content ?? '';
|
|
16
|
+
const tcCount = message.tool_calls?.length ?? 0;
|
|
17
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [text.length > 0 && _jsx(Text, { children: text }), tcCount > 0 && (_jsx(Text, { color: "yellow", dimColor: true, children: ` └─ 调用了 ${tcCount} 个工具:${(message.tool_calls ?? [])
|
|
18
|
+
.map((tc) => tc.function.name)
|
|
19
|
+
.join(', ')}` }))] }));
|
|
20
|
+
}
|
|
21
|
+
case 'tool': {
|
|
22
|
+
const lines = message.content.split('\n');
|
|
23
|
+
const preview = lines.slice(0, MAX_TOOL_PREVIEW_LINES).join('\n');
|
|
24
|
+
const hidden = Math.max(0, lines.length - MAX_TOOL_PREVIEW_LINES);
|
|
25
|
+
return (_jsx(Box, { marginBottom: 1, paddingLeft: 2, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "gray", dimColor: true, children: "[tool result]" }), _jsx(Text, { color: "gray", children: preview }), hidden > 0 && (_jsx(Text, { color: "gray", dimColor: true, children: `... (省略 ${hidden} 行)` }))] }) }));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|