@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.
@@ -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
- config.workspace = resolve(body.value);
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
- post('session', { action: 'switch', ref: item.id }).then(() => {
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 post('session', { action: 'switch', ref: item.id });
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 post('session', { action: 'switch', ref: item.id });
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
- scrollToEnd();
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: color-mix(in srgb, var(--bg) 82%, transparent);
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
- animation: rise 320ms var(--ease) both;
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 var(--dur) var(--ease) both;
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 160ms var(--ease) both;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentbanana/bananacli",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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": {