@vincentbanana/bananacli 0.1.0 → 0.2.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/dist/cli/repl.js CHANGED
@@ -487,6 +487,7 @@ export async function startRepl(options) {
487
487
  if ((session.usage.costUsd ?? 0) > 0)
488
488
  parts.push(`累计 ${formatCost(session.usage.costUsd ?? 0)}`);
489
489
  ui.hint(parts.join(' · '));
490
+ ui.line(ui.divider());
490
491
  const todosAfter = JSON.stringify(session.todos ?? []);
491
492
  if (todosAfter !== todosBefore && session.todos?.length) {
492
493
  ui.line(ui.c.bold('任务清单'));
@@ -611,7 +612,7 @@ export async function startRepl(options) {
611
612
  await handleInput(answer);
612
613
  }
613
614
  if (!exiting)
614
- ui.line();
615
+ ui.line(ui.divider());
615
616
  }
616
617
  }
617
618
  finally {
package/dist/cli/ui.js CHANGED
@@ -118,7 +118,12 @@ export function truncateDisplay(text, maxWidth, ellipsis = '…') {
118
118
  return out + ellipsis;
119
119
  }
120
120
  export function toolLine(label, detail) {
121
- return `${c.magenta('⏺')} ${c.bold(label)} ${c.dim(detail)}`;
121
+ return `${c.dim('│')} ${c.magenta('⏺')} ${c.bold(label)} ${c.dim(detail)}`;
122
+ }
123
+ /** 回合之间的细分隔线,让每轮对话在视觉上分成独立区块。 */
124
+ export function divider() {
125
+ const width = Math.max(24, Math.min(72, (process.stdout.columns ?? 80) - 14));
126
+ return c.dim('─'.repeat(width));
122
127
  }
123
128
  export function bullet(text) {
124
129
  return `${c.gray('•')} ${text}`;
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { createProvider } from "./providers/index.js";
6
6
  import { SessionStore } from "./session/store.js";
7
7
  import { allTools } from "./tools/index.js";
8
8
  import { loadPluginTools } from "./tools/plugins.js";
9
+ import { startUiServer, openBrowser } from "./server/ui-server.js";
9
10
  import { join } from 'node:path';
10
11
  import { startRepl } from "./cli/repl.js";
11
12
  import { runSetupWizard } from "./cli/setup.js";
@@ -32,6 +33,9 @@ ${'🍌'} BananaCLI v${ui.version} - 终端里的多 provider AI agent
32
33
  --no-tools 不给 agent 任何工具(纯对话)
33
34
  --no-reasoning 不显示推理模型的思考过程
34
35
  --setup 运行/重新运行配置向导(配置 API key、模型、权限)
36
+ --ui 启动图形界面(本地服务 + 浏览器),不开终端对话
37
+ --port <端口> 指定 GUI 端口(默认随机端口)
38
+ --no-open 启动 GUI 时不自动打开浏览器
35
39
  -h, --help 显示帮助
36
40
  -v, --version 显示版本
37
41
 
@@ -71,6 +75,9 @@ async function resolve(argv) {
71
75
  'no-tools': { type: 'boolean' },
72
76
  'no-reasoning': { type: 'boolean' },
73
77
  setup: { type: 'boolean' },
78
+ ui: { type: 'boolean' },
79
+ port: { type: 'string' },
80
+ 'no-open': { type: 'boolean' },
74
81
  help: { type: 'boolean', short: 'h' },
75
82
  version: { type: 'boolean', short: 'v' },
76
83
  },
@@ -156,6 +163,9 @@ async function resolve(argv) {
156
163
  noTools: Boolean(values['no-tools']),
157
164
  yolo: Boolean(values.yolo),
158
165
  setup: Boolean(values.setup),
166
+ ui: Boolean(values.ui),
167
+ port: values.port ? Number(values.port) : undefined,
168
+ launchBrowser: values['no-open'] !== true,
159
169
  firstRun: !existsSync(configPath()),
160
170
  missingKey,
161
171
  };
@@ -231,6 +241,21 @@ async function main() {
231
241
  ui.hint(`已加载 ${pluginResult.tools.length} 个插件工具: ${pluginResult.tools.map((tool) => tool.name).join(', ')}`);
232
242
  }
233
243
  }
244
+ // GUI 模式:本地服务 + 浏览器,不进入终端对话(不需要先有可用 provider)
245
+ if (resolved.ui) {
246
+ const handle = await startUiServer({ config, store, tools, port: resolved.port });
247
+ ui.line();
248
+ ui.ok(`GUI 已启动:${handle.url}`);
249
+ ui.hint('只监听 127.0.0.1,URL 里的 token 是访问凭证,不要分享。按 Ctrl+C 退出。');
250
+ if (resolved.launchBrowser)
251
+ openBrowser(handle.url);
252
+ await new Promise((resolvePromise) => {
253
+ process.on('SIGINT', () => {
254
+ void handle.close().finally(() => resolvePromise());
255
+ });
256
+ });
257
+ return;
258
+ }
234
259
  const outcome = await startRepl({
235
260
  config,
236
261
  store,
@@ -0,0 +1,385 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { mkdir, writeFile } from 'node:fs/promises';
4
+ import { extname, join, resolve, dirname } from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { bananaHome } from "../config/config.js";
8
+ import { runTurn } from "../agent/loop.js";
9
+ import { compactMessages } from "../agent/context.js";
10
+ import { createProvider } from "../providers/index.js";
11
+ import { estimateCost, formatCost, resolvePricing } from "../providers/pricing.js";
12
+ import { SessionStore } from "../session/store.js";
13
+ import { BackupStore, newTurnId } from "../session/backups.js";
14
+ import { allTools, permissionFor, summarizeInput } from "../tools/index.js";
15
+ import { loadPluginTools } from "../tools/plugins.js";
16
+ import { resolvePath } from "../tools/fs.js";
17
+ const MIME = {
18
+ '.html': 'text/html; charset=utf-8',
19
+ '.css': 'text/css; charset=utf-8',
20
+ '.js': 'text/javascript; charset=utf-8',
21
+ '.svg': 'image/svg+xml',
22
+ };
23
+ const IMAGE_TYPES = {
24
+ '.png': 'image/png',
25
+ '.jpg': 'image/jpeg',
26
+ '.jpeg': 'image/jpeg',
27
+ '.gif': 'image/gif',
28
+ '.webp': 'image/webp',
29
+ };
30
+ const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
31
+ /** 静态资源目录:默认是 CLI 包自带的 ui/,GUI 包启动时可以传自己的目录进来。 */
32
+ function defaultAssetsDir() {
33
+ return join(dirname(fileURLToPath(import.meta.url)), '..', 'ui');
34
+ }
35
+ let activeAssetsDir = null;
36
+ function assetRoot() {
37
+ return activeAssetsDir ?? defaultAssetsDir();
38
+ }
39
+ /** 启动只监听回环地址的本地 GUI 服务;token 保护所有接口。 */
40
+ export async function startUiServer(options) {
41
+ const { config, store } = options;
42
+ const token = randomBytes(24).toString('hex');
43
+ const host = options.host ?? '127.0.0.1';
44
+ const prefix = `/t/${token}`;
45
+ const assets = options.assetsDir ?? defaultAssetsDir();
46
+ activeAssetsDir = assets;
47
+ let turn = null;
48
+ const provider = () => createProvider(config.current, config);
49
+ const backups = (sessionId) => new BackupStore(join(bananaHome(), 'backups'), sessionId);
50
+ const info = async (session) => ({
51
+ version: '0.1.0',
52
+ provider: { name: config.current, model: config.providers[config.current]?.model ?? '', protocol: config.providers[config.current]?.protocol ?? 'openai' },
53
+ providers: Object.entries(config.providers).map(([name, item]) => ({
54
+ name,
55
+ model: item.model,
56
+ protocol: item.protocol,
57
+ current: name === config.current,
58
+ keySaved: Boolean(item.apiKey),
59
+ keyEnv: item.apiKeyEnv ?? null,
60
+ })),
61
+ workspace: config.workspace,
62
+ platform: process.platform,
63
+ permissions: config.permissions,
64
+ tools: options.tools.map((tool) => ({ name: tool.name, approval: tool.approval, permission: permissionFor(config.permissions, tool) })),
65
+ session: {
66
+ id: session.id,
67
+ title: session.title,
68
+ messages: session.messages,
69
+ todos: session.todos ?? [],
70
+ usage: session.usage ?? { inputTokens: 0, outputTokens: 0 },
71
+ },
72
+ });
73
+ const listSessions = async () => (await store.list()).map((item) => ({
74
+ id: item.id,
75
+ title: item.title,
76
+ updatedAt: item.updatedAt,
77
+ turns: item.turns,
78
+ }));
79
+ const server = createServer((req, res) => {
80
+ handle(req, res).catch((error) => {
81
+ if (!res.headersSent)
82
+ json(res, 500, { error: error instanceof Error ? error.message : String(error) });
83
+ else
84
+ res.end();
85
+ });
86
+ });
87
+ async function handle(req, res) {
88
+ const url = new URL(req.url ?? '/', `http://${host}`);
89
+ if (!url.pathname.startsWith(prefix))
90
+ return notFound(res);
91
+ const path = url.pathname.slice(prefix.length) || '/';
92
+ if (path === '/' || path === '/index.html')
93
+ return sendFile(res, 'index.html');
94
+ if (path === '/style.css')
95
+ return sendFile(res, 'style.css');
96
+ if (path === '/app.js')
97
+ return sendFile(res, 'app.js');
98
+ let session = turn?.session ?? (await store.lastActive().then(async (id) => (id ? await store.load(id) : null))) ?? store.create();
99
+ if (path === '/api/info' && req.method === 'GET')
100
+ return json(res, 200, await info(session));
101
+ if (path === '/api/sessions' && req.method === 'GET')
102
+ return json(res, 200, { sessions: await listSessions(), current: session.id });
103
+ if (path === '/api/session' && req.method === 'POST') {
104
+ const body = await readJson(req);
105
+ if (body.action === 'new') {
106
+ session = store.create(body.title?.trim() || '新对话');
107
+ await store.save(session);
108
+ }
109
+ else if (body.action === 'rename') {
110
+ session.title = body.title?.trim() || session.title;
111
+ await store.save(session);
112
+ }
113
+ else if (body.action === 'switch') {
114
+ const found = body.ref ? await store.find(body.ref) : null;
115
+ if (!found)
116
+ return json(res, 404, { error: '没找到这个对话' });
117
+ session = found;
118
+ }
119
+ else if (body.action === 'delete') {
120
+ const found = body.ref ? await store.find(body.ref) : null;
121
+ if (found) {
122
+ await store.remove(found.id);
123
+ await backups(found.id).clear();
124
+ if (found.id === session.id) {
125
+ session = store.create();
126
+ await store.save(session);
127
+ }
128
+ }
129
+ }
130
+ else if (body.action === 'clear') {
131
+ session.messages = [];
132
+ session.todos = [];
133
+ session.usage = { inputTokens: 0, outputTokens: 0 };
134
+ await store.save(session);
135
+ }
136
+ if (turn)
137
+ turn.session = session;
138
+ return json(res, 200, { session: session.id, sessions: await listSessions(), info: await info(session) });
139
+ }
140
+ if (path === '/api/action' && req.method === 'POST') {
141
+ const body = await readJson(req);
142
+ if (body.action === 'provider' && body.name && config.providers[body.name]) {
143
+ config.current = body.name;
144
+ session.provider = body.name;
145
+ }
146
+ else if (body.action === 'model' && body.value) {
147
+ const current = config.providers[config.current];
148
+ if (current)
149
+ current.model = body.value;
150
+ }
151
+ else if (body.action === 'permission' && body.name && body.value) {
152
+ config.permissions = { ...config.permissions, [body.name]: body.value };
153
+ }
154
+ else if (body.action === 'apiKey') {
155
+ const current = config.providers[config.current];
156
+ if (current) {
157
+ const value = String(body.value ?? '').trim();
158
+ if (value)
159
+ current.apiKey = value;
160
+ else
161
+ delete current.apiKey;
162
+ }
163
+ }
164
+ else if (body.action === 'system' && body.value) {
165
+ config.systemPrompt = body.value;
166
+ }
167
+ else if (body.action === 'workspace' && body.value) {
168
+ config.workspace = resolve(body.value);
169
+ }
170
+ const { saveConfig } = await import("../config/config.js");
171
+ await saveConfig(config);
172
+ // 没配 key 也要能把设置存下去,这里不能因为构造 provider 失败而 500
173
+ try {
174
+ session.model = provider().model;
175
+ session.provider = config.current;
176
+ await store.save(session);
177
+ }
178
+ catch {
179
+ /* provider 暂时不可用,不影响设置已保存 */
180
+ }
181
+ return json(res, 200, { info: await info(session) });
182
+ }
183
+ if (path === '/api/undo' && req.method === 'POST') {
184
+ const result = await backups(session.id).undo();
185
+ return json(res, 200, { restored: result.restored, deleted: result.deleted, had: Boolean(result.turn) });
186
+ }
187
+ if (path === '/api/attach' && req.method === 'POST') {
188
+ const name = decodeURIComponent(new URL(req.url ?? '/', 'http://x').searchParams.get('name') ?? 'image.png');
189
+ const body = await readBody(req);
190
+ const buffer = Buffer.from(body, 'base64');
191
+ const dir = join(bananaHome(), 'attachments');
192
+ await mkdir(dir, { recursive: true });
193
+ const file = join(dir, `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}${extname(name).toLowerCase()}`);
194
+ await writeFile(file, buffer);
195
+ return json(res, 200, { path: file, bytes: buffer.byteLength });
196
+ }
197
+ if (path === '/api/approve' && req.method === 'POST') {
198
+ const body = await readJson(req);
199
+ const active = turn;
200
+ const resolvePending = active?.pending.get(body.id);
201
+ if (resolvePending && active) {
202
+ active.pending.delete(body.id);
203
+ resolvePending(Boolean(body.allowed));
204
+ }
205
+ return json(res, 200, { ok: Boolean(resolvePending) });
206
+ }
207
+ if (path === '/api/abort' && req.method === 'POST') {
208
+ if (turn) {
209
+ for (const resolvePending of turn.pending.values())
210
+ resolvePending(false);
211
+ turn.pending.clear();
212
+ turn.controller.abort();
213
+ }
214
+ return json(res, 200, { ok: Boolean(turn) });
215
+ }
216
+ if (path === '/api/chat' && req.method === 'POST') {
217
+ const body = await readJson(req);
218
+ if (turn)
219
+ return json(res, 409, { error: '当前正在跑一轮对话' });
220
+ return chat(body, res, session);
221
+ }
222
+ return notFound(res);
223
+ }
224
+ async function chat(body, res, session) {
225
+ const controller = new AbortController();
226
+ const active = { controller, pending: new Map(), session };
227
+ turn = active;
228
+ res.writeHead(200, {
229
+ 'Content-Type': 'text/event-stream; charset=utf-8',
230
+ 'Cache-Control': 'no-cache, no-transform',
231
+ Connection: 'keep-alive',
232
+ 'X-Accel-Buffering': 'no',
233
+ });
234
+ const send = (type, payload = {}) => {
235
+ if (!res.writableEnded)
236
+ res.write(`data: ${JSON.stringify({ type, ...payload })}\n\n`);
237
+ };
238
+ send('start', { session: session.id });
239
+ res.on('close', () => {
240
+ if (!res.writableEnded)
241
+ controller.abort();
242
+ });
243
+ const dataUrls = await Promise.all((body.images ?? []).map(async (file) => {
244
+ const absolute = resolve(file);
245
+ const type = IMAGE_TYPES[extname(absolute).toLowerCase()];
246
+ if (!type)
247
+ return null;
248
+ const { readFile } = await import('node:fs/promises');
249
+ const buffer = await readFile(absolute);
250
+ if (buffer.byteLength > MAX_IMAGE_BYTES)
251
+ return null;
252
+ return `data:${type};base64,${buffer.toString('base64')}`;
253
+ }));
254
+ const todosBefore = JSON.stringify(session.todos ?? []);
255
+ session.todos ??= [];
256
+ const recorder = await backups(session.id).beginTurn(newTurnId(), config.workspace);
257
+ const approve = async (kind, summary, preview) => {
258
+ const id = randomBytes(6).toString('hex');
259
+ send('approval', { id, kind, summary, preview });
260
+ return new Promise((resolvePromise) => {
261
+ active.pending.set(id, resolvePromise);
262
+ });
263
+ };
264
+ const toolContext = {
265
+ workspace: config.workspace,
266
+ signal: controller.signal,
267
+ approve,
268
+ log: (message) => send('status', { message }),
269
+ allowOutsideWorkspace: config.allowOutsideWorkspace,
270
+ permissions: config.permissions,
271
+ todos: session.todos,
272
+ };
273
+ 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)));
295
+ session.messages = result.messages;
296
+ session.provider = config.current;
297
+ session.model = provider().model;
298
+ const pricing = resolvePricing(session.model, config.providers[config.current]?.pricing);
299
+ const cost = estimateCost(result.usage, pricing);
300
+ session.usage = {
301
+ inputTokens: (session.usage?.inputTokens ?? 0) + (result.usage.inputTokens ?? 0),
302
+ outputTokens: (session.usage?.outputTokens ?? 0) + (result.usage.outputTokens ?? 0),
303
+ costUsd: (session.usage?.costUsd ?? 0) + cost,
304
+ };
305
+ await store.save(session);
306
+ await recorder.commit().catch(() => { });
307
+ turn = null;
308
+ send('done', {
309
+ stopReason: result.stopReason,
310
+ error: result.error ?? '',
311
+ steps: result.steps,
312
+ ms: Date.now() - started,
313
+ usage: result.usage,
314
+ cost: formatCost(cost),
315
+ todos: JSON.stringify(session.todos ?? []) !== todosBefore ? session.todos : undefined,
316
+ info: await info(session),
317
+ });
318
+ res.end();
319
+ }
320
+ const port = await new Promise((resolvePromise, rejectPromise) => {
321
+ server.once('error', rejectPromise);
322
+ server.listen(options.port ?? 0, host, () => resolvePromise(server.address().port));
323
+ });
324
+ return {
325
+ url: `http://${host}:${port}${prefix}/`,
326
+ token,
327
+ port,
328
+ close: async () => {
329
+ // 浏览器可能留着 keep-alive 连接,先断开再关,否则 Windows 上会触发 libuv 断言
330
+ server.closeAllConnections?.();
331
+ await new Promise((resolvePromise) => server.close(() => resolvePromise()));
332
+ },
333
+ };
334
+ }
335
+ function json(res, status, payload) {
336
+ const body = JSON.stringify(payload);
337
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body) });
338
+ res.end(body);
339
+ }
340
+ function notFound(res) {
341
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
342
+ res.end('not found');
343
+ }
344
+ async function sendFile(res, name) {
345
+ const { readFile } = await import('node:fs/promises');
346
+ try {
347
+ const file = await readFile(join(assetRoot(), name));
348
+ res.writeHead(200, {
349
+ 'Content-Type': MIME[extname(name)] ?? 'application/octet-stream',
350
+ 'Content-Length': file.byteLength,
351
+ 'Cache-Control': 'no-store',
352
+ });
353
+ res.end(file);
354
+ }
355
+ catch {
356
+ notFound(res);
357
+ }
358
+ }
359
+ async function readBody(req) {
360
+ const chunks = [];
361
+ for await (const chunk of req)
362
+ chunks.push(Buffer.from(chunk));
363
+ return Buffer.concat(chunks).toString('utf8');
364
+ }
365
+ async function readJson(req) {
366
+ try {
367
+ return JSON.parse(await readBody(req));
368
+ }
369
+ catch {
370
+ return {};
371
+ }
372
+ }
373
+ /** 打开系统默认浏览器。 */
374
+ export function openBrowser(url) {
375
+ const command = process.platform === 'darwin' ? { file: 'open', args: [url] }
376
+ : process.platform === 'win32' ? { file: 'cmd', args: ['/c', 'start', '""', url] }
377
+ : { file: 'xdg-open', args: [url] };
378
+ try {
379
+ spawn(command.file, command.args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
380
+ }
381
+ catch {
382
+ // 打不开就让用户手动复制链接
383
+ }
384
+ }
385
+ export const __testing = { MIME, IMAGE_TYPES, resolvePath, assetRoot };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentbanana/bananacli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A multi-provider AI agent for your terminal: file tools, shell, web search, persistent sessions, diff preview and undo",
5
5
  "type": "module",
6
6
  "engines": {
@@ -11,10 +11,12 @@
11
11
  },
12
12
  "main": "dist/index.js",
13
13
  "exports": {
14
- ".": "./dist/index.js"
14
+ ".": "./dist/index.js",
15
+ "./server": "./dist/server/ui-server.js"
15
16
  },
16
17
  "files": [
17
18
  "dist",
19
+ "ui",
18
20
  "README.md",
19
21
  "LICENSE"
20
22
  ],
@@ -23,7 +25,8 @@
23
25
  "build": "tsc -p tsconfig.build.json",
24
26
  "typecheck": "tsc --noEmit",
25
27
  "dev": "node --watch src/index.ts",
26
- "test": "node test/prompt.test.mjs && node test/ui.test.mjs && node test/p0.test.mjs && node test/features.test.mjs && node test/smoke.mjs",
28
+ "test": "node test/prompt.test.mjs && node test/ui.test.mjs && node test/p0.test.mjs && node test/features.test.mjs && node test/gui.test.mjs && node test/smoke.mjs",
29
+ "gui:sync": "node scripts/sync-gui.mjs",
27
30
  "prepare": "npm run build",
28
31
  "prepublishOnly": "npm run typecheck && npm test && npm run build"
29
32
  },