@vincentbanana/bananacli 0.4.1 → 0.4.3
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/tools/web.js +60 -3
- package/dist/ui/app.js +32 -10
- package/dist/ui/style.css +10 -3
- package/package.json +2 -2
package/dist/tools/web.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { fail, ok } from "./types.js";
|
|
2
2
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
3
|
-
const USER_AGENT = 'Mozilla/5.0 (
|
|
3
|
+
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36';
|
|
4
4
|
async function fetchText(url, init, ctx) {
|
|
5
5
|
const controller = new AbortController();
|
|
6
6
|
const timeoutMs = init.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
@@ -26,6 +26,12 @@ function decodeEntities(text) {
|
|
|
26
26
|
.replace(/"/g, '"')
|
|
27
27
|
.replace(/'/g, "'")
|
|
28
28
|
.replace(/ /g, ' ')
|
|
29
|
+
.replace(/ /g, ' ')
|
|
30
|
+
.replace(/ /g, ' ')
|
|
31
|
+
.replace(/·/g, '·')
|
|
32
|
+
.replace(/…/g, '…')
|
|
33
|
+
.replace(/—/g, '—')
|
|
34
|
+
.replace(/–/g, '–')
|
|
29
35
|
.replace(/</g, '<')
|
|
30
36
|
.replace(/>/g, '>')
|
|
31
37
|
.replace(/&/g, '&');
|
|
@@ -84,6 +90,55 @@ async function searchBrave(query, count, ctx) {
|
|
|
84
90
|
snippet: stripTags(item.description ?? '').slice(0, 500),
|
|
85
91
|
}));
|
|
86
92
|
}
|
|
93
|
+
async function searchBing(query, count, ctx) {
|
|
94
|
+
const html = await fetchText(`https://www.bing.com/search?q=${encodeURIComponent(query)}&count=${count}`, {
|
|
95
|
+
headers: {
|
|
96
|
+
'user-agent': USER_AGENT,
|
|
97
|
+
accept: 'text/html,application/xhtml+xml',
|
|
98
|
+
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
99
|
+
},
|
|
100
|
+
timeoutMs: 20_000,
|
|
101
|
+
}, ctx);
|
|
102
|
+
return parseBing(html, count);
|
|
103
|
+
}
|
|
104
|
+
/** Bing 有时把结果包成 /ck/a?...&u=a1<base64> 的跳转链接,这里还原成真实地址。 */
|
|
105
|
+
export function unwrapBing(href) {
|
|
106
|
+
if (!/bing\.com\/ck\/a/i.test(href))
|
|
107
|
+
return href;
|
|
108
|
+
try {
|
|
109
|
+
const raw = new URL(href).searchParams.get('u');
|
|
110
|
+
if (!raw || !raw.startsWith('a1'))
|
|
111
|
+
return href;
|
|
112
|
+
const base64 = raw.slice(2).replace(/-/g, '+').replace(/_/g, '/');
|
|
113
|
+
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
114
|
+
const decoded = Buffer.from(padded, 'base64').toString('utf8');
|
|
115
|
+
return /^https?:/i.test(decoded) ? decoded : href;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return href;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export function parseBing(html, count) {
|
|
122
|
+
const hits = [];
|
|
123
|
+
for (const block of html.matchAll(/<li class="b_algo"[\s\S]*?<\/li>/gi)) {
|
|
124
|
+
const item = block[0];
|
|
125
|
+
const link = item.match(/<h2[^>]*>\s*<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
|
|
126
|
+
if (!link)
|
|
127
|
+
continue;
|
|
128
|
+
const url = unwrapBing(decodeEntities(link[1] ?? ''));
|
|
129
|
+
if (!/^https?:/i.test(url))
|
|
130
|
+
continue;
|
|
131
|
+
const snippet = item.match(/<p[^>]*>([\s\S]*?)<\/p>/i);
|
|
132
|
+
hits.push({
|
|
133
|
+
title: decodeEntities(stripTags(link[2] ?? '')).trim(),
|
|
134
|
+
url,
|
|
135
|
+
snippet: decodeEntities(stripTags(snippet?.[1] ?? '')).trim().slice(0, 500),
|
|
136
|
+
});
|
|
137
|
+
if (hits.length >= count)
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
return hits;
|
|
141
|
+
}
|
|
87
142
|
async function searchDuckDuckGo(query, count, ctx) {
|
|
88
143
|
const errors = [];
|
|
89
144
|
const endpoints = [
|
|
@@ -125,7 +180,7 @@ export const webSearchTool = {
|
|
|
125
180
|
name: 'web_search',
|
|
126
181
|
description: [
|
|
127
182
|
'联网搜索,返回标题 + 链接 + 摘要。',
|
|
128
|
-
'优先使用 TAVILY_API_KEY / BRAVE_API_KEY;没有配置时自动回退到 DuckDuckGo。',
|
|
183
|
+
'优先使用 TAVILY_API_KEY / BRAVE_API_KEY;没有配置时自动回退到 Bing、DuckDuckGo。',
|
|
129
184
|
'搜不到就换关键词再试,别靠猜。',
|
|
130
185
|
].join(''),
|
|
131
186
|
parameters: {
|
|
@@ -148,13 +203,15 @@ export const webSearchTool = {
|
|
|
148
203
|
const order = [
|
|
149
204
|
() => searchTavily(query, count, ctx),
|
|
150
205
|
() => searchBrave(query, count, ctx),
|
|
206
|
+
() => searchBing(query, count, ctx),
|
|
151
207
|
];
|
|
152
208
|
const problems = [];
|
|
153
209
|
for (const attempt of order) {
|
|
154
210
|
try {
|
|
155
211
|
hits = await attempt();
|
|
156
|
-
if (hits)
|
|
212
|
+
if (hits && hits.length)
|
|
157
213
|
break;
|
|
214
|
+
hits = null;
|
|
158
215
|
}
|
|
159
216
|
catch (error) {
|
|
160
217
|
problems.push(error.message);
|
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() {
|
package/dist/ui/style.css
CHANGED
|
@@ -204,8 +204,7 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|
|
204
204
|
height: 54px;
|
|
205
205
|
padding: 0 16px;
|
|
206
206
|
border-bottom: 1px solid var(--line);
|
|
207
|
-
background:
|
|
208
|
-
backdrop-filter: saturate(140%) blur(8px);
|
|
207
|
+
background: var(--bg);
|
|
209
208
|
}
|
|
210
209
|
|
|
211
210
|
.topbar-title {
|
|
@@ -257,9 +256,17 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|
|
257
256
|
max-width: 780px;
|
|
258
257
|
margin: 0 auto;
|
|
259
258
|
padding: 0 24px;
|
|
260
|
-
|
|
259
|
+
/* 历史消息不做入场动画:切换会话时整屏重绘,动画会让人以为界面在乱动,
|
|
260
|
+
长对话里还会明显卡顿。新消息的即时感交给滚动和光标。 */
|
|
261
|
+
content-visibility: auto;
|
|
262
|
+
contain-intrinsic-size: auto 120px;
|
|
261
263
|
}
|
|
262
264
|
|
|
265
|
+
/* 切换会话时给一点即时反馈,避免点了像没反应 */
|
|
266
|
+
.thread { transition: opacity 120ms var(--ease); }
|
|
267
|
+
body.switching .thread { opacity: 0.45; }
|
|
268
|
+
body.switching { cursor: progress; }
|
|
269
|
+
|
|
263
270
|
@keyframes rise {
|
|
264
271
|
from { opacity: 0; transform: translateY(8px); }
|
|
265
272
|
to { opacity: 1; transform: none; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincentbanana/bananacli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
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": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"build": "tsc -p tsconfig.build.json && node scripts/copy-ui.mjs",
|
|
26
26
|
"typecheck": "tsc --noEmit",
|
|
27
27
|
"dev": "node --watch src/index.ts",
|
|
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",
|
|
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/web-search.test.mjs && node test/smoke.mjs",
|
|
29
29
|
"gui:sync": "node scripts/sync-gui.mjs",
|
|
30
30
|
"prepare": "npm run build",
|
|
31
31
|
"prepublishOnly": "npm run typecheck && npm test && npm run build && node test/pack.test.mjs"
|