minimal-agent 0.6.7 → 0.6.8
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 +3 -3
- package/package.json +1 -1
- package/src/askBridge.js +61 -0
- package/src/cli/streamJson.js +118 -16
- package/src/prompts/system.js +6 -2
- package/src/tools/ask/ask.js +89 -0
- package/src/tools/index.js +3 -1
- package/src/ui/App.js +16 -1
- package/src/ui/AskQuestion.js +177 -0
- package/src/ui/hooks/useChat.js +41 -0
- package/workflows/plan.yaml +90 -0
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# minimal-agent
|
|
2
2
|
|
|
3
3
|
> 一个**最小化但功能完整**的 AI 编程助手 —— Bun + React + TypeScript + Ink TUI 构建,
|
|
4
|
-
> OpenAI 兼容、多 provider,
|
|
4
|
+
> OpenAI 兼容、多 provider,11 个内置工具 + Skills + 插件 + Workflow DSL + 自动上下文压缩。
|
|
5
5
|
> 为**学习与教学**而生:代码量克制、注释充分,把一个 agent「从输入到工具循环到落盘」讲透。
|
|
6
6
|
|
|
7
7
|
`v0.5.1` · Runtime: **Bun**(主)/ Node ≥ 20 · License: **PolyForm Noncommercial 1.0.0**
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
## 特性
|
|
24
24
|
|
|
25
|
-
- **
|
|
25
|
+
- **11 个内置工具**:Read / Write / Edit / MultiEdit / Glob / Grep / WebSearch / WebFetch / WebBrowser / Bash / AskUserQuestion(向用户提结构化选择题,动态工作流交互底座)
|
|
26
26
|
- **OpenAI 兼容**:Message 形状对齐 Chat Completions,一套代码接 MiniMax / 火山 ARK / DeepSeek / OpenAI / Moonshot…
|
|
27
27
|
- **Skills 系统**:放一个 `SKILL.md` 就能扩展能力,运行时实时扫描注入 prompt,改文件即生效
|
|
28
28
|
- **插件系统**:`plugins/<id>/` drop-in,自带 `/ralph-loop`(复制任务循环)与 `/workflow`(YAML DAG 执行器)
|
|
@@ -200,7 +200,7 @@ dev 下 Bun 透明回退到 `.ts` 兄弟,发布前 `tsc --build` 原地产出
|
|
|
200
200
|
| `src/loop.ts` | `runQuery()` 主循环(AsyncGenerator)—— 系统的「心脏」 |
|
|
201
201
|
| `src/llm/client.ts` | OpenAI 兼容流式客户端(text / tool_call / reasoning 三路) |
|
|
202
202
|
| `src/types.ts` | 对齐 Chat Completions 的 Message / ToolCall / 事件类型 |
|
|
203
|
-
| `src/tools/` |
|
|
203
|
+
| `src/tools/` | 11 个工具 + `executeTool()` 参数防线(JSON.parse → Zod → call) |
|
|
204
204
|
| `src/context/compact.ts` | 自动上下文压缩 |
|
|
205
205
|
| `src/prompts/` | system prompt 组装 + Skills 实时扫描注入 |
|
|
206
206
|
| `src/plugins/` | 插件框架(discovery / 命令路由 / hook 引擎 / transcript) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minimal-agent",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
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/src/askBridge.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/askBridge.ts —— 「向人提问」的宿主交互桥(host interaction channel)
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 动态工作流的地基:让一个**工具**(AskUserQuestion)能在执行中途停下来问用户,
|
|
6
|
+
* 拿到选择再继续。对齐上游 Claude Code 的 AskUserQuestion —— 交互不是给工作流引擎
|
|
7
|
+
* 特制的「双向生成器通道」,而是**骑在既有 tool_call → tool_result 回路上**:
|
|
8
|
+
* 「等用户」只不过是这个工具返回得慢而已。所以同一座桥同时服务于:
|
|
9
|
+
* - 主循环 TAOR(模型自己决定何时 AskUserQuestion)
|
|
10
|
+
* - workflow 的 `tool: AskUserQuestion` 步骤
|
|
11
|
+
* - agent: 节点 miniReAct 把 AskUserQuestion 纳入工具白名单
|
|
12
|
+
* → 这就是「交互 + TAOR = 动态工作流」。
|
|
13
|
+
*
|
|
14
|
+
* 为什么放在 src 根(而非 ui/ 或 tools/):它是工具(产出问题)与宿主/UI(回答)
|
|
15
|
+
* 之间的中介,两边都要 import。放在中立位置,避免 tools → ui 的反向层级耦合。
|
|
16
|
+
*
|
|
17
|
+
* 两种宿主:
|
|
18
|
+
* - TUI:useChat mount 时 setAskHandler() 注册一个会弹出选择界面的处理器。
|
|
19
|
+
* - 非交互(`-p` / gateway / webchat 子进程):没有 handler →
|
|
20
|
+
* requestUserChoice() 自动选**推荐项(每题首项)**并在 stderr 注记,
|
|
21
|
+
* 绝不挂起(保 `-p` 单行 JSON 契约 + 子进程不卡死)。
|
|
22
|
+
* ============================================================
|
|
23
|
+
*/
|
|
24
|
+
let handler = null;
|
|
25
|
+
/**
|
|
26
|
+
* 注册 / 注销 UI 处理器。
|
|
27
|
+
* TUI 的 useChat 在 mount 时注册、unmount 时传 null 注销。
|
|
28
|
+
* 单进程单 UI,模块级单例即可;HTTP 多租户场景以后再按 session 隔离(留 TODO)。
|
|
29
|
+
*/
|
|
30
|
+
export function setAskHandler(h) {
|
|
31
|
+
handler = h;
|
|
32
|
+
}
|
|
33
|
+
/** 是否有交互 UI 在场(测试 / 诊断用)。 */
|
|
34
|
+
export function hasAskHandler() {
|
|
35
|
+
return handler !== null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 向用户提问,返回答案。
|
|
39
|
+
*
|
|
40
|
+
* - 有 handler(TUI)→ 交给它弹界面、等用户。
|
|
41
|
+
* - 无 handler(非交互宿主)→ 自动选每题首项(约定=推荐),stderr 注记,不挂起。
|
|
42
|
+
*/
|
|
43
|
+
export async function requestUserChoice(questions, signal) {
|
|
44
|
+
if (handler)
|
|
45
|
+
return handler(questions, signal);
|
|
46
|
+
// 非交互兜底
|
|
47
|
+
return questions.map((q) => {
|
|
48
|
+
const pick = q.options[0]?.label ?? '';
|
|
49
|
+
try {
|
|
50
|
+
process.stderr.write(`[AskUserQuestion·非交互] "${q.header}" 无 UI,自动选推荐项:"${pick}"\n`);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* stderr 不可用也不阻断 */
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
header: q.header,
|
|
57
|
+
question: q.question,
|
|
58
|
+
selected: pick ? [pick] : [],
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
package/src/cli/streamJson.js
CHANGED
|
@@ -10,17 +10,22 @@
|
|
|
10
10
|
* 逐行转发成 WebSocket event 帧。也是给网关预留的"流式子进程"契约。
|
|
11
11
|
*
|
|
12
12
|
* 约定:
|
|
13
|
-
* - stdout 只允许 jsonl 事件流(一行一个 JSON.stringify(LoopEvent)
|
|
14
|
-
*
|
|
15
|
-
* -
|
|
13
|
+
* - stdout 只允许 jsonl 事件流(一行一个 JSON.stringify(LoopEvent)),含「问答 over wire」
|
|
14
|
+
* 的出站事件 `{type:'ask',requestId,questions}`(AskUserQuestion 在远程通道弹问题)。
|
|
15
|
+
* - stdin 是**逐行 JSON 控制通道**(见 attachStdinChannel):首行 `{type:'prompt',text}` 为本轮输入,
|
|
16
|
+
* 后续 `{type:'ask_answer',requestId,answers}` 回灌用户选择。兼容:首行非控制消息 → 整段当裸 prompt。
|
|
17
|
+
* argv 也可给 prompt(此时 stdin 全部行按 ask_answer 解析)。--compact 走压缩动作(无需 prompt)。
|
|
18
|
+
* - 跑完 / 中断 / 出错都先 saveContext 再退出(finalize 还会 unref stdin,让本轮结束后进程能退出)。
|
|
16
19
|
* ============================================================
|
|
17
20
|
*/
|
|
21
|
+
import crypto from 'node:crypto';
|
|
18
22
|
import { getWorkingDir } from '../bootstrap/workingDir.js';
|
|
19
23
|
import { forceCompact } from '../context/compact.js';
|
|
20
24
|
import { recordRecentDir } from '../context/recentDirs.js';
|
|
21
25
|
import { saveContext } from '../context/persistContext.js';
|
|
26
|
+
import { setAskHandler } from '../askBridge.js';
|
|
22
27
|
import { runWithPlugins } from '../plugins/pluginRunner.js';
|
|
23
|
-
import { extractPromptArgs
|
|
28
|
+
import { extractPromptArgs } from './print.js';
|
|
24
29
|
/** 一行一个 JSON 事件写 stdout。EPIPE 时静默。 */
|
|
25
30
|
function emitLine(ev) {
|
|
26
31
|
try {
|
|
@@ -34,6 +39,79 @@ function firstUserTitle(history) {
|
|
|
34
39
|
const u = history.find((m) => m.role === 'user');
|
|
35
40
|
return u && typeof u.content === 'string' ? u.content : undefined;
|
|
36
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* stdin 控制通道:把 stdin 从「一次性裸 prompt」升级为**逐行 JSON 控制通道**,
|
|
44
|
+
* 支撑「问答 over wire」的入站半边。两种控制消息:
|
|
45
|
+
* - `{ type:'prompt', text }` —— 首行,本轮的用户输入(替代裸 prompt)
|
|
46
|
+
* - `{ type:'ask_answer', requestId, answers }` —— AskUserQuestion 的回答,按 requestId 关联
|
|
47
|
+
*
|
|
48
|
+
* 向后兼容:首个非空行**不是** `{type:'prompt'}` 控制消息时,判定为 legacy 模式 ——
|
|
49
|
+
* 整段 stdin 当裸 prompt 读到 EOF(保 `echo "x" | minimal-agent -p --output-format stream-json` 仍可用,
|
|
50
|
+
* legacy 下天然无交互、一次性)。argv 已给 prompt 时,stdin 全部行都按 ask_answer 解析。
|
|
51
|
+
*
|
|
52
|
+
* 返回 prompt 的 Promise;同时把 ask_answer 路由给 onAskAnswer。stdin 全程保持打开,
|
|
53
|
+
* 直到子进程结束(broker 在 turn 末 end stdin)。
|
|
54
|
+
*/
|
|
55
|
+
export function attachStdinChannel(opts) {
|
|
56
|
+
const input = opts.input ?? process.stdin;
|
|
57
|
+
return new Promise((resolvePrompt) => {
|
|
58
|
+
let promptResolved = false;
|
|
59
|
+
const resolveOnce = (text) => {
|
|
60
|
+
if (promptResolved)
|
|
61
|
+
return;
|
|
62
|
+
promptResolved = true;
|
|
63
|
+
resolvePrompt(text);
|
|
64
|
+
};
|
|
65
|
+
if (opts.argvPrompt !== undefined)
|
|
66
|
+
resolveOnce(opts.argvPrompt);
|
|
67
|
+
// 无 stdin 管道(TTY):argv 没给 prompt 也只能返回空(调用方据空报错)。
|
|
68
|
+
if (input.isTTY) {
|
|
69
|
+
resolveOnce('');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let buf = '';
|
|
73
|
+
let legacy = false;
|
|
74
|
+
input.setEncoding('utf8');
|
|
75
|
+
input.on('data', (chunk) => {
|
|
76
|
+
buf += chunk;
|
|
77
|
+
if (legacy)
|
|
78
|
+
return; // legacy:整段累积到 EOF
|
|
79
|
+
let nl;
|
|
80
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
81
|
+
const line = buf.slice(0, nl);
|
|
82
|
+
buf = buf.slice(nl + 1);
|
|
83
|
+
if (!line.trim())
|
|
84
|
+
continue;
|
|
85
|
+
let msg = null;
|
|
86
|
+
try {
|
|
87
|
+
msg = JSON.parse(line);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
msg = null;
|
|
91
|
+
}
|
|
92
|
+
if (!promptResolved) {
|
|
93
|
+
if (msg && msg.type === 'prompt' && typeof msg.text === 'string') {
|
|
94
|
+
resolveOnce(msg.text);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
// 首行不是 prompt 控制消息 → legacy:把它放回去,整段当裸 prompt 读到 EOF
|
|
98
|
+
legacy = true;
|
|
99
|
+
buf = buf.length ? `${line}\n${buf}` : line;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else if (msg && msg.type === 'ask_answer' && typeof msg.requestId === 'string') {
|
|
104
|
+
opts.onAskAnswer(msg.requestId, Array.isArray(msg.answers) ? msg.answers : []);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
input.on('end', () => {
|
|
109
|
+
// legacy 一次性:EOF 时整段 stdin = 裸 prompt
|
|
110
|
+
if (!promptResolved)
|
|
111
|
+
resolveOnce(buf.trim());
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
37
115
|
export async function runStreamJsonMode(provider, args, initialHistory, options) {
|
|
38
116
|
process.stdout.on('error', (err) => {
|
|
39
117
|
if (err.code === 'EPIPE')
|
|
@@ -45,6 +123,16 @@ export async function runStreamJsonMode(provider, args, initialHistory, options)
|
|
|
45
123
|
if (finalized)
|
|
46
124
|
return;
|
|
47
125
|
finalized = true;
|
|
126
|
+
// 释放 stdin:attachStdinChannel 的 'data' 监听会让进程在本轮结束后**仍存活**
|
|
127
|
+
// (等下一行输入)→ broker 永远收不到子进程 close、web 端 conn.running 卡死。
|
|
128
|
+
// turn 已结束(无挂起提问),unref 让 stdout 排空后进程自然退出;中途挂起期间
|
|
129
|
+
// 不会走到这里,故等答案时进程仍被 stdin 监听保活。
|
|
130
|
+
try {
|
|
131
|
+
process.stdin.unref();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
/* 某些 stdin 类型无 unref,忽略 */
|
|
135
|
+
}
|
|
48
136
|
await saveContext(history);
|
|
49
137
|
};
|
|
50
138
|
// --- 压缩动作(/compact):不需要 prompt ---
|
|
@@ -64,20 +152,33 @@ export async function runStreamJsonMode(provider, args, initialHistory, options)
|
|
|
64
152
|
return;
|
|
65
153
|
}
|
|
66
154
|
// --- 普通一轮 ---
|
|
155
|
+
// 「问答 over wire」入站半边:pendingAsks 持本轮未决提问(requestId → resolver)。
|
|
156
|
+
const pendingAsks = new Map();
|
|
157
|
+
const rejectAllAsks = (reason) => {
|
|
158
|
+
for (const p of pendingAsks.values())
|
|
159
|
+
p.reject(new Error(reason));
|
|
160
|
+
pendingAsks.clear();
|
|
161
|
+
};
|
|
162
|
+
// 注册 askBridge handler:工具调 requestUserChoice → emit `ask` 到 stdout、挂起等 stdin 回灌的 ask_answer。
|
|
163
|
+
// 同一时刻可有多个未决问题(按 requestId 区分),但工具 isConcurrencySafe:false → 实际串行。
|
|
164
|
+
setAskHandler((questions) => new Promise((resolve, reject) => {
|
|
165
|
+
const requestId = crypto.randomUUID();
|
|
166
|
+
pendingAsks.set(requestId, { resolve, reject });
|
|
167
|
+
emitLine({ type: 'ask', requestId, questions });
|
|
168
|
+
}));
|
|
67
169
|
const promptArgs = extractPromptArgs(args);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
170
|
+
const prompt = await attachStdinChannel({
|
|
171
|
+
argvPrompt: promptArgs.length > 0 ? promptArgs.join(' ') : undefined,
|
|
172
|
+
onAskAnswer: (requestId, answers) => {
|
|
173
|
+
const p = pendingAsks.get(requestId);
|
|
174
|
+
if (p) {
|
|
175
|
+
pendingAsks.delete(requestId);
|
|
176
|
+
p.resolve(answers);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
});
|
|
79
180
|
if (!prompt.trim()) {
|
|
80
|
-
emitLine({ type: 'error', error: 'prompt
|
|
181
|
+
emitLine({ type: 'error', error: '未提供 prompt(argv / stdin 都为空)', code: 'llm_error' });
|
|
81
182
|
process.exit(1);
|
|
82
183
|
}
|
|
83
184
|
// 记录最近目录(侧栏用);title 优先用历史里第一条用户消息(稳定),否则用当前 prompt
|
|
@@ -89,6 +190,7 @@ export async function runStreamJsonMode(provider, args, initialHistory, options)
|
|
|
89
190
|
process.exit(130);
|
|
90
191
|
interrupted = true;
|
|
91
192
|
abortController.abort();
|
|
193
|
+
rejectAllAsks('已中断'); // 挂起的提问立即落败,工具回 ok:false → loop 收口 interrupted
|
|
92
194
|
emitLine({ type: 'interrupted' });
|
|
93
195
|
void finalize().finally(() => process.exit(130));
|
|
94
196
|
});
|
package/src/prompts/system.js
CHANGED
|
@@ -84,6 +84,9 @@ ${toolList}
|
|
|
84
84
|
仅在 WebFetch 明确无法满足(如需要 JS 渲染后的内容、点击按钮、填表单、截图)时再尝试。
|
|
85
85
|
如果 WebBrowser 报"无法启动浏览器/缺少依赖",不要重试,改回 WebFetch 或告知用户安装依赖。
|
|
86
86
|
- 一次回答里可以并行调用多个只读工具(Read/Glob/Grep/WebSearch/WebFetch),写工具(Edit/Write/Bash)请逐个执行。
|
|
87
|
+
- 需要**用户拍板**时用 AskUserQuestion 提结构化选择题(最多 4 题、每题 2-4 选项;第一个选项放你的推荐并在 label 末尾标「(推荐)」):
|
|
88
|
+
① 需求/方案有取舍、没有绝对对错(多个合理方案各有利弊)→ 让用户选;② 关键信息缺失或模糊、继续做风险大 → 让用户补充。
|
|
89
|
+
能自己从代码/上下文/常识判断出答案的别问(免打断);界面会自动附「其他(自由输入)」逃生项,无需你手动加。
|
|
87
90
|
|
|
88
91
|
# 技能系统(积极使用)
|
|
89
92
|
${skillHint}
|
|
@@ -126,7 +129,7 @@ ${skillHint}
|
|
|
126
129
|
|
|
127
130
|
# 行为约束
|
|
128
131
|
- 没有用户许可不要执行破坏性操作(rm -rf / git reset --hard / git push --force 等)。
|
|
129
|
-
-
|
|
132
|
+
- 如果用户的请求不清晰、或有多个合理方向需要取舍:优先用 AskUserQuestion 让用户拍板(带推荐项),而不是替他猜着干;只缺一点小信息时一句话澄清即可。
|
|
130
133
|
- 任务完成后用一两句话总结改了什么,不要长篇大论。
|
|
131
134
|
|
|
132
135
|
# 修改 vs 讨论(动手边界,重要)
|
|
@@ -168,7 +171,8 @@ export async function buildFullSystemPrompt(cwd, tools) {
|
|
|
168
171
|
'- 以下内容来源于项目根目录的 `minimal-agent.md` 文件,具有时效性,可能已过时。',
|
|
169
172
|
'- 它是参考线索,不是绝对真理。当它与实际代码或文件内容冲突时,以代码为准。',
|
|
170
173
|
'- 涉及代码和项目文件的任何操作前,必须先用 Read / Grep 工具确认当前代码的实际状态,再基于最新信息做决策,不要仅凭此文档的描述操作。',
|
|
171
|
-
'-
|
|
174
|
+
'- 同步规则:仅当你在**本轮任务中实际用 Write/Edit/MultiEdit 改动了代码或项目结构**、且这些改动使 `minimal-agent.md` 某处描述过时时,才在本轮结束的总结里用一句话**指出过时的具体位置**(哪一节 / 哪条),并询问用户是否要同步、或建议运行 `/init` 重建。',
|
|
175
|
+
'- 除非用户已明确授权,**不要擅自 Write 改写 `minimal-agent.md`**(它通常已纳入 git,盲目重写会污染 diff;任务进行中代码尚未定型,过早同步会把暂态写进文档、反而加重误导)。纯讨论 / 只读 / 搜索类的轮次不做任何同步动作。',
|
|
172
176
|
'',
|
|
173
177
|
projectInstructions,
|
|
174
178
|
].join('\n');
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/tools/ask/ask.ts —— AskUserQuestion 工具(向用户提结构化选择题)
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 对齐上游 Claude Code 的 AskUserQuestion:让模型在「需要用户拍板」时停下来问,
|
|
6
|
+
* 而不是替用户瞎猜。它本身只是一个普通工具 —— call() 调 askBridge 的
|
|
7
|
+
* requestUserChoice(),TUI 弹选择界面、用户选完把答案当 tool_result 喂回模型。
|
|
8
|
+
* 「等用户」= 这个工具返回得慢;非交互宿主(-p/gateway)自动选推荐项(见 askBridge)。
|
|
9
|
+
*
|
|
10
|
+
* 这一个工具同时让主循环 / workflow(tool: AskUserQuestion)/ agent: 节点
|
|
11
|
+
* 都获得「问用户」的能力 —— 动态工作流的交互底座。
|
|
12
|
+
* ============================================================
|
|
13
|
+
*/
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import { requestUserChoice } from '../../askBridge.js';
|
|
16
|
+
import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
|
|
17
|
+
import { toToolParameters } from '../../utils/zodToJson.js';
|
|
18
|
+
// ---------------- 1. Zod 输入 schema ----------------
|
|
19
|
+
const optionSchema = z.object({
|
|
20
|
+
label: z
|
|
21
|
+
.string()
|
|
22
|
+
.min(1)
|
|
23
|
+
.describe('选项显示文本(简洁);把推荐项放第一个,并在其 label 末尾标「(推荐)」'),
|
|
24
|
+
description: z
|
|
25
|
+
.string()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe('该选项的说明 / 取舍,帮用户理解后果'),
|
|
28
|
+
});
|
|
29
|
+
const questionSchema = z.object({
|
|
30
|
+
question: z.string().min(1).describe('要问用户的完整问题'),
|
|
31
|
+
header: z
|
|
32
|
+
.string()
|
|
33
|
+
.min(1)
|
|
34
|
+
.describe('问题的短标签(≤12 字),如「交互底座」「鉴权方式」'),
|
|
35
|
+
multiSelect: z
|
|
36
|
+
.boolean()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe('是否允许多选;默认 false(单选)'),
|
|
39
|
+
options: z
|
|
40
|
+
.array(optionSchema)
|
|
41
|
+
.min(2)
|
|
42
|
+
.describe('2-4 个互斥选项;第一个应为你的推荐项'),
|
|
43
|
+
});
|
|
44
|
+
const inputSchema = z.object({
|
|
45
|
+
questions: z
|
|
46
|
+
.array(questionSchema)
|
|
47
|
+
.min(1)
|
|
48
|
+
.describe('1-4 个问题(通常 1 个;确需一次问多件事时才多个)'),
|
|
49
|
+
});
|
|
50
|
+
// ---------------- 2. JSON Schema(由 Zod 自动派生) ----------------
|
|
51
|
+
const parameters = toToolParameters(inputSchema);
|
|
52
|
+
// ---------------- 3. Description(指导模型何时/如何用) ----------------
|
|
53
|
+
const description = `向用户提出结构化的选择题(最多 4 题,每题 2-4 个选项),用于需要用户做决定的场合。
|
|
54
|
+
使用时机:
|
|
55
|
+
- 需求 / 方案有取舍、没有绝对对错(多个合理方案各有利弊)→ 让用户拍板;
|
|
56
|
+
- 关键信息缺失或模糊、继续做风险大 → 让用户补充。
|
|
57
|
+
不要用于:你自己能从代码 / 上下文 / 常识判断出答案的问题(那样只会打断用户)。
|
|
58
|
+
约定:每题第一个选项放你的推荐,并在其 label 末尾标「(推荐)」。
|
|
59
|
+
界面会自动附带「其他(自由输入)」逃生项,**无需**你手动加。
|
|
60
|
+
返回值是用户的选择文本,据此继续。`;
|
|
61
|
+
// ---------------- 4. call() ----------------
|
|
62
|
+
async function call(input, signal) {
|
|
63
|
+
try {
|
|
64
|
+
const answers = await requestUserChoice(input.questions, signal);
|
|
65
|
+
const lines = ['用户已回答 AskUserQuestion:', ''];
|
|
66
|
+
answers.forEach((a, i) => {
|
|
67
|
+
lines.push(`Q${i + 1} [${a.header}] ${a.question}`);
|
|
68
|
+
lines.push(`A${i + 1}: ${a.selected.join('、') || '(未选)'}`);
|
|
69
|
+
lines.push('');
|
|
70
|
+
});
|
|
71
|
+
return { ok: true, content: lines.join('\n').trimEnd() };
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
// 用户 ESC / Ctrl+C 取消提问 → 当普通工具失败喂回(主循环让模型换思路;
|
|
75
|
+
// workflow tool 步骤则按 onError 处理)。
|
|
76
|
+
return { ok: false, error: `提问被取消 / 中断:${e.message}` };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// ---------------- 5. 导出 ----------------
|
|
80
|
+
export const askUserQuestionTool = {
|
|
81
|
+
name: 'AskUserQuestion',
|
|
82
|
+
description,
|
|
83
|
+
inputSchema,
|
|
84
|
+
parameters,
|
|
85
|
+
isReadOnly: true,
|
|
86
|
+
isConcurrencySafe: false,
|
|
87
|
+
maxResultSizeChars: DEFAULT_MAX_RESULT_SIZE_CHARS,
|
|
88
|
+
call,
|
|
89
|
+
};
|
package/src/tools/index.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* 这层是"防线":模型给的参数永远不可信,必须 Zod 校验后才进 call()。
|
|
13
13
|
* ============================================================
|
|
14
14
|
*/
|
|
15
|
+
import { askUserQuestionTool } from './ask/ask.js';
|
|
15
16
|
import { bashTool } from './bash/bash.js';
|
|
16
17
|
import { editTool } from './edit/edit.js';
|
|
17
18
|
import { multiEditTool } from './edit/multi-edit.js';
|
|
@@ -34,6 +35,7 @@ export const ALL_TOOLS = [
|
|
|
34
35
|
webfetchTool,
|
|
35
36
|
webbrowserTool,
|
|
36
37
|
bashTool,
|
|
38
|
+
askUserQuestionTool,
|
|
37
39
|
];
|
|
38
40
|
/** 按名字查表 */
|
|
39
41
|
export function getToolByName(name) {
|
|
@@ -90,5 +92,5 @@ export async function executeTool(name, rawArguments, signal) {
|
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
// 导出每个工具,方便测试单独 import
|
|
93
|
-
export { readTool, editTool, writeTool, globTool, grepTool, webSearchTool, webfetchTool, webbrowserTool, bashTool };
|
|
95
|
+
export { readTool, editTool, writeTool, globTool, grepTool, webSearchTool, webfetchTool, webbrowserTool, bashTool, askUserQuestionTool };
|
|
94
96
|
export { multiEditTool } from './edit/multi-edit.js';
|
package/src/ui/App.js
CHANGED
|
@@ -14,6 +14,7 @@ import { listArchives, restoreArchive } from '../context/archive.js';
|
|
|
14
14
|
import { saveContext } from '../context/persistContext.js';
|
|
15
15
|
import { sessionFileFor } from '../context/sessionPath.js';
|
|
16
16
|
import { listSessions } from '../context/sessionRegistry.js';
|
|
17
|
+
import { AskQuestion } from './AskQuestion.js';
|
|
17
18
|
import { InputBox } from './InputBox.js';
|
|
18
19
|
import { LiveArea } from './LiveArea.js';
|
|
19
20
|
import { MessageList } from './MessageList.js';
|
|
@@ -160,6 +161,18 @@ export function App({ provider, initialHistory }) {
|
|
|
160
161
|
chat.ephemeralLine(`[tool result · #${hit.tool_call_id.slice(-8)} · 完整 ${hit.content.split('\n').length} 行]\n${hit.content}`);
|
|
161
162
|
return;
|
|
162
163
|
}
|
|
164
|
+
// /plan <目标> —— 便捷别名,等价 /workflow plan(跑 plan 工作流:
|
|
165
|
+
// 调研 → AskUserQuestion 选择 → 产出 ./PLAN.md)。改写后透传给 pluginRunner。
|
|
166
|
+
// 目标里的引号剥掉,避免破坏 workflow 参数 tokenizer("..." 整体当一个位置参数)。
|
|
167
|
+
if (text.startsWith('/plan ') || text.trim() === '/plan') {
|
|
168
|
+
const goal = text.slice('/plan'.length).trim().replace(/"/g, '');
|
|
169
|
+
if (goal.length === 0) {
|
|
170
|
+
chat.ephemeralLine('[/plan] 用法:/plan <目标>(= /workflow plan:调研→提问选择→产出 ./PLAN.md)');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
void chat.submit(`/workflow plan "${goal}"`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
163
176
|
// 非命令 → 走正常 LLM 流程
|
|
164
177
|
void chat.submit(text);
|
|
165
178
|
},
|
|
@@ -177,5 +190,7 @@ export function App({ provider, initialHistory }) {
|
|
|
177
190
|
// 它必须是兄弟节点中第一个,否则 above-sibling 会跟 Static commit 一起被重打
|
|
178
191
|
// 到 stdout(实测看到 banner 反复打印 N 次)。
|
|
179
192
|
const bannerText = `minimal-agent · ${provider.name}/${provider.model} · /new 清空 · /compact 压缩 · /exit 退出 · Ctrl+C 中断`;
|
|
180
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(MessageList, { history: deferredHistory, compactGeneration: chat.compactGeneration, bannerText: bannerText, terminalRows: terminalRows }), _jsxs(Box, { flexDirection: "column", width: Math.max(1, terminalCols - 1), children: [_jsx(LiveArea, { progressStage: chat.progressStage, reasoningPreview: chat.reasoningPreview, streamingText: chat.streamingText, runningTools: chat.runningTools, pluginProgress: chat.pluginProgress, terminalCols: terminalCols, terminalRows: terminalRows }), 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..." }) })),
|
|
193
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(MessageList, { history: deferredHistory, compactGeneration: chat.compactGeneration, bannerText: bannerText, terminalRows: terminalRows }), _jsxs(Box, { flexDirection: "column", width: Math.max(1, terminalCols - 1), children: [_jsx(LiveArea, { progressStage: chat.progressStage, reasoningPreview: chat.reasoningPreview, streamingText: chat.streamingText, runningTools: chat.runningTools, pluginProgress: chat.pluginProgress, terminalCols: terminalCols, terminalRows: terminalRows }), 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..." }) })), chat.pendingAsk ? (_jsx(AskQuestion, { questions: chat.pendingAsk, onAnswer: chat.answerAsk, onCancel: chat.abort })) : (
|
|
194
|
+
/* handleSubmit 包装做 slash 命令拦截,非命令透传 chat.submit */
|
|
195
|
+
_jsx(InputBox, { onSubmit: handleSubmit, disabled: chat.isLoading, onAbort: chat.abort, onClear: handleClear, onCompact: chat.compactNow })), _jsx(StatusLine, { provider: provider, history: chat.history, pluginProgress: chat.pluginProgress })] })] }));
|
|
181
196
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* ============================================================
|
|
4
|
+
* src/ui/AskQuestion.tsx —— AskUserQuestion 工具的 TUI 问答界面
|
|
5
|
+
* ------------------------------------------------------------
|
|
6
|
+
* 这是 AskUserQuestion 工具向用户提问时弹出的选择题界面。由 askBridge 的
|
|
7
|
+
* AskHandler(在 useChat mount 时通过 setAskHandler 注册)触发:handler 把
|
|
8
|
+
* 问题数组存进 pendingAsk state,App 看到 pendingAsk 存在便渲染本组件,
|
|
9
|
+
* 并让它**独占输入焦点**(屏蔽 InputBox),用户答完后 handler 的 Promise
|
|
10
|
+
* 以 AskAnswer[] resolve、继续工具回路;ESC/Ctrl+C 取消则 reject。
|
|
11
|
+
*
|
|
12
|
+
* 逐题进行:从第 0 题问到最后一题,每题答完推进,末题答完一次性回调 onAnswer。
|
|
13
|
+
* 每题选项列表末尾自动追加一个「✏️ 其他(自由输入)」伪选项,命中即切到文本
|
|
14
|
+
* 输入子模式,让用户填选项之外的答案(自由输入按单值处理,覆盖多选集合)。
|
|
15
|
+
*
|
|
16
|
+
* Ink 用法与风格对齐 cli/configWizard.tsx:useInput 手写键位、↑↓ 取模移动
|
|
17
|
+
* 高亮、`> ` 前缀 + cyan 高亮、gray 操作提示、文本输入用累积 draft + backspace。
|
|
18
|
+
* ============================================================
|
|
19
|
+
*/
|
|
20
|
+
import { useState } from 'react';
|
|
21
|
+
import { Box, Text, useInput } from 'ink';
|
|
22
|
+
/** description 过长截断到此长度,防止 live 区被超长文本撑爆。 */
|
|
23
|
+
const DESC_MAX = 80;
|
|
24
|
+
function clampDesc(s) {
|
|
25
|
+
return s.length > DESC_MAX ? `${s.slice(0, DESC_MAX - 1)}…` : s;
|
|
26
|
+
}
|
|
27
|
+
export function AskQuestion({ questions, onAnswer, onCancel, }) {
|
|
28
|
+
// 当前进行到第几题
|
|
29
|
+
const [questionIndex, setQuestionIndex] = useState(0);
|
|
30
|
+
// 当前题高亮的选项下标(含末尾「其他」伪选项)
|
|
31
|
+
const [idx, setIdx] = useState(0);
|
|
32
|
+
// 多选时已选中的真实 option 下标集合
|
|
33
|
+
const [picked, setPicked] = useState(new Set());
|
|
34
|
+
// 已累积的答案
|
|
35
|
+
const [answers, setAnswers] = useState([]);
|
|
36
|
+
// 是否处于「其他(自由输入)」文本子模式
|
|
37
|
+
const [textMode, setTextMode] = useState(false);
|
|
38
|
+
// 文本子模式的累积草稿
|
|
39
|
+
const [draft, setDraft] = useState('');
|
|
40
|
+
const current = questions[questionIndex];
|
|
41
|
+
const multi = current?.multiSelect === true;
|
|
42
|
+
const options = current?.options ?? [];
|
|
43
|
+
// 末尾「其他」伪选项占据下标 options.length,故列表总长 = options.length + 1
|
|
44
|
+
const otherIndex = options.length;
|
|
45
|
+
const listLength = options.length + 1;
|
|
46
|
+
/** 切到下一题,重置所有逐题状态;若已是末题则一次性回调 onAnswer。 */
|
|
47
|
+
function advance(answer) {
|
|
48
|
+
const nextAnswers = [...answers, answer];
|
|
49
|
+
const nextQ = questionIndex + 1;
|
|
50
|
+
if (nextQ >= questions.length) {
|
|
51
|
+
onAnswer(nextAnswers);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
setAnswers(nextAnswers);
|
|
55
|
+
setQuestionIndex(nextQ);
|
|
56
|
+
setIdx(0);
|
|
57
|
+
setPicked(new Set());
|
|
58
|
+
setTextMode(false);
|
|
59
|
+
setDraft('');
|
|
60
|
+
}
|
|
61
|
+
useInput((input, key) => {
|
|
62
|
+
// 没有当前题(理论不会发生)→ 直接当作取消
|
|
63
|
+
if (!current) {
|
|
64
|
+
onCancel();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// ---- 文本子模式:自由输入 ----
|
|
68
|
+
if (textMode) {
|
|
69
|
+
if (key.escape) {
|
|
70
|
+
// 退回选项列表(不取消整轮)
|
|
71
|
+
setTextMode(false);
|
|
72
|
+
setDraft('');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (key.return) {
|
|
76
|
+
const text = draft.trim();
|
|
77
|
+
if (text.length === 0)
|
|
78
|
+
return;
|
|
79
|
+
// 自由输入按单值处理,覆盖多选集合
|
|
80
|
+
advance({
|
|
81
|
+
header: current.header,
|
|
82
|
+
question: current.question,
|
|
83
|
+
selected: [text],
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (key.backspace || key.delete) {
|
|
88
|
+
setDraft((s) => s.slice(0, -1));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (input && !key.meta && !key.ctrl) {
|
|
92
|
+
setDraft((s) => s + input);
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// ---- 选项列表模式 ----
|
|
97
|
+
if (key.escape || (key.ctrl && input === 'c')) {
|
|
98
|
+
onCancel();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (key.upArrow) {
|
|
102
|
+
setIdx((i) => (i - 1 + listLength) % listLength);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (key.downArrow) {
|
|
106
|
+
setIdx((i) => (i + 1) % listLength);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// 高亮落在「其他」伪选项:Enter(单选/多选都)进入文本子模式
|
|
110
|
+
const onOther = idx === otherIndex;
|
|
111
|
+
if (multi) {
|
|
112
|
+
// 多选:空格 toggle;Enter 确认本题
|
|
113
|
+
if (input === ' ') {
|
|
114
|
+
if (onOther) {
|
|
115
|
+
// 「其他」上按空格 → 进入自由输入
|
|
116
|
+
setTextMode(true);
|
|
117
|
+
setDraft('');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
setPicked((prev) => {
|
|
121
|
+
const next = new Set(prev);
|
|
122
|
+
if (next.has(idx))
|
|
123
|
+
next.delete(idx);
|
|
124
|
+
else
|
|
125
|
+
next.add(idx);
|
|
126
|
+
return next;
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (key.return) {
|
|
131
|
+
if (onOther) {
|
|
132
|
+
setTextMode(true);
|
|
133
|
+
setDraft('');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const selected = options
|
|
137
|
+
.map((o, i) => (picked.has(i) ? o.label : null))
|
|
138
|
+
.filter((l) => l !== null);
|
|
139
|
+
advance({
|
|
140
|
+
header: current.header,
|
|
141
|
+
question: current.question,
|
|
142
|
+
selected,
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// 单选:Enter 选中当前高亮项
|
|
149
|
+
if (key.return) {
|
|
150
|
+
if (onOther) {
|
|
151
|
+
setTextMode(true);
|
|
152
|
+
setDraft('');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const opt = options[idx];
|
|
156
|
+
if (!opt)
|
|
157
|
+
return;
|
|
158
|
+
advance({
|
|
159
|
+
header: current.header,
|
|
160
|
+
question: current.question,
|
|
161
|
+
selected: [opt.label],
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
// 无题可问(空数组)→ 渲染占位(useInput 已处理取消,这里只防御渲染崩)
|
|
166
|
+
if (!current) {
|
|
167
|
+
return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "gray", children: "\uFF08\u65E0\u95EE\u9898\uFF09" }) }));
|
|
168
|
+
}
|
|
169
|
+
const progress = `提问 (${questionIndex + 1}/${questions.length}) · ${current.header}`;
|
|
170
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "cyan", bold: true, children: progress }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: current.question }) }), textMode ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "\u270F\uFE0F \u81EA\u7531\u8F93\u5165\uFF1A" }), _jsx(Box, { marginTop: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1, children: _jsx(Text, { children: draft || ' ' }) }), _jsx(Text, { color: "gray", children: "\u8F93\u5165\u540E Enter \u63D0\u4EA4 \u00B7 ESC \u8FD4\u56DE" })] })) : (_jsxs(Box, { flexDirection: "column", children: [options.map((opt, i) => {
|
|
171
|
+
const active = i === idx;
|
|
172
|
+
const box = multi ? (picked.has(i) ? '[x] ' : '[ ] ') : '';
|
|
173
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: active ? 'cyan' : undefined, children: [active ? '> ' : ' ', box, opt.label] }), opt.description ? (_jsxs(Text, { color: "gray", children: [' ', clampDesc(opt.description)] })) : null] }, `${i}-${opt.label}`));
|
|
174
|
+
}), _jsxs(Text, { color: idx === otherIndex ? 'cyan' : undefined, children: [idx === otherIndex ? '> ' : ' ', multi ? ' ' : '', "\u270F\uFE0F \u5176\u4ED6\uFF08\u81EA\u7531\u8F93\u5165\uFF09"] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: multi
|
|
175
|
+
? '↑↓ 移动 · 空格 多选 · Enter 确认本题 · ESC 取消'
|
|
176
|
+
: '↑↓ 选择 · Enter 确认 · ESC 取消' }) })] }))] }));
|
|
177
|
+
}
|
package/src/ui/hooks/useChat.js
CHANGED
|
@@ -23,6 +23,7 @@ import { getActiveSession, setActiveSession, } from '../../context/sessionRegist
|
|
|
23
23
|
import { resolveSessionFile, sessionFileFor, } from '../../context/sessionPath.js';
|
|
24
24
|
import { buildFullSystemPrompt } from '../../prompts/system.js';
|
|
25
25
|
import { ALL_TOOLS } from '../../tools/index.js';
|
|
26
|
+
import { setAskHandler, } from '../../askBridge.js';
|
|
26
27
|
import { clearFileState } from '../../tools/shared/fileState.js';
|
|
27
28
|
import { runWithPlugins } from '../../plugins/pluginRunner.js';
|
|
28
29
|
/**
|
|
@@ -123,6 +124,12 @@ export function useChat(args) {
|
|
|
123
124
|
const [runningToolsMap, setRunningToolsMap] = useState(new Map());
|
|
124
125
|
const runningTools = useMemo(() => Array.from(runningToolsMap.values()), [runningToolsMap]);
|
|
125
126
|
const abortRef = useRef(null);
|
|
127
|
+
// ---- AskUserQuestion:执行中向用户提问 ----
|
|
128
|
+
// pendingAsk 非 null 时 App 渲染 <AskQuestion> 独占输入(不渲染 InputBox)。
|
|
129
|
+
// askResolverRef 持当前提问的 resolve/reject —— 用户答完 answerAsk 调 resolve,
|
|
130
|
+
// 让 askBridge.requestUserChoice 的 Promise 落定、工具 call() 返回、执行继续。
|
|
131
|
+
const [pendingAsk, setPendingAsk] = useState(null);
|
|
132
|
+
const askResolverRef = useRef(null);
|
|
126
133
|
const streamingFlush = useThrottledFlush({
|
|
127
134
|
delayMs: STREAM_FLUSH_MS,
|
|
128
135
|
maxChars: STREAM_FLUSH_MAX_CHARS,
|
|
@@ -186,6 +193,38 @@ export function useChat(args) {
|
|
|
186
193
|
abortRef.current?.abort();
|
|
187
194
|
};
|
|
188
195
|
}, []);
|
|
196
|
+
// mount 时把「弹选择界面」的处理器注册到 askBridge;unmount 注销。
|
|
197
|
+
// 工具 isConcurrencySafe:false + workflow 串行 → 同一时刻至多一个 pending,单槽即可。
|
|
198
|
+
// signal abort(用户 ESC / 中断)时 reject 并清掉 pending,让工具按「失败/取消」收尾。
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
setAskHandler((questions, signal) => {
|
|
201
|
+
return new Promise((resolve, reject) => {
|
|
202
|
+
askResolverRef.current?.reject(new Error('被新的提问取代'));
|
|
203
|
+
askResolverRef.current = { resolve, reject };
|
|
204
|
+
setPendingAsk(questions);
|
|
205
|
+
const onAbort = () => {
|
|
206
|
+
if (!askResolverRef.current)
|
|
207
|
+
return;
|
|
208
|
+
askResolverRef.current = null;
|
|
209
|
+
setPendingAsk(null);
|
|
210
|
+
reject(new Error('已被用户中断'));
|
|
211
|
+
};
|
|
212
|
+
if (signal?.aborted) {
|
|
213
|
+
onAbort();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
return () => setAskHandler(null);
|
|
220
|
+
}, []);
|
|
221
|
+
/** 用户答完当前提问:清 pending + resolve 工具侧 Promise,执行继续。 */
|
|
222
|
+
const answerAsk = useCallback((answers) => {
|
|
223
|
+
const r = askResolverRef.current;
|
|
224
|
+
askResolverRef.current = null;
|
|
225
|
+
setPendingAsk(null);
|
|
226
|
+
r?.resolve(answers);
|
|
227
|
+
}, []);
|
|
189
228
|
// mount 时异步读 active session(默认 'default',注册表里有就用注册值)。
|
|
190
229
|
// 启动时初始 history 已经由 main.tsx/Root 走默认路径加载完了;
|
|
191
230
|
// 如果注册表说 active 不是 default,理论上 main.tsx 应该按 active 加载——
|
|
@@ -470,6 +509,8 @@ export function useChat(args) {
|
|
|
470
509
|
compactNow,
|
|
471
510
|
switchSession,
|
|
472
511
|
ephemeralLine,
|
|
512
|
+
pendingAsk,
|
|
513
|
+
answerAsk,
|
|
473
514
|
};
|
|
474
515
|
}
|
|
475
516
|
export function handleEvent(ev, setters) {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# ============================================================
|
|
2
|
+
# workflows/plan.yaml —— 复刻 Claude Code「plan」流程的动态工作流
|
|
3
|
+
# ------------------------------------------------------------
|
|
4
|
+
# 旗舰演示 AskUserQuestion + 「受工作流指导的有界 TAOR」+ 人机交互:
|
|
5
|
+
# 探索(agent 只读调研) → 澄清(直接用工具问用户,带推荐项)
|
|
6
|
+
# → 产出计划(agent 综合上下文落盘,遇取舍再回头问用户拍板) → 守门 assert。
|
|
7
|
+
#
|
|
8
|
+
# 与 adaptive-research 的区别:那条线演示「指导 + 随机应变」的旋钮,
|
|
9
|
+
# 这条线把「交互(向用户提问)」也编进确定性骨架里 —— 既有 agent 节点的自主,
|
|
10
|
+
# 又有 tool: AskUserQuestion 节点的「停下来问人」,二者共骑同一座 askBridge。
|
|
11
|
+
#
|
|
12
|
+
# 非交互宿主(-p / gateway / 子进程)下没有 UI handler,AskUserQuestion
|
|
13
|
+
# 会自动选每题首项(约定=推荐项),整条流程照常跑完、绝不挂起。
|
|
14
|
+
# ============================================================
|
|
15
|
+
name: plan
|
|
16
|
+
description: |
|
|
17
|
+
受工作流指导 + 可向用户提问 + 有界 TAOR 的动态计划流程。
|
|
18
|
+
- 先用有界子 agent 只读调研目标,梳理关键事实与未决点(不写盘、不副作用);
|
|
19
|
+
- 再用 AskUserQuestion 直接问用户「计划要做到多详细」(带推荐项的单选);
|
|
20
|
+
- 然后子 agent 综合调研与用户选择产出中文实现计划落到 ./PLAN.md,
|
|
21
|
+
遇到取舍 / 模糊点可再调 AskUserQuestion 让用户拍板;
|
|
22
|
+
- 最后 assert 守门,确认 PLAN.md 确实生成。
|
|
23
|
+
这是 minimal-agent 的旗舰演示:把「探索→澄清→(带推荐的)选择→产出计划」
|
|
24
|
+
编成确定性骨架,每个 agent 节点内是有界 TAOR,交互骑在 tool_call 回路上。
|
|
25
|
+
version: "0.1"
|
|
26
|
+
|
|
27
|
+
inputs:
|
|
28
|
+
- name: goal
|
|
29
|
+
type: string
|
|
30
|
+
required: true
|
|
31
|
+
description: "要做的事 / 目标"
|
|
32
|
+
|
|
33
|
+
steps:
|
|
34
|
+
# 1. 探索:agent 只读调研,自主用 Read/Grep/Glob 梳理事实与未决点(不写盘)
|
|
35
|
+
- id: research
|
|
36
|
+
agent: |
|
|
37
|
+
只读调研目标「${inputs.goal}」相关的代码 / 资料:用 Read、Grep、Glob 工具
|
|
38
|
+
(不要写文件、不要执行副作用命令)摸清现状,梳理出 3-6 条关键事实
|
|
39
|
+
以及 1-3 个仍然未决 / 需要用户拍板的点。
|
|
40
|
+
用简洁的中文 markdown 列点整理后直接作为你的最终回答返回,供后续步骤参考。
|
|
41
|
+
allowed_tools: [Read, Grep, Glob]
|
|
42
|
+
capture:
|
|
43
|
+
content: findings
|
|
44
|
+
|
|
45
|
+
# 2. 澄清:直接用 AskUserQuestion 工具问用户「计划深度」(演示 tool 节点交互)
|
|
46
|
+
# 第一个选项是推荐项(label 末尾标「(推荐)」),非交互宿主会自动选它。
|
|
47
|
+
- id: clarify_depth
|
|
48
|
+
tool: AskUserQuestion
|
|
49
|
+
args:
|
|
50
|
+
questions:
|
|
51
|
+
- header: "计划深度"
|
|
52
|
+
question: "希望这份实现计划做到多详细?"
|
|
53
|
+
options:
|
|
54
|
+
- label: "详细分步 (推荐)"
|
|
55
|
+
description: "拆成可执行的有序步骤,每步说清动作、涉及文件与验收点"
|
|
56
|
+
- label: "快速概览"
|
|
57
|
+
description: "只给高层方向与关键决策,不展开到每一步"
|
|
58
|
+
- label: "带风险/回滚评估"
|
|
59
|
+
description: "在详细分步之外,额外标注每步风险与回滚方案"
|
|
60
|
+
capture:
|
|
61
|
+
content: depth_answer
|
|
62
|
+
|
|
63
|
+
# 3. 产出计划:agent 综合 findings + 用户选择落盘 ./PLAN.md,
|
|
64
|
+
# 遇取舍/模糊点可再调 AskUserQuestion 让用户拍板(带推荐项),写完用 Read 自检。
|
|
65
|
+
- id: draft_plan
|
|
66
|
+
agent: |
|
|
67
|
+
综合下面两份输入,为目标「${inputs.goal}」写一份中文实现计划,
|
|
68
|
+
用 Write 工具落到 ./PLAN.md(目录不存在就先创建)。
|
|
69
|
+
|
|
70
|
+
—— 调研结论(findings)——
|
|
71
|
+
${findings}
|
|
72
|
+
|
|
73
|
+
—— 用户对计划深度的选择 ——
|
|
74
|
+
${depth_answer}
|
|
75
|
+
|
|
76
|
+
要求:
|
|
77
|
+
- 计划详细程度遵循用户上面的选择;
|
|
78
|
+
- 若过程中遇到方案取舍或关键信息模糊、继续做风险大,调用 AskUserQuestion
|
|
79
|
+
让用户拍板(每题第一个选项放你的推荐并在 label 末尾标「(推荐)」),
|
|
80
|
+
据用户回答继续;
|
|
81
|
+
- 写完后用 Read 自检 ./PLAN.md 内容确实落好了再收尾。
|
|
82
|
+
allowed_tools: [Read, Grep, Glob, Write, AskUserQuestion]
|
|
83
|
+
capture:
|
|
84
|
+
content: plan_text
|
|
85
|
+
|
|
86
|
+
# 4. 守门:确认计划文件确实生成,否则给出中文排查提示
|
|
87
|
+
- id: verify
|
|
88
|
+
type: assert
|
|
89
|
+
condition: 'fileExists("./PLAN.md")'
|
|
90
|
+
onFail: "计划文件 ./PLAN.md 未生成,检查 draft_plan 步是否成功用 Write 落盘"
|