minimal-agent 0.5.5 → 0.6.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/package.json +1 -1
- package/plugins/workflow-runner/src/expressions.js +63 -8
- package/plugins/workflow-runner/src/index.js +2 -2
- package/plugins/workflow-runner/src/loader.js +14 -0
- package/plugins/workflow-runner/src/runner.js +52 -6
- package/plugins/workflow-runner/src/stepExecutors/assert.js +2 -2
- package/plugins/workflow-runner/src/stepExecutors/llm.js +11 -4
- package/plugins/workflow-runner/src/stepExecutors/skill.js +3 -1
- package/plugins/workflow-runner/src/stepExecutors/tool.js +38 -3
- package/plugins/workflow-runner/src/voteHelpers.js +7 -1
- package/src/cli/print.js +24 -3
- package/src/context/persistContext.js +17 -1
- package/src/llm/client.js +16 -2
- package/src/loop.js +16 -29
- package/src/plugins/commandRouter.js +26 -2
- package/src/prompts/system.js +16 -16
- package/src/tools/read/read.js +5 -1
- package/src/tools/shared/fileState.js +23 -0
- package/src/ui/App.js +155 -11
- package/src/ui/InputBox.js +31 -57
- package/src/ui/LiveArea.js +132 -0
- package/src/ui/MessageList.js +214 -21
- package/src/ui/ProgressPanel.js +4 -48
- package/src/ui/StatusLine.js +21 -2
- package/src/ui/hooks/useChat.js +417 -105
- package/src/ui/hooks/useTokenUsage.js +0 -1
- package/src/ui/liveViewport.js +176 -0
- package/workflows/schema.json +2 -2
package/src/ui/hooks/useChat.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 这个 hook 是 UI 层的核心:
|
|
6
6
|
* - 维护 history 数组(用 ref 持久引用,version 计数器触发重渲染)
|
|
7
7
|
* - 维护"当前正在打字"的 streamingText
|
|
8
|
-
* - 维护"
|
|
8
|
+
* - 维护"工作过程显示"用的 progressStage / reasoningPreview / runningTools
|
|
9
9
|
* - 暴露 submit() 给 InputBox 调用、abort() 给 Ctrl+C 处理
|
|
10
10
|
*
|
|
11
11
|
* 为什么 history 用 ref 而不是 state?
|
|
@@ -17,32 +17,199 @@
|
|
|
17
17
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
18
18
|
import { getWorkingDir } from '../../bootstrap/workingDir.js';
|
|
19
19
|
import { forceCompact } from '../../context/compact.js';
|
|
20
|
-
import { clearContext } from '../../context/persistContext.js';
|
|
20
|
+
import { clearContext, loadContext, saveContext, } from '../../context/persistContext.js';
|
|
21
21
|
import { resetReactiveCompactState } from '../../context/reactiveCompact.js';
|
|
22
|
+
import { getActiveSession, setActiveSession, } from '../../context/sessionRegistry.js';
|
|
23
|
+
import { resolveSessionFile, sessionFileFor, } from '../../context/sessionPath.js';
|
|
22
24
|
import { buildFullSystemPrompt } from '../../prompts/system.js';
|
|
23
25
|
import { ALL_TOOLS } from '../../tools/index.js';
|
|
24
26
|
import { clearFileState } from '../../tools/shared/fileState.js';
|
|
25
27
|
import { runWithPlugins } from '../../plugins/pluginRunner.js';
|
|
28
|
+
/**
|
|
29
|
+
* reasoningPreview 环形 buffer 容量(字符)。
|
|
30
|
+
* LiveArea 现在多行显示思维链尾部(最近 N 行),需要喂饱足够文本;
|
|
31
|
+
* 1200 字符约 15~20 行中文,LiveArea wrapToDisplayLines 后再取尾部最近 N 行。
|
|
32
|
+
* 上界由 LiveArea 的 contentMaxLines 兜底,这里只管"留够素材"。
|
|
33
|
+
*/
|
|
34
|
+
const REASONING_PREVIEW_MAX_CHARS = 1200;
|
|
35
|
+
/**
|
|
36
|
+
* streamingText 环形 buffer 容量(字符)—— 仅用于 live 区"答案流式预览"。
|
|
37
|
+
*
|
|
38
|
+
* ★ 安全性:完整答案由 loop.ts 独立组装进 history(loop.ts 的 assistantText →
|
|
39
|
+
* assistantMsg.content,整条 push),streamingText 是**纯预览**,环形截断不丢
|
|
40
|
+
* 最终结果(落定后 <Static> 渲染完整 message.content 接管)。
|
|
41
|
+
* 截断保证长答案流式时 LiveArea 只需 wrap 末段、render 成本恒定。
|
|
42
|
+
*/
|
|
43
|
+
const STREAM_PREVIEW_MAX_CHARS = 1500;
|
|
44
|
+
/**
|
|
45
|
+
* Streaming text setState 节流参数。
|
|
46
|
+
*
|
|
47
|
+
* Why throttle:LLM 流式输出 60+ chunk/秒,原本每个 delta 立即 setStreamingText
|
|
48
|
+
* 在 Windows PowerShell 终端下 Ink ANSI cursor 重绘跟不上,导致每个字打成新行
|
|
49
|
+
* (已确认根因,Plan agent 验证过)。这里用 leading-edge throttle:首个 delta
|
|
50
|
+
* 启动 100ms timer,期间所有 delta 累积到 ref,到点 flush 一次 setStreamingText。
|
|
51
|
+
*
|
|
52
|
+
* STREAM_FLUSH_MS : 100ms 节流(React rerender 频率 ≤ 10 Hz,叠加 Ink 内部
|
|
53
|
+
* 32ms throttle 后实际重画 ≤ 10 Hz,Windows 终端能跟上)
|
|
54
|
+
* STREAM_FLUSH_MAX_CHARS : 缓冲超 200 字符立即 flush(防长段中文 100ms 内塞太
|
|
55
|
+
* 多触发 React 大量 reconcile)
|
|
56
|
+
*
|
|
57
|
+
* Why not debounce:debounce 在 token 持续到达时永远刷新定时器,流式期间可能
|
|
58
|
+
* 完全不更新 UI,等于"看不见"。leading-edge throttle 保证稳定 10 Hz 出现新内容。
|
|
59
|
+
*/
|
|
60
|
+
const STREAM_FLUSH_MS = 100;
|
|
61
|
+
const STREAM_FLUSH_MAX_CHARS = 200;
|
|
62
|
+
/** 按字符尾部截断(多字节字符 JS 字符串以 UTF-16 code unit 计,对中文已足够"差不多")。 */
|
|
63
|
+
function sliceLatest(s, maxChars) {
|
|
64
|
+
if (s.length <= maxChars)
|
|
65
|
+
return s;
|
|
66
|
+
return s.slice(s.length - maxChars);
|
|
67
|
+
}
|
|
68
|
+
function useThrottledFlush({ delayMs, maxChars, flushEmpty, onFlush, }) {
|
|
69
|
+
const bufferRef = useRef('');
|
|
70
|
+
const timerRef = useRef(null);
|
|
71
|
+
const flush = useCallback(() => {
|
|
72
|
+
if (timerRef.current) {
|
|
73
|
+
clearTimeout(timerRef.current);
|
|
74
|
+
timerRef.current = null;
|
|
75
|
+
}
|
|
76
|
+
const buffered = bufferRef.current;
|
|
77
|
+
bufferRef.current = '';
|
|
78
|
+
if (buffered.length === 0 && !flushEmpty)
|
|
79
|
+
return;
|
|
80
|
+
onFlush(buffered);
|
|
81
|
+
}, [flushEmpty, onFlush]);
|
|
82
|
+
const cancel = useCallback(() => {
|
|
83
|
+
if (timerRef.current) {
|
|
84
|
+
clearTimeout(timerRef.current);
|
|
85
|
+
timerRef.current = null;
|
|
86
|
+
}
|
|
87
|
+
bufferRef.current = '';
|
|
88
|
+
}, []);
|
|
89
|
+
const schedule = useCallback(() => {
|
|
90
|
+
if (timerRef.current !== null)
|
|
91
|
+
return;
|
|
92
|
+
timerRef.current = setTimeout(flush, delayMs);
|
|
93
|
+
}, [delayMs, flush]);
|
|
94
|
+
const append = useCallback((delta) => {
|
|
95
|
+
bufferRef.current += delta;
|
|
96
|
+
if (typeof maxChars === 'number' && bufferRef.current.length >= maxChars) {
|
|
97
|
+
flush();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
schedule();
|
|
101
|
+
}, [flush, maxChars, schedule]);
|
|
102
|
+
return useMemo(() => ({ append, flush, cancel, schedule }), [append, flush, cancel, schedule]);
|
|
103
|
+
}
|
|
26
104
|
export function useChat(args) {
|
|
27
105
|
// 历史用 ref(可变),用 version 触发 re-render
|
|
28
106
|
const historyRef = useRef(args.initialHistory.slice());
|
|
29
107
|
const [version, setVersion] = useState(0);
|
|
30
108
|
const bump = useCallback(() => setVersion((v) => v + 1), []);
|
|
31
109
|
const [streamingText, setStreamingText] = useState('');
|
|
32
|
-
const [streamingReasoning, setStreamingReasoning] = useState('');
|
|
33
|
-
const [toolStatus, setToolStatus] = useState(null);
|
|
34
110
|
const [isLoading, setIsLoading] = useState(false);
|
|
35
111
|
const [error, setError] = useState(null);
|
|
36
112
|
const [interrupted, setInterrupted] = useState(false);
|
|
37
|
-
const [compacting, setCompacting] = useState(false);
|
|
38
113
|
const [pluginProgress, setPluginProgress] = useState(null);
|
|
114
|
+
// ---- 工作过程显示相关 state ----
|
|
115
|
+
const [compactGeneration, setCompactGeneration] = useState(0);
|
|
116
|
+
const [progressStage, setProgressStage] = useState('idle');
|
|
117
|
+
const [reasoningPreview, setReasoningPreview] = useState('');
|
|
118
|
+
/**
|
|
119
|
+
* 工具并行执行:以 callId 为 key 保存所有运行中工具。
|
|
120
|
+
* 用 Map 是为了 O(1) 增删;对外 memo 物化成稳定数组(Map 迭代顺序 = 插入顺序,
|
|
121
|
+
* 给 React 渲染天然稳定)。
|
|
122
|
+
*/
|
|
123
|
+
const [runningToolsMap, setRunningToolsMap] = useState(new Map());
|
|
124
|
+
const runningTools = useMemo(() => Array.from(runningToolsMap.values()), [runningToolsMap]);
|
|
39
125
|
const abortRef = useRef(null);
|
|
126
|
+
const streamingFlush = useThrottledFlush({
|
|
127
|
+
delayMs: STREAM_FLUSH_MS,
|
|
128
|
+
maxChars: STREAM_FLUSH_MAX_CHARS,
|
|
129
|
+
onFlush: useCallback((buffered) => {
|
|
130
|
+
setStreamingText((prev) => sliceLatest(prev + buffered, STREAM_PREVIEW_MAX_CHARS));
|
|
131
|
+
}, []),
|
|
132
|
+
});
|
|
133
|
+
// ---- reasoning preview setState 节流 ----
|
|
134
|
+
// 用户反馈思考阶段需要看到流式思维链。reasoning delta 频率跟 text delta 一样高
|
|
135
|
+
// (60+ chunk/秒),不节流的话每个 delta 立即 setReasoningPreview → LiveArea
|
|
136
|
+
// rerender → Ink 重绘扛不住。复用 streaming 一样的 100ms / 200 字符节流参数。
|
|
137
|
+
const reasoningFlush = useThrottledFlush({
|
|
138
|
+
delayMs: STREAM_FLUSH_MS,
|
|
139
|
+
maxChars: STREAM_FLUSH_MAX_CHARS,
|
|
140
|
+
onFlush: useCallback((buffered) => {
|
|
141
|
+
setReasoningPreview((prev) => sliceLatest(prev + buffered, REASONING_PREVIEW_MAX_CHARS));
|
|
142
|
+
}, []),
|
|
143
|
+
});
|
|
144
|
+
// ---- tool_start/end 的 bump 节流 ----
|
|
145
|
+
// tool_start / tool_end 单独触发 bump 会导致每个工具事件都是一次顶层 rerender;
|
|
146
|
+
// 200ms 内合并成单次 bump,避免高频"工具状态切换"压垮 Ink ANSI 重绘。
|
|
147
|
+
// 注意:assistant_message 走立即 bump(不节流),因为 history 增长时 Static 需要
|
|
148
|
+
// 立即 commit 新 item,不能延迟。
|
|
149
|
+
const toolBumpFlush = useThrottledFlush({
|
|
150
|
+
delayMs: 200,
|
|
151
|
+
flushEmpty: true,
|
|
152
|
+
onFlush: useCallback(() => {
|
|
153
|
+
bump();
|
|
154
|
+
}, [bump]),
|
|
155
|
+
});
|
|
156
|
+
/**
|
|
157
|
+
* 把 turn 结束 / 切换 session / clearHistory 时的"工作过程显示 state
|
|
158
|
+
* 归位"集中成一个函数。React 18 自动 batch 这一组 setState(同一个 microtask 内
|
|
159
|
+
* 调用),结果就是 1 次 commit。集中后调用点更清晰,也方便后续再优化。
|
|
160
|
+
*/
|
|
161
|
+
const resetTurnState = useCallback(() => {
|
|
162
|
+
setStreamingText('');
|
|
163
|
+
setProgressStage('idle');
|
|
164
|
+
setReasoningPreview('');
|
|
165
|
+
setRunningToolsMap(new Map());
|
|
166
|
+
}, []);
|
|
167
|
+
/** turn 结束 / 中断 / 切换时一并取消所有节流 buffer 的 pending timer。 */
|
|
168
|
+
const cancelAllThrottles = useCallback(() => {
|
|
169
|
+
streamingFlush.cancel();
|
|
170
|
+
reasoningFlush.cancel();
|
|
171
|
+
toolBumpFlush.cancel();
|
|
172
|
+
}, [streamingFlush, reasoningFlush, toolBumpFlush]);
|
|
173
|
+
// ---- v2 multi-session ----
|
|
174
|
+
// activeSessionId 启动时同步默认 'default';mount 后异步读 active-sessions.json
|
|
175
|
+
// 更新为该 cwd 真正激活的 session。所有持久化路径都按 ref 取最新值,
|
|
176
|
+
// ref 比 state 顺手——switchSession 内同步切换 ref 后下一行立刻 saveContext 能用新值。
|
|
177
|
+
const activeSessionIdRef = useRef('default');
|
|
178
|
+
const [activeSessionId, setActiveSessionId] = useState('default');
|
|
179
|
+
/** 当前 active session 对应的持久化文件路径(同步,按 ref 取) */
|
|
180
|
+
const sessionFile = useCallback(() => {
|
|
181
|
+
return sessionFileFor(getWorkingDir(), activeSessionIdRef.current);
|
|
182
|
+
}, []);
|
|
40
183
|
// 卸载时取消任何进行中的请求
|
|
41
184
|
useEffect(() => {
|
|
42
185
|
return () => {
|
|
43
186
|
abortRef.current?.abort();
|
|
44
187
|
};
|
|
45
188
|
}, []);
|
|
189
|
+
// mount 时异步读 active session(默认 'default',注册表里有就用注册值)。
|
|
190
|
+
// 启动时初始 history 已经由 main.tsx/Root 走默认路径加载完了;
|
|
191
|
+
// 如果注册表说 active 不是 default,理论上 main.tsx 应该按 active 加载——
|
|
192
|
+
// 但当前 main.tsx 用的是 getContextPath() 走 default。为了兼容这个不一致,
|
|
193
|
+
// 此处把 ref/state 设为 active id 但不重新 loadContext —— 由用户手动 /session
|
|
194
|
+
// 切回正确 session(极少数 edge case,避免启动时异步加载导致界面闪烁)。
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
let cancelled = false;
|
|
197
|
+
void (async () => {
|
|
198
|
+
try {
|
|
199
|
+
const id = await getActiveSession(getWorkingDir());
|
|
200
|
+
if (cancelled)
|
|
201
|
+
return;
|
|
202
|
+
activeSessionIdRef.current = id;
|
|
203
|
+
setActiveSessionId(id);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// 读失败保持 default,不影响功能
|
|
207
|
+
}
|
|
208
|
+
})();
|
|
209
|
+
return () => {
|
|
210
|
+
cancelled = true;
|
|
211
|
+
};
|
|
212
|
+
}, []);
|
|
46
213
|
const submit = useCallback(async (input) => {
|
|
47
214
|
if (isLoading)
|
|
48
215
|
return;
|
|
@@ -52,53 +219,15 @@ export function useChat(args) {
|
|
|
52
219
|
setIsLoading(true);
|
|
53
220
|
setError(null);
|
|
54
221
|
setInterrupted(false);
|
|
222
|
+
cancelAllThrottles();
|
|
55
223
|
setStreamingText('');
|
|
56
224
|
setPluginProgress(null);
|
|
225
|
+
// 进入新一轮,把工作过程显示相关 state 全部归位
|
|
226
|
+
setProgressStage('idle');
|
|
227
|
+
setReasoningPreview('');
|
|
228
|
+
setRunningToolsMap(new Map());
|
|
57
229
|
const ac = new AbortController();
|
|
58
230
|
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
|
-
};
|
|
102
231
|
try {
|
|
103
232
|
for await (const ev of runWithPlugins(trimmed, {
|
|
104
233
|
provider: args.provider,
|
|
@@ -106,13 +235,24 @@ export function useChat(args) {
|
|
|
106
235
|
signal: ac.signal,
|
|
107
236
|
})) {
|
|
108
237
|
handleEvent(ev, {
|
|
109
|
-
setStreamingText
|
|
110
|
-
setStreamingReasoning: throttledSetStreamingReasoning,
|
|
111
|
-
setToolStatus,
|
|
112
|
-
setCompacting,
|
|
238
|
+
setStreamingText,
|
|
113
239
|
setError,
|
|
114
240
|
setInterrupted,
|
|
115
241
|
setPluginProgress,
|
|
242
|
+
setCompactGeneration,
|
|
243
|
+
setProgressStage,
|
|
244
|
+
setReasoningPreview,
|
|
245
|
+
setRunningToolsMap,
|
|
246
|
+
// 节流相关 —— buffer 累积 + 100ms timer flush
|
|
247
|
+
appendStreamingDelta: streamingFlush.append,
|
|
248
|
+
flushStreamingBuffer: streamingFlush.flush,
|
|
249
|
+
cancelStreamingFlush: streamingFlush.cancel,
|
|
250
|
+
// reasoning preview 节流 + 落定清空
|
|
251
|
+
appendReasoningDelta: reasoningFlush.append,
|
|
252
|
+
cancelReasoningFlush: reasoningFlush.cancel,
|
|
253
|
+
// tool_start/end 节流 bump
|
|
254
|
+
scheduleToolBump: toolBumpFlush.schedule,
|
|
255
|
+
cancelToolBump: toolBumpFlush.cancel,
|
|
116
256
|
bump,
|
|
117
257
|
});
|
|
118
258
|
}
|
|
@@ -121,20 +261,48 @@ export function useChat(args) {
|
|
|
121
261
|
setError(`未捕获异常:${e.message}`);
|
|
122
262
|
}
|
|
123
263
|
finally {
|
|
124
|
-
flushStream();
|
|
125
264
|
setIsLoading(false);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
setToolStatus(null);
|
|
129
|
-
setCompacting(false);
|
|
265
|
+
cancelAllThrottles();
|
|
266
|
+
resetTurnState();
|
|
130
267
|
setPluginProgress(null);
|
|
131
268
|
abortRef.current = null;
|
|
132
|
-
|
|
269
|
+
// v2 multi-session:持久化到当前 active session 文件。
|
|
270
|
+
saveContext(historyRef.current, sessionFile()).catch(() => { });
|
|
133
271
|
}
|
|
134
|
-
}, [
|
|
272
|
+
}, [
|
|
273
|
+
args.provider,
|
|
274
|
+
bump,
|
|
275
|
+
isLoading,
|
|
276
|
+
sessionFile,
|
|
277
|
+
streamingFlush,
|
|
278
|
+
cancelAllThrottles,
|
|
279
|
+
reasoningFlush,
|
|
280
|
+
toolBumpFlush,
|
|
281
|
+
resetTurnState,
|
|
282
|
+
]);
|
|
135
283
|
const abort = useCallback(() => {
|
|
136
284
|
abortRef.current?.abort();
|
|
137
285
|
}, []);
|
|
286
|
+
/**
|
|
287
|
+
* v2.x 新增:把一段临时提示文本作为 _ephemeral 的 system 消息插入 history。
|
|
288
|
+
*
|
|
289
|
+
* 用途:替代 slash 命令分支直接 process.stdout.write 旁路 Ink 的写法
|
|
290
|
+
* (那种写法每次都让 ink log-update 的 previousLineCount 失步,
|
|
291
|
+
* 下一帧 eraseLines 会留下未擦行)。
|
|
292
|
+
*
|
|
293
|
+
* 流程:push 进 historyRef → bump() 触发 history useMemo 重算 →
|
|
294
|
+
* MessageList <Static> 把它作为新 item commit 进 scrollback。
|
|
295
|
+
* saveContext 写盘时过滤掉这些 ephemeral 消息。
|
|
296
|
+
*/
|
|
297
|
+
const ephemeralLine = useCallback((text) => {
|
|
298
|
+
historyRef.current.push({
|
|
299
|
+
role: 'system',
|
|
300
|
+
content: text,
|
|
301
|
+
_ephemeral: true,
|
|
302
|
+
timestamp: Date.now(),
|
|
303
|
+
});
|
|
304
|
+
bump();
|
|
305
|
+
}, [bump]);
|
|
138
306
|
/**
|
|
139
307
|
* /new —— 清空历史,重新生成 system prompt(带最新 skills + 项目指令)。
|
|
140
308
|
* 不复用启动时的初始 history 快照,避免把磁盘里的损坏数据带回来。
|
|
@@ -150,19 +318,20 @@ export function useChat(args) {
|
|
|
150
318
|
historyRef.current.push({ role: 'system', content: newSystemPrompt });
|
|
151
319
|
// 允许新 session 再次触发反应式压缩自救
|
|
152
320
|
resetReactiveCompactState();
|
|
153
|
-
|
|
154
|
-
setStreamingReasoning('');
|
|
155
|
-
setToolStatus(null);
|
|
321
|
+
cancelAllThrottles();
|
|
156
322
|
setError(null);
|
|
157
323
|
setInterrupted(false);
|
|
158
|
-
|
|
324
|
+
// 清空工作过程显示状态,避免 /new 后旧 turn 残留闪现
|
|
325
|
+
resetTurnState();
|
|
326
|
+
// 强制 MessageList 内 <Static> remount,避免旧消息卡在屏幕上
|
|
327
|
+
setCompactGeneration((g) => g + 1);
|
|
159
328
|
bump();
|
|
160
329
|
// 清空磁盘上的持久化文件(防止下次启动又加载损坏数据)
|
|
161
330
|
await clearContext();
|
|
162
331
|
// 清空 pre-read guard 状态表(防止跨 session 的 Read 记录污染新会话)
|
|
163
332
|
clearFileState();
|
|
164
|
-
//
|
|
165
|
-
}, [bump, isLoading]);
|
|
333
|
+
// 刚清空,不需要再保存一次"空历史"
|
|
334
|
+
}, [bump, isLoading, cancelAllThrottles, resetTurnState]);
|
|
166
335
|
/**
|
|
167
336
|
* /compact —— 手动跑一次压缩(不看阈值)。
|
|
168
337
|
* isLoading 时拒绝执行;压缩自身的 setIsLoading(true) 会让 InputBox 显示 ⏳。
|
|
@@ -173,13 +342,18 @@ export function useChat(args) {
|
|
|
173
342
|
if (isLoading)
|
|
174
343
|
return;
|
|
175
344
|
setIsLoading(true);
|
|
176
|
-
|
|
345
|
+
// 手动压缩仍然把 progressStage 切到 compacting,给 LiveArea 显示用
|
|
346
|
+
setProgressStage('compacting');
|
|
177
347
|
setError(null);
|
|
348
|
+
// 压缩前清掉任何待 flush 的 streaming buffer(避免压缩中 timer 触发干扰)
|
|
349
|
+
cancelAllThrottles();
|
|
178
350
|
let success = false;
|
|
179
351
|
try {
|
|
180
352
|
const r = await forceCompact(historyRef.current, args.provider);
|
|
181
353
|
historyRef.current.length = 0;
|
|
182
354
|
historyRef.current.push(...r.messages);
|
|
355
|
+
// 压缩后 MessageList 要 remount,丢掉旧 <Static> 缓存
|
|
356
|
+
setCompactGeneration((g) => g + 1);
|
|
183
357
|
bump();
|
|
184
358
|
success = true; // ✅ 标记成功
|
|
185
359
|
}
|
|
@@ -188,78 +362,198 @@ export function useChat(args) {
|
|
|
188
362
|
// ❌ 不修改 historyRef.current,保持原样(不保存可能损坏的数据)
|
|
189
363
|
}
|
|
190
364
|
finally {
|
|
191
|
-
|
|
365
|
+
setProgressStage('idle');
|
|
192
366
|
setIsLoading(false);
|
|
193
367
|
// ✅ 只在成功时才持久化,避免保存损坏的历史到磁盘
|
|
194
368
|
if (success) {
|
|
195
|
-
|
|
369
|
+
// v2 multi-session:持久化到当前 active session 文件
|
|
370
|
+
saveContext(historyRef.current, sessionFile()).catch(() => { });
|
|
196
371
|
args.onCompactDone?.();
|
|
197
372
|
}
|
|
198
373
|
}
|
|
199
|
-
}, [
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
374
|
+
}, [
|
|
375
|
+
args.provider,
|
|
376
|
+
args.onCompactDone,
|
|
377
|
+
bump,
|
|
378
|
+
isLoading,
|
|
379
|
+
sessionFile,
|
|
380
|
+
cancelAllThrottles,
|
|
381
|
+
]);
|
|
382
|
+
/**
|
|
383
|
+
* /session <id> —— 切换到指定 session(同 cwd 下)。
|
|
384
|
+
*
|
|
385
|
+
* 流程:
|
|
386
|
+
* 1) saveContext 保住当前 session 的最新历史
|
|
387
|
+
* 2) setActiveSession 更新注册表
|
|
388
|
+
* 3) 改 activeSessionIdRef + state(让 UI 显示 / 持久化路径都走新 id)
|
|
389
|
+
* 4) resolveSessionFile + loadContext 读新 session 历史(不存在则新建空)
|
|
390
|
+
* 5) 替换 historyRef.current(保留 system prompt 在头部)
|
|
391
|
+
* 6) bump + setCompactGeneration 触发 Static remount
|
|
392
|
+
*
|
|
393
|
+
* isLoading 时拒绝执行,避免与 runQuery 抢状态。
|
|
394
|
+
*/
|
|
395
|
+
const switchSession = useCallback(async (id, opts) => {
|
|
396
|
+
if (isLoading)
|
|
397
|
+
return;
|
|
398
|
+
const trimmed = id.trim();
|
|
399
|
+
if (trimmed.length === 0)
|
|
400
|
+
return;
|
|
401
|
+
// force=true 时即便同 id 也强制重新加载(/restore 覆盖文件后用)
|
|
402
|
+
if (!opts?.force && trimmed === activeSessionIdRef.current)
|
|
403
|
+
return;
|
|
404
|
+
const cwd = getWorkingDir();
|
|
405
|
+
// 1. 保住当前 session
|
|
406
|
+
try {
|
|
407
|
+
await saveContext(historyRef.current, sessionFileFor(cwd, activeSessionIdRef.current));
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
// 保存失败不阻塞切换(用户可能就是想切走止损)
|
|
411
|
+
}
|
|
412
|
+
// 2. 更新注册表
|
|
413
|
+
try {
|
|
414
|
+
await setActiveSession(cwd, trimmed);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
// 注册失败不阻塞切换
|
|
418
|
+
}
|
|
419
|
+
// 3. 切 ref + state
|
|
420
|
+
activeSessionIdRef.current = trimmed;
|
|
421
|
+
setActiveSessionId(trimmed);
|
|
422
|
+
// 4. 加载新 session 历史(resolveSessionFile 支持 v1 → v2 路径兜底)
|
|
423
|
+
let loaded = null;
|
|
424
|
+
try {
|
|
425
|
+
const newPath = await resolveSessionFile(cwd, trimmed);
|
|
426
|
+
loaded = await loadContext(newPath);
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
loaded = null;
|
|
430
|
+
}
|
|
431
|
+
// 5. 替换 historyRef,保留启动时的 system prompt 在头部
|
|
432
|
+
const systemMsg = historyRef.current.find((m) => m.role === 'system');
|
|
433
|
+
historyRef.current.length = 0;
|
|
434
|
+
if (systemMsg)
|
|
435
|
+
historyRef.current.push(systemMsg);
|
|
436
|
+
if (loaded && loaded.length > 0) {
|
|
437
|
+
// loaded 可能含旧 system prompt(旧版 last-context.json 保存了一份),跳过
|
|
438
|
+
const rest = loaded[0]?.role === 'system' ? loaded.slice(1) : loaded;
|
|
439
|
+
historyRef.current.push(...rest);
|
|
440
|
+
}
|
|
441
|
+
// 6. 清掉中间态、触发 Static remount
|
|
442
|
+
cancelAllThrottles();
|
|
443
|
+
setError(null);
|
|
444
|
+
setInterrupted(false);
|
|
445
|
+
resetTurnState();
|
|
446
|
+
setCompactGeneration((g) => g + 1);
|
|
447
|
+
bump();
|
|
448
|
+
}, [bump, isLoading, cancelAllThrottles, resetTurnState]);
|
|
449
|
+
// history 用 useMemo 托管:version 变化时返回 historyRef.current 的浅拷贝。
|
|
450
|
+
// 为什么必须 .slice():runQuery 内部 mutate 同一个数组(push 新消息),如果这里
|
|
451
|
+
// 返回同一引用,Ink <Static items={history}> 浅比较看不到 prop 变化 → 不 commit
|
|
452
|
+
// 新增 message → turn 结束 LiveArea 消失后屏幕空白,看起来像"输出被吃掉"。
|
|
453
|
+
// slice 后元素引用不变,只是数组容器是新的,足够让 Static 检测到 length 增长并 commit。
|
|
454
|
+
const history = useMemo(() => historyRef.current.slice(), [version]);
|
|
204
455
|
return {
|
|
205
456
|
history,
|
|
206
457
|
streamingText,
|
|
207
|
-
streamingReasoning,
|
|
208
|
-
toolStatus,
|
|
209
458
|
isLoading,
|
|
210
459
|
error,
|
|
211
460
|
interrupted,
|
|
212
|
-
compacting,
|
|
213
461
|
pluginProgress,
|
|
462
|
+
compactGeneration,
|
|
463
|
+
progressStage,
|
|
464
|
+
reasoningPreview,
|
|
465
|
+
runningTools,
|
|
466
|
+
activeSessionId,
|
|
214
467
|
submit,
|
|
215
468
|
abort,
|
|
216
469
|
clearHistory,
|
|
217
470
|
compactNow,
|
|
471
|
+
switchSession,
|
|
472
|
+
ephemeralLine,
|
|
218
473
|
};
|
|
219
474
|
}
|
|
220
|
-
|
|
221
|
-
* 把一个事件派发到对应的 setState。
|
|
222
|
-
*
|
|
223
|
-
* 接受 LoopEvent(框架已识别)或 PluginEvent(插件私有,type 可为任意字符串)。
|
|
224
|
-
* switch 只匹配 LoopEvent 字面量 type;未识别走 default no-op,等同静默忽略。
|
|
225
|
-
*/
|
|
226
|
-
function handleEvent(ev, setters) {
|
|
475
|
+
export function handleEvent(ev, setters) {
|
|
227
476
|
// 收口到 LoopEvent:未识别的 PluginEvent type 走 default no-op
|
|
228
477
|
const e = ev;
|
|
229
478
|
switch (e.type) {
|
|
230
|
-
case '
|
|
231
|
-
|
|
479
|
+
case 'user_message_committed':
|
|
480
|
+
// loop 刚把用户消息 push 进 history。立即 bump → history useMemo 重算 →
|
|
481
|
+
// <Static> 把用户消息 commit 进 scrollback,第一时间置顶(T-A-O-R)。
|
|
482
|
+
setters.bump();
|
|
232
483
|
break;
|
|
233
|
-
case '
|
|
234
|
-
|
|
235
|
-
setters.setStreamingReasoning(prev => prev + e.delta);
|
|
236
|
-
}
|
|
484
|
+
case 'text':
|
|
485
|
+
setters.appendStreamingDelta(e.delta);
|
|
237
486
|
break;
|
|
238
487
|
case 'assistant_message':
|
|
239
|
-
|
|
240
|
-
|
|
488
|
+
// history 已被 runQuery 直接 push。先 flush 残留 streaming buffer
|
|
489
|
+
// 把末段 text 落到 streamingText state(avoid 视觉跳跃),再清空
|
|
490
|
+
// streamingText —— Static 已 commit 完整 assistant message 接管显示。
|
|
491
|
+
setters.flushStreamingBuffer();
|
|
492
|
+
setters.setStreamingText(() => '');
|
|
493
|
+
// 该段 reasoning 已随 assistant 消息进 history → MessageList 提交进 scrollback(💭 块)。
|
|
494
|
+
// 清空 Zone B 的 live 预览:① 本轮 streaming 阶段不再回显旧 reasoning;
|
|
495
|
+
// ② 多轮工具调用时,下一轮 thinking 从空白起步,不残留上一轮思维链。
|
|
496
|
+
setters.cancelReasoningFlush();
|
|
497
|
+
setters.setReasoningPreview(() => '');
|
|
498
|
+
setters.cancelToolBump();
|
|
499
|
+
setters.setProgressStage('idle');
|
|
500
|
+
setters.setRunningToolsMap(() => new Map());
|
|
241
501
|
setters.bump();
|
|
242
502
|
break;
|
|
243
503
|
case 'tool_start':
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
504
|
+
// 把工具加入运行集合;并行多个工具时这里会累积。
|
|
505
|
+
// v2: argsFriendly 是 loop.ts 通过 LoopEvent 扩展字段传过来的人话描述
|
|
506
|
+
// (如 "Reading src/foo.ts")。LoopEvent 类型上没声明此字段时通过
|
|
507
|
+
// (e as { argsFriendly?: string }) 安全读取,缺省 fallback 在 UI 层。
|
|
508
|
+
setters.setRunningToolsMap((prev) => {
|
|
509
|
+
const next = new Map(prev);
|
|
510
|
+
next.set(e.toolCallId, {
|
|
511
|
+
toolName: e.toolName,
|
|
512
|
+
argsPreview: e.argsPreview,
|
|
513
|
+
status: 'running',
|
|
514
|
+
argsFriendly: e.argsFriendly,
|
|
515
|
+
});
|
|
516
|
+
return next;
|
|
248
517
|
});
|
|
249
|
-
|
|
518
|
+
// 工具状态切换走 200ms 节流,避免连续 tool_start/end 高频 rerender
|
|
519
|
+
// 压垮 Ink ANSI 重绘。
|
|
520
|
+
setters.scheduleToolBump();
|
|
250
521
|
break;
|
|
251
522
|
case 'tool_end':
|
|
252
|
-
|
|
523
|
+
// 仅删除对应 callId 的 entry;其余并行工具继续
|
|
524
|
+
setters.setRunningToolsMap((prev) => {
|
|
525
|
+
if (!prev.has(e.toolCallId))
|
|
526
|
+
return prev;
|
|
527
|
+
const next = new Map(prev);
|
|
528
|
+
next.delete(e.toolCallId);
|
|
529
|
+
return next;
|
|
530
|
+
});
|
|
531
|
+
setters.scheduleToolBump();
|
|
532
|
+
break;
|
|
533
|
+
case 'tool_messages_committed':
|
|
534
|
+
setters.cancelStreamingFlush();
|
|
535
|
+
setters.setStreamingText(() => '');
|
|
536
|
+
setters.cancelReasoningFlush();
|
|
537
|
+
setters.setReasoningPreview(() => '');
|
|
538
|
+
setters.cancelToolBump();
|
|
539
|
+
setters.setProgressStage('idle');
|
|
540
|
+
setters.setRunningToolsMap(() => new Map());
|
|
253
541
|
setters.bump();
|
|
254
542
|
break;
|
|
255
543
|
case 'compact_start':
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
544
|
+
// reactive 自救场景:LLM 失败前可能已经 yield 过部分 text delta,
|
|
545
|
+
// 压缩前清掉残留 streamingText + 待 flush buffer,避免重发后新文本追加在
|
|
546
|
+
// 旧 buffer 后面。autoCompact 场景下 streamingText 本来就是空。
|
|
547
|
+
// 注意:progressStage 由 loop 端的 stage_change 'compacting' 负责切换。
|
|
548
|
+
setters.cancelStreamingFlush();
|
|
549
|
+
setters.setStreamingText(() => '');
|
|
550
|
+
setters.cancelReasoningFlush();
|
|
551
|
+
setters.setReasoningPreview(() => '');
|
|
259
552
|
setters.bump();
|
|
260
553
|
break;
|
|
261
554
|
case 'compact_done':
|
|
262
|
-
|
|
555
|
+
// 触发 MessageList 内 <Static> remount,丢弃旧消息缓存
|
|
556
|
+
setters.setCompactGeneration((g) => g + 1);
|
|
263
557
|
setters.bump();
|
|
264
558
|
break;
|
|
265
559
|
case 'turn_done':
|
|
@@ -267,16 +561,17 @@ function handleEvent(ev, setters) {
|
|
|
267
561
|
break;
|
|
268
562
|
case 'interrupted':
|
|
269
563
|
setters.setInterrupted(true);
|
|
270
|
-
setters.setStreamingText('');
|
|
271
|
-
setters.setStreamingReasoning('');
|
|
272
564
|
setters.bump();
|
|
273
565
|
break;
|
|
274
566
|
case 'error':
|
|
275
567
|
setters.setError(e.error);
|
|
276
|
-
setters.setStreamingText('');
|
|
277
|
-
setters.setStreamingReasoning('');
|
|
278
568
|
setters.bump();
|
|
279
569
|
break;
|
|
570
|
+
case 'warning':
|
|
571
|
+
// 通用非致命警告。TUI 不把它当红色 error 渲染;workflow 这类插件已另发
|
|
572
|
+
// plugin_progress 把同一文案镜像到状态行(LiveArea)。这里显式 no-op 让它
|
|
573
|
+
// 成为"已识别事件",不触发 default 分支对未知事件的 dev 告警(见 default)。
|
|
574
|
+
break;
|
|
280
575
|
case 'plugin_progress':
|
|
281
576
|
setters.setPluginProgress({
|
|
282
577
|
pluginId: e.pluginId,
|
|
@@ -286,5 +581,22 @@ function handleEvent(ev, setters) {
|
|
|
286
581
|
});
|
|
287
582
|
setters.bump();
|
|
288
583
|
break;
|
|
584
|
+
case 'reasoning':
|
|
585
|
+
// reasoning delta 频率跟 text delta 一样高(60+/秒),不节流的话每个 delta 立即
|
|
586
|
+
// setReasoningPreview → LiveArea rerender → Ink 重绘扛不住。
|
|
587
|
+
setters.appendReasoningDelta(e.delta);
|
|
588
|
+
// 不调用 bump:reasoningPreview 是 LiveArea 单独的 state,history 没变化,
|
|
589
|
+
// 避免高频 reasoning token 引起 MessageList re-render
|
|
590
|
+
break;
|
|
591
|
+
case 'stage_change':
|
|
592
|
+
// 注意:tool_executing → streaming 切回时不清 runningTools,
|
|
593
|
+
// 工具集合的清空时机是各自 tool_end
|
|
594
|
+
setters.setProgressStage(e.stage);
|
|
595
|
+
break;
|
|
596
|
+
default:
|
|
597
|
+
// 落到这里 = 插件私有事件(PluginEvent,type 任意字符串),TUI 不识别即忽略。
|
|
598
|
+
// 这里**故意不**写 console.warn —— Ink 渲染期间写 stdout/stderr 会撕裂终端画面。
|
|
599
|
+
// 要排查插件 yield 了拼错的事件 type,请用 `minimal-agent -p --verbose`(print.ts 落 stderr)。
|
|
600
|
+
break;
|
|
289
601
|
}
|
|
290
602
|
}
|