@vincentbanana/bananacli 0.4.2 → 0.4.4
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/server/ui-server.js +22 -1
- package/dist/tools/web.js +41 -3
- package/dist/ui/app.js +78 -13
- package/dist/ui/style.css +56 -5
- package/package.json +1 -1
package/dist/server/ui-server.js
CHANGED
|
@@ -258,7 +258,28 @@ export async function startUiServer(options) {
|
|
|
258
258
|
config.systemPrompt = body.value;
|
|
259
259
|
}
|
|
260
260
|
else if (body.action === 'workspace' && body.value) {
|
|
261
|
-
|
|
261
|
+
const next = resolve(String(body.value).trim());
|
|
262
|
+
if (!existsSync(next)) {
|
|
263
|
+
return json(res, 400, { error: `目录不存在:${next}` });
|
|
264
|
+
}
|
|
265
|
+
config.workspace = next;
|
|
266
|
+
}
|
|
267
|
+
else if (body.action === 'terminal') {
|
|
268
|
+
// 在系统终端里打开当前工作区(GUI 跑在浏览器里,开终端必须借系统命令)
|
|
269
|
+
const dir = config.workspace;
|
|
270
|
+
if (!existsSync(dir))
|
|
271
|
+
return json(res, 400, { error: `目录不存在:${dir}` });
|
|
272
|
+
try {
|
|
273
|
+
const target = process.platform === 'win32'
|
|
274
|
+
? { file: 'cmd', args: ['/c', 'start', '', 'cmd', '/k', 'cd', '/d', dir] }
|
|
275
|
+
: process.platform === 'darwin'
|
|
276
|
+
? { file: 'open', args: ['-a', 'Terminal', dir] }
|
|
277
|
+
: { file: 'x-terminal-emulator', args: ['--working-directory', dir] };
|
|
278
|
+
spawn(target.file, target.args, { detached: true, stdio: 'ignore' }).unref();
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
return json(res, 500, { error: `打开终端失败:${error instanceof Error ? error.message : String(error)}` });
|
|
282
|
+
}
|
|
262
283
|
}
|
|
263
284
|
const { saveConfig } = await import("../config/config.js");
|
|
264
285
|
await saveConfig(config);
|
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
|
@@ -135,6 +135,33 @@ function closeSidebarIfOverlay() {
|
|
|
135
135
|
if (window.matchMedia('(max-width: 900px)').matches) $('sidebar').classList.remove('open');
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
let switching = false;
|
|
139
|
+
/**
|
|
140
|
+
* 切换会话。直接吃 POST 返回的 info/sessions,不再多跑一次 refresh:
|
|
141
|
+
* 少一次往返、也不会和其他请求抢着改 state(之前表现为「点了没反应」)。
|
|
142
|
+
*/
|
|
143
|
+
async function switchSession(ref) {
|
|
144
|
+
if (switching) return;
|
|
145
|
+
switching = true;
|
|
146
|
+
document.body.classList.add('switching');
|
|
147
|
+
try {
|
|
148
|
+
const result = await post('session', { action: 'switch', ref });
|
|
149
|
+
if (result?.info) state.info = result.info;
|
|
150
|
+
if (result?.sessions) state.sessions = result.sessions;
|
|
151
|
+
renderHeader();
|
|
152
|
+
renderSidebar();
|
|
153
|
+
renderThread();
|
|
154
|
+
$('filesTitle').textContent = `项目文件 · ${state.info?.workspace ?? ''}`;
|
|
155
|
+
closeSidebarIfOverlay();
|
|
156
|
+
} catch (error) {
|
|
157
|
+
toast('切换会话失败:' + (error?.message ?? error));
|
|
158
|
+
await refresh(true);
|
|
159
|
+
} finally {
|
|
160
|
+
switching = false;
|
|
161
|
+
document.body.classList.remove('switching');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
138
165
|
function renderSidebar() {
|
|
139
166
|
const list = $('sessionList');
|
|
140
167
|
list.innerHTML = '';
|
|
@@ -152,10 +179,7 @@ function renderSidebar() {
|
|
|
152
179
|
|
|
153
180
|
node.addEventListener('click', (event) => {
|
|
154
181
|
if (event.target.closest('[data-del], [data-menu]')) return;
|
|
155
|
-
|
|
156
|
-
refresh(true);
|
|
157
|
-
closeSidebarIfOverlay();
|
|
158
|
-
});
|
|
182
|
+
switchSession(item.id);
|
|
159
183
|
});
|
|
160
184
|
|
|
161
185
|
node.querySelector('[data-del]').addEventListener('click', (event) => {
|
|
@@ -207,9 +231,7 @@ function openSessionMenu(item, anchor) {
|
|
|
207
231
|
event.stopPropagation();
|
|
208
232
|
close();
|
|
209
233
|
if (action === 'switch') {
|
|
210
|
-
await
|
|
211
|
-
await refresh(true);
|
|
212
|
-
closeSidebarIfOverlay();
|
|
234
|
+
await switchSession(item.id);
|
|
213
235
|
} else if (action === 'rename') {
|
|
214
236
|
const title = prompt('新的标题', item.title || '');
|
|
215
237
|
if (title && title.trim()) {
|
|
@@ -219,8 +241,7 @@ function openSessionMenu(item, anchor) {
|
|
|
219
241
|
toast('已重命名');
|
|
220
242
|
}
|
|
221
243
|
} else {
|
|
222
|
-
await
|
|
223
|
-
await refresh(true);
|
|
244
|
+
await switchSession(item.id);
|
|
224
245
|
downloadSession(action === 'json' ? 'json' : 'md', item.id);
|
|
225
246
|
}
|
|
226
247
|
});
|
|
@@ -350,7 +371,8 @@ function renderThread() {
|
|
|
350
371
|
if (node) THREAD.appendChild(node);
|
|
351
372
|
}
|
|
352
373
|
renderTurnMeta();
|
|
353
|
-
|
|
374
|
+
// 切换会话时瞬间定位到底部;平滑滚动只用于新消息
|
|
375
|
+
scrollToEnd(false);
|
|
354
376
|
}
|
|
355
377
|
|
|
356
378
|
function renderTurnMeta() {
|
|
@@ -488,7 +510,7 @@ async function send() {
|
|
|
488
510
|
renderEmpty();
|
|
489
511
|
|
|
490
512
|
const userMsg = document.createElement('article');
|
|
491
|
-
userMsg.className = 'msg user';
|
|
513
|
+
userMsg.className = 'msg user enter';
|
|
492
514
|
userMsg.innerHTML = '<div class="msg-role">你</div>';
|
|
493
515
|
const userBody = document.createElement('div');
|
|
494
516
|
userBody.className = 'msg-body';
|
|
@@ -497,7 +519,7 @@ async function send() {
|
|
|
497
519
|
THREAD.appendChild(userMsg);
|
|
498
520
|
|
|
499
521
|
const botMsg = document.createElement('article');
|
|
500
|
-
botMsg.className = 'msg assistant';
|
|
522
|
+
botMsg.className = 'msg assistant enter';
|
|
501
523
|
botMsg.innerHTML = '<div class="msg-role">Banana</div>';
|
|
502
524
|
const toolWrap = document.createElement('div');
|
|
503
525
|
toolWrap.className = 'tool-group';
|
|
@@ -800,7 +822,7 @@ async function runCommand(text) {
|
|
|
800
822
|
THREAD.appendChild(userMsg);
|
|
801
823
|
|
|
802
824
|
const botMsg = document.createElement('article');
|
|
803
|
-
botMsg.className = 'msg assistant';
|
|
825
|
+
botMsg.className = 'msg assistant enter';
|
|
804
826
|
const role = document.createElement('div');
|
|
805
827
|
role.className = 'msg-role';
|
|
806
828
|
role.textContent = '命令输出';
|
|
@@ -923,6 +945,49 @@ function renderSettings() {
|
|
|
923
945
|
});
|
|
924
946
|
body.appendChild(keyField);
|
|
925
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">会弹出一个系统终端窗口,已切到当前工作区,方便你手动跑命令。</div>';
|
|
981
|
+
terminalField.querySelector('button').addEventListener('click', async () => {
|
|
982
|
+
try {
|
|
983
|
+
await post('action', { action: 'terminal' });
|
|
984
|
+
toast('终端已打开');
|
|
985
|
+
} catch (error) {
|
|
986
|
+
toast('打开终端失败:' + (error?.message ?? error));
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
body.appendChild(terminalField);
|
|
990
|
+
|
|
926
991
|
const permField = document.createElement('div');
|
|
927
992
|
permField.className = 'field';
|
|
928
993
|
permField.innerHTML = '<label>权限</label>';
|
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;
|
|
@@ -204,8 +206,7 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|
|
204
206
|
height: 54px;
|
|
205
207
|
padding: 0 16px;
|
|
206
208
|
border-bottom: 1px solid var(--line);
|
|
207
|
-
background:
|
|
208
|
-
backdrop-filter: saturate(140%) blur(8px);
|
|
209
|
+
background: var(--bg);
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
.topbar-title {
|
|
@@ -257,8 +258,57 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|
|
257
258
|
max-width: 780px;
|
|
258
259
|
margin: 0 auto;
|
|
259
260
|
padding: 0 24px;
|
|
260
|
-
|
|
261
|
+
/* 历史消息不做入场动画:切换会话时整屏重绘,动画会让人以为界面在乱动,
|
|
262
|
+
长对话里还会明显卡顿。新消息的即时感交给滚动和光标。 */
|
|
263
|
+
content-visibility: auto;
|
|
264
|
+
contain-intrinsic-size: auto 120px;
|
|
265
|
+
}
|
|
266
|
+
|
|
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;
|
|
261
308
|
}
|
|
309
|
+
.thread { transition: opacity 120ms var(--ease); }
|
|
310
|
+
body.switching .thread { opacity: 0.45; }
|
|
311
|
+
body.switching { cursor: progress; }
|
|
262
312
|
|
|
263
313
|
@keyframes rise {
|
|
264
314
|
from { opacity: 0; transform: translateY(8px); }
|
|
@@ -513,7 +563,7 @@ pre.code code { border: 0; background: none; padding: 0; font-size: 12.6px; line
|
|
|
513
563
|
box-shadow: var(--shadow-lg);
|
|
514
564
|
display: flex;
|
|
515
565
|
flex-direction: column;
|
|
516
|
-
animation: slide
|
|
566
|
+
animation: slide 250ms var(--spring) both;
|
|
517
567
|
will-change: transform;
|
|
518
568
|
}
|
|
519
569
|
@keyframes slide {
|
|
@@ -767,7 +817,8 @@ pre.code code { border: 0; background: none; padding: 0; font-size: 12.6px; line
|
|
|
767
817
|
border-radius: 10px;
|
|
768
818
|
background: var(--bg);
|
|
769
819
|
box-shadow: var(--shadow-lg);
|
|
770
|
-
animation: pop
|
|
820
|
+
animation: pop 190ms var(--spring) both;
|
|
821
|
+
transform-origin: top right;
|
|
771
822
|
}
|
|
772
823
|
.popover button {
|
|
773
824
|
display: block;
|
package/package.json
CHANGED