@vincentbanana/bananacli 0.4.3 → 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
@@ -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,49 @@ 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">会弹出一个系统终端窗口,已切到当前工作区,方便你手动跑命令。</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
+
948
991
  const permField = document.createElement('div');
949
992
  permField.className = 'field';
950
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;
@@ -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 var(--dur) var(--ease) both;
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 160ms var(--ease) both;
820
+ animation: pop 190ms var(--spring) both;
821
+ transform-origin: top right;
778
822
  }
779
823
  .popover button {
780
824
  display: block;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentbanana/bananacli",
3
- "version": "0.4.3",
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": {