foliko 1.1.63 → 1.1.64
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/.agent/sessions/cli_default.json +119 -301
- package/cli/bin/foliko.js +2 -2
- package/cli/src/commands/chat.js +15 -26
- package/cli/src/ui/chat-ui.js +102 -165
- package/cli/src/ui/footer-bar.js +7 -32
- package/cli/src/ui/message-bubble.js +24 -2
- package/cli/src/ui/status-bar.js +177 -0
- package/package.json +1 -2
- package/plugins/qq-plugin.js +1 -1
- package/src/core/agent-chat.js +50 -17
- package/src/core/agent.js +17 -27
- package/src/core/chat-session.js +7 -161
- package/src/core/constants.js +198 -0
- package/src/core/context-compressor.js +6 -181
- package/src/core/framework.js +125 -6
- package/src/core/plugin-base.js +7 -5
- package/src/core/provider.js +6 -0
- package/src/core/subagent.js +16 -135
- package/src/core/tool-executor.js +2 -70
- package/src/executors/mcp-executor.js +1 -1
- package/src/utils/chat-queue.js +11 -22
- package/src/utils/download.js +5 -4
- package/src/utils/message-validator.js +283 -0
- package/src/utils/retry.js +168 -22
- package/src/utils/sandbox.js +60 -207
- package/cli/src/utils/debounce.js +0 -106
package/cli/bin/foliko.js
CHANGED
package/cli/src/commands/chat.js
CHANGED
|
@@ -7,50 +7,39 @@ const path = require('path');
|
|
|
7
7
|
const { Framework } = require('../../../src');
|
|
8
8
|
const { ChatUI } = require('../ui/chat-ui');
|
|
9
9
|
const { logger, LOG_LEVELS } = require('../../../src/utils/logger');
|
|
10
|
+
const { DEFAULT_PROVIDERS } = require('../../../src/core/provider');
|
|
10
11
|
|
|
11
12
|
// // 加载 .env 文件
|
|
12
13
|
// dotenv.config({ override: true,quiet: true});
|
|
13
14
|
|
|
14
|
-
//
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
// Provider 默认配置
|
|
23
|
-
const PROVIDER_DEFAULTS = {
|
|
24
|
-
minimax: {
|
|
25
|
-
model: 'MiniMax-M2.7',
|
|
26
|
-
baseURL: 'https://api.minimaxi.com/v1',
|
|
27
|
-
},
|
|
28
|
-
deepseek: {
|
|
29
|
-
model: 'deepseek-chat',
|
|
30
|
-
baseURL: 'https://api.deepseek.com/v1',
|
|
31
|
-
},
|
|
15
|
+
// 各 provider 默认使用的模型
|
|
16
|
+
const PROVIDER_MODELS = {
|
|
17
|
+
minimax: 'MiniMax-M2.7',
|
|
18
|
+
deepseek: 'deepseek-chat',
|
|
19
|
+
openai: 'gpt-4o',
|
|
20
|
+
anthropic: 'claude-sonnet-4-20250514',
|
|
32
21
|
};
|
|
33
22
|
|
|
34
23
|
/**
|
|
35
24
|
* 获取环境变量配置
|
|
36
25
|
*/
|
|
37
26
|
function getEnvConfig() {
|
|
38
|
-
const provider = process.env.FOLIKO_PROVIDER ||
|
|
39
|
-
const
|
|
27
|
+
const provider = process.env.FOLIKO_PROVIDER || 'minimax';
|
|
28
|
+
const providerInfo = DEFAULT_PROVIDERS[provider];
|
|
29
|
+
const defaultModel = PROVIDER_MODELS[provider] || 'deepseek-chat';
|
|
40
30
|
|
|
41
|
-
//
|
|
31
|
+
// API key: 优先 FOLIKO_API_KEY,否则找 {PROVIDER}_API_KEY
|
|
42
32
|
let apiKey = process.env.FOLIKO_API_KEY || null;
|
|
43
33
|
if (!apiKey) {
|
|
44
|
-
// 根据 provider 查找对应的 API key
|
|
45
34
|
const upperProvider = provider.toUpperCase().replace(/-/g, '_');
|
|
46
35
|
apiKey = process.env[`${upperProvider}_API_KEY`] || null;
|
|
47
36
|
}
|
|
48
37
|
|
|
49
38
|
return {
|
|
50
|
-
model: process.env.FOLIKO_MODEL ||
|
|
51
|
-
provider
|
|
52
|
-
baseURL: process.env.FOLIKO_BASE_URL ||
|
|
53
|
-
apiKey
|
|
39
|
+
model: process.env.FOLIKO_MODEL || defaultModel,
|
|
40
|
+
provider,
|
|
41
|
+
baseURL: process.env.FOLIKO_BASE_URL || (providerInfo ? providerInfo.baseURL : null),
|
|
42
|
+
apiKey,
|
|
54
43
|
};
|
|
55
44
|
}
|
|
56
45
|
|
package/cli/src/ui/chat-ui.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
const chalk = require('chalk').default;
|
|
6
6
|
const figlet = require('figlet');
|
|
7
7
|
const { CLEAR_LINE, CYAN, DIM, GREEN, RED, YELLOW, colored } = require('../utils/ansi');
|
|
8
|
-
const { TUI, ProcessTerminal, Editor,
|
|
8
|
+
const { TUI, ProcessTerminal, Editor, Text, CombinedAutocompleteProvider, matchesKey, Container } = require('@earendil-works/pi-tui');
|
|
9
9
|
const { cleanResponse } = require('../../../src/utils');
|
|
10
10
|
const { logEmitter } = require('../../../src/utils/logger');
|
|
11
11
|
const { renderLine } = require('../utils/markdown');
|
|
@@ -14,6 +14,7 @@ const Queue=require('js-queue');
|
|
|
14
14
|
const hl = require('cli-highlight');
|
|
15
15
|
const { renderDiffWithHeader } = require('../utils/render-diff');
|
|
16
16
|
const { FooterBar } = require('./footer-bar');
|
|
17
|
+
const { StatusBar } = require('./status-bar');
|
|
17
18
|
const queue=new Queue();
|
|
18
19
|
// Foliko 主色(蓝绿)
|
|
19
20
|
const folikoPrimary = chalk.hex('#2A9D8F');
|
|
@@ -70,15 +71,15 @@ class ChatUI {
|
|
|
70
71
|
const terminal = new ProcessTerminal();
|
|
71
72
|
const tui=this.tui = new TUI(terminal);
|
|
72
73
|
this.messageContainer= new Container(0)
|
|
73
|
-
this.statusContainer= new Container(0)
|
|
74
74
|
const text=`${folikoTerracotta("Ctrl+C")} 退出 | ${folikoTerracotta("/")} 指令列表 | ${folikoTerracotta("Enter")} 发送 | ${folikoTerracotta("Shift+Enter")} 换行`
|
|
75
75
|
this.messageContainer.addChild(
|
|
76
76
|
new Text(FOLIKO_LOGO + folikoGold(`\n欢迎使用Foliko!\n`)+folikoSand(text),3,2),
|
|
77
77
|
);
|
|
78
78
|
this.tui.addChild(this.messageContainer)
|
|
79
|
-
this.tui.addChild(this.statusContainer)
|
|
80
|
-
|
|
81
79
|
|
|
80
|
+
// 状态栏组件(通知/工具调用/思考中),插在消息区和编辑器之间
|
|
81
|
+
this.statusBar = new StatusBar(tui, this.editor);
|
|
82
|
+
this.tui.addChild(this.statusBar.container);
|
|
82
83
|
|
|
83
84
|
this._currentBotMessage=null
|
|
84
85
|
|
|
@@ -135,132 +136,6 @@ class ChatUI {
|
|
|
135
136
|
if (initialFooter.length > 0) {
|
|
136
137
|
this._footerText.setText(initialFooter[0]);
|
|
137
138
|
}
|
|
138
|
-
const tooler=new Loader(
|
|
139
|
-
tui,
|
|
140
|
-
(s) => chalk.green(s), // indicatorColorFn
|
|
141
|
-
(s) => chalk.yellow(s), // messageColorFn
|
|
142
|
-
"工具调用...",
|
|
143
|
-
{
|
|
144
|
-
frames:["|", "/", "-", "\\"].map(str=>folikoTerracotta(str))
|
|
145
|
-
}
|
|
146
|
-
)
|
|
147
|
-
const loader=new Loader(
|
|
148
|
-
tui,
|
|
149
|
-
(s) => chalk.cyan(s), // indicatorColorFn
|
|
150
|
-
(s) => chalk.dim(s), // messageColorFn
|
|
151
|
-
"正在思考中...",
|
|
152
|
-
{
|
|
153
|
-
frames:["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"].map(str=>folikoPrimary(str))
|
|
154
|
-
}
|
|
155
|
-
)
|
|
156
|
-
// Spacer 占位,用于隐藏时保持布局
|
|
157
|
-
const toolerSpacer = new Spacer(0);
|
|
158
|
-
const loaderSpacer = new Spacer(0);
|
|
159
|
-
const notifierSpacer = new Spacer(0);
|
|
160
|
-
|
|
161
|
-
this.statusContainer.addChild(toolerSpacer); // tooler 位置
|
|
162
|
-
this.statusContainer.addChild(loaderSpacer); // loader 位置
|
|
163
|
-
this.statusContainer.addChild(notifierSpacer); // notifier 位置
|
|
164
|
-
|
|
165
|
-
const self = this;
|
|
166
|
-
|
|
167
|
-
this.tooler = {
|
|
168
|
-
dom: tooler,
|
|
169
|
-
spacer: toolerSpacer,
|
|
170
|
-
showed:false,
|
|
171
|
-
index:1,
|
|
172
|
-
show(text) {
|
|
173
|
-
this.showed = true;
|
|
174
|
-
self.statusContainer.children[this.index] = self.tooler.dom;
|
|
175
|
-
this.dom.setMessage(text);
|
|
176
|
-
tui.requestRender();
|
|
177
|
-
return self.tooler.dom;
|
|
178
|
-
},
|
|
179
|
-
hide() {
|
|
180
|
-
this.showed = false;
|
|
181
|
-
self.statusContainer.children[this.index] = self.tooler.spacer;
|
|
182
|
-
tui.requestRender();
|
|
183
|
-
},
|
|
184
|
-
setText(text) {
|
|
185
|
-
if(!this.showed){
|
|
186
|
-
this.show(text);
|
|
187
|
-
} else {
|
|
188
|
-
this.dom.setMessage(text);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
this.notifier = {
|
|
194
|
-
dom: null,
|
|
195
|
-
spacer: notifierSpacer,
|
|
196
|
-
showed: false,
|
|
197
|
-
_timeout: null,
|
|
198
|
-
index:0,
|
|
199
|
-
show(text, duration = 5000) {
|
|
200
|
-
if (self.notifier._timeout) {
|
|
201
|
-
clearTimeout(self.notifier._timeout);
|
|
202
|
-
}
|
|
203
|
-
if (!self.notifier.dom) {
|
|
204
|
-
self.notifier.dom = new Loader(
|
|
205
|
-
tui,
|
|
206
|
-
(s) => chalk.cyan(s),
|
|
207
|
-
(s) => chalk.yellow(s),
|
|
208
|
-
text,
|
|
209
|
-
);
|
|
210
|
-
}
|
|
211
|
-
self.notifier.dom.setMessage(text);
|
|
212
|
-
self.statusContainer.children[this.index] = self.notifier.dom;
|
|
213
|
-
self.notifier.showed = true;
|
|
214
|
-
tui.requestRender();
|
|
215
|
-
self.notifier._timeout = setTimeout(() => {
|
|
216
|
-
self.notifier.hide();
|
|
217
|
-
}, duration);
|
|
218
|
-
},
|
|
219
|
-
hide() {
|
|
220
|
-
if (self.notifier._timeout) {
|
|
221
|
-
clearTimeout(self.notifier._timeout);
|
|
222
|
-
self.notifier._timeout = null;
|
|
223
|
-
}
|
|
224
|
-
self.notifier.showed = false;
|
|
225
|
-
self.statusContainer.children[this.index] = self.notifier.spacer;
|
|
226
|
-
tui.requestRender();
|
|
227
|
-
},
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
this.loader = {
|
|
231
|
-
dom: loader,
|
|
232
|
-
spacer: loaderSpacer,
|
|
233
|
-
startTime: null,
|
|
234
|
-
timer: null,
|
|
235
|
-
index:2,
|
|
236
|
-
show() {
|
|
237
|
-
self.statusContainer.children[this.index] = self.loader.dom;
|
|
238
|
-
editor.disableSubmit = true;
|
|
239
|
-
self.startTime = Date.now();
|
|
240
|
-
self.loader.dom.setMessage("正在思考中... (0秒)");
|
|
241
|
-
self.loader.dom.start();
|
|
242
|
-
// 每秒更新计时
|
|
243
|
-
self.loader.timer = setInterval(() => {
|
|
244
|
-
const elapsed = Math.floor((Date.now() - self.startTime) / 1000);
|
|
245
|
-
const seconds = elapsed % 60;
|
|
246
|
-
const minutes = Math.floor(elapsed / 60);
|
|
247
|
-
const timeStr = minutes > 0 ? `${minutes}分${seconds}秒` : `${seconds}秒`;
|
|
248
|
-
self.loader.dom.setMessage(`正在思考中... (${timeStr})`);
|
|
249
|
-
}, 1000);
|
|
250
|
-
tui.requestRender();
|
|
251
|
-
return self.loader.dom;
|
|
252
|
-
},
|
|
253
|
-
hide() {
|
|
254
|
-
editor.disableSubmit = false;
|
|
255
|
-
if (self.loader.timer) {
|
|
256
|
-
clearInterval(self.loader.timer);
|
|
257
|
-
self.loader.timer = null;
|
|
258
|
-
}
|
|
259
|
-
self.loader.dom.stop();
|
|
260
|
-
self.statusContainer.children[this.index] = self.loader.spacer;
|
|
261
|
-
tui.requestRender();
|
|
262
|
-
}
|
|
263
|
-
};
|
|
264
139
|
tui.requestRender();
|
|
265
140
|
// 中断状态(供监听器访问)
|
|
266
141
|
this.interrupted = false;
|
|
@@ -270,6 +145,11 @@ class ChatUI {
|
|
|
270
145
|
|
|
271
146
|
// 流式输出缓冲区(可重置)
|
|
272
147
|
this._lineBuffer = '';
|
|
148
|
+
// 流式渲染缓冲区:合并高频 chunk 后统一刷新 Markdown,减少重复解析
|
|
149
|
+
this._streamBufferTimer = null;
|
|
150
|
+
this._pendingTextUpdate = false;
|
|
151
|
+
// renderLine 的共享状态(引用传递,跨调用保持 codeBlock/think 状态)
|
|
152
|
+
this._renderState = { inThink: true, inCodeBlock: false };
|
|
273
153
|
// agent.framework.on("console:log",async (...args)=>{
|
|
274
154
|
// this.create_message(args.join(' '),chalk.dim('● '),true);
|
|
275
155
|
// })
|
|
@@ -318,11 +198,25 @@ class ChatUI {
|
|
|
318
198
|
}
|
|
319
199
|
const time = timestamp ? new Date(timestamp).toLocaleTimeString('zh-CN') : '';
|
|
320
200
|
const notificationText = `🔔 [${source}] ${title}${time ? ` (${time})` : ''}`;
|
|
321
|
-
this.notifier.show(notificationText);
|
|
201
|
+
this.statusBar.notifier.show(notificationText);
|
|
322
202
|
// 显示消息内容
|
|
323
203
|
this.create_message(`**${title}**\n${message}`, "🔔 ");
|
|
324
204
|
};
|
|
325
205
|
this.agent.framework.on('notification', this._notificationHandler);
|
|
206
|
+
|
|
207
|
+
// 监听 usage 事件(用于 footer 更新)
|
|
208
|
+
this._usageHandler = (data) => {
|
|
209
|
+
if (this.footerBar && data.usage) {
|
|
210
|
+
const u = data.usage;
|
|
211
|
+
this.footerBar.updateUsage(u.promptTokens || 0, u.completionTokens || 0);
|
|
212
|
+
const footerLines = this.footerBar.render(this.tui.width || process.stdout.columns || 80);
|
|
213
|
+
if (footerLines.length > 0 && this._footerText) {
|
|
214
|
+
this._footerText.setText(footerLines[0]);
|
|
215
|
+
}
|
|
216
|
+
this.tui.requestRender();
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
agent.framework.on('agent:usage', this._usageHandler);
|
|
326
220
|
}
|
|
327
221
|
}
|
|
328
222
|
|
|
@@ -330,6 +224,11 @@ class ChatUI {
|
|
|
330
224
|
* 清理监听器
|
|
331
225
|
*/
|
|
332
226
|
dispose() {
|
|
227
|
+
// 清理流式缓冲区定时器
|
|
228
|
+
if (this._streamBufferTimer) {
|
|
229
|
+
clearTimeout(this._streamBufferTimer);
|
|
230
|
+
this._streamBufferTimer = null;
|
|
231
|
+
}
|
|
333
232
|
if (this._logHandler && this.agent.framework) {
|
|
334
233
|
logEmitter.off('log', this._logHandler);
|
|
335
234
|
this._logHandler = null;
|
|
@@ -338,11 +237,21 @@ class ChatUI {
|
|
|
338
237
|
this.agent.framework.off('notification', this._notificationHandler);
|
|
339
238
|
this._notificationHandler = null;
|
|
340
239
|
}
|
|
240
|
+
if (this._usageHandler && this.agent.framework) {
|
|
241
|
+
this.agent.framework.off('agent:usage', this._usageHandler);
|
|
242
|
+
this._usageHandler = null;
|
|
243
|
+
}
|
|
341
244
|
}
|
|
342
245
|
|
|
343
246
|
clear_message_done(){
|
|
344
|
-
this.
|
|
345
|
-
this.
|
|
247
|
+
this._flushStreamBuffer(); // 确保最后的文本被刷新
|
|
248
|
+
if (this._streamBufferTimer) {
|
|
249
|
+
clearTimeout(this._streamBufferTimer);
|
|
250
|
+
this._streamBufferTimer = null;
|
|
251
|
+
}
|
|
252
|
+
this._pendingTextUpdate = false;
|
|
253
|
+
this.statusBar.loader.hide();
|
|
254
|
+
this.statusBar.tooler.hide();
|
|
346
255
|
this._lineBuffer=""
|
|
347
256
|
this.isResponding=false
|
|
348
257
|
this._currentBotMessage=null
|
|
@@ -409,11 +318,8 @@ class ChatUI {
|
|
|
409
318
|
}
|
|
410
319
|
|
|
411
320
|
create_message(text, icon="", isBot=false, bgFn=null){
|
|
412
|
-
// 使用 MessageBubble 组件实现左侧图标 + 右侧 markdown 布局
|
|
413
|
-
// bgFn: 可选背景色函数 (line) => coloredLine,用于代码块等整块背景
|
|
414
321
|
const bubble = new MessageBubble(icon, text, isBot, markdownTheme, 0, 0, 1, bgFn);
|
|
415
|
-
|
|
416
|
-
children.splice(children.length, 0, bubble);
|
|
322
|
+
this.messageContainer.addChild(bubble);
|
|
417
323
|
this.tui.requestRender();
|
|
418
324
|
return bubble
|
|
419
325
|
}
|
|
@@ -422,8 +328,8 @@ class ChatUI {
|
|
|
422
328
|
|
|
423
329
|
_clearContext() {
|
|
424
330
|
// 隐藏 loader 和 tooler
|
|
425
|
-
this.tooler.hide();
|
|
426
|
-
this.loader.hide();
|
|
331
|
+
this.statusBar.tooler.hide();
|
|
332
|
+
this.statusBar.loader.hide();
|
|
427
333
|
|
|
428
334
|
const { sessionId } = this;
|
|
429
335
|
|
|
@@ -457,9 +363,10 @@ class ChatUI {
|
|
|
457
363
|
}
|
|
458
364
|
|
|
459
365
|
// 清空 messageContainer 中的消息,保留 welcome
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
366
|
+
const welcomeChild = this.messageContainer.children[0];
|
|
367
|
+
this.messageContainer.clear();
|
|
368
|
+
if (welcomeChild) {
|
|
369
|
+
this.messageContainer.addChild(welcomeChild);
|
|
463
370
|
}
|
|
464
371
|
|
|
465
372
|
// 重置 footer 统计
|
|
@@ -475,8 +382,8 @@ class ChatUI {
|
|
|
475
382
|
|
|
476
383
|
_compressContext() {
|
|
477
384
|
// 隐藏 loader 和 tooler
|
|
478
|
-
this.tooler.hide();
|
|
479
|
-
this.loader.hide();
|
|
385
|
+
this.statusBar.tooler.hide();
|
|
386
|
+
this.statusBar.loader.hide();
|
|
480
387
|
|
|
481
388
|
const { sessionId } = this;
|
|
482
389
|
|
|
@@ -504,14 +411,14 @@ class ChatUI {
|
|
|
504
411
|
}
|
|
505
412
|
|
|
506
413
|
const beforeCount = messages ? messages.length : 0;
|
|
507
|
-
this.tooler.show(`${colored('[压缩]', YELLOW)} 压缩前: ${beforeCount} 条`);
|
|
414
|
+
this.statusBar.tooler.show(`${colored('[压缩]', YELLOW)} 压缩前: ${beforeCount} 条`);
|
|
508
415
|
|
|
509
416
|
if (messages && messages.length > 0) {
|
|
510
417
|
// 使用 ContextCompressor 压缩
|
|
511
418
|
if (chatHandler._contextCompressor) {
|
|
512
419
|
chatHandler._contextCompressor.compress(sessionId, messages, messageStore).then(() => {
|
|
513
420
|
const afterCount = messages.length;
|
|
514
|
-
this.tooler.setText(`${colored('[压缩]', YELLOW)} 压缩后: ${afterCount} 条 (保留${chatHandler._contextCompressor._keepRecentMessages}条)`);
|
|
421
|
+
this.statusBar.tooler.setText(`${colored('[压缩]', YELLOW)} 压缩后: ${afterCount} 条 (保留${chatHandler._contextCompressor._keepRecentMessages}条)`);
|
|
515
422
|
// 同步回 SessionContext
|
|
516
423
|
if (this.agent.framework) {
|
|
517
424
|
const sessionCtx = this.agent.framework.getSessionContext(sessionId);
|
|
@@ -520,49 +427,79 @@ class ChatUI {
|
|
|
520
427
|
}
|
|
521
428
|
}
|
|
522
429
|
setTimeout(() => {
|
|
523
|
-
this.tooler.hide();
|
|
430
|
+
this.statusBar.tooler.hide();
|
|
524
431
|
this.create_message(`${colored('[提示]', CYAN)} 上下文已压缩`,chalk.dim('● '))
|
|
525
432
|
this.tui.requestRender();
|
|
526
433
|
}, 2000);
|
|
527
434
|
}).catch(err => {
|
|
528
|
-
this.tooler.setText(`${colored('[错误]', RED)} 压缩失败: ${err.message}`);
|
|
435
|
+
this.statusBar.tooler.setText(`${colored('[错误]', RED)} 压缩失败: ${err.message}`);
|
|
529
436
|
});
|
|
530
437
|
} else {
|
|
531
|
-
this.tooler.setText(`${colored('[错误]', RED)} 无 ContextCompressor`);
|
|
438
|
+
this.statusBar.tooler.setText(`${colored('[错误]', RED)} 无 ContextCompressor`);
|
|
532
439
|
}
|
|
533
440
|
} else {
|
|
534
|
-
this.tooler.setText(`${colored('[提示]', CYAN)} 无消息可压缩`);
|
|
441
|
+
this.statusBar.tooler.setText(`${colored('[提示]', CYAN)} 无消息可压缩`);
|
|
535
442
|
}
|
|
536
443
|
} else {
|
|
537
|
-
this.tooler.setText(`${colored('[错误]', RED)} 无 AgentChatHandler`);
|
|
444
|
+
this.statusBar.tooler.setText(`${colored('[错误]', RED)} 无 AgentChatHandler`);
|
|
538
445
|
}
|
|
539
446
|
}
|
|
447
|
+
/**
|
|
448
|
+
* 刷新流式输出缓冲区:合并高频 chunk 后统一渲染,减少 Markdown 重复解析
|
|
449
|
+
*/
|
|
450
|
+
_flushStreamBuffer() {
|
|
451
|
+
this._streamBufferTimer = null;
|
|
452
|
+
if (!this._lineBuffer) return;
|
|
453
|
+
|
|
454
|
+
// 渲染剩余的 partial line(没有 \n 结尾的尾部文字)
|
|
455
|
+
const rendered = renderLine(this._lineBuffer, this._renderState, true);
|
|
456
|
+
if (!this._currentBotMessage) {
|
|
457
|
+
this._currentBotMessage = this.create_message(rendered, colored('● ', GREEN), true);
|
|
458
|
+
} else {
|
|
459
|
+
this._currentBotMessage.appendContent(rendered);
|
|
460
|
+
}
|
|
461
|
+
this._lineBuffer = '';
|
|
462
|
+
}
|
|
463
|
+
|
|
540
464
|
/**
|
|
541
465
|
* 设置 session scope 事件监听
|
|
542
466
|
*/
|
|
543
467
|
_setupSessionListeners() {
|
|
544
|
-
const renderState = { inThink: true, inCodeBlock: false };
|
|
545
|
-
|
|
546
468
|
this.sessionScope.on('stream:chunk', ({ chunk }) => {
|
|
547
469
|
if (this.interrupted) return;
|
|
548
470
|
|
|
549
471
|
if (chunk.type === 'text') {
|
|
550
|
-
this._lineBuffer += chunk.text
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
472
|
+
this._lineBuffer += chunk.text
|
|
473
|
+
|
|
474
|
+
// 逐行处理:遇到 \n 立即渲染完整行,实现即时反馈
|
|
475
|
+
// 行尾补回 \n 确保 TUI 正确换行(空行也要渲染,保留段落分隔)
|
|
476
|
+
// while (this._lineBuffer.includes('\n')) {
|
|
477
|
+
// if (this.interrupted) break;
|
|
478
|
+
|
|
479
|
+
// const nlIndex = this._lineBuffer.indexOf('\n');
|
|
480
|
+
// const line = this._lineBuffer.substring(0, nlIndex);
|
|
481
|
+
// this._lineBuffer = this._lineBuffer.substring(nlIndex + 1);
|
|
482
|
+
|
|
483
|
+
// const rendered = line;
|
|
484
|
+
// if (!this._currentBotMessage) {
|
|
485
|
+
// this._currentBotMessage = this.create_message(rendered, colored('● ', GREEN), true);
|
|
486
|
+
// } else {
|
|
487
|
+
// this._currentBotMessage.appendContent(rendered);
|
|
488
|
+
// }
|
|
489
|
+
|
|
490
|
+
// }
|
|
491
|
+
|
|
492
|
+
// 兜底:partial line(无 \n 结尾)用 timer 刷新,防止尾部文字卡住
|
|
493
|
+
if (this._lineBuffer && !this._streamBufferTimer) {
|
|
494
|
+
this._streamBufferTimer = setTimeout(() => this._flushStreamBuffer(), 0);
|
|
558
495
|
}
|
|
559
496
|
} else if (chunk.type === 'tool-call') {
|
|
560
497
|
const args = chunk.input ? JSON.stringify(chunk.input).slice(0, 50) : '';
|
|
561
|
-
this.tooler.show(`${chalk.yellow('[Tool]')} ${folikoGold(chunk.toolName)} ${chalk.gray(args+'...')}`)
|
|
498
|
+
this.statusBar.tooler.show(`${chalk.yellow('[Tool]')} ${folikoGold(chunk.toolName)} ${chalk.gray(args+'...')}`)
|
|
562
499
|
} else if(chunk.type==='tool-result'){
|
|
563
500
|
const result = chunk.result;
|
|
564
501
|
const shortResult = typeof result === 'string' ? result.slice(0, 30) : JSON.stringify(result).slice(0, 30);
|
|
565
|
-
this.tooler.show(`${chalk.green('[Tool]')} ${folikoGold(chunk.toolName)} ${chalk.gray(shortResult+'...')}`)
|
|
502
|
+
this.statusBar.tooler.show(`${chalk.green('[Tool]')} ${folikoGold(chunk.toolName)} ${chalk.gray(shortResult+'...')}`)
|
|
566
503
|
|
|
567
504
|
// 如果是 edit/edit_file 的结果并且包含 diff,渲染彩色 diff
|
|
568
505
|
if (chunk.toolName === 'edit' || chunk.toolName === 'edit_file') {
|
|
@@ -575,7 +512,7 @@ class ChatUI {
|
|
|
575
512
|
const diffContent = renderDiffWithHeader(resultObj.diff, resultObj.filePath);
|
|
576
513
|
const bgColor = chalk.bgHex('#1a1a2e');
|
|
577
514
|
this.create_message(diffContent, chalk.yellow('▸ '), true, (line) => bgColor(line));
|
|
578
|
-
this.tooler.show(`${chalk.green('[Diff]')} ${folikoGold(resultObj.filePath)}`);
|
|
515
|
+
this.statusBar.tooler.show(`${chalk.green('[Diff]')} ${folikoGold(resultObj.filePath)}`);
|
|
579
516
|
}
|
|
580
517
|
}
|
|
581
518
|
// setTimeout(() => this.tooler.hide(), 3000);
|
|
@@ -592,7 +529,7 @@ class ChatUI {
|
|
|
592
529
|
}
|
|
593
530
|
});
|
|
594
531
|
this.sessionScope.on('message:start', async () => {
|
|
595
|
-
this.loader.show();
|
|
532
|
+
this.statusBar.loader.show();
|
|
596
533
|
})
|
|
597
534
|
this.sessionScope.on('message:complete', async ({ content, requestId }) => {
|
|
598
535
|
try{
|
package/cli/src/ui/footer-bar.js
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
const chalk = require('chalk').default;
|
|
9
|
+
const { visibleWidth, truncateToWidth } = require('@earendil-works/pi-tui');
|
|
9
10
|
const { TokenCounter } = require('../../../src/core/token-counter');
|
|
10
11
|
|
|
11
12
|
// Foliko 配色
|
|
12
13
|
const folikoDim = chalk.hex('#6B7280');
|
|
13
14
|
const folikoAccent = chalk.hex('#2A9D8F');
|
|
14
|
-
const folikoGold = chalk.hex('#
|
|
15
|
+
const folikoGold = chalk.hex('#999a9c');
|
|
15
16
|
|
|
16
17
|
class FooterBar {
|
|
17
18
|
/**
|
|
@@ -182,10 +183,10 @@ class FooterBar {
|
|
|
182
183
|
const parts = [];
|
|
183
184
|
|
|
184
185
|
// ↑ input tokens
|
|
185
|
-
parts.push(
|
|
186
|
+
parts.push(chalk.dim(`↑${this._formatNum(s.inputTokens)}`));
|
|
186
187
|
|
|
187
188
|
// ↓ output tokens
|
|
188
|
-
parts.push(
|
|
189
|
+
parts.push(chalk.dim(`↓${this._formatNum(s.outputTokens)}`));
|
|
189
190
|
|
|
190
191
|
// $ cost
|
|
191
192
|
if (s.cost > 0) {
|
|
@@ -212,7 +213,7 @@ class FooterBar {
|
|
|
212
213
|
let line = parts.join(' ');
|
|
213
214
|
|
|
214
215
|
// 计算实际显示宽度(去除 ANSI 编码)
|
|
215
|
-
const visibleLen = visibleWidth
|
|
216
|
+
const visibleLen = visibleWidth(line);
|
|
216
217
|
|
|
217
218
|
// 如果太长,缩短模型名
|
|
218
219
|
if (visibleLen > width) {
|
|
@@ -226,38 +227,12 @@ class FooterBar {
|
|
|
226
227
|
}
|
|
227
228
|
|
|
228
229
|
// 截断到可用宽度
|
|
229
|
-
if (visibleWidth
|
|
230
|
-
line = truncateToWidth
|
|
230
|
+
if (visibleWidth(line) > width) {
|
|
231
|
+
line = truncateToWidth(line, width);
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
return [line];
|
|
234
235
|
}
|
|
235
236
|
}
|
|
236
237
|
|
|
237
|
-
function visibleWidth(text) {
|
|
238
|
-
// 简易 ANSI 不可见宽度计算
|
|
239
|
-
const stripped = text.replace(/\x1B\[[0-9;]*m/g, '');
|
|
240
|
-
return stripped.length;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function truncateToWidth(text, maxWidth) {
|
|
244
|
-
const stripped = text.replace(/\x1B\[[0-9;]*m/g, '');
|
|
245
|
-
if (stripped.length <= maxWidth) return text;
|
|
246
|
-
|
|
247
|
-
// 需要截断,保留 ANSI 编码不变
|
|
248
|
-
let result = '';
|
|
249
|
-
let visLen = 0;
|
|
250
|
-
const regex = /(\x1B\[[0-9;]*m)|(.)/g;
|
|
251
|
-
let match;
|
|
252
|
-
while ((match = regex.exec(text)) !== null && visLen < maxWidth) {
|
|
253
|
-
if (match[1]) {
|
|
254
|
-
result += match[1];
|
|
255
|
-
} else {
|
|
256
|
-
result += match[2];
|
|
257
|
-
visLen++;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
return result;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
238
|
module.exports = { FooterBar };
|
|
@@ -28,11 +28,22 @@ class MessageBubble {
|
|
|
28
28
|
this._cachedLines = null;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* 实现 pi-tui Component 的 invalidate 接口
|
|
33
|
+
* 主题变化时由 TUI 调用,清除所有缓存
|
|
34
|
+
*/
|
|
35
|
+
invalidate() {
|
|
36
|
+
this._cacheKey = null;
|
|
37
|
+
this._cachedLines = null;
|
|
38
|
+
if (this._markdown) {
|
|
39
|
+
this._markdown.invalidate?.();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
31
43
|
setContent(content) {
|
|
32
44
|
if (this.content !== content) {
|
|
33
45
|
this.content = content;
|
|
34
|
-
this.
|
|
35
|
-
this._cachedLines = null;
|
|
46
|
+
this.invalidate();
|
|
36
47
|
}
|
|
37
48
|
}
|
|
38
49
|
|
|
@@ -41,6 +52,17 @@ class MessageBubble {
|
|
|
41
52
|
this.setContent(content);
|
|
42
53
|
}
|
|
43
54
|
|
|
55
|
+
/**
|
|
56
|
+
* 追加已渲染的内容(流式输出增量追加)
|
|
57
|
+
* 比 setText 更高效:跳过全量置换,只追加新内容并清缓存
|
|
58
|
+
* @param {string} renderedDelta - 新渲染的 ANSI 文本片段
|
|
59
|
+
*/
|
|
60
|
+
appendContent(renderedDelta) {
|
|
61
|
+
this.content += renderedDelta;
|
|
62
|
+
this._cacheKey = null;
|
|
63
|
+
this._cachedLines = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
44
66
|
render(width) {
|
|
45
67
|
// 检查缓存
|
|
46
68
|
const cacheKey = `${width}:${this.content}`;
|