@vincentbanana/bananacli 0.4.3 → 0.4.5
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/config/config.js +33 -2
- package/dist/server/ui-server.js +111 -1
- package/dist/tools/web.js +41 -3
- package/dist/ui/app.js +236 -3
- package/dist/ui/index.html +20 -0
- package/dist/ui/style.css +160 -4
- package/package.json +1 -1
package/dist/config/config.js
CHANGED
|
@@ -1,10 +1,41 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, copyFileSync, cpSync } from 'node:fs';
|
|
2
2
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
export function bananaHome() {
|
|
6
6
|
const custom = process.env['BANANA_HOME']?.trim();
|
|
7
|
-
|
|
7
|
+
if (custom)
|
|
8
|
+
return resolve(custom);
|
|
9
|
+
// Windows 默认落到 D 盘:配置、会话、备份、缓存都不写系统盘。
|
|
10
|
+
// 老版本的数据在 %USERPROFILE%\.banana,第一次启动时自动搬过去(只搬一次)。
|
|
11
|
+
if (process.platform === 'win32') {
|
|
12
|
+
for (const candidate of ['D:\\BananaCLI-data', 'D:\\BananaData']) {
|
|
13
|
+
try {
|
|
14
|
+
mkdirSync(candidate, { recursive: true });
|
|
15
|
+
const legacy = join(homedir(), '.banana');
|
|
16
|
+
if (existsSync(legacy)) {
|
|
17
|
+
if (!existsSync(join(candidate, 'config.json')) && existsSync(join(legacy, 'config.json'))) {
|
|
18
|
+
copyFileSync(join(legacy, 'config.json'), join(candidate, 'config.json'));
|
|
19
|
+
}
|
|
20
|
+
for (const name of ['sessions', 'backups']) {
|
|
21
|
+
if (!existsSync(join(candidate, name)) && existsSync(join(legacy, name))) {
|
|
22
|
+
try {
|
|
23
|
+
cpSync(join(legacy, name), join(candidate, name), { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// 单个会话复制失败不影响启动
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return candidate;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// 没有 D 盘或没写权限,试下一个位置
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return join(homedir(), '.banana');
|
|
8
39
|
}
|
|
9
40
|
export function configPath() {
|
|
10
41
|
const custom = process.env['BANANA_CONFIG']?.trim();
|
package/dist/server/ui-server.js
CHANGED
|
@@ -81,6 +81,10 @@ export async function startUiServer(options) {
|
|
|
81
81
|
'多半是内核版本太旧或安装不完整,重新安装即可:npm install -g @vincentbanana/bananacli\n');
|
|
82
82
|
}
|
|
83
83
|
let turn = null;
|
|
84
|
+
// 内嵌终端:不弹外部窗口,命令在当前工作区里执行,cd 之后的工作目录会被记住
|
|
85
|
+
let terminalCwd = null;
|
|
86
|
+
let terminalChild = null;
|
|
87
|
+
const terminalWorkdir = () => terminalCwd && existsSync(terminalCwd) ? terminalCwd : config.workspace;
|
|
84
88
|
const provider = () => createProvider(config.current, config);
|
|
85
89
|
const backups = (sessionId) => new BackupStore(join(bananaHome(), 'backups'), sessionId);
|
|
86
90
|
const info = async (session) => ({
|
|
@@ -258,7 +262,13 @@ export async function startUiServer(options) {
|
|
|
258
262
|
config.systemPrompt = body.value;
|
|
259
263
|
}
|
|
260
264
|
else if (body.action === 'workspace' && body.value) {
|
|
261
|
-
|
|
265
|
+
const next = resolve(String(body.value).trim());
|
|
266
|
+
if (!existsSync(next)) {
|
|
267
|
+
return json(res, 400, { error: `目录不存在:${next}` });
|
|
268
|
+
}
|
|
269
|
+
config.workspace = next;
|
|
270
|
+
// 换工作区后终端也要跟着换,否则 cd 还停在旧目录
|
|
271
|
+
terminalCwd = null;
|
|
262
272
|
}
|
|
263
273
|
const { saveConfig } = await import("../config/config.js");
|
|
264
274
|
await saveConfig(config);
|
|
@@ -312,8 +322,108 @@ export async function startUiServer(options) {
|
|
|
312
322
|
return json(res, 409, { error: '当前正在跑一轮对话' });
|
|
313
323
|
return chat(body, res, session);
|
|
314
324
|
}
|
|
325
|
+
if (path === '/api/terminal' && req.method === 'POST') {
|
|
326
|
+
const body = await readJson(req);
|
|
327
|
+
return runTerminal(String(body.command ?? ''), res);
|
|
328
|
+
}
|
|
315
329
|
return notFound(res);
|
|
316
330
|
}
|
|
331
|
+
/**
|
|
332
|
+
* 内嵌终端:把一条命令跑在工作区里,输出按行流式推给界面。
|
|
333
|
+
* 这里刻意不依赖 node-pty(原生编译在 Windows 上太重),用管道 + 目录标记实现,
|
|
334
|
+
* 足以跑 git/npm/测试这类常用命令;cd 之后目录会被记住,Ctrl+C 会杀掉子进程。
|
|
335
|
+
*/
|
|
336
|
+
async function runTerminal(command, res) {
|
|
337
|
+
const cwd = terminalWorkdir();
|
|
338
|
+
const trimmed = command.trim();
|
|
339
|
+
if (!trimmed)
|
|
340
|
+
return json(res, 200, { cwd });
|
|
341
|
+
if (terminalChild)
|
|
342
|
+
return json(res, 409, { error: '上一条命令还在跑,先按停止。' });
|
|
343
|
+
res.writeHead(200, {
|
|
344
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
345
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
346
|
+
Connection: 'keep-alive',
|
|
347
|
+
'X-Accel-Buffering': 'no',
|
|
348
|
+
});
|
|
349
|
+
const send = (type, payload = {}) => {
|
|
350
|
+
if (!res.writableEnded)
|
|
351
|
+
res.write(`data: ${JSON.stringify({ type, ...payload })}\n\n`);
|
|
352
|
+
};
|
|
353
|
+
send('start', { cwd });
|
|
354
|
+
const isWin = process.platform === 'win32';
|
|
355
|
+
const marker = '__BANANA_CWD__';
|
|
356
|
+
// 命令跑完后回显一次当前目录,用来记住 cd 的结果
|
|
357
|
+
const wrapped = isWin ? `${trimmed} & echo ${marker}%CD%` : `${trimmed}\n echo ${marker}$(pwd)`;
|
|
358
|
+
let child;
|
|
359
|
+
try {
|
|
360
|
+
child = isWin
|
|
361
|
+
? spawn('cmd.exe', ['/d', '/s', '/c', wrapped], { cwd, env: process.env, windowsHide: true })
|
|
362
|
+
: spawn('/bin/sh', ['-c', wrapped], { cwd, env: process.env, windowsHide: true });
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
send('exit', { code: -1, error: error instanceof Error ? error.message : String(error), cwd });
|
|
366
|
+
res.end();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
terminalChild = child;
|
|
370
|
+
let tailOut = '';
|
|
371
|
+
let tailErr = '';
|
|
372
|
+
const pump = (text, stream) => {
|
|
373
|
+
let buffer = (stream === 'stdout' ? tailOut : tailErr) + text;
|
|
374
|
+
const lines = buffer.split('\n');
|
|
375
|
+
buffer = lines.pop() ?? '';
|
|
376
|
+
for (const raw of lines) {
|
|
377
|
+
const line = raw.replace(/\r$/, '');
|
|
378
|
+
if (line.startsWith(marker)) {
|
|
379
|
+
const next = line.slice(marker.length).trim();
|
|
380
|
+
if (next && existsSync(next))
|
|
381
|
+
terminalCwd = next;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
send('out', { stream, data: line + '\n' });
|
|
385
|
+
}
|
|
386
|
+
if (stream === 'stdout')
|
|
387
|
+
tailOut = buffer;
|
|
388
|
+
else
|
|
389
|
+
tailErr = buffer;
|
|
390
|
+
};
|
|
391
|
+
const flush = () => {
|
|
392
|
+
for (const [buffer, stream] of [[tailOut, 'stdout'], [tailErr, 'stderr']]) {
|
|
393
|
+
if (!buffer)
|
|
394
|
+
continue;
|
|
395
|
+
if (!buffer.startsWith(marker))
|
|
396
|
+
send('out', { stream, data: buffer.replace(/\r$/, '') });
|
|
397
|
+
}
|
|
398
|
+
tailOut = '';
|
|
399
|
+
tailErr = '';
|
|
400
|
+
};
|
|
401
|
+
child.stdout?.setEncoding('utf8');
|
|
402
|
+
child.stderr?.setEncoding('utf8');
|
|
403
|
+
child.stdout?.on('data', (chunk) => pump(chunk, 'stdout'));
|
|
404
|
+
child.stderr?.on('data', (chunk) => pump(chunk, 'stderr'));
|
|
405
|
+
// 前端断开(按停止 / 关页面)时连同子进程一起收掉
|
|
406
|
+
res.on('close', () => {
|
|
407
|
+
if (terminalChild === child)
|
|
408
|
+
terminalChild = null;
|
|
409
|
+
if (!child.killed)
|
|
410
|
+
child.kill();
|
|
411
|
+
});
|
|
412
|
+
await new Promise((resolvePromise) => {
|
|
413
|
+
child.on('error', (error) => {
|
|
414
|
+
send('out', { stream: 'stderr', data: `启动失败:${error.message}\n` });
|
|
415
|
+
});
|
|
416
|
+
child.on('close', (code) => {
|
|
417
|
+
flush();
|
|
418
|
+
if (terminalChild === child)
|
|
419
|
+
terminalChild = null;
|
|
420
|
+
send('exit', { code: code ?? 0, cwd: terminalWorkdir() });
|
|
421
|
+
if (!res.writableEnded)
|
|
422
|
+
res.end();
|
|
423
|
+
resolvePromise();
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
}
|
|
317
427
|
/**
|
|
318
428
|
* 执行 CLI 命令:命令表只有一份(cli/commands.ts),GUI 只是换了个输出通道。
|
|
319
429
|
* 输出逐行推给前端,行为和终端里一致。
|
package/dist/tools/web.js
CHANGED
|
@@ -54,7 +54,7 @@ function unwrapDuckDuckGo(href) {
|
|
|
54
54
|
return decodeURIComponent(match[1]);
|
|
55
55
|
}
|
|
56
56
|
async function searchTavily(query, count, ctx) {
|
|
57
|
-
const apiKey = process.env['TAVILY_API_KEY'];
|
|
57
|
+
const apiKey = process.env['TAVILY_API_KEY'] ?? process.env['BANANA_TAVILY_KEY'];
|
|
58
58
|
if (!apiKey)
|
|
59
59
|
return null;
|
|
60
60
|
const response = await fetch('https://api.tavily.com/search', {
|
|
@@ -73,10 +73,10 @@ async function searchTavily(query, count, ctx) {
|
|
|
73
73
|
}));
|
|
74
74
|
}
|
|
75
75
|
async function searchBrave(query, count, ctx) {
|
|
76
|
-
const apiKey = process.env['BRAVE_API_KEY'];
|
|
76
|
+
const apiKey = process.env['BRAVE_API_KEY'] ?? process.env['BANANA_BRAVE_KEY'];
|
|
77
77
|
if (!apiKey)
|
|
78
78
|
return null;
|
|
79
|
-
const url = `https://api.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`;
|
|
79
|
+
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`;
|
|
80
80
|
const response = await fetch(url, {
|
|
81
81
|
headers: { 'x-subscription-token': apiKey, accept: 'application/json' },
|
|
82
82
|
signal: ctx.signal,
|
|
@@ -176,6 +176,43 @@ function parseDuckDuckGo(html, count) {
|
|
|
176
176
|
}
|
|
177
177
|
return hits;
|
|
178
178
|
}
|
|
179
|
+
/** 归一化 URL:忽略大小写、www. 前缀、末尾斜杠和 #hash,用来去重。 */
|
|
180
|
+
export function normalizeUrl(href) {
|
|
181
|
+
try {
|
|
182
|
+
const url = new URL(href);
|
|
183
|
+
const host = url.hostname.replace(/^www\./i, '').toLowerCase();
|
|
184
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
185
|
+
return `${host}${path}${url.search}`;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return href.trim();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** 搜索结果清洗:去掉站内跳转、重复链接和空条目。 */
|
|
192
|
+
export function sanitizeHits(hits, count) {
|
|
193
|
+
const seen = new Set();
|
|
194
|
+
const out = [];
|
|
195
|
+
for (const hit of hits) {
|
|
196
|
+
const url = (hit.url ?? '').trim();
|
|
197
|
+
if (!/^https?:\/\//i.test(url))
|
|
198
|
+
continue;
|
|
199
|
+
// 搜索引擎自己的搜索页 / 跳转页不是结果
|
|
200
|
+
if (/^https?:\/\/([^/]*\.)?(bing|duckduckgo|google|baidu)\.(com|cn)\//i.test(url) && /\/(search|ck\/a|url|link)\b/i.test(url))
|
|
201
|
+
continue;
|
|
202
|
+
const key = normalizeUrl(url);
|
|
203
|
+
if (seen.has(key))
|
|
204
|
+
continue;
|
|
205
|
+
seen.add(key);
|
|
206
|
+
out.push({
|
|
207
|
+
title: (hit.title ?? '').trim() || url,
|
|
208
|
+
url,
|
|
209
|
+
snippet: (hit.snippet ?? '').trim().slice(0, 500),
|
|
210
|
+
});
|
|
211
|
+
if (out.length >= count)
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
179
216
|
export const webSearchTool = {
|
|
180
217
|
name: 'web_search',
|
|
181
218
|
description: [
|
|
@@ -221,6 +258,7 @@ export const webSearchTool = {
|
|
|
221
258
|
hits = await searchDuckDuckGo(query, count, ctx);
|
|
222
259
|
if (problems.length)
|
|
223
260
|
ctx.log(`搜索 API 降级: ${problems.join('; ')}`);
|
|
261
|
+
hits = sanitizeHits(hits, count);
|
|
224
262
|
if (!hits.length)
|
|
225
263
|
return fail(`没有找到 "${query}" 的结果,换个关键词试试。`);
|
|
226
264
|
const body = hits
|
package/dist/ui/app.js
CHANGED
|
@@ -510,7 +510,7 @@ async function send() {
|
|
|
510
510
|
renderEmpty();
|
|
511
511
|
|
|
512
512
|
const userMsg = document.createElement('article');
|
|
513
|
-
userMsg.className = 'msg user';
|
|
513
|
+
userMsg.className = 'msg user enter';
|
|
514
514
|
userMsg.innerHTML = '<div class="msg-role">你</div>';
|
|
515
515
|
const userBody = document.createElement('div');
|
|
516
516
|
userBody.className = 'msg-body';
|
|
@@ -519,7 +519,7 @@ async function send() {
|
|
|
519
519
|
THREAD.appendChild(userMsg);
|
|
520
520
|
|
|
521
521
|
const botMsg = document.createElement('article');
|
|
522
|
-
botMsg.className = 'msg assistant';
|
|
522
|
+
botMsg.className = 'msg assistant enter';
|
|
523
523
|
botMsg.innerHTML = '<div class="msg-role">Banana</div>';
|
|
524
524
|
const toolWrap = document.createElement('div');
|
|
525
525
|
toolWrap.className = 'tool-group';
|
|
@@ -822,7 +822,7 @@ async function runCommand(text) {
|
|
|
822
822
|
THREAD.appendChild(userMsg);
|
|
823
823
|
|
|
824
824
|
const botMsg = document.createElement('article');
|
|
825
|
-
botMsg.className = 'msg assistant';
|
|
825
|
+
botMsg.className = 'msg assistant enter';
|
|
826
826
|
const role = document.createElement('div');
|
|
827
827
|
role.className = 'msg-role';
|
|
828
828
|
role.textContent = '命令输出';
|
|
@@ -945,6 +945,45 @@ function renderSettings() {
|
|
|
945
945
|
});
|
|
946
946
|
body.appendChild(keyField);
|
|
947
947
|
|
|
948
|
+
// 工作区:换目录后所有文件工具和命令都跟着换,所以放显眼位置
|
|
949
|
+
const workspaceField = document.createElement('div');
|
|
950
|
+
workspaceField.className = 'field';
|
|
951
|
+
workspaceField.innerHTML =
|
|
952
|
+
'<label>工作区</label>' +
|
|
953
|
+
`<div class="row"><input value="${escapeHtml(info.workspace ?? '')}" spellcheck="false" /><button class="btn">切换</button></div>` +
|
|
954
|
+
'<div class="hint">Banana 只能读写这个目录下的文件,命令也在这里执行。绝对路径,例如 D:\\project。</div>';
|
|
955
|
+
workspaceField.querySelector('button').addEventListener('click', async () => {
|
|
956
|
+
const input = workspaceField.querySelector('input');
|
|
957
|
+
const value = input.value.trim();
|
|
958
|
+
if (!value) {
|
|
959
|
+
toast('请填写目录路径');
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
try {
|
|
963
|
+
await post('action', { action: 'workspace', value });
|
|
964
|
+
await refreshSettings();
|
|
965
|
+
renderHeader();
|
|
966
|
+
toast('工作区已切换到 ' + value);
|
|
967
|
+
} catch (error) {
|
|
968
|
+
toast('切换失败:' + (error?.message ?? error));
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
workspaceField.querySelector('input').addEventListener('keydown', (event) => {
|
|
972
|
+
if (event.key === 'Enter') workspaceField.querySelector('button').click();
|
|
973
|
+
});
|
|
974
|
+
body.appendChild(workspaceField);
|
|
975
|
+
|
|
976
|
+
const terminalField = document.createElement('div');
|
|
977
|
+
terminalField.className = 'field';
|
|
978
|
+
terminalField.innerHTML =
|
|
979
|
+
'<label>终端</label><div class="row"><button class="btn">在界面里打开终端</button></div>' +
|
|
980
|
+
'<div class="hint">内嵌在对话下方,不弹外部窗口;直接在当前工作区执行命令,cd 会记住目录。</div>';
|
|
981
|
+
terminalField.querySelector('button').addEventListener('click', () => {
|
|
982
|
+
closeSettings();
|
|
983
|
+
termOpen();
|
|
984
|
+
});
|
|
985
|
+
body.appendChild(terminalField);
|
|
986
|
+
|
|
948
987
|
const permField = document.createElement('div');
|
|
949
988
|
permField.className = 'field';
|
|
950
989
|
permField.innerHTML = '<label>权限</label>';
|
|
@@ -1155,6 +1194,199 @@ function closeSettings() {
|
|
|
1155
1194
|
setTimeout(() => { if ($('settingsSheet').hidden) $('settingsBody').innerHTML = ''; }, 240);
|
|
1156
1195
|
}
|
|
1157
1196
|
|
|
1197
|
+
// ---------------- 内嵌终端 ----------------
|
|
1198
|
+
const term = { open: false, running: false, history: [], cursor: 0, controller: null, size: 0 };
|
|
1199
|
+
|
|
1200
|
+
const ANSI_COLORS = {
|
|
1201
|
+
30: '#6b6b72', 31: '#b4453a', 32: '#2f7d50', 33: '#966d1f',
|
|
1202
|
+
34: '#3b63b0', 35: '#7b4d9e', 36: '#257f80', 37: '#55555c',
|
|
1203
|
+
90: '#8c8c93', 91: '#c9564a', 92: '#3f9a66', 93: '#b0852d',
|
|
1204
|
+
94: '#527cd1', 95: '#9a67c4', 96: '#2f9a9a', 97: '#16161a',
|
|
1205
|
+
};
|
|
1206
|
+
|
|
1207
|
+
/** 把 ANSI 上色转成 span;其他控制序列直接丢掉,避免满屏乱码。 */
|
|
1208
|
+
function ansiToHtml(input) {
|
|
1209
|
+
const text = input.replace(/\x1b\][^\x07]*\x07/g, '').replace(/\x1b\[[0-9;?]*[A-LN-Za-ln-z]/g, '');
|
|
1210
|
+
let out = '';
|
|
1211
|
+
let last = 0;
|
|
1212
|
+
let color = '';
|
|
1213
|
+
let bold = false;
|
|
1214
|
+
let dim = false;
|
|
1215
|
+
const style = () =>
|
|
1216
|
+
color || bold || dim
|
|
1217
|
+
? `color:${color || 'inherit'};font-weight:${bold ? 600 : 400};opacity:${dim ? 0.7 : 1}`
|
|
1218
|
+
: '';
|
|
1219
|
+
for (const match of text.matchAll(/\x1b\[([0-9;]*)m/g)) {
|
|
1220
|
+
out += `<span style="${style()}">${escapeHtml(text.slice(last, match.index))}</span>`;
|
|
1221
|
+
last = match.index + match[0].length;
|
|
1222
|
+
for (const code of (match[1] || '0').split(';').filter(Boolean).map(Number)) {
|
|
1223
|
+
if (code === 0) { color = ''; bold = false; dim = false; }
|
|
1224
|
+
else if (code === 1) bold = true;
|
|
1225
|
+
else if (code === 2) dim = true;
|
|
1226
|
+
else if (code === 22) { bold = false; dim = false; }
|
|
1227
|
+
else if (code === 39) color = '';
|
|
1228
|
+
else if (ANSI_COLORS[code]) color = ANSI_COLORS[code];
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
out += `<span style="${style()}">${escapeHtml(text.slice(last))}</span>`;
|
|
1232
|
+
return out;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function termWrite(text, kind = 'out') {
|
|
1236
|
+
if (!text) return;
|
|
1237
|
+
const body = $('termBody');
|
|
1238
|
+
const chunk = document.createElement('span');
|
|
1239
|
+
chunk.className = 'term-chunk' + (kind === 'stderr' ? ' stderr' : kind === 'input' ? ' input' : '');
|
|
1240
|
+
chunk.innerHTML = ansiToHtml(text);
|
|
1241
|
+
body.appendChild(chunk);
|
|
1242
|
+
term.size += text.length;
|
|
1243
|
+
if (term.size > 240_000) {
|
|
1244
|
+
body.textContent = '';
|
|
1245
|
+
term.size = 0;
|
|
1246
|
+
termWrite('输出太多,已清空旧内容。\n', 'input');
|
|
1247
|
+
}
|
|
1248
|
+
body.scrollTop = body.scrollHeight;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function termOpen() {
|
|
1252
|
+
const panel = $('terminalPanel');
|
|
1253
|
+
if (!term.open) {
|
|
1254
|
+
term.open = true;
|
|
1255
|
+
panel.hidden = false;
|
|
1256
|
+
requestAnimationFrame(() => panel.classList.add('show'));
|
|
1257
|
+
if (!term.size) {
|
|
1258
|
+
termWrite('在当前工作区执行命令;cd 会记住目录,clear 清屏。\n', 'input');
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
$('termCwd').textContent = state.info?.workspace ?? '';
|
|
1262
|
+
$('termInput').focus();
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function termClose() {
|
|
1266
|
+
term.open = false;
|
|
1267
|
+
const panel = $('terminalPanel');
|
|
1268
|
+
panel.classList.remove('show');
|
|
1269
|
+
panel.classList.add('closing');
|
|
1270
|
+
setTimeout(() => {
|
|
1271
|
+
if (term.open) return;
|
|
1272
|
+
panel.hidden = true;
|
|
1273
|
+
panel.classList.remove('closing');
|
|
1274
|
+
}, 160);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function termToggle() {
|
|
1278
|
+
if (term.open) termClose();
|
|
1279
|
+
else termOpen();
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
async function termRun(command) {
|
|
1283
|
+
const cmd = command.trim();
|
|
1284
|
+
if (!cmd || term.running) return;
|
|
1285
|
+
if (/^(clear|cls)$/i.test(cmd)) {
|
|
1286
|
+
$('termBody').textContent = '';
|
|
1287
|
+
term.size = 0;
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
term.running = true;
|
|
1291
|
+
$('termStop').hidden = false;
|
|
1292
|
+
$('termSend').disabled = true;
|
|
1293
|
+
termWrite(`\n> ${cmd}\n`, 'input');
|
|
1294
|
+
|
|
1295
|
+
const history = term.history;
|
|
1296
|
+
if (history[history.length - 1] !== cmd) history.push(cmd);
|
|
1297
|
+
term.cursor = history.length;
|
|
1298
|
+
|
|
1299
|
+
term.controller = new AbortController();
|
|
1300
|
+
try {
|
|
1301
|
+
const response = await fetch(API + 'terminal', {
|
|
1302
|
+
method: 'POST',
|
|
1303
|
+
headers: { 'content-type': 'application/json' },
|
|
1304
|
+
body: JSON.stringify({ command: cmd }),
|
|
1305
|
+
signal: term.controller.signal,
|
|
1306
|
+
});
|
|
1307
|
+
if (!response.ok) {
|
|
1308
|
+
const payload = await response.json().catch(() => ({}));
|
|
1309
|
+
termWrite((payload.error ?? `终端启动失败(${response.status})`) + '\n', 'stderr');
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
const reader = response.body.getReader();
|
|
1313
|
+
const decoder = new TextDecoder();
|
|
1314
|
+
let buffer = '';
|
|
1315
|
+
for (;;) {
|
|
1316
|
+
const { done, value } = await reader.read();
|
|
1317
|
+
if (done) break;
|
|
1318
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1319
|
+
let index;
|
|
1320
|
+
while ((index = buffer.indexOf('\n\n')) >= 0) {
|
|
1321
|
+
const raw = buffer.slice(0, index);
|
|
1322
|
+
buffer = buffer.slice(index + 2);
|
|
1323
|
+
const line = raw.startsWith('data: ') ? raw.slice(6) : raw;
|
|
1324
|
+
if (!line) continue;
|
|
1325
|
+
let event;
|
|
1326
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
1327
|
+
if (event.type === 'out') termWrite(event.data, event.stream);
|
|
1328
|
+
else if (event.type === 'start' || event.type === 'exit') {
|
|
1329
|
+
if (event.cwd) $('termCwd').textContent = event.cwd;
|
|
1330
|
+
if (event.error) termWrite(event.error + '\n', 'stderr');
|
|
1331
|
+
if (event.type === 'exit' && event.code) termWrite(`进程退出,代码 ${event.code}\n`, 'stderr');
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
} catch (error) {
|
|
1336
|
+
if (error?.name !== 'AbortError') termWrite(`终端错误:${error?.message ?? error}\n`, 'stderr');
|
|
1337
|
+
else termWrite('已停止。\n', 'input');
|
|
1338
|
+
} finally {
|
|
1339
|
+
term.running = false;
|
|
1340
|
+
term.controller = null;
|
|
1341
|
+
$('termStop').hidden = true;
|
|
1342
|
+
$('termSend').disabled = false;
|
|
1343
|
+
if (term.open) $('termInput').focus();
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function bindTerminal() {
|
|
1348
|
+
$('termBtn').innerHTML = svg('terminal');
|
|
1349
|
+
$('termStop').innerHTML = svg('stop');
|
|
1350
|
+
$('termClear').innerHTML = svg('trash');
|
|
1351
|
+
$('termClose').innerHTML = svg('x');
|
|
1352
|
+
$('termBtn').addEventListener('click', termToggle);
|
|
1353
|
+
$('termClose').addEventListener('click', termClose);
|
|
1354
|
+
$('termClear').addEventListener('click', () => {
|
|
1355
|
+
$('termBody').textContent = '';
|
|
1356
|
+
term.size = 0;
|
|
1357
|
+
});
|
|
1358
|
+
$('termStop').addEventListener('click', () => term.controller?.abort());
|
|
1359
|
+
$('termForm').addEventListener('submit', (event) => {
|
|
1360
|
+
event.preventDefault();
|
|
1361
|
+
const input = $('termInput');
|
|
1362
|
+
const value = input.value;
|
|
1363
|
+
input.value = '';
|
|
1364
|
+
termRun(value);
|
|
1365
|
+
});
|
|
1366
|
+
$('termInput').addEventListener('keydown', (event) => {
|
|
1367
|
+
if (event.key === 'ArrowUp') {
|
|
1368
|
+
event.preventDefault();
|
|
1369
|
+
if (!term.history.length) return;
|
|
1370
|
+
term.cursor = Math.max(0, term.cursor - 1);
|
|
1371
|
+
$('termInput').value = term.history[term.cursor] ?? '';
|
|
1372
|
+
} else if (event.key === 'ArrowDown') {
|
|
1373
|
+
event.preventDefault();
|
|
1374
|
+
term.cursor = Math.min(term.history.length, term.cursor + 1);
|
|
1375
|
+
$('termInput').value = term.history[term.cursor] ?? '';
|
|
1376
|
+
} else if (event.key === 'Escape') {
|
|
1377
|
+
termClose();
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
window.addEventListener('keydown', (event) => {
|
|
1381
|
+
if (event.ctrlKey && event.key === '`') {
|
|
1382
|
+
event.preventDefault();
|
|
1383
|
+
termToggle();
|
|
1384
|
+
}
|
|
1385
|
+
});
|
|
1386
|
+
// 新建 / 切换会话时同步一下工作区显示
|
|
1387
|
+
$('termCwd').textContent = state.info?.workspace ?? '';
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1158
1390
|
function bind() {
|
|
1159
1391
|
$('newChat').innerHTML = svg('plus');
|
|
1160
1392
|
$('sendBtn').innerHTML = svg('send');
|
|
@@ -1163,6 +1395,7 @@ function bind() {
|
|
|
1163
1395
|
$('closeSettings').innerHTML = svg('x');
|
|
1164
1396
|
$('filesBtn').innerHTML = svg('panel');
|
|
1165
1397
|
$('closeFiles').innerHTML = svg('x');
|
|
1398
|
+
bindTerminal();
|
|
1166
1399
|
hydrate();
|
|
1167
1400
|
loadTheme();
|
|
1168
1401
|
|
package/dist/ui/index.html
CHANGED
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
<span data-icon="chip"></span><span id="modelLabel">—</span>
|
|
44
44
|
</button>
|
|
45
45
|
<button class="icon-btn" id="filesBtn" title="项目文件" aria-label="项目文件"></button>
|
|
46
|
+
<button class="icon-btn" id="termBtn" title="终端(Ctrl+`)" aria-label="终端"></button>
|
|
46
47
|
<button class="icon-btn" id="themeBtn" title="切换主题" aria-label="切换主题"></button>
|
|
47
48
|
<button class="icon-btn" id="abortBtn" title="停止" aria-label="停止" hidden></button>
|
|
48
49
|
</div>
|
|
@@ -50,6 +51,25 @@
|
|
|
50
51
|
|
|
51
52
|
<div class="thread" id="thread"></div>
|
|
52
53
|
|
|
54
|
+
<section class="terminal" id="terminalPanel" hidden>
|
|
55
|
+
<header class="term-head">
|
|
56
|
+
<span class="term-dot" aria-hidden="true"></span>
|
|
57
|
+
<span class="term-title">终端</span>
|
|
58
|
+
<span class="term-cwd" id="termCwd"></span>
|
|
59
|
+
<div class="term-actions">
|
|
60
|
+
<button class="icon-btn" id="termStop" title="停止" aria-label="停止" hidden></button>
|
|
61
|
+
<button class="icon-btn" id="termClear" title="清空" aria-label="清空"></button>
|
|
62
|
+
<button class="icon-btn" id="termClose" title="收起(Ctrl+`)" aria-label="收起"></button>
|
|
63
|
+
</div>
|
|
64
|
+
</header>
|
|
65
|
+
<div class="term-body" id="termBody" aria-live="polite"></div>
|
|
66
|
+
<form class="term-row" id="termForm">
|
|
67
|
+
<span class="term-prompt" aria-hidden="true">></span>
|
|
68
|
+
<input id="termInput" autocomplete="off" spellcheck="false" placeholder="输入命令,Enter 执行,↑↓ 翻历史" />
|
|
69
|
+
<button class="btn tiny" type="submit" id="termSend">运行</button>
|
|
70
|
+
</form>
|
|
71
|
+
</section>
|
|
72
|
+
|
|
53
73
|
<div class="composer-wrap">
|
|
54
74
|
<div class="attachments" id="attachments" hidden></div>
|
|
55
75
|
<form class="composer" id="composer">
|
package/dist/ui/style.css
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
--radius: 12px;
|
|
14
14
|
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
|
15
15
|
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
|
16
|
+
/* 带一点点回弹,用于按压力气和弹出层,别过量 */
|
|
17
|
+
--spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
16
18
|
--dur: 220ms;
|
|
17
19
|
--sidebar: 274px;
|
|
18
20
|
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
|
@@ -262,7 +264,48 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|
|
262
264
|
contain-intrinsic-size: auto 120px;
|
|
263
265
|
}
|
|
264
266
|
|
|
265
|
-
/*
|
|
267
|
+
/* ---------- 轻微回弹 ----------
|
|
268
|
+
只给「按下去」和「弹出来」加一点过冲,不影响滚动手感。 */
|
|
269
|
+
.btn,
|
|
270
|
+
.icon-btn,
|
|
271
|
+
.send-btn,
|
|
272
|
+
.s-act,
|
|
273
|
+
.popover button,
|
|
274
|
+
.session-item {
|
|
275
|
+
transition:
|
|
276
|
+
background 140ms var(--ease),
|
|
277
|
+
color 140ms var(--ease),
|
|
278
|
+
border-color 140ms var(--ease),
|
|
279
|
+
transform 180ms var(--spring);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.btn:active,
|
|
283
|
+
.popover button:active,
|
|
284
|
+
.s-act:active {
|
|
285
|
+
transform: scale(0.95);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.btn:hover,
|
|
289
|
+
.icon-btn:hover {
|
|
290
|
+
transform: translateY(-1px);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.send-btn:not(:disabled):hover {
|
|
294
|
+
transform: translateY(-1px) scale(1.04);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.send-btn:not(:disabled):active {
|
|
298
|
+
transform: scale(0.93);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.session-item:active {
|
|
302
|
+
transform: scale(0.985);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/* 新消息轻轻上浮,只有真正新产生的消息走这条 */
|
|
306
|
+
.msg.enter {
|
|
307
|
+
animation: rise 240ms var(--spring) both;
|
|
308
|
+
}
|
|
266
309
|
.thread { transition: opacity 120ms var(--ease); }
|
|
267
310
|
body.switching .thread { opacity: 0.45; }
|
|
268
311
|
body.switching { cursor: progress; }
|
|
@@ -520,7 +563,7 @@ pre.code code { border: 0; background: none; padding: 0; font-size: 12.6px; line
|
|
|
520
563
|
box-shadow: var(--shadow-lg);
|
|
521
564
|
display: flex;
|
|
522
565
|
flex-direction: column;
|
|
523
|
-
animation: slide
|
|
566
|
+
animation: slide 250ms var(--spring) both;
|
|
524
567
|
will-change: transform;
|
|
525
568
|
}
|
|
526
569
|
@keyframes slide {
|
|
@@ -774,7 +817,8 @@ pre.code code { border: 0; background: none; padding: 0; font-size: 12.6px; line
|
|
|
774
817
|
border-radius: 10px;
|
|
775
818
|
background: var(--bg);
|
|
776
819
|
box-shadow: var(--shadow-lg);
|
|
777
|
-
animation: pop
|
|
820
|
+
animation: pop 190ms var(--spring) both;
|
|
821
|
+
transform-origin: top right;
|
|
778
822
|
}
|
|
779
823
|
.popover button {
|
|
780
824
|
display: block;
|
|
@@ -889,4 +933,116 @@ pre.code code { border: 0; background: none; padding: 0; font-size: 12.6px; line
|
|
|
889
933
|
.only-narrow { display: inline-grid; }
|
|
890
934
|
.msg { padding: 0 16px; }
|
|
891
935
|
.composer-wrap { padding: 8px 14px 12px; }
|
|
892
|
-
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/* ---------- 内嵌终端 ---------- */
|
|
939
|
+
.terminal {
|
|
940
|
+
display: flex;
|
|
941
|
+
flex-direction: column;
|
|
942
|
+
height: 38vh;
|
|
943
|
+
min-height: 170px;
|
|
944
|
+
border-top: 1px solid var(--line);
|
|
945
|
+
background: var(--panel);
|
|
946
|
+
animation: termIn 220ms var(--spring) both;
|
|
947
|
+
}
|
|
948
|
+
.terminal[hidden] { display: none; }
|
|
949
|
+
.terminal.closing { animation: termOut 150ms var(--ease-in) both; }
|
|
950
|
+
@keyframes termIn { from { opacity: 0; transform: translateY(14px) scale(0.995); } to { opacity: 1; transform: none; } }
|
|
951
|
+
@keyframes termOut { to { opacity: 0; transform: translateY(10px); } }
|
|
952
|
+
|
|
953
|
+
.term-head {
|
|
954
|
+
display: flex;
|
|
955
|
+
align-items: center;
|
|
956
|
+
gap: 8px;
|
|
957
|
+
height: 38px;
|
|
958
|
+
padding: 0 10px 0 14px;
|
|
959
|
+
border-bottom: 1px solid var(--line);
|
|
960
|
+
font-size: 12px;
|
|
961
|
+
color: var(--text-3);
|
|
962
|
+
}
|
|
963
|
+
.term-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: 0.55; }
|
|
964
|
+
.term-title { font-weight: 600; color: var(--text-2); letter-spacing: 0.02em; }
|
|
965
|
+
.term-cwd { font-family: var(--mono); font-size: 11.5px; color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
966
|
+
.term-actions { margin-left: auto; display: flex; gap: 2px; align-items: center; }
|
|
967
|
+
|
|
968
|
+
.term-body {
|
|
969
|
+
flex: 1;
|
|
970
|
+
overflow-y: auto;
|
|
971
|
+
padding: 10px 14px;
|
|
972
|
+
font-family: var(--mono);
|
|
973
|
+
font-size: 12.4px;
|
|
974
|
+
line-height: 1.6;
|
|
975
|
+
color: var(--text-2);
|
|
976
|
+
white-space: pre-wrap;
|
|
977
|
+
overflow-wrap: anywhere;
|
|
978
|
+
scrollbar-gutter: stable;
|
|
979
|
+
}
|
|
980
|
+
.term-chunk.stderr { color: #a8493d; }
|
|
981
|
+
.term-chunk.input { color: var(--text-3); }
|
|
982
|
+
|
|
983
|
+
.term-row {
|
|
984
|
+
display: flex;
|
|
985
|
+
align-items: center;
|
|
986
|
+
gap: 8px;
|
|
987
|
+
padding: 8px 12px;
|
|
988
|
+
border-top: 1px solid var(--line);
|
|
989
|
+
background: var(--bg);
|
|
990
|
+
}
|
|
991
|
+
.term-prompt { font-family: var(--mono); color: var(--text-3); font-size: 13px; }
|
|
992
|
+
.term-row input {
|
|
993
|
+
flex: 1;
|
|
994
|
+
min-width: 0;
|
|
995
|
+
height: 30px;
|
|
996
|
+
border: 0;
|
|
997
|
+
outline: none;
|
|
998
|
+
background: transparent;
|
|
999
|
+
color: var(--text);
|
|
1000
|
+
font-family: var(--mono);
|
|
1001
|
+
font-size: 12.8px;
|
|
1002
|
+
}
|
|
1003
|
+
.term-row input:focus { transform: none; box-shadow: none; }
|
|
1004
|
+
|
|
1005
|
+
/* ---------- 全局轻微弹力 ----------
|
|
1006
|
+
所有可点组件统一手感:按下回缩、松开弹回;幅度很小,不抢内容。 */
|
|
1007
|
+
button,
|
|
1008
|
+
.session-item,
|
|
1009
|
+
.popover button,
|
|
1010
|
+
.files-row,
|
|
1011
|
+
.palette-item,
|
|
1012
|
+
.suggest button,
|
|
1013
|
+
.tool-head,
|
|
1014
|
+
summary {
|
|
1015
|
+
transition:
|
|
1016
|
+
transform 200ms var(--spring),
|
|
1017
|
+
background-color 140ms var(--ease),
|
|
1018
|
+
color 140ms var(--ease),
|
|
1019
|
+
border-color 160ms var(--ease),
|
|
1020
|
+
box-shadow 160ms var(--ease),
|
|
1021
|
+
opacity 140ms var(--ease);
|
|
1022
|
+
}
|
|
1023
|
+
button:active,
|
|
1024
|
+
.session-item:active,
|
|
1025
|
+
.files-row:active,
|
|
1026
|
+
.palette-item:active {
|
|
1027
|
+
transform: scale(0.96);
|
|
1028
|
+
}
|
|
1029
|
+
@media (hover: hover) {
|
|
1030
|
+
button:hover,
|
|
1031
|
+
.files-row:hover,
|
|
1032
|
+
.palette-item:hover {
|
|
1033
|
+
transform: translateY(-1px);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/* 输入类控件聚焦时轻轻抬起 */
|
|
1038
|
+
.field input,
|
|
1039
|
+
.field select,
|
|
1040
|
+
.term-row input,
|
|
1041
|
+
.composer textarea {
|
|
1042
|
+
transition: transform 180ms var(--spring), border-color 160ms var(--ease), box-shadow 160ms var(--ease);
|
|
1043
|
+
}
|
|
1044
|
+
.field input:focus,
|
|
1045
|
+
.field select:focus,
|
|
1046
|
+
.composer textarea:focus {
|
|
1047
|
+
transform: translateY(-1px);
|
|
1048
|
+
}
|
package/package.json
CHANGED