@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 +2 -1
- package/dist/cli/ui.js +6 -1
- package/dist/index.js +25 -0
- package/dist/server/ui-server.js +385 -0
- package/package.json +6 -3
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.
|
|
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
|
},
|