@vincentbanana/bananacli 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/cli/ui.js +18 -1
- package/dist/index.js +18 -7
- package/dist/server/ui-server.js +281 -30
- package/dist/ui/app.js +1253 -0
- package/dist/ui/index.html +108 -0
- package/dist/ui/style.css +885 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -31,6 +31,19 @@
|
|
|
31
31
|
- **改前先看 diff、改后能回滚**:写文件前展示具体增删行,`/undo` 还原上一轮所有文件改动。
|
|
32
32
|
- **多模态与扩展**:`/image` 附加截图,`.banana/tools/` 放脚本即可注册自定义工具,成本按 token 估算。
|
|
33
33
|
|
|
34
|
+
## 图形界面
|
|
35
|
+
|
|
36
|
+
同一个内核,两种界面。CLI 加 `--ui` 就能开图形界面:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
banana --ui # 打开图形界面
|
|
40
|
+
npm install -g @vincentbanana/banana-gui && banana-gui # 或装独立的 GUI 包
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
图形界面与终端**共用同一套内核**(agent 循环、工具、权限、`commands.ts` 里的斜杠命令),行为完全一致,配置也共用 `~/.banana/config.json`。
|
|
44
|
+
|
|
45
|
+
界面提供:会话管理与导出(Markdown / JSON)、`/` 命令面板、`@` 文件补全、项目文件面板、工具输出详情、三态主题、API Key 设置。
|
|
46
|
+
|
|
34
47
|
## 平台支持
|
|
35
48
|
|
|
36
49
|
| 平台 | 状态 | 说明 |
|
package/dist/cli/ui.js
CHANGED
|
@@ -35,11 +35,24 @@ export const c = {
|
|
|
35
35
|
magenta: paint(35, 39),
|
|
36
36
|
gray: paint(90, 39),
|
|
37
37
|
};
|
|
38
|
+
let sink = null;
|
|
39
|
+
/** 把输出转到别处(GUI 用);返回还原函数。 */
|
|
40
|
+
export function captureOutput(target) {
|
|
41
|
+
const previous = sink;
|
|
42
|
+
sink = target;
|
|
43
|
+
return () => {
|
|
44
|
+
sink = previous;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
38
47
|
export function write(text) {
|
|
48
|
+
if (sink) {
|
|
49
|
+
sink(text);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
39
52
|
process.stdout.write(text);
|
|
40
53
|
}
|
|
41
54
|
export function line(text = '') {
|
|
42
|
-
|
|
55
|
+
write(`${text}\n`);
|
|
43
56
|
}
|
|
44
57
|
export function info(text) {
|
|
45
58
|
line(c.cyan(text));
|
|
@@ -174,6 +187,10 @@ export function relativeTime(iso) {
|
|
|
174
187
|
return `${Math.round(hours / 24)} 天前`;
|
|
175
188
|
}
|
|
176
189
|
export function clearScreen() {
|
|
190
|
+
if (sink) {
|
|
191
|
+
sink('\u001b[clear]\n');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
177
194
|
if (process.stdout.isTTY)
|
|
178
195
|
process.stdout.write('\u001b[2J\u001b[H');
|
|
179
196
|
}
|
package/dist/index.js
CHANGED
|
@@ -220,13 +220,23 @@ async function main() {
|
|
|
220
220
|
provider = createProvider(config.current, config);
|
|
221
221
|
}
|
|
222
222
|
catch (error) {
|
|
223
|
-
|
|
224
|
-
if (
|
|
225
|
-
ui.
|
|
226
|
-
|
|
223
|
+
// GUI 模式下不因缺 key 而退出:界面本身可以用来填 key
|
|
224
|
+
if (resolved.ui) {
|
|
225
|
+
ui.warn(error instanceof Error ? error.message : String(error));
|
|
226
|
+
ui.hint(' 图形界面会照常打开,在设置里填入 API Key 即可开始使用。');
|
|
227
|
+
provider = null;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
ui.error(error instanceof Error ? error.message : String(error));
|
|
231
|
+
if (interactive)
|
|
232
|
+
ui.hint(' 运行 `banana --setup` 运行配置向导,或设置对应环境变量后重试。');
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (provider) {
|
|
237
|
+
session.provider = provider.name;
|
|
238
|
+
session.model = provider.model;
|
|
227
239
|
}
|
|
228
|
-
session.provider = provider.name;
|
|
229
|
-
session.model = provider.model;
|
|
230
240
|
const tools = resolved.noTools ? [] : [...allTools];
|
|
231
241
|
if (resolved.noTools)
|
|
232
242
|
ui.hint('已禁用全部工具(--no-tools)。');
|
|
@@ -256,10 +266,11 @@ async function main() {
|
|
|
256
266
|
});
|
|
257
267
|
return;
|
|
258
268
|
}
|
|
269
|
+
// 走到这里说明 provider 可用(GUI 分支已在上方返回)
|
|
259
270
|
const outcome = await startRepl({
|
|
260
271
|
config,
|
|
261
272
|
store,
|
|
262
|
-
provider,
|
|
273
|
+
provider: provider,
|
|
263
274
|
tools,
|
|
264
275
|
session,
|
|
265
276
|
oneShot: resolved.oneShot,
|
package/dist/server/ui-server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
4
5
|
import { extname, join, resolve, dirname } from 'node:path';
|
|
5
6
|
import { spawn } from 'node:child_process';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
@@ -14,6 +15,9 @@ import { BackupStore, newTurnId } from "../session/backups.js";
|
|
|
14
15
|
import { allTools, permissionFor, summarizeInput } from "../tools/index.js";
|
|
15
16
|
import { loadPluginTools } from "../tools/plugins.js";
|
|
16
17
|
import { resolvePath } from "../tools/fs.js";
|
|
18
|
+
import { commands, findCommand, suggestCommand } from "../cli/commands.js";
|
|
19
|
+
import { completeCommand, completeFileReference } from "../cli/completion.js";
|
|
20
|
+
import { captureOutput } from "../cli/ui.js";
|
|
17
21
|
const MIME = {
|
|
18
22
|
'.html': 'text/html; charset=utf-8',
|
|
19
23
|
'.css': 'text/css; charset=utf-8',
|
|
@@ -28,13 +32,40 @@ const IMAGE_TYPES = {
|
|
|
28
32
|
'.webp': 'image/webp',
|
|
29
33
|
};
|
|
30
34
|
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
const MAX_FILE_BYTES = 400 * 1024;
|
|
36
|
+
const TEXT_EXT = new Set([
|
|
37
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.md', '.txt', '.yml', '.yaml',
|
|
38
|
+
'.toml', '.css', '.scss', '.html', '.sh', '.bash', '.zsh', '.py', '.go', '.rs', '.java',
|
|
39
|
+
'.c', '.h', '.cpp', '.hpp', '.sql', '.xml', '.ini', '.conf', '.env', '.gitignore', '.lock',
|
|
40
|
+
]);
|
|
41
|
+
const SKIP_DIRS = new Set([
|
|
42
|
+
'node_modules', '.git', 'dist', 'build', 'out', '.next', '.nuxt', 'coverage', '__pycache__',
|
|
43
|
+
'.venv', 'venv', 'target', '.cache', '.idea', '.gradle',
|
|
44
|
+
]);
|
|
45
|
+
/**
|
|
46
|
+
* 静态资源目录。同一个包在两种形态下路径不同,所以按候选顺序找第一个真实存在的:
|
|
47
|
+
* - 源码运行:src/server/ -> src/ui
|
|
48
|
+
* - 编译产物:dist/server/ -> dist/ui(构建时由 scripts/copy-ui.mjs 复制)
|
|
49
|
+
* - 兜底:包根 ui/ 或环境变量指定的目录(GUI 独立包用这个)
|
|
50
|
+
*/
|
|
51
|
+
export function resolveAssetsDir() {
|
|
52
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
53
|
+
const candidates = [
|
|
54
|
+
process.env.BANANA_UI_DIR,
|
|
55
|
+
join(here, '..', 'ui'),
|
|
56
|
+
join(here, '..', '..', 'ui'),
|
|
57
|
+
join(here, '..', '..', 'src', 'ui'),
|
|
58
|
+
].filter((item) => Boolean(item));
|
|
59
|
+
for (const candidate of candidates) {
|
|
60
|
+
if (existsSync(join(candidate, 'index.html')))
|
|
61
|
+
return candidate;
|
|
62
|
+
}
|
|
63
|
+
// 都找不到时返回默认位置,让 404 报错能指向一个具体路径
|
|
64
|
+
return join(here, '..', 'ui');
|
|
34
65
|
}
|
|
35
66
|
let activeAssetsDir = null;
|
|
36
67
|
function assetRoot() {
|
|
37
|
-
return activeAssetsDir ??
|
|
68
|
+
return activeAssetsDir ?? resolveAssetsDir();
|
|
38
69
|
}
|
|
39
70
|
/** 启动只监听回环地址的本地 GUI 服务;token 保护所有接口。 */
|
|
40
71
|
export async function startUiServer(options) {
|
|
@@ -42,7 +73,7 @@ export async function startUiServer(options) {
|
|
|
42
73
|
const token = randomBytes(24).toString('hex');
|
|
43
74
|
const host = options.host ?? '127.0.0.1';
|
|
44
75
|
const prefix = `/t/${token}`;
|
|
45
|
-
const assets = options.assetsDir ??
|
|
76
|
+
const assets = options.assetsDir ?? resolveAssetsDir();
|
|
46
77
|
activeAssetsDir = assets;
|
|
47
78
|
let turn = null;
|
|
48
79
|
const provider = () => createProvider(config.current, config);
|
|
@@ -57,6 +88,8 @@ export async function startUiServer(options) {
|
|
|
57
88
|
current: name === config.current,
|
|
58
89
|
keySaved: Boolean(item.apiKey),
|
|
59
90
|
keyEnv: item.apiKeyEnv ?? null,
|
|
91
|
+
// 环境变量名只是"配置里写了",不代表真的设了值,所以把可用性也算出来
|
|
92
|
+
keyReady: Boolean(item.apiKey || (item.apiKeyEnv && process.env[item.apiKeyEnv])),
|
|
60
93
|
})),
|
|
61
94
|
workspace: config.workspace,
|
|
62
95
|
platform: process.platform,
|
|
@@ -100,6 +133,36 @@ export async function startUiServer(options) {
|
|
|
100
133
|
return json(res, 200, await info(session));
|
|
101
134
|
if (path === '/api/sessions' && req.method === 'GET')
|
|
102
135
|
return json(res, 200, { sessions: await listSessions(), current: session.id });
|
|
136
|
+
if (path === '/api/files' && req.method === 'GET') {
|
|
137
|
+
const target = url.searchParams.get('path') ?? '.';
|
|
138
|
+
try {
|
|
139
|
+
return json(res, 200, { entries: await listTree(config, target) });
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (path === '/api/file' && req.method === 'GET') {
|
|
146
|
+
const requested = url.searchParams.get('path') ?? '';
|
|
147
|
+
try {
|
|
148
|
+
const context = sandboxContext(config);
|
|
149
|
+
const absolute = resolvePath(requested, context);
|
|
150
|
+
const info = await stat(absolute);
|
|
151
|
+
if (!info.isFile())
|
|
152
|
+
return json(res, 400, { error: '不是文件' });
|
|
153
|
+
if (info.size > MAX_FILE_BYTES)
|
|
154
|
+
return json(res, 200, { path: requested, tooLarge: true, bytes: info.size });
|
|
155
|
+
const ext = extname(absolute).toLowerCase();
|
|
156
|
+
if (!TEXT_EXT.has(ext) && ext !== '') {
|
|
157
|
+
return json(res, 200, { path: requested, binary: true, bytes: info.size });
|
|
158
|
+
}
|
|
159
|
+
const content = await readFile(absolute, 'utf8');
|
|
160
|
+
return json(res, 200, { path: requested, content, bytes: info.size, truncated: info.size >= MAX_FILE_BYTES });
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
103
166
|
if (path === '/api/session' && req.method === 'POST') {
|
|
104
167
|
const body = await readJson(req);
|
|
105
168
|
if (body.action === 'new') {
|
|
@@ -137,6 +200,31 @@ export async function startUiServer(options) {
|
|
|
137
200
|
turn.session = session;
|
|
138
201
|
return json(res, 200, { session: session.id, sessions: await listSessions(), info: await info(session) });
|
|
139
202
|
}
|
|
203
|
+
if (path === '/api/complete' && req.method === 'GET') {
|
|
204
|
+
const line = url.searchParams.get('line') ?? '';
|
|
205
|
+
// 与 CLI 共用同一份补全实现
|
|
206
|
+
const files = completeFileReference(line, config.workspace, { limit: 12 });
|
|
207
|
+
if (files)
|
|
208
|
+
return json(res, 200, { kind: 'file', replace: files[1], candidates: files[0] });
|
|
209
|
+
const commandHits = completeCommand(line, commands.map((command) => command.name));
|
|
210
|
+
if (commandHits)
|
|
211
|
+
return json(res, 200, { kind: 'command', replace: commandHits[1], candidates: commandHits[0] });
|
|
212
|
+
return json(res, 200, { kind: 'none', replace: '', candidates: [] });
|
|
213
|
+
}
|
|
214
|
+
if (path === '/api/command' && req.method === 'POST') {
|
|
215
|
+
const body = await readJson(req);
|
|
216
|
+
return runCliCommand(body.input ?? '', res, session);
|
|
217
|
+
}
|
|
218
|
+
if (path === '/api/commands' && req.method === 'GET') {
|
|
219
|
+
return json(res, 200, {
|
|
220
|
+
commands: commands.map((command) => ({
|
|
221
|
+
name: command.name,
|
|
222
|
+
aliases: command.aliases ?? [],
|
|
223
|
+
usage: command.usage,
|
|
224
|
+
summary: command.summary,
|
|
225
|
+
})),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
140
228
|
if (path === '/api/action' && req.method === 'POST') {
|
|
141
229
|
const body = await readJson(req);
|
|
142
230
|
if (body.action === 'provider' && body.name && config.providers[body.name]) {
|
|
@@ -221,6 +309,98 @@ export async function startUiServer(options) {
|
|
|
221
309
|
}
|
|
222
310
|
return notFound(res);
|
|
223
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* 执行 CLI 命令:命令表只有一份(cli/commands.ts),GUI 只是换了个输出通道。
|
|
314
|
+
* 输出逐行推给前端,行为和终端里一致。
|
|
315
|
+
*/
|
|
316
|
+
async function runCliCommand(input, res, session) {
|
|
317
|
+
const trimmed = input.trim();
|
|
318
|
+
const [token = '', ...rest] = trimmed.replace(/^\//, '').split(/\s+/);
|
|
319
|
+
const command = findCommand(token);
|
|
320
|
+
if (!command) {
|
|
321
|
+
const suggestion = suggestCommand(token);
|
|
322
|
+
json(res, 200, {
|
|
323
|
+
ok: false,
|
|
324
|
+
output: suggestion
|
|
325
|
+
? `未知命令 /${token},你是不是想用 /${suggestion}?`
|
|
326
|
+
: `未知命令 /${token}。输入 /help 看可用命令。`,
|
|
327
|
+
info: await info(session),
|
|
328
|
+
});
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const chunks = [];
|
|
332
|
+
const restore = captureOutput((text) => chunks.push(text));
|
|
333
|
+
let next = session;
|
|
334
|
+
let failure = '';
|
|
335
|
+
// provider 惰性创建:像 /help、/cwd 这类命令不需要 key 也能跑
|
|
336
|
+
let lazyProvider = null;
|
|
337
|
+
const needProvider = () => {
|
|
338
|
+
if (!lazyProvider) {
|
|
339
|
+
try {
|
|
340
|
+
lazyProvider = provider();
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}\n请先在设置里填入 API Key。`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return lazyProvider;
|
|
347
|
+
};
|
|
348
|
+
try {
|
|
349
|
+
await command.run(rest.join(' '), {
|
|
350
|
+
config,
|
|
351
|
+
store,
|
|
352
|
+
session,
|
|
353
|
+
get provider() {
|
|
354
|
+
return needProvider();
|
|
355
|
+
},
|
|
356
|
+
tools: options.tools,
|
|
357
|
+
ask: async (question) => {
|
|
358
|
+
throw new Error(`${question}(这个命令需要交互输入,请用界面上的对应功能)`);
|
|
359
|
+
},
|
|
360
|
+
confirm: async () => false,
|
|
361
|
+
setSession: (value) => {
|
|
362
|
+
next = value;
|
|
363
|
+
},
|
|
364
|
+
setSessionAndSave: async (value) => {
|
|
365
|
+
next = value;
|
|
366
|
+
await store.save(value);
|
|
367
|
+
},
|
|
368
|
+
setProvider: () => { },
|
|
369
|
+
setConfig: (value) => Object.assign(config, value),
|
|
370
|
+
setTools: () => { },
|
|
371
|
+
saveSession: async () => {
|
|
372
|
+
await store.save(next);
|
|
373
|
+
},
|
|
374
|
+
reloadProvider: () => { },
|
|
375
|
+
compactNow: async () => { },
|
|
376
|
+
runSetup: async () => {
|
|
377
|
+
throw new Error('配置向导请在终端运行 banana --setup');
|
|
378
|
+
},
|
|
379
|
+
undo: async () => backups(next.id).undo(),
|
|
380
|
+
clearBackups: async (sessionId) => {
|
|
381
|
+
await backups(sessionId).clear();
|
|
382
|
+
},
|
|
383
|
+
attachImage: async () => ({ ok: false, message: '请直接在界面里粘贴图片' }),
|
|
384
|
+
listImages: () => [],
|
|
385
|
+
clearImages: () => { },
|
|
386
|
+
clearScreen: () => { },
|
|
387
|
+
exit: () => { },
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
failure = error instanceof Error ? error.message : String(error);
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
restore();
|
|
395
|
+
}
|
|
396
|
+
return json(res, 200, {
|
|
397
|
+
ok: !failure,
|
|
398
|
+
output: chunks.join(''),
|
|
399
|
+
error: failure,
|
|
400
|
+
info: await info(next),
|
|
401
|
+
sessions: await listSessions(),
|
|
402
|
+
});
|
|
403
|
+
}
|
|
224
404
|
async function chat(body, res, session) {
|
|
225
405
|
const controller = new AbortController();
|
|
226
406
|
const active = { controller, pending: new Map(), session };
|
|
@@ -235,11 +415,28 @@ export async function startUiServer(options) {
|
|
|
235
415
|
if (!res.writableEnded)
|
|
236
416
|
res.write(`data: ${JSON.stringify({ type, ...payload })}\n\n`);
|
|
237
417
|
};
|
|
418
|
+
const finish = () => {
|
|
419
|
+
turn = null;
|
|
420
|
+
if (!res.writableEnded)
|
|
421
|
+
res.end();
|
|
422
|
+
};
|
|
238
423
|
send('start', { session: session.id });
|
|
239
424
|
res.on('close', () => {
|
|
240
425
|
if (!res.writableEnded)
|
|
241
426
|
controller.abort();
|
|
242
427
|
});
|
|
428
|
+
// 没配 key 之类的问题要在流里说清楚,否则前端会一直转圈
|
|
429
|
+
const current = config.providers[config.current];
|
|
430
|
+
const hasKey = Boolean(current?.apiKey || (current?.apiKeyEnv && process.env[current.apiKeyEnv]));
|
|
431
|
+
if (!hasKey) {
|
|
432
|
+
send('error', {
|
|
433
|
+
message: `还没有配置 ${config.current} 的 API key。点右上角的模型按钮,在设置里填入 API Key 就能开始对话。`,
|
|
434
|
+
hint: '也可以在终端运行 banana --setup 走一遍配置向导。',
|
|
435
|
+
code: 'no_api_key',
|
|
436
|
+
});
|
|
437
|
+
finish();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
243
440
|
const dataUrls = await Promise.all((body.images ?? []).map(async (file) => {
|
|
244
441
|
const absolute = resolve(file);
|
|
245
442
|
const type = IMAGE_TYPES[extname(absolute).toLowerCase()];
|
|
@@ -271,30 +468,49 @@ export async function startUiServer(options) {
|
|
|
271
468
|
todos: session.todos,
|
|
272
469
|
};
|
|
273
470
|
const started = Date.now();
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
471
|
+
let result;
|
|
472
|
+
try {
|
|
473
|
+
result = await runTurn({
|
|
474
|
+
provider: provider(),
|
|
475
|
+
tools: options.tools,
|
|
476
|
+
permissions: config.permissions,
|
|
477
|
+
system: config.systemPrompt,
|
|
478
|
+
maxSteps: config.maxSteps,
|
|
479
|
+
maxContextChars: config.maxContextChars,
|
|
480
|
+
toolsWorkspace: config.workspace,
|
|
481
|
+
allowOutsideWorkspace: config.allowOutsideWorkspace,
|
|
482
|
+
signal: controller.signal,
|
|
483
|
+
todos: session.todos,
|
|
484
|
+
recordChange: (path, beforeContent) => recorder.record(path, beforeContent),
|
|
485
|
+
onText: (chunk) => send('text', { chunk }),
|
|
486
|
+
onReasoning: (chunk) => send('reasoning', { chunk }),
|
|
487
|
+
onToolStart: (name, input) => send('tool_start', { name, detail: summarizeInput(input) }),
|
|
488
|
+
onToolEnd: (name, toolResult) => send('tool_end', {
|
|
489
|
+
name,
|
|
490
|
+
ok: toolResult.ok,
|
|
491
|
+
display: toolResult.display ?? '',
|
|
492
|
+
error: toolResult.ok ? '' : toolResult.content.slice(0, 400),
|
|
493
|
+
// 详情面板用:命令输出 / 搜索结果等,最多 8KB
|
|
494
|
+
detail: toolResult.content.slice(0, 8000),
|
|
495
|
+
}),
|
|
496
|
+
onNotice: (message) => send('status', { message }),
|
|
497
|
+
onCompact: () => send('status', { message: '已压缩历史对话' }),
|
|
498
|
+
onRetry: (info) => send('status', { message: `网络重试 ${info.attempt}:${info.reason}` }),
|
|
499
|
+
approve,
|
|
500
|
+
}, session.messages, body.message, dataUrls.filter((item) => Boolean(item)));
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
// 任何异常都要以 error 事件收尾,否则前端会一直等下去
|
|
504
|
+
send('error', {
|
|
505
|
+
message: error instanceof Error ? error.message : String(error),
|
|
506
|
+
code: 'turn_failed',
|
|
507
|
+
});
|
|
508
|
+
finish();
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
295
511
|
session.messages = result.messages;
|
|
296
512
|
session.provider = config.current;
|
|
297
|
-
session.model = provider().model;
|
|
513
|
+
session.model = session.model || provider().model;
|
|
298
514
|
const pricing = resolvePricing(session.model, config.providers[config.current]?.pricing);
|
|
299
515
|
const cost = estimateCost(result.usage, pricing);
|
|
300
516
|
session.usage = {
|
|
@@ -304,7 +520,6 @@ export async function startUiServer(options) {
|
|
|
304
520
|
};
|
|
305
521
|
await store.save(session);
|
|
306
522
|
await recorder.commit().catch(() => { });
|
|
307
|
-
turn = null;
|
|
308
523
|
send('done', {
|
|
309
524
|
stopReason: result.stopReason,
|
|
310
525
|
error: result.error ?? '',
|
|
@@ -315,7 +530,7 @@ export async function startUiServer(options) {
|
|
|
315
530
|
todos: JSON.stringify(session.todos ?? []) !== todosBefore ? session.todos : undefined,
|
|
316
531
|
info: await info(session),
|
|
317
532
|
});
|
|
318
|
-
|
|
533
|
+
finish();
|
|
319
534
|
}
|
|
320
535
|
const port = await new Promise((resolvePromise, rejectPromise) => {
|
|
321
536
|
server.once('error', rejectPromise);
|
|
@@ -337,6 +552,42 @@ function json(res, status, payload) {
|
|
|
337
552
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body) });
|
|
338
553
|
res.end(body);
|
|
339
554
|
}
|
|
555
|
+
function sandboxContext(config) {
|
|
556
|
+
return {
|
|
557
|
+
workspace: config.workspace,
|
|
558
|
+
signal: new AbortController().signal,
|
|
559
|
+
approve: async () => false,
|
|
560
|
+
log: () => { },
|
|
561
|
+
allowOutsideWorkspace: false,
|
|
562
|
+
permissions: config.permissions,
|
|
563
|
+
todos: [],
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
/** 目录树:只返回一层子项,按需展开,避免大仓库一次拉全量。 */
|
|
567
|
+
async function listTree(config, requested) {
|
|
568
|
+
const { readdir, stat: statFile } = await import('node:fs/promises');
|
|
569
|
+
const root = resolvePath(requested || '.', sandboxContext(config));
|
|
570
|
+
const info = await statFile(root);
|
|
571
|
+
if (!info.isDirectory())
|
|
572
|
+
throw new Error(`${root} 不是目录`);
|
|
573
|
+
const items = await readdir(root, { withFileTypes: true });
|
|
574
|
+
const entries = [];
|
|
575
|
+
for (const item of items) {
|
|
576
|
+
if (item.name.startsWith('.'))
|
|
577
|
+
continue;
|
|
578
|
+
if (item.isDirectory() && SKIP_DIRS.has(item.name))
|
|
579
|
+
continue;
|
|
580
|
+
const rel = join(requested === '.' ? '' : requested, item.name).split('\\').join('/');
|
|
581
|
+
if (item.isDirectory()) {
|
|
582
|
+
entries.push({ name: item.name, path: rel, type: 'dir' });
|
|
583
|
+
}
|
|
584
|
+
else if (item.isFile()) {
|
|
585
|
+
entries.push({ name: item.name, path: rel, type: 'file' });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
entries.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
|
|
589
|
+
return entries.slice(0, 500);
|
|
590
|
+
}
|
|
340
591
|
function notFound(res) {
|
|
341
592
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
342
593
|
res.end('not found');
|