@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 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
- process.stdout.write(`${text}\n`);
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
- ui.error(error instanceof Error ? error.message : String(error));
224
- if (interactive)
225
- ui.hint(' 运行 `banana --setup` 运行配置向导,或设置对应环境变量后重试。');
226
- process.exit(1);
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,
@@ -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
- /** 静态资源目录:默认是 CLI 包自带的 ui/,GUI 包启动时可以传自己的目录进来。 */
32
- function defaultAssetsDir() {
33
- return join(dirname(fileURLToPath(import.meta.url)), '..', 'ui');
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 ?? defaultAssetsDir();
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 ?? defaultAssetsDir();
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
- const result = await runTurn({
275
- provider: provider(),
276
- tools: options.tools,
277
- permissions: config.permissions,
278
- system: config.systemPrompt,
279
- maxSteps: config.maxSteps,
280
- maxContextChars: config.maxContextChars,
281
- toolsWorkspace: config.workspace,
282
- allowOutsideWorkspace: config.allowOutsideWorkspace,
283
- signal: controller.signal,
284
- todos: session.todos,
285
- recordChange: (path, beforeContent) => recorder.record(path, beforeContent),
286
- onText: (chunk) => send('text', { chunk }),
287
- onReasoning: (chunk) => send('reasoning', { chunk }),
288
- onToolStart: (name, input) => send('tool_start', { name, detail: summarizeInput(input) }),
289
- onToolEnd: (name, toolResult) => send('tool_end', { name, ok: toolResult.ok, display: toolResult.display ?? '', error: toolResult.ok ? '' : toolResult.content.slice(0, 400) }),
290
- onNotice: (message) => send('status', { message }),
291
- onCompact: () => send('status', { message: '已压缩历史对话' }),
292
- onRetry: (info) => send('status', { message: `网络重试 ${info.attempt}:${info.reason}` }),
293
- approve,
294
- }, session.messages, body.message, dataUrls.filter((item) => Boolean(item)));
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
- res.end();
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');